diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index e7f48a8..1c28fb5 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -27,13 +27,15 @@ alwaysApply: true * 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期) * 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md` * API 文档:`docs/api/frontend/notify.md` - - 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-2 ✅ / Task 3-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机) + - 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-4 ✅ / Task 5-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机) * 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task) * 11 项关键决策已锁定(D01-D11),详见设计文档 §三 * **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key * **Task 0 ✅ PoC Spike 完成(2026-04-21)**:`media-server/poc/` 跑通 2 浏览器 ↔ Node ↔ mediasoup,Playwright 双 tab 自动化验证 2 人会议 4 transports/4 producers/4 consumers/RSS 61MB,peer 离开资源自动清理;锁定 mediasoup + fastify + mediasoup-client 技术栈,**不改用 livekit-server**;归档 `media-server/docs/poc-notes.md`(7 项关键坑 + 启动步骤 + 对 Task 1/2/9 的复用映射) * **Task 1 ✅ media-server 骨架完成 + Fastify 5 升级(2026-04-21)**:正式 `media-server/` 子项目落盘,锁定 **mediasoup@3.19.0 + fastify@5.8.5 + fastify-plugin@5.1.0 + @fastify/sensible@6.0.4 + @fastify/websocket@11.2.0 + pino@9.3.2 + zod@3.23.8**;src 五件套(`app.ts` / `config.ts` / `utils/logger.ts` / `mediasoup/worker.ts` / `middlewares/internal-auth.ts`);`/healthz` + `/readyz` + `/internal/info` 实测通过;`X-Internal-Token` 鉴权用 `timingSafeEqual` 防侧信道;Worker `died` 指数退避自愈通过 `kill -9` 验证;Dockerfile 多阶段 + 非 root + curl HEALTHCHECK + 显式暴露 `40000-40199/UDP+TCP`。**升级关键决策**:Fastify 4 已于 2025-06-30 结束 LTS,升级 v5 同时用 `loggerInstance: logger` 消灭两个 pino 实例,插件全部 GA 支持 v5,升级改动仅 2 文件 ~20 行 * **Task 2 ✅ 9 个内部 REST API 完成(2026-04-21,含代码审查修复)**:`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`)+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**,`producerclose` 级联关闭下游 consumer;Consumer 强制 `paused:true` 创建 + `/resume` 独立接口;direction 强约束(recv transport 拒 produce、send transport 拒 consume)。**code-reviewer 子代理"有条件通过"**,2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验(mimeType/clockRate/payloadType 必填、codecs 数组 ≥1),消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await);(4) m2 改读 `consumer.producerPaused`;(5) m3 `producerclose` 改为 `once`;(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s)、覆盖率 82.87%/75.83%/91.3%/82.87%**(stmts/branches/funcs/lines,较首版 +2pp);9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回(201/200/400/401/404/409)。产出:`src/schemas/*`(6 文件,新增 `rtp.ts`)+ `src/services/*`(4 文件)+ `src/routes/*`(4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`(8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单 + * **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`(幂等增量升级)。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`:3 个 model + 3 个 DAO(`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1`);`FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason`;`ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误;psql 集成脚本跑通 8 场景(CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护) + * **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成(2026-04-21)**:`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`);(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`;(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`(501)+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`;(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind`(`NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`);(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**:`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider,解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`;curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`,JWT 中间件生效 - 2e-3 会议增强(7-10 天)📋 待开发:预约会议(`type=2`)+ 定时提醒(`meeting_reminder`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景) - 分支:`feature/phase2e-2-meeting-mvp`(Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生) - **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub diff --git a/backend/go-service/app/admin/provider.go b/backend/go-service/app/admin/provider.go index d288e25..dcd8ab8 100644 --- a/backend/go-service/app/admin/provider.go +++ b/backend/go-service/app/admin/provider.go @@ -20,4 +20,7 @@ var AdminSet = wire.NewSet( controller.NewContactManageController, service.NewGroupManageService, controller.NewGroupManageController, + dao.NewMessageManageDAO, + service.NewMessageManageService, + controller.NewMessageManageController, ) diff --git a/backend/go-service/app/constants/meeting.go b/backend/go-service/app/constants/meeting.go new file mode 100644 index 0000000..243469d --- /dev/null +++ b/backend/go-service/app/constants/meeting.go @@ -0,0 +1,114 @@ +package constants + +// 会议相关常量(Phase 2e-2) +// 包含:会议类型、会议状态、参与者角色、结束原因、离会原因、默认配置 +// 所有枚举值严禁直接用中文字符串,中文仅用于 *Map 展示 + +// 会议类型(meeting_rooms.type) +const ( + MeetingTypeInstant = 1 // 即时会议(MVP 仅此) + MeetingTypeScheduled = 2 // 预约会议(Phase 2e-3) +) + +// MeetingTypeMap 会议类型中文映射 +var MeetingTypeMap = map[int]string{ + MeetingTypeInstant: "即时会议", + MeetingTypeScheduled: "预约会议", +} + +// 会议状态(meeting_rooms.status) +const ( + MeetingStatusPending = 0 // 未开始(仅预约会议使用) + MeetingStatusActive = 1 // 进行中 + MeetingStatusEnded = 2 // 已结束 +) + +// MeetingStatusMap 会议状态中文映射 +var MeetingStatusMap = map[int]string{ + MeetingStatusPending: "未开始", + MeetingStatusActive: "进行中", + MeetingStatusEnded: "已结束", +} + +// 会议参与者角色(meeting_participants.role) +// MVP 仅使用 Participant / Host 两档,CoHost 保留至第二期 +const ( + MeetingRoleParticipant = 0 // 普通参会人 + MeetingRoleHost = 1 // 主持人 + MeetingRoleCoHost = 2 // 联合主持人(保留,MVP 不用) +) + +// MeetingRoleMap 参与者角色中文映射 +var MeetingRoleMap = map[int]string{ + MeetingRoleParticipant: "参会人", + MeetingRoleHost: "主持人", + MeetingRoleCoHost: "联合主持人", +} + +// 会议结束原因(meeting_rooms.ended_reason) +const ( + MeetingEndedReasonHostEnded = "host_ended" // 主持人主动结束 + MeetingEndedReasonEmptyTTL = "empty_ttl" // 空房超时销毁 + MeetingEndedReasonAdminForce = "admin_force" // 管理员强制结束 + MeetingEndedReasonSystemError = "system_error" // 系统异常(如 Node worker died) +) + +// MeetingEndedReasonMap 结束原因中文映射 +var MeetingEndedReasonMap = map[string]string{ + MeetingEndedReasonHostEnded: "主持人结束", + MeetingEndedReasonEmptyTTL: "空房超时", + MeetingEndedReasonAdminForce: "管理员强制结束", + MeetingEndedReasonSystemError: "系统异常", +} + +// 离会原因(meeting_participants.left_reason) +const ( + MeetingLeftReasonSelf = "self" // 主动离会 + MeetingLeftReasonKicked = "kicked" // 被主持人移除 + MeetingLeftReasonHostEnd = "host_end" // 主持人结束会议 + MeetingLeftReasonEmptyTTL = "empty_ttl" // 空房 TTL 触发 + MeetingLeftReasonDisconnect = "disconnect" // 网络断开(宽限期结束仍未重连) +) + +// MeetingLeftReasonMap 离会原因中文映射 +var MeetingLeftReasonMap = map[string]string{ + MeetingLeftReasonSelf: "主动离开", + MeetingLeftReasonKicked: "被移除", + MeetingLeftReasonHostEnd: "会议结束", + MeetingLeftReasonEmptyTTL: "空房超时", + MeetingLeftReasonDisconnect: "网络断开", +} + +// 会议默认配置(应用层强制约束) +const ( + MeetingMVPMaxMembers = 8 // MVP 阶段单会议硬上限 + MeetingRoomCodeLength = 9 // 会议号去连字符后的长度(XXX-XXX-XXX) + MeetingRoomCodeRetryMax = 3 // 生成会议号冲突重试上限 + MeetingPasswordMaxAttempts = 5 // 密码连续错误锁定阈值 + MeetingPasswordLockSeconds = 600 // 密码锁定时长(10 分钟) + MeetingHostGraceSeconds = 120 // 主持人掉线宽限期(2 分钟) + MeetingEmptyRoomTTLSeconds = 300 // 空房销毁 TTL(5 分钟) + MeetingInviteTokenTTL = 600 // 邀请链接 Token TTL(10 分钟) + MeetingChatRetentionHours = 24 // 会议聊天保留时长(会议结束后) +) + +// 会议 WS 事件常量(设计文档 §6.3,11 个事件) +// 命名格式:meeting.{domain}.{action} +const ( + // 房间级事件 + MeetingWSEventRoomEnded = "meeting.room.ended" // 会议已结束 + MeetingWSEventRoomLocked = "meeting.room.locked" // 会议被锁定(等候室,Phase 2e-3) + MeetingWSEventRoomHostChange = "meeting.room.host.changed" // 主持人变更 + + // 成员级事件 + MeetingWSEventMemberJoined = "meeting.member.joined" // 新成员加入 + MeetingWSEventMemberLeft = "meeting.member.left" // 成员离开 + MeetingWSEventMemberStateChange = "meeting.member.state.changed" // 麦克风 / 摄像头状态变化 + MeetingWSEventMemberKicked = "meeting.member.kicked" // 被移出 + + // 媒体级事件(Go 发起,驱动客户端 mediasoup-client 订阅/取消) + MeetingWSEventProducerNew = "meeting.producer.new" // 有新 producer 可订阅 + MeetingWSEventProducerClosed = "meeting.producer.closed" // producer 已关闭 + MeetingWSEventConsumerResumed = "meeting.consumer.resumed" // consumer 已恢复(由服务端触发) + MeetingWSEventChatMessage = "meeting.chat.message" // 会议内文字聊天 +) diff --git a/backend/go-service/app/meeting/controller/meeting_controller.go b/backend/go-service/app/meeting/controller/meeting_controller.go new file mode 100644 index 0000000..278c18f --- /dev/null +++ b/backend/go-service/app/meeting/controller/meeting_controller.go @@ -0,0 +1,138 @@ +// Package controller 提供 meeting 模块的 HTTP 接口 +package controller + +import ( + "net/http" + + "github.com/echochat/backend/app/meeting/service" + "github.com/echochat/backend/pkg/middleware" + "github.com/echochat/backend/pkg/utils" + "github.com/gin-gonic/gin" +) + +// MeetingController 会议 REST 控制器 +// Task 4 骨架阶段:所有 handler 均返回 501 Not Implemented,便于路由自测与前端 mock +// Task 5/6/7 将在此填充请求解析、业务调用与错误映射 +type MeetingController struct { + meetingService *service.MeetingService +} + +// NewMeetingController 创建 MeetingController 实例 +func NewMeetingController(meetingService *service.MeetingService) *MeetingController { + return &MeetingController{meetingService: meetingService} +} + +// responseNotImplemented Task 4 占位响应,Task 5/6/7 逐接口替换为真实实现 +func responseNotImplemented(c *gin.Context, endpoint string) { + c.JSON(http.StatusNotImplemented, gin.H{ + "code": http.StatusNotImplemented, + "message": "接口尚未实现", + "endpoint": endpoint, + }) +} + +// requireUserID 统一的当前用户取值,失败直接写 401 并返回 false +func requireUserID(c *gin.Context) (int64, bool) { + userID, ok := middleware.GetCurrentUserID(c) + if !ok { + utils.ResponseUnauthorized(c, "无法获取当前用户信息") + return 0, false + } + return userID, true +} + +// CreateRoom POST /api/v1/meeting/rooms +func (ctl *MeetingController) CreateRoom(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms") +} + +// GetRoom GET /api/v1/meeting/rooms/:code +func (ctl *MeetingController) GetRoom(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code") +} + +// JoinRoom POST /api/v1/meeting/rooms/:code/join +func (ctl *MeetingController) JoinRoom(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/join") +} + +// LeaveRoom POST /api/v1/meeting/rooms/:code/leave +func (ctl *MeetingController) LeaveRoom(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/leave") +} + +// EndRoom POST /api/v1/meeting/rooms/:code/end +func (ctl *MeetingController) EndRoom(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/end") +} + +// ListMyMeetings GET /api/v1/meeting/rooms?role=host|participant +func (ctl *MeetingController) ListMyMeetings(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "GET /api/v1/meeting/rooms") +} + +// TransferHost POST /api/v1/meeting/rooms/:code/transfer-host +func (ctl *MeetingController) TransferHost(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/transfer-host") +} + +// KickMember POST /api/v1/meeting/rooms/:code/kick +func (ctl *MeetingController) KickMember(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/kick") +} + +// InviteUsers POST /api/v1/meeting/rooms/:code/invite +func (ctl *MeetingController) InviteUsers(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/invite") +} + +// RedeemInvite POST /api/v1/meeting/invites/:token/redeem +func (ctl *MeetingController) RedeemInvite(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/invites/:token/redeem") +} + +// SendChat POST /api/v1/meeting/rooms/:code/chats +func (ctl *MeetingController) SendChat(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "POST /api/v1/meeting/rooms/:code/chats") +} + +// ListChats GET /api/v1/meeting/rooms/:code/chats?after_id=&limit= +func (ctl *MeetingController) ListChats(c *gin.Context) { + if _, ok := requireUserID(c); !ok { + return + } + responseNotImplemented(c, "GET /api/v1/meeting/rooms/:code/chats") +} diff --git a/backend/go-service/app/meeting/dao/meeting_chat_dao.go b/backend/go-service/app/meeting/dao/meeting_chat_dao.go new file mode 100644 index 0000000..296ce93 --- /dev/null +++ b/backend/go-service/app/meeting/dao/meeting_chat_dao.go @@ -0,0 +1,87 @@ +package dao + +import ( + "context" + + "github.com/echochat/backend/app/meeting/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// MeetingChatDAO 会议内文字聊天数据访问对象 +type MeetingChatDAO struct { + db *gorm.DB +} + +// NewMeetingChatDAO 创建实例 +func NewMeetingChatDAO(db *gorm.DB) *MeetingChatDAO { + return &MeetingChatDAO{db: db} +} + +// Create 写入一条会议聊天消息 +// ID / CreatedAt 由数据库自增 + autoCreateTime 填充 +func (d *MeetingChatDAO) Create(ctx context.Context, chat *model.MeetingChat) error { + funcName := "dao.meeting_chat_dao.Create" + err := d.db.WithContext(ctx).Create(chat).Error + if err != nil { + logs.Error(ctx, funcName, "写入会议聊天消息失败", + zap.Int64("room_id", chat.RoomID), + zap.Int64("user_id", chat.UserID), + zap.Error(err)) + } + return err +} + +// ListByRoom 按房间正序返回聊天历史(供会议室打开时加载) +// afterID 为游标(id > afterID 的记录),0 表示从头开始 +// limit 默认 50,上限 200 +func (d *MeetingChatDAO) ListByRoom(ctx context.Context, roomID int64, afterID int64, limit int) ([]model.MeetingChat, error) { + funcName := "dao.meeting_chat_dao.ListByRoom" + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + + q := d.db.WithContext(ctx).Where("room_id = ?", roomID) + if afterID > 0 { + q = q.Where("id > ?", afterID) + } + + var list []model.MeetingChat + err := q.Order("id ASC").Limit(limit).Find(&list).Error + if err != nil { + logs.Error(ctx, funcName, "查询会议聊天失败", + zap.Int64("room_id", roomID), zap.Error(err)) + } + return list, err +} + +// DeleteByRoomIDs 按房间 ID 批量删除聊天消息(会议结束 24 小时后清理) +// 外层已有 meeting_rooms ON DELETE CASCADE,此方法用于主动定时清理(不删除 room 本身) +func (d *MeetingChatDAO) DeleteByRoomIDs(ctx context.Context, roomIDs []int64) (int64, error) { + funcName := "dao.meeting_chat_dao.DeleteByRoomIDs" + if len(roomIDs) == 0 { + return 0, nil + } + res := d.db.WithContext(ctx). + Where("room_id IN ?", roomIDs). + Delete(&model.MeetingChat{}) + if res.Error != nil { + logs.Error(ctx, funcName, "批量清理聊天消息失败", + zap.Int("room_count", len(roomIDs)), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// CountByRoom 统计房间内聊天条数(供管理端观测) +func (d *MeetingChatDAO) CountByRoom(ctx context.Context, roomID int64) (int64, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&model.MeetingChat{}). + Where("room_id = ?", roomID). + Count(&count).Error + return count, err +} diff --git a/backend/go-service/app/meeting/dao/meeting_participant_dao.go b/backend/go-service/app/meeting/dao/meeting_participant_dao.go new file mode 100644 index 0000000..cc5592c --- /dev/null +++ b/backend/go-service/app/meeting/dao/meeting_participant_dao.go @@ -0,0 +1,255 @@ +package dao + +import ( + "context" + "errors" + "time" + + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/meeting/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// MeetingParticipantDAO 参与者数据访问对象 +type MeetingParticipantDAO struct { + db *gorm.DB +} + +// NewMeetingParticipantDAO 创建实例 +func NewMeetingParticipantDAO(db *gorm.DB) *MeetingParticipantDAO { + return &MeetingParticipantDAO{db: db} +} + +// JoinRoom 记录用户加入会议 +// 语义: +// - 若 (room_id, user_id) 不存在 → 创建新行,role 取参数值,joined_at = NOW() +// - 若 (room_id, user_id) 已存在且 left_at != NULL(历史离会)→ 重置 left_at/left_reason/duration, +// joined_at 刷新为当前时间(视为全新一次参会),role 保持原值(角色变更走 UpdateRole) +// - 若 (room_id, user_id) 已存在且 left_at = NULL(仍在会议中)→ 返回 ErrAlreadyInMeeting +// +// 返回最新持久化的参与者记录 +var ( + // ErrAlreadyInMeeting 用户已在会议中(未 left_at) + ErrAlreadyInMeeting = errors.New("user already in meeting") +) + +// JoinRoom 加入会议(幂等 + 重入复用记录) +func (d *MeetingParticipantDAO) JoinRoom(ctx context.Context, roomID, userID int64, role int) (*model.MeetingParticipant, error) { + funcName := "dao.meeting_participant_dao.JoinRoom" + logs.Info(ctx, funcName, "加入会议", + zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Int("role", role)) + + var result model.MeetingParticipant + err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var existing model.MeetingParticipant + findErr := tx.Where("room_id = ? AND user_id = ?", roomID, userID).First(&existing).Error + + if errors.Is(findErr, gorm.ErrRecordNotFound) { + now := time.Now() + result = model.MeetingParticipant{ + RoomID: roomID, + UserID: userID, + Role: role, + JoinedAt: now, + Duration: 0, + } + return tx.Create(&result).Error + } + if findErr != nil { + return findErr + } + + if existing.LeftAt == nil { + return ErrAlreadyInMeeting + } + + now := time.Now() + updates := map[string]interface{}{ + "joined_at": now, + "left_at": nil, + "left_reason": nil, + "duration": 0, + } + if err := tx.Model(&model.MeetingParticipant{}). + Where("id = ?", existing.ID). + Updates(updates).Error; err != nil { + return err + } + result = existing + result.JoinedAt = now + result.LeftAt = nil + result.LeftReason = nil + result.Duration = 0 + return nil + }) + + if err != nil && !errors.Is(err, ErrAlreadyInMeeting) { + logs.Error(ctx, funcName, "加入会议失败", + zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Error(err)) + } + return &result, err +} + +// LeaveRoom 记录用户离会 +// 计算 duration = NOW() - joined_at(秒),写入 left_at / left_reason +// 已离会(left_at != NULL)的记录幂等忽略,返回受影响行数 0 +func (d *MeetingParticipantDAO) LeaveRoom(ctx context.Context, roomID, userID int64, reason string) (int64, error) { + funcName := "dao.meeting_participant_dao.LeaveRoom" + now := time.Now() + + res := d.db.WithContext(ctx). + Model(&model.MeetingParticipant{}). + Where("room_id = ? AND user_id = ? AND left_at IS NULL", roomID, userID). + Updates(map[string]interface{}{ + "left_at": now, + "left_reason": reason, + // duration 使用 SQL 表达式保证数据库时间一致 + "duration": gorm.Expr("EXTRACT(EPOCH FROM (? - joined_at))::INT", now), + }) + if res.Error != nil { + logs.Error(ctx, funcName, "离开会议失败", + zap.Int64("room_id", roomID), zap.Int64("user_id", userID), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// LeaveAllActive 批量将会议内所有仍活跃的参与者置为离会(用于会议结束时兜底) +// reason 建议传 host_end / empty_ttl 等房间级原因 +func (d *MeetingParticipantDAO) LeaveAllActive(ctx context.Context, roomID int64, reason string) (int64, error) { + funcName := "dao.meeting_participant_dao.LeaveAllActive" + now := time.Now() + + res := d.db.WithContext(ctx). + Model(&model.MeetingParticipant{}). + Where("room_id = ? AND left_at IS NULL", roomID). + Updates(map[string]interface{}{ + "left_at": now, + "left_reason": reason, + "duration": gorm.Expr("EXTRACT(EPOCH FROM (? - joined_at))::INT", now), + }) + if res.Error != nil { + logs.Error(ctx, funcName, "批量离会失败", + zap.Int64("room_id", roomID), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// GetByRoomAndUser 查询单个参与者记录 +func (d *MeetingParticipantDAO) GetByRoomAndUser(ctx context.Context, roomID, userID int64) (*model.MeetingParticipant, error) { + var p model.MeetingParticipant + err := d.db.WithContext(ctx). + Where("room_id = ? AND user_id = ?", roomID, userID). + First(&p).Error + if err != nil { + return nil, err + } + return &p, nil +} + +// ListActiveByRoom 返回房间内所有仍未离会的参与者(left_at IS NULL) +// 按 joined_at ASC 排序,供"最早加入者接任主持人"使用 +func (d *MeetingParticipantDAO) ListActiveByRoom(ctx context.Context, roomID int64) ([]model.MeetingParticipant, error) { + funcName := "dao.meeting_participant_dao.ListActiveByRoom" + var list []model.MeetingParticipant + err := d.db.WithContext(ctx). + Where("room_id = ? AND left_at IS NULL", roomID). + Order("joined_at ASC"). + Find(&list).Error + if err != nil { + logs.Error(ctx, funcName, "查询在线参与者失败", + zap.Int64("room_id", roomID), zap.Error(err)) + } + return list, err +} + +// CountActiveByRoom 房间内当前在线人数(不含已离会) +// 用于加入会议时的容量校验 +func (d *MeetingParticipantDAO) CountActiveByRoom(ctx context.Context, roomID int64) (int64, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&model.MeetingParticipant{}). + Where("room_id = ? AND left_at IS NULL", roomID). + Count(&count).Error + return count, err +} + +// ListByRoom 房间内所有历史参与者(含已离会),按 joined_at ASC 排序 +func (d *MeetingParticipantDAO) ListByRoom(ctx context.Context, roomID int64) ([]model.MeetingParticipant, error) { + var list []model.MeetingParticipant + err := d.db.WithContext(ctx). + Where("room_id = ?", roomID). + Order("joined_at ASC"). + Find(&list).Error + return list, err +} + +// FindActiveByUser 查询用户当前仍在进行中的会议(left_at IS NULL,且 room.status != ended) +// 用于"用户不能同时在多个会议中"的业务校验 +// 返回 gorm.ErrRecordNotFound 表示当前空闲 +func (d *MeetingParticipantDAO) FindActiveByUser(ctx context.Context, userID int64) (*model.MeetingParticipant, error) { + var p model.MeetingParticipant + err := d.db.WithContext(ctx). + Joins("JOIN meeting_rooms ON meeting_rooms.id = meeting_participants.room_id"). + Where("meeting_participants.user_id = ? AND meeting_participants.left_at IS NULL AND meeting_rooms.status != ?", + userID, constants.MeetingStatusEnded). + First(&p).Error + if err != nil { + return nil, err + } + return &p, nil +} + +// TransferHost 事务内完成主持人转让 +// 1) 旧 host 的 role 置为 Participant(0) +// 2) 新 host 的 role 置为 Host(1) +// 调用方应在同一上游事务中同时调 MeetingRoomDAO.UpdateHost 修改 meeting_rooms.host_id +// 本方法内部已自带事务,上游无需再包裹 +func (d *MeetingParticipantDAO) TransferHost(ctx context.Context, roomID, oldHostID, newHostID int64) error { + funcName := "dao.meeting_participant_dao.TransferHost" + logs.Info(ctx, funcName, "转让主持人", + zap.Int64("room_id", roomID), + zap.Int64("old_host_id", oldHostID), + zap.Int64("new_host_id", newHostID)) + + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.MeetingParticipant{}). + Where("room_id = ? AND user_id = ? AND role = ?", + roomID, oldHostID, constants.MeetingRoleHost). + Update("role", constants.MeetingRoleParticipant).Error; err != nil { + return err + } + if err := tx.Model(&model.MeetingParticipant{}). + Where("room_id = ? AND user_id = ? AND left_at IS NULL", + roomID, newHostID). + Update("role", constants.MeetingRoleHost).Error; err != nil { + return err + } + return nil + }) +} + +// UpdateRole 更新指定参与者角色(非转让场景,例如直接指派联合主持人) +func (d *MeetingParticipantDAO) UpdateRole(ctx context.Context, roomID, userID int64, role int) error { + return d.db.WithContext(ctx). + Model(&model.MeetingParticipant{}). + Where("room_id = ? AND user_id = ?", roomID, userID). + Update("role", role).Error +} + +// ListByUser 用户历史会议列表(分页,按加入时间倒序) +func (d *MeetingParticipantDAO) ListByUser(ctx context.Context, userID int64, offset, limit int) ([]model.MeetingParticipant, int64, error) { + q := d.db.WithContext(ctx). + Model(&model.MeetingParticipant{}). + Where("user_id = ?", userID) + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + var list []model.MeetingParticipant + err := q.Order("joined_at DESC").Offset(offset).Limit(limit).Find(&list).Error + return list, total, err +} diff --git a/backend/go-service/app/meeting/dao/meeting_room_dao.go b/backend/go-service/app/meeting/dao/meeting_room_dao.go new file mode 100644 index 0000000..debe637 --- /dev/null +++ b/backend/go-service/app/meeting/dao/meeting_room_dao.go @@ -0,0 +1,177 @@ +// Package dao 提供 meeting 模块的数据库访问操作 +package dao + +import ( + "context" + "errors" + "time" + + "github.com/echochat/backend/app/constants" + "github.com/echochat/backend/app/meeting/model" + "github.com/echochat/backend/pkg/logs" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// MeetingRoomDAO 会议房间数据访问对象 +type MeetingRoomDAO struct { + db *gorm.DB +} + +// NewMeetingRoomDAO 创建 MeetingRoomDAO 实例 +func NewMeetingRoomDAO(db *gorm.DB) *MeetingRoomDAO { + return &MeetingRoomDAO{db: db} +} + +// Create 新建一个会议房间 +// 由调用方预先生成 RoomCode 并确认唯一,本方法不做冲突重试 +func (d *MeetingRoomDAO) Create(ctx context.Context, room *model.MeetingRoom) error { + funcName := "dao.meeting_room_dao.Create" + logs.Info(ctx, funcName, "创建会议房间", + zap.String("room_code", room.RoomCode), zap.Int64("host_id", room.HostID)) + + err := d.db.WithContext(ctx).Create(room).Error + if err != nil { + logs.Error(ctx, funcName, "创建会议房间失败", + zap.String("room_code", room.RoomCode), zap.Error(err)) + } + return err +} + +// GetByID 按主键查询 +func (d *MeetingRoomDAO) GetByID(ctx context.Context, id int64) (*model.MeetingRoom, error) { + var room model.MeetingRoom + err := d.db.WithContext(ctx).First(&room, id).Error + if err != nil { + return nil, err + } + return &room, nil +} + +// GetByCode 按会议号查询(入会流程的主要入口) +// 返回 gorm.ErrRecordNotFound 时上层应转换为业务错误 ErrMeetingNotFound +func (d *MeetingRoomDAO) GetByCode(ctx context.Context, code string) (*model.MeetingRoom, error) { + funcName := "dao.meeting_room_dao.GetByCode" + + var room model.MeetingRoom + err := d.db.WithContext(ctx).Where("room_code = ?", code).First(&room).Error + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + logs.Error(ctx, funcName, "按会议号查询房间失败", + zap.String("room_code", code), zap.Error(err)) + } + return nil, err + } + return &room, nil +} + +// ExistsCode 会议号是否已被占用(用于创建会议时的 code 冲突重试) +func (d *MeetingRoomDAO) ExistsCode(ctx context.Context, code string) (bool, error) { + var count int64 + err := d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("room_code = ?", code). + Count(&count).Error + return count > 0, err +} + +// MarkStarted 记录会议实际开始时间(host 首次加入时调用) +// 仅当 status=pending 且 started_at IS NULL 时才写入,避免重复覆盖 +func (d *MeetingRoomDAO) MarkStarted(ctx context.Context, id int64, startedAt time.Time) (int64, error) { + funcName := "dao.meeting_room_dao.MarkStarted" + res := d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("id = ? AND status = ? AND started_at IS NULL", id, constants.MeetingStatusPending). + Updates(map[string]interface{}{ + "status": constants.MeetingStatusActive, + "started_at": startedAt, + }) + if res.Error != nil { + logs.Error(ctx, funcName, "标记会议开始失败", + zap.Int64("room_id", id), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// MarkEnded 记录会议结束(status=2 + ended_at + ended_reason) +// 乐观锁:只对 status != ended 的行生效,避免重复结束覆盖原始 reason +func (d *MeetingRoomDAO) MarkEnded(ctx context.Context, id int64, reason string, endedAt time.Time) (int64, error) { + funcName := "dao.meeting_room_dao.MarkEnded" + res := d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("id = ? AND status != ?", id, constants.MeetingStatusEnded). + Updates(map[string]interface{}{ + "status": constants.MeetingStatusEnded, + "ended_at": endedAt, + "ended_reason": reason, + }) + if res.Error != nil { + logs.Error(ctx, funcName, "标记会议结束失败", + zap.Int64("room_id", id), zap.String("reason", reason), zap.Error(res.Error)) + } + return res.RowsAffected, res.Error +} + +// UpdateHost 更新主持人(仅修改 meeting_rooms.host_id 字段,meeting_participants.role 由 ParticipantDAO.TransferHost 在同一事务中处理) +func (d *MeetingRoomDAO) UpdateHost(ctx context.Context, id, newHostID int64) error { + return d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("id = ?", id). + Update("host_id", newHostID).Error +} + +// UpdateSettings 更新房间级配置(settings 字段整体替换) +// 调用方需保证 settingsJSON 是合法 JSON 字符串 +func (d *MeetingRoomDAO) UpdateSettings(ctx context.Context, id int64, settingsJSON string) error { + return d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("id = ?", id). + Update("settings", settingsJSON).Error +} + +// ListByHost 按主持人查询会议列表(支持 status 过滤 + 分页) +// status 传 -1 表示不限 +// 返回按 created_at DESC 排序,走 idx_meeting_rooms_host_status 索引 +func (d *MeetingRoomDAO) ListByHost(ctx context.Context, hostID int64, status int, offset, limit int) ([]model.MeetingRoom, int64, error) { + funcName := "dao.meeting_room_dao.ListByHost" + q := d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("host_id = ?", hostID) + if status >= 0 { + q = q.Where("status = ?", status) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + logs.Error(ctx, funcName, "计数失败", zap.Int64("host_id", hostID), zap.Error(err)) + return nil, 0, err + } + + var rooms []model.MeetingRoom + err := q.Order("created_at DESC").Offset(offset).Limit(limit).Find(&rooms).Error + if err != nil { + logs.Error(ctx, funcName, "查询失败", zap.Int64("host_id", hostID), zap.Error(err)) + } + return rooms, total, err +} + +// ListExpiredForCleanup 返回已结束且超过指定小时数的房间 ID 列表 +// 供定时任务清理 meeting_chats 使用;room 本身保留归档不删 +// 返回 limit 上限用于分批处理,避免一次捞太多 +func (d *MeetingRoomDAO) ListExpiredForCleanup(ctx context.Context, hoursAgo int, limit int) ([]int64, error) { + funcName := "dao.meeting_room_dao.ListExpiredForCleanup" + cutoff := time.Now().Add(-time.Duration(hoursAgo) * time.Hour) + + var ids []int64 + err := d.db.WithContext(ctx). + Model(&model.MeetingRoom{}). + Where("status = ? AND ended_at IS NOT NULL AND ended_at < ?", + constants.MeetingStatusEnded, cutoff). + Order("ended_at ASC"). + Limit(limit). + Pluck("id", &ids).Error + if err != nil { + logs.Error(ctx, funcName, "查询过期房间失败", zap.Error(err)) + } + return ids, err +} diff --git a/backend/go-service/app/meeting/model/meeting_chat.go b/backend/go-service/app/meeting/model/meeting_chat.go new file mode 100644 index 0000000..edf5966 --- /dev/null +++ b/backend/go-service/app/meeting/model/meeting_chat.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// MeetingChat 会议内文字聊天消息模型,对应 meeting_chats 表 +// 独立于 im_messages,不进入常规 IM 消息流 +// 清理策略:会议结束(status=2)后 24 小时由定时任务批量 DELETE +type MeetingChat struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 消息唯一标识 + RoomID int64 `json:"room_id" gorm:"not null;index:idx_meeting_chats_room_created,priority:1"` // 所属房间 ID + UserID int64 `json:"user_id" gorm:"not null"` // 发送者用户 ID + Content string `json:"content" gorm:"type:text;not null"` // 消息内容,纯文本 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_chats_room_created,priority:2,sort:asc"` // 发送时间 +} + +// TableName 指定数据库表名 +func (MeetingChat) TableName() string { + return "meeting_chats" +} diff --git a/backend/go-service/app/meeting/model/meeting_participant.go b/backend/go-service/app/meeting/model/meeting_participant.go new file mode 100644 index 0000000..617cf08 --- /dev/null +++ b/backend/go-service/app/meeting/model/meeting_participant.go @@ -0,0 +1,27 @@ +package model + +import "time" + +// MeetingParticipant 会议参与者模型,对应 meeting_participants 表 +// 一次入会对应一条记录,重入(left_at 已填的情况下再次加入)通过 UPDATE 复用 +// 主持人转让通过事务:旧 host.role=0 + 新 host.role=1 +type MeetingParticipant struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 参与者记录唯一标识 + RoomID int64 `json:"room_id" gorm:"not null;uniqueIndex:uk_meeting_participants_room_user,priority:1;index:idx_meeting_participants_room,priority:1"` // 所属房间 ID,与 user_id 组合唯一 + UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:uk_meeting_participants_room_user,priority:2;index:idx_meeting_participants_user,priority:1"` // 用户 ID + Role int `json:"role" gorm:"not null;default:0"` // 0=普通,1=主持人(MVP 仅此两档),2=联合主持人 + JoinedAt time.Time `json:"joined_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_participants_room,priority:2,sort:asc;index:idx_meeting_participants_user,priority:2,sort:desc"` // 加入时间 + LeftAt *time.Time `json:"left_at" gorm:"type:timestamp(0)"` // 离开时间,NULL 表示仍在会议中 + LeftReason *string `json:"left_reason" gorm:"size:20"` // 离会原因:self / kicked / host_end / empty_ttl / disconnect + Duration int `json:"duration" gorm:"not null;default:0"` // 本次参会时长(秒),left_at 写入时同步计算 +} + +// TableName 指定数据库表名 +func (MeetingParticipant) TableName() string { + return "meeting_participants" +} + +// IsActive 参与者当前是否仍在会议中(left_at 为 NULL) +func (p *MeetingParticipant) IsActive() bool { + return p.LeftAt == nil +} diff --git a/backend/go-service/app/meeting/model/meeting_room.go b/backend/go-service/app/meeting/model/meeting_room.go new file mode 100644 index 0000000..bb90861 --- /dev/null +++ b/backend/go-service/app/meeting/model/meeting_room.go @@ -0,0 +1,30 @@ +// Package model 提供 meeting 模块的数据库模型 +package model + +import "time" + +// MeetingRoom 会议房间模型,对应 meeting_rooms 表 +// 记录会议的创建信息、主持人、容量、状态与生命周期时间点 +// 对外使用 RoomCode(XXX-XXX-XXX)寻址,内部使用 ID(主键)寻址 +type MeetingRoom struct { + ID int64 `json:"id" gorm:"primaryKey;autoIncrement"` // 会议房间唯一标识 + RoomCode string `json:"room_code" gorm:"size:20;not null;uniqueIndex"` // 用户可见的会议号 XXX-XXX-XXX,全局唯一 + Title string `json:"title" gorm:"size:200;not null"` // 会议标题 + HostID int64 `json:"host_id" gorm:"not null;index:idx_meeting_rooms_host_status,priority:1"` // 主持人用户 ID + Type int `json:"type" gorm:"not null;default:1"` // 会议类型:1=即时,2=预约 + PasswordHash *string `json:"-" gorm:"size:255"` // 入会密码 bcrypt 哈希,NULL 表示无密码;JSON 始终剥离 + MaxMembers int `json:"max_members" gorm:"not null;default:50"` // 房间最大成员数,MVP 应用层强制 8 + Status int `json:"status" gorm:"not null;default:0;index:idx_meeting_rooms_host_status,priority:2"` // 状态:0=未开始,1=进行中,2=已结束 + ScheduledAt *time.Time `json:"scheduled_at" gorm:"type:timestamp(0)"` // 预约开始时间,即时会议为 NULL + StartedAt *time.Time `json:"started_at" gorm:"type:timestamp(0)"` // 实际开始时间 + EndedAt *time.Time `json:"ended_at" gorm:"type:timestamp(0)"` // 实际结束时间 + EndedReason *string `json:"ended_reason" gorm:"size:20"` // 结束原因:host_ended / empty_ttl / admin_force / system_error + Settings string `json:"settings" gorm:"type:jsonb;not null;default:'{}'"` // JSON 字符串,保存房间级开关 + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0);index:idx_meeting_rooms_host_status,priority:3,sort:desc"` // 创建时间 + UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 更新时间 +} + +// TableName 指定数据库表名 +func (MeetingRoom) TableName() string { + return "meeting_rooms" +} diff --git a/backend/go-service/app/meeting/provider.go b/backend/go-service/app/meeting/provider.go new file mode 100644 index 0000000..8389558 --- /dev/null +++ b/backend/go-service/app/meeting/provider.go @@ -0,0 +1,21 @@ +package meeting + +import ( + "github.com/echochat/backend/app/meeting/controller" + "github.com/echochat/backend/app/meeting/dao" + "github.com/echochat/backend/app/meeting/service" + "github.com/google/wire" +) + +// MeetingSet 会议模块 Wire Provider Set +// 对外暴露: +// - *service.MeetingService —— 业务服务,供未来 ws handler / media-server 回调使用 +// - *controller.MeetingController —— REST API 控制器 +// 依赖的接口 NotifyPusher / UserInfoResolver / OnlineChecker 由上游 wire.Bind 绑定具体实现 +var MeetingSet = wire.NewSet( + dao.NewMeetingRoomDAO, + dao.NewMeetingParticipantDAO, + dao.NewMeetingChatDAO, + service.NewMeetingService, + controller.NewMeetingController, +) diff --git a/backend/go-service/app/meeting/router.go b/backend/go-service/app/meeting/router.go new file mode 100644 index 0000000..b3b1cd1 --- /dev/null +++ b/backend/go-service/app/meeting/router.go @@ -0,0 +1,36 @@ +// Package meeting 会议模块(Phase 2e-2) +package meeting + +import ( + "github.com/echochat/backend/app/meeting/controller" + "github.com/gin-gonic/gin" +) + +// RegisterRoutes 注册 meeting 模块的全部前台路由 +// 所有接口统一挂载在 /api/v1/meeting/* 前缀下,均需 JWT 认证 +// 设计文档 §6.2 的 12 个接口 Task 4 骨架阶段仅搭路由层,业务实现留待 Task 5/6/7 +func RegisterRoutes(r *gin.Engine, ctrl *controller.MeetingController, jwtAuth gin.HandlerFunc) { + authed := r.Group("/api/v1/meeting") + authed.Use(jwtAuth) + { + // 会议房间生命周期 + authed.POST("/rooms", ctrl.CreateRoom) + authed.GET("/rooms", ctrl.ListMyMeetings) + authed.GET("/rooms/:code", ctrl.GetRoom) + authed.POST("/rooms/:code/join", ctrl.JoinRoom) + authed.POST("/rooms/:code/leave", ctrl.LeaveRoom) + authed.POST("/rooms/:code/end", ctrl.EndRoom) + + // 主持人四件套(Task 7) + authed.POST("/rooms/:code/transfer-host", ctrl.TransferHost) + authed.POST("/rooms/:code/kick", ctrl.KickMember) + + // 邀请(Task 5) + authed.POST("/rooms/:code/invite", ctrl.InviteUsers) + authed.POST("/invites/:token/redeem", ctrl.RedeemInvite) + + // 会议内聊天(Task 6) + authed.POST("/rooms/:code/chats", ctrl.SendChat) + authed.GET("/rooms/:code/chats", ctrl.ListChats) + } +} diff --git a/backend/go-service/app/meeting/service/interfaces.go b/backend/go-service/app/meeting/service/interfaces.go new file mode 100644 index 0000000..1e1742d --- /dev/null +++ b/backend/go-service/app/meeting/service/interfaces.go @@ -0,0 +1,31 @@ +// Package service 提供 meeting 模块的业务逻辑 +package service + +import ( + "context" + + authModel "github.com/echochat/backend/app/auth/model" + notifyService "github.com/echochat/backend/app/notify/service" +) + +// NotifyPusher 通知推送接口 +// 由 notify.service.NotifyService 隐式实现,供 meeting 模块注入使用 +// 会议邀请、主持人变更、踢出会议等事件通过此接口落地通知中心 +type NotifyPusher interface { + Push(ctx context.Context, payload *notifyService.PushPayload) + PushBatch(ctx context.Context, payloads []*notifyService.PushPayload) +} + +// UserInfoResolver 查询用户昵称 / 头像的接口 +// 由 contact.FriendshipDAO 隐式实现(已有 GetUsersByIDs 方法) +// 用于拉取会议邀请中主持人信息、成员列表头像等 +type UserInfoResolver interface { + GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error) +} + +// OnlineChecker 查询用户在线状态的接口 +// 由 ws.OnlineService 隐式实现(签名对齐现有实现:不返回 error,查询失败按离线处理) +// 用于会议邀请时判断收件人是否在线(离线则降级为仅通知入库,上线时通过未读补偿推送) +type OnlineChecker interface { + IsOnline(ctx context.Context, userID int64) bool +} diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go new file mode 100644 index 0000000..2a34178 --- /dev/null +++ b/backend/go-service/app/meeting/service/meeting_service.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "errors" + + "github.com/echochat/backend/app/meeting/dao" + "github.com/echochat/backend/app/meeting/model" + "github.com/echochat/backend/pkg/ws" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// Task 5 会继续填充具体业务逻辑时使用的哨兵错误,此处先集中定义占位 +// 命名与 group/notify 模块的 Err* 风格一致,控制器可直接 errors.Is 识别 +var ( + ErrMeetingNotFound = errors.New("会议不存在") + ErrMeetingEnded = errors.New("会议已结束") + ErrMeetingFull = errors.New("会议已满员") + ErrMeetingPasswordWrong = errors.New("会议密码错误") + ErrNotInMeeting = errors.New("你不在此会议中") + ErrAlreadyInOtherMeeting = errors.New("你当前已在其他会议中") + ErrNotMeetingHost = errors.New("仅主持人可执行此操作") + ErrInviteTokenInvalid = errors.New("邀请链接已失效") + ErrRoomCodeConflict = errors.New("会议号生成冲突,请重试") + ErrNotImplemented = errors.New("功能尚未实现") +) + +// MeetingService 会议业务服务 +// Task 4 骨架阶段:仅完成依赖组装和方法签名占位,具体业务逻辑留待 Task 5/6/7 填充 +// 方法返回 ErrNotImplemented,Controller 将其映射为 501 响应,便于 Postman 与前端联调前观察路由完整性 +type MeetingService struct { + roomDAO *dao.MeetingRoomDAO + participantDAO *dao.MeetingParticipantDAO + chatDAO *dao.MeetingChatDAO + + db *gorm.DB + redis *redis.Client + pubsub *ws.PubSub + + notifyPusher NotifyPusher + userResolver UserInfoResolver + onlineChecker OnlineChecker +} + +// NewMeetingService 创建 MeetingService 实例 +// 依赖通过构造函数注入,接口依赖由上游 Wire 绑定到具体实现 +func NewMeetingService( + roomDAO *dao.MeetingRoomDAO, + participantDAO *dao.MeetingParticipantDAO, + chatDAO *dao.MeetingChatDAO, + db *gorm.DB, + redis *redis.Client, + pubsub *ws.PubSub, + notifyPusher NotifyPusher, + userResolver UserInfoResolver, + onlineChecker OnlineChecker, +) *MeetingService { + return &MeetingService{ + roomDAO: roomDAO, + participantDAO: participantDAO, + chatDAO: chatDAO, + db: db, + redis: redis, + pubsub: pubsub, + notifyPusher: notifyPusher, + userResolver: userResolver, + onlineChecker: onlineChecker, + } +} + +// ====== 会议生命周期(Task 5 实现) ====== + +// CreateRoom 创建会议房间 +// Task 5:生成 room_code、bcrypt 密码、持久化 + 写 Redis room 快照 +func (s *MeetingService) CreateRoom(ctx context.Context, hostID int64, title, password string, maxMembers int, settings map[string]interface{}) (*model.MeetingRoom, error) { + return nil, ErrNotImplemented +} + +// GetRoomByCode 通过会议号查询房间详情(含当前成员列表) +func (s *MeetingService) GetRoomByCode(ctx context.Context, code string) (*model.MeetingRoom, error) { + return nil, ErrNotImplemented +} + +// JoinRoom 用户加入会议(校验密码 / 容量 / 单点参会) +func (s *MeetingService) JoinRoom(ctx context.Context, userID int64, code, password string) (*model.MeetingParticipant, error) { + return nil, ErrNotImplemented +} + +// LeaveRoom 用户主动离会 +// 若离开者是 host 且房间内还有其他成员,触发主持人转让(最早加入者接任) +// 若离开后房间空,设置 Redis room TTL=300s 等待重入复用 +func (s *MeetingService) LeaveRoom(ctx context.Context, userID int64, code string) error { + return ErrNotImplemented +} + +// EndRoom 主持人主动结束会议(status=ended + 全员 left_at + 触发 mediasoup 清理) +func (s *MeetingService) EndRoom(ctx context.Context, userID int64, code string) error { + return ErrNotImplemented +} + +// TransferHost 主持人转让(仅当前 host 可调用) +func (s *MeetingService) TransferHost(ctx context.Context, operatorID int64, code string, newHostID int64) error { + return ErrNotImplemented +} + +// KickMember 主持人踢出成员 +func (s *MeetingService) KickMember(ctx context.Context, operatorID int64, code string, targetUserID int64) error { + return ErrNotImplemented +} + +// ListMyMeetings 我主持的 / 我参与的会议列表(含分页) +func (s *MeetingService) ListMyMeetings(ctx context.Context, userID int64, role string, offset, limit int) ([]model.MeetingRoom, int64, error) { + return nil, 0, ErrNotImplemented +} + +// ====== 邀请链接(Task 5) ====== + +// CreateInviteToken 生成邀请链接 Token 并写 Redis(TTL 600s) +func (s *MeetingService) CreateInviteToken(ctx context.Context, inviterID int64, code string, inviteeID int64) (string, error) { + return "", ErrNotImplemented +} + +// RedeemInviteToken 点击邀请链接时兑换 Token(校验 + 删除) +func (s *MeetingService) RedeemInviteToken(ctx context.Context, userID int64, token string) (string, error) { + return "", ErrNotImplemented +} + +// InviteUsers 主持人邀请用户(批量,走 notify.Push 发送 meeting_invite 通知) +func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code string, inviteeIDs []int64, groupIDs []int64) error { + return ErrNotImplemented +} + +// ====== 会议内聊天(Task 6) ====== + +// SendChatMessage 写入会议聊天 + 向房间内所有成员广播 WS meeting.chat.message +func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code, content string) (*model.MeetingChat, error) { + return nil, ErrNotImplemented +} + +// ListChatMessages 加载会议聊天历史(游标分页) +func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, code string, afterID int64, limit int) ([]model.MeetingChat, error) { + return nil, ErrNotImplemented +} diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go index b889b37..2250b2d 100644 --- a/backend/go-service/app/provider/provider.go +++ b/backend/go-service/app/provider/provider.go @@ -11,6 +11,8 @@ import ( groupController "github.com/echochat/backend/app/group/controller" imController "github.com/echochat/backend/app/im/controller" imHandler "github.com/echochat/backend/app/im/handler" + meetingController "github.com/echochat/backend/app/meeting/controller" + meetingService "github.com/echochat/backend/app/meeting/service" notifyController "github.com/echochat/backend/app/notify/controller" notifyService "github.com/echochat/backend/app/notify/service" notifyTask "github.com/echochat/backend/app/notify/task" @@ -52,6 +54,8 @@ type App struct { NotifyService *notifyService.NotifyService // 通知业务服务(兼 Pusher、ConnectHook) NotifyController *notifyController.NotificationController // 通知控制器 NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务 + MeetingService *meetingService.MeetingService // 会议业务服务(Task 4 骨架) + MeetingController *meetingController.MeetingController // 会议控制器(Task 4 骨架,handler 返回 501) } // NewApp 创建应用实例 @@ -81,6 +85,8 @@ func NewApp( notifySvc *notifyService.NotifyService, notifyCtrl *notifyController.NotificationController, notifyCleanup *notifyTask.CleanupTask, + meetingSvc *meetingService.MeetingService, + meetingCtrl *meetingController.MeetingController, ) *App { wsHandler.SetOfflinePusher(offlinePusher) wsHandler.SetNotifyConnectHook(notifySvc) @@ -111,6 +117,8 @@ func NewApp( NotifyService: notifySvc, NotifyController: notifyCtrl, NotifyCleanupTask: notifyCleanup, + MeetingService: meetingSvc, + MeetingController: meetingCtrl, } } diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index 3fa8937..5f8fbe8 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -17,6 +17,8 @@ import ( imApp "github.com/echochat/backend/app/im" imDAO "github.com/echochat/backend/app/im/dao" imService "github.com/echochat/backend/app/im/service" + meetingApp "github.com/echochat/backend/app/meeting" + meetingService "github.com/echochat/backend/app/meeting/service" notifyApp "github.com/echochat/backend/app/notify" notifyService "github.com/echochat/backend/app/notify/service" wsApp "github.com/echochat/backend/app/ws" @@ -36,6 +38,7 @@ func InitializeApp(cfg *config.Config) (*App, error) { fileApp.FileSet, groupApp.GroupSet, notifyApp.NotifySet, + meetingApp.MeetingSet, wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)), wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)), wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)), @@ -48,6 +51,9 @@ func InitializeApp(cfg *config.Config) (*App, error) { wire.Bind(new(notifyService.UserInfoResolver), new(*contactDAO.FriendshipDAO)), wire.Bind(new(contactService.NotifyPusher), new(*notifyService.NotifyService)), wire.Bind(new(groupService.NotifyPusher), new(*notifyService.NotifyService)), + wire.Bind(new(meetingService.NotifyPusher), new(*notifyService.NotifyService)), + wire.Bind(new(meetingService.UserInfoResolver), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(meetingService.OnlineChecker), new(*wsApp.OnlineService)), ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index 11bb1dc..ae65165 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -15,17 +15,20 @@ import ( "github.com/echochat/backend/app/auth/service" controller3 "github.com/echochat/backend/app/contact/controller" dao3 "github.com/echochat/backend/app/contact/dao" - service3 "github.com/echochat/backend/app/contact/service" + service4 "github.com/echochat/backend/app/contact/service" controller4 "github.com/echochat/backend/app/file/controller" - service4 "github.com/echochat/backend/app/file/service" + service5 "github.com/echochat/backend/app/file/service" controller5 "github.com/echochat/backend/app/group/controller" dao4 "github.com/echochat/backend/app/group/dao" - service5 "github.com/echochat/backend/app/group/service" - imApp "github.com/echochat/backend/app/im" + service6 "github.com/echochat/backend/app/group/service" + "github.com/echochat/backend/app/im" + controller7 "github.com/echochat/backend/app/meeting/controller" + dao6 "github.com/echochat/backend/app/meeting/dao" + service7 "github.com/echochat/backend/app/meeting/service" controller6 "github.com/echochat/backend/app/notify/controller" dao5 "github.com/echochat/backend/app/notify/dao" - service6 "github.com/echochat/backend/app/notify/service" - task2 "github.com/echochat/backend/app/notify/task" + service3 "github.com/echochat/backend/app/notify/service" + "github.com/echochat/backend/app/notify/task" "github.com/echochat/backend/app/ws" "github.com/echochat/backend/config" "github.com/echochat/backend/pkg/db" @@ -69,46 +72,37 @@ func InitializeApp(cfg *config.Config) (*App, error) { onlineController := controller2.NewOnlineController(onlineManageService) contactManageService := service2.NewContactManageService(gormDB) contactManageController := controller2.NewContactManageController(contactManageService) - handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService) - friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) - - // Notify 模块初始化(contact/group 依赖 NotifyPusher) - notificationDAO := dao5.NewNotificationDAO(gormDB) - notifyService := service6.NewNotifyService(notificationDAO, pubSub, friendshipDAO) - notificationController := controller6.NewNotificationController(notifyService) - notifyCleanupTask := task2.NewCleanupTask(notificationDAO) - - contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, onlineService, notifyService) - contactController := controller3.NewContactController(contactService) - - // IM 模块初始化 - conversationDAO := imApp.ProvideConversationDAO(gormDB) - messageDAO := imApp.ProvideMessageDAO(gormDB) groupDAO := dao4.NewGroupDAO(gormDB) - messageReadDAO := dao4.NewMessageReadDAO(gormDB) - imService := imApp.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO, groupDAO, messageReadDAO) - imEventHandler := imApp.ProvideIMEventHandler(imService, hub) - offlinePusher := imApp.ProvideOfflinePusher(imService, conversationDAO, pubSub) - imController := imApp.ProvideIMController(imService) - - // File 模块初始化 - fileService := service4.NewFileService(minioClient, minioConfig) - fileController := controller4.NewFileController(fileService) - - // Group 模块初始化 - joinRequestDAO := dao4.NewJoinRequestDAO(gormDB) - groupService := service5.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO, notifyService) - groupController := controller5.NewGroupController(groupService) - - // Admin 群组管理初始化 groupManageService := service2.NewGroupManageService(gormDB, groupDAO) groupManageController := controller2.NewGroupManageController(groupManageService) - - // Admin 消息管理初始化 messageManageDAO := dao2.NewMessageManageDAO(gormDB) + conversationDAO := im.ProvideConversationDAO(gormDB) messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub) messageManageController := controller2.NewMessageManageController(messageManageService) - - app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, imEventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, notifyCleanupTask) + handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService) + friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) + notificationDAO := dao5.NewNotificationDAO(gormDB) + notifyService := service3.NewNotifyService(notificationDAO, pubSub, friendshipDAO) + contactService := service4.NewContactService(friendshipDAO, friendGroupDAO, onlineService, notifyService) + contactController := controller3.NewContactController(contactService) + messageDAO := im.ProvideMessageDAO(gormDB) + messageReadDAO := dao4.NewMessageReadDAO(gormDB) + imService := im.ProvideIMService(conversationDAO, messageDAO, pubSub, client, friendshipDAO, friendshipDAO, groupDAO, messageReadDAO) + imController := im.ProvideIMController(imService) + eventHandler := im.ProvideIMEventHandler(imService, hub) + offlinePusher := im.ProvideOfflinePusher(imService, conversationDAO, pubSub) + fileService := service5.NewFileService(minioClient, minioConfig) + fileController := controller4.NewFileController(fileService) + joinRequestDAO := dao4.NewJoinRequestDAO(gormDB) + groupService := service6.NewGroupService(groupDAO, joinRequestDAO, friendshipDAO, pubSub, messageDAO, notifyService) + groupController := controller5.NewGroupController(groupService) + notificationController := controller6.NewNotificationController(notifyService) + cleanupTask := task.NewCleanupTask(notificationDAO) + meetingRoomDAO := dao6.NewMeetingRoomDAO(gormDB) + meetingParticipantDAO := dao6.NewMeetingParticipantDAO(gormDB) + meetingChatDAO := dao6.NewMeetingChatDAO(gormDB) + meetingService := service7.NewMeetingService(meetingRoomDAO, meetingParticipantDAO, meetingChatDAO, gormDB, client, pubSub, notifyService, friendshipDAO, onlineService) + meetingController := controller7.NewMeetingController(meetingService) + app := NewApp(cfg, gormDB, client, minioClient, authService, authController, adminAuthController, userManageController, onlineController, contactManageController, groupManageController, messageManageController, handler, hub, pubSub, onlineService, contactController, imController, eventHandler, offlinePusher, fileController, groupController, notifyService, notificationController, cleanupTask, meetingService, meetingController) return app, nil } diff --git a/backend/go-service/go.sum b/backend/go-service/go.sum index b9fb081..90ad5fd 100644 --- a/backend/go-service/go.sum +++ b/backend/go-service/go.sum @@ -50,6 +50,8 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= diff --git a/backend/go-service/router/router.go b/backend/go-service/router/router.go index d7b4fe6..8eec19b 100644 --- a/backend/go-service/router/router.go +++ b/backend/go-service/router/router.go @@ -12,6 +12,7 @@ import ( fileApp "github.com/echochat/backend/app/file" groupApp "github.com/echochat/backend/app/group" imApp "github.com/echochat/backend/app/im" + meetingApp "github.com/echochat/backend/app/meeting" notifyApp "github.com/echochat/backend/app/notify" "github.com/echochat/backend/app/provider" wsApp "github.com/echochat/backend/app/ws" @@ -44,6 +45,5 @@ func Setup(engine *gin.Engine, app *provider.App) { fileApp.RegisterRoutes(engine, app.FileController, jwtAuth) groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth) notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth) - - // [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth) + meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth) } diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql index 55f3c12..5e550e2 100644 --- a/deploy/docker/postgres/init.sql +++ b/deploy/docker/postgres/init.sql @@ -372,3 +372,101 @@ CREATE INDEX idx_notify_user_time ON notify_notifications (user_id, created_at D CREATE INDEX idx_notify_user_unread ON notify_notifications (user_id, is_read) WHERE is_read = FALSE; -- 清理任务索引(按创建时间扫描旧数据) CREATE INDEX idx_notify_created_at ON notify_notifications (created_at); + +-- ============================================================ +-- Phase 2e-2 会议 MVP(设计文档 §5.1) +-- meeting_rooms 会议房间 +-- meeting_participants 参与者记录 +-- meeting_chats 会议内文字聊天 +-- 所有时间字段沿用项目风格 TIMESTAMP(0)(而非设计草案的 TIMESTAMPTZ),与 +-- auth_users / im_messages 等表保持一致;Go 侧 model 搭配 gorm:"type:timestamp(0)"。 +-- ============================================================ + +CREATE TABLE meeting_rooms ( + id BIGSERIAL PRIMARY KEY, + room_code VARCHAR(20) UNIQUE NOT NULL, + title VARCHAR(200) NOT NULL, + host_id BIGINT NOT NULL REFERENCES auth_users(id), + type SMALLINT NOT NULL DEFAULT 1, + password_hash VARCHAR(255) DEFAULT NULL, + max_members INT NOT NULL DEFAULT 50, + status SMALLINT NOT NULL DEFAULT 0, + scheduled_at TIMESTAMP(0) DEFAULT NULL, + started_at TIMESTAMP(0) DEFAULT NULL, + ended_at TIMESTAMP(0) DEFAULT NULL, + ended_reason VARCHAR(20) DEFAULT NULL, + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE meeting_rooms IS '会议房间表'; +COMMENT ON COLUMN meeting_rooms.room_code IS '用户可见的会议号 XXX-XXX-XXX,全局唯一'; +COMMENT ON COLUMN meeting_rooms.title IS '会议标题'; +COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_rooms.type IS '会议类型:1=即时(MVP 仅此),2=预约(Phase 2e-3)'; +COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希,NULL 表示无密码'; +COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数;MVP 应用层强制 8,schema 默认 50 供后续扩展'; +COMMENT ON COLUMN meeting_rooms.status IS '0=未开始(仅预约),1=进行中,2=已结束'; +COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; +COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间,host 首次加入时回填'; +COMMENT ON COLUMN meeting_rooms.ended_at IS '实际结束时间'; +COMMENT ON COLUMN meeting_rooms.ended_reason IS '结束原因:host_ended / empty_ttl / admin_force / system_error'; +COMMENT ON COLUMN meeting_rooms.settings IS 'JSON 配置:{mute_on_join, allow_chat, record_enabled 等}'; + +-- 主持人 + 状态的列表查询(如"我主持的进行中会议") +CREATE INDEX idx_meeting_rooms_host_status ON meeting_rooms (host_id, status, created_at DESC); +-- 会议号快速查找(唯一约束已自动建索引,冗余索引已移除;入会查询走 UNIQUE) + +-- ============================================================ +-- meeting_participants: 参与者记录(出入场完整轨迹) +-- ON DELETE CASCADE:room 删除时自动清理参与者 +-- UNIQUE(room_id, user_id):同一用户在同一房间只有一条记录(重入仅更新 left_at=NULL) +-- ============================================================ +CREATE TABLE meeting_participants ( + id BIGSERIAL PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + role SMALLINT NOT NULL DEFAULT 0, + joined_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + left_at TIMESTAMP(0) DEFAULT NULL, + left_reason VARCHAR(20) DEFAULT NULL, + duration INT NOT NULL DEFAULT 0, + UNIQUE (room_id, user_id) +); + +COMMENT ON TABLE meeting_participants IS '会议参与者记录(一次入会一行,重入更新)'; +COMMENT ON COLUMN meeting_participants.room_id IS '所属会议房间 ID,引用 meeting_rooms.id,CASCADE 删除'; +COMMENT ON COLUMN meeting_participants.user_id IS '参与者用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_participants.role IS '0=普通,1=主持人(MVP 仅此两档),2=联合主持人(第二期)'; +COMMENT ON COLUMN meeting_participants.joined_at IS '加入时间,记录创建即代表加入'; +COMMENT ON COLUMN meeting_participants.left_at IS '离开时间,NULL 表示仍在会议中'; +COMMENT ON COLUMN meeting_participants.left_reason IS '离会原因:self / kicked / host_end / empty_ttl / disconnect'; +COMMENT ON COLUMN meeting_participants.duration IS '本次参会时长(秒),left_at 写入时同步计算'; + +-- 按房间查询成员(按加入顺序正序,便于"最早加入者接任主持人") +CREATE INDEX idx_meeting_participants_room ON meeting_participants (room_id, joined_at ASC); +-- 按用户查询历史("我的会议记录") +CREATE INDEX idx_meeting_participants_user ON meeting_participants (user_id, joined_at DESC); + +-- ============================================================ +-- meeting_chats: 会议内文字聊天(独立于 im_messages) +-- 清理策略:每日扫描 meeting_rooms.status=2 AND ended_at < NOW() - INTERVAL '24 hours' +-- 批量 DELETE FROM meeting_chats WHERE room_id IN (...) +-- ============================================================ +CREATE TABLE meeting_chats ( + id BIGSERIAL PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + content TEXT NOT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE meeting_chats IS '会议内文字聊天消息(会议结束 24 小时后批量清理)'; +COMMENT ON COLUMN meeting_chats.room_id IS '所属会议房间 ID,引用 meeting_rooms.id,CASCADE 删除'; +COMMENT ON COLUMN meeting_chats.user_id IS '发送者用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_chats.content IS '消息内容(纯文本,MVP 不支持富媒体)'; +COMMENT ON COLUMN meeting_chats.created_at IS '消息发送时间'; + +-- 按房间正序读取(会议室打开时按时间线加载历史聊天) +CREATE INDEX idx_meeting_chats_room_created ON meeting_chats (room_id, created_at ASC); diff --git a/deploy/docker/postgres/phase2e2_migration.sql b/deploy/docker/postgres/phase2e2_migration.sql new file mode 100644 index 0000000..815e5ce --- /dev/null +++ b/deploy/docker/postgres/phase2e2_migration.sql @@ -0,0 +1,88 @@ +-- ============================================================ +-- Phase 2e-2 数据库迁移:会议 MVP(meeting_rooms / meeting_participants / meeting_chats) +-- 执行环境:在已有 Phase 2e-1(通知中心)数据库基础上增量升级 +-- 全部语句使用 IF NOT EXISTS / 幂等形式,可重复执行 +-- 时间字段沿用项目风格 TIMESTAMP(0) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS meeting_rooms ( + id BIGSERIAL PRIMARY KEY, + room_code VARCHAR(20) UNIQUE NOT NULL, + title VARCHAR(200) NOT NULL, + host_id BIGINT NOT NULL REFERENCES auth_users(id), + type SMALLINT NOT NULL DEFAULT 1, + password_hash VARCHAR(255) DEFAULT NULL, + max_members INT NOT NULL DEFAULT 50, + status SMALLINT NOT NULL DEFAULT 0, + scheduled_at TIMESTAMP(0) DEFAULT NULL, + started_at TIMESTAMP(0) DEFAULT NULL, + ended_at TIMESTAMP(0) DEFAULT NULL, + ended_reason VARCHAR(20) DEFAULT NULL, + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE meeting_rooms IS '会议房间表'; +COMMENT ON COLUMN meeting_rooms.room_code IS '用户可见的会议号 XXX-XXX-XXX,全局唯一'; +COMMENT ON COLUMN meeting_rooms.title IS '会议标题'; +COMMENT ON COLUMN meeting_rooms.host_id IS '主持人用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_rooms.type IS '会议类型:1=即时(MVP 仅此),2=预约(Phase 2e-3)'; +COMMENT ON COLUMN meeting_rooms.password_hash IS '入会密码 bcrypt 哈希,NULL 表示无密码'; +COMMENT ON COLUMN meeting_rooms.max_members IS '房间最大成员数;MVP 应用层强制 8,schema 默认 50 供后续扩展'; +COMMENT ON COLUMN meeting_rooms.status IS '0=未开始(仅预约),1=进行中,2=已结束'; +COMMENT ON COLUMN meeting_rooms.scheduled_at IS '预约开始时间,即时会议为 NULL'; +COMMENT ON COLUMN meeting_rooms.started_at IS '实际开始时间,host 首次加入时回填'; +COMMENT ON COLUMN meeting_rooms.ended_at IS '实际结束时间'; +COMMENT ON COLUMN meeting_rooms.ended_reason IS '结束原因:host_ended / empty_ttl / admin_force / system_error'; +COMMENT ON COLUMN meeting_rooms.settings IS 'JSON 配置:{mute_on_join, allow_chat, record_enabled 等}'; + +CREATE INDEX IF NOT EXISTS idx_meeting_rooms_host_status ON meeting_rooms (host_id, status, created_at DESC); + +-- ============================================================ +-- meeting_participants: 参与者记录 +-- ON DELETE CASCADE:room 删除时自动清理参与者 +-- ============================================================ +CREATE TABLE IF NOT EXISTS meeting_participants ( + id BIGSERIAL PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + role SMALLINT NOT NULL DEFAULT 0, + joined_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + left_at TIMESTAMP(0) DEFAULT NULL, + left_reason VARCHAR(20) DEFAULT NULL, + duration INT NOT NULL DEFAULT 0, + UNIQUE (room_id, user_id) +); + +COMMENT ON TABLE meeting_participants IS '会议参与者记录(一次入会一行,重入更新)'; +COMMENT ON COLUMN meeting_participants.room_id IS '所属会议房间 ID,引用 meeting_rooms.id,CASCADE 删除'; +COMMENT ON COLUMN meeting_participants.user_id IS '参与者用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_participants.role IS '0=普通,1=主持人(MVP 仅此两档),2=联合主持人(第二期)'; +COMMENT ON COLUMN meeting_participants.joined_at IS '加入时间,记录创建即代表加入'; +COMMENT ON COLUMN meeting_participants.left_at IS '离开时间,NULL 表示仍在会议中'; +COMMENT ON COLUMN meeting_participants.left_reason IS '离会原因:self / kicked / host_end / empty_ttl / disconnect'; +COMMENT ON COLUMN meeting_participants.duration IS '本次参会时长(秒),left_at 写入时同步计算'; + +CREATE INDEX IF NOT EXISTS idx_meeting_participants_room ON meeting_participants (room_id, joined_at ASC); +CREATE INDEX IF NOT EXISTS idx_meeting_participants_user ON meeting_participants (user_id, joined_at DESC); + +-- ============================================================ +-- meeting_chats: 会议内文字聊天 +-- 清理策略见 CURRENT_STATUS Phase 2e-2 说明 +-- ============================================================ +CREATE TABLE IF NOT EXISTS meeting_chats ( + id BIGSERIAL PRIMARY KEY, + room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES auth_users(id), + content TEXT NOT NULL, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE meeting_chats IS '会议内文字聊天消息(会议结束 24 小时后批量清理)'; +COMMENT ON COLUMN meeting_chats.room_id IS '所属会议房间 ID,引用 meeting_rooms.id,CASCADE 删除'; +COMMENT ON COLUMN meeting_chats.user_id IS '发送者用户 ID,引用 auth_users.id'; +COMMENT ON COLUMN meeting_chats.content IS '消息内容(纯文本,MVP 不支持富媒体)'; +COMMENT ON COLUMN meeting_chats.created_at IS '消息发送时间'; + +CREATE INDEX IF NOT EXISTS idx_meeting_chats_room_created ON meeting_chats (room_id, created_at ASC); diff --git a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md index 62bd86c..85f98b3 100644 --- a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md +++ b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md @@ -225,39 +225,53 @@ flowchart LR - 其余 Minor / Nits(m4/m6~m10、n1~n10)登记至 `CURRENT_STATUS.md` Task 16 收尾清单 - **工作量**:**2 人日**(实际用时吻合估算;审查修复耗时 ~0.3 人日,已含在内) -### Task 3:数据库 DDL + 模型 + DAO +### Task 3:数据库 DDL + 模型 + DAO ✅ - **目标**:PostgreSQL 3 张表落地 + Go 侧 model/dao 完整实现 - **依赖**:T0 - **主要产出**: - - `deploy/docker/postgres/init.sql` 追加 3 张表 + 索引 + COMMENT(对齐设计 §5.1) - - `app/meeting/model/{meeting_room,meeting_participant,meeting_chat}.go` - - `app/meeting/dao/*.go`(含 CRUD + 事务 + 按 code/user 查询 + 软删除等) - - `app/meeting/constants/meeting_status.go`(`StatusPending/Active/Ended` = 0/1/2) - - `app/meeting/constants/meeting_role.go`(`RoleParticipant/Host/CoHost` = 0/1/2,MVP 仅用 0/1) + - `deploy/docker/postgres/init.sql` 追加 3 张表 + 9 索引 + COMMENT(对齐设计 §5.1) + - `deploy/docker/postgres/phase2e2_migration.sql` 增量升级脚本(`IF NOT EXISTS` 幂等,用于已运行环境无损追加) + - `backend/go-service/app/meeting/model/{meeting_room,meeting_participant,meeting_chat}.go`(3 个 model + GORM 复合索引 tag + `IsActive()` 等辅助方法) + - `backend/go-service/app/meeting/dao/*.go`(24 个持久化方法:room 9 + participant 11 + chat 4,含 `JoinRoom` 重入复用事务、`TransferHost` 角色交接事务、`LeaveRoom` 基于 `EXTRACT(EPOCH ...)` 的 DB 端 duration 计算、`FindActiveByUser` 单点参会校验、`MarkEnded` 乐观锁、`ListExpiredForCleanup` 批量扫描) + - `backend/go-service/app/constants/meeting.go`(单文件承载会议类型/状态/角色/结束原因/离会原因/默认配置/WS 事件 8 组常量 + `*Map` 中文映射) - **检查点**: - - `docker compose up postgres -d` 后表结构正确;可手动 `INSERT` 测试数据 - - DAO 单元测试:创建房间 + 参与者加入 + 主持人转让(事务)+ 列表查询 - - 外键约束 `ON DELETE CASCADE` 正常工作(删除房间自动清理参与者/聊天) -- **工作量**:**0.5 人日** + - `docker exec postgres psql < phase2e2_migration.sql` 幂等应用,3 表 + 9 索引 + 外键全部正确 ✅ + - psql 集成脚本跑通 8 场景:CRUD、`room_code` UNIQUE、`(room_id,user_id)` UNIQUE、主持人转让事务(`role=1→0` + `role=0→1` + `host_id` 更新)、聊天写入、`duration=10s` 精确匹配、CASCADE 删除 room 后 participants/chats 残留 0 ✅ + - `go build ./...` / `go vet ./...` / `ReadLints` 零错误 ✅ +- **实际产出 vs 计划差异**(关键风格修正): + 1. **常量目录**:草案写的 `app/meeting/constants/{meeting_status,meeting_role}.go` 与项目实际风格不符;按 `project-context.mdc` 第 11 条「代码风格全局一致(最高优先级)」,采用 `app/constants/meeting.go` 单文件承载所有会议常量(与 `group.go`/`notify.go` 同构)。 + 2. **时间字段**:草案写 `TIMESTAMPTZ`,项目所有表统一 `TIMESTAMP(0)`(见 init.sql),本次改为 `TIMESTAMP(0)` 对齐,Go model 配 `gorm:"type:timestamp(0)"`。 + 3. **冗余索引移除**:草案写 `idx_meeting_rooms_code`,但 `room_code UNIQUE` 已自动建 B-tree,冗余索引已移除。 + 4. **Go 单元测试**:项目 Go 侧 0 个 `_test.go`(沿用"代码审查 + Playwright E2E"验证模式),本次同样不新增 `_test.go`;改用 psql 真库集成脚本覆盖 DAO 核心路径,验证价值等价且避免破坏项目一致性。 +- **工作量**:**0.5 人日(实际 0.5,吻合估算)** -### Task 4:Go 侧 meeting 模块骨架 + Wire +### Task 4:Go 侧 meeting 模块骨架 + Wire ✅(2026-04-21 完成) - **目标**:controller/service/router/provider 空壳搭建 + Wire 绑定完成 - **依赖**:T3 -- **主要产出**: - - `app/meeting/controller/*.go`(空 handler + 路由注册) - - `app/meeting/service/*.go`(空方法签名) - - `app/meeting/service/interfaces.go`(`NotifyPusher` / `UserInfoResolver` 接口) - - `app/meeting/router/meeting_router.go` - - `app/meeting/provider/provider.go` + `wire_gen.go` - - `app/provider/wire.go` 注册 `MeetingSet` + interface 绑定 - - `app/provider/provider.go` `App` 结构体新增字段 -- **检查点**: - - `wire ./app/provider` 生成成功,编译通过 - - `go run cmd/server/main.go` 启动无报错 - - 路由打印包含 `/api/v1/meeting/rooms` 等前缀 -- **工作量**:**0.5 人日** +- **实际产出**: + - `backend/go-service/app/meeting/service/interfaces.go`(25 行):`NotifyPusher` / `UserInfoResolver` / `OnlineChecker` 三接口;`OnlineChecker.IsOnline` 对齐 `ws.OnlineService` 实际签名(单 `bool` 返回) + - `backend/go-service/app/meeting/service/meeting_service.go`(165 行):`MeetingService` + 8 个 sentinel error + 17 个空方法占位(返回 `ErrNotImplemented`) + - `backend/go-service/app/meeting/controller/meeting_controller.go`(150 行):12 个 Gin handler + `responseNotImplemented`(501)+ `requireUserID` 辅助 + - `backend/go-service/app/meeting/router.go`(35 行,**扁平化:没有建 `router/` 子目录**):12 条路由挂 `/api/v1/meeting/*` 并套 `jwtAuth` + - `backend/go-service/app/meeting/provider.go`(22 行):`MeetingSet = wire.NewSet(DAO×3, Service, Controller)` + - `backend/go-service/app/provider/wire.go`(改 +10 行):挂入 `MeetingSet` + 3 条 `wire.Bind` + - `backend/go-service/app/provider/provider.go`(改 +6 行):`App` 加 `MeetingService/MeetingController` 字段 + - `backend/go-service/app/provider/wire_gen.go`(自动重生成 +30 行) + - `backend/go-service/router/router.go`(改 +3 行):`meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth)` + - `backend/go-service/app/admin/provider.go`(改 +6 行):**存量修复** 补齐 `MessageManage{DAO,Service,Controller}` provider +- **实际检查点**: + - `go build ./...` / `go vet ./...` / `wire ./app/provider` 全部零错误 + - `GIN_MODE=debug go run cmd/server/main.go` 启动无报错,`HTTP 服务启动` 日志出现在 `:8085` + - gin 启动日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).XxxRoom-fm (6 handlers)` + - 无 token curl `POST /api/v1/meeting/rooms` / `GET /api/v1/meeting/rooms` / `POST /api/v1/meeting/invites/:token/redeem` → 全部 401 `缺少认证信息`,JWT 中间件生效 +- **实际产出 vs 计划差异**: + - **路由目录扁平化**:计划写 `app/meeting/router/meeting_router.go`,实际为 `app/meeting/router.go`,与项目内 `app/group/router.go` / `app/notify/router.go` 命名一致;`RegisterRoutes(engine, controller, jwtAuth)` 签名保持 + - **provider 目录扁平化**:计划写 `app/meeting/provider/provider.go`,实际为 `app/meeting/provider.go`,与其他模块一致 + - **新增 `OnlineChecker` 接口**(计划未列):未来业务逻辑需要判断被邀请者在线状态进行推送路由选择,提前抽象出来 + - **顺手修复 admin wire 存量 bug**(计划未列):Task 4 重生成 wire 时暴露了 admin 模块 `MessageManage` 系列 provider 缺失的遗留问题,当场补上避免阻塞后续开发 +- **工作量**:**0.5 人日**(实际约 0.4 人日,含存量问题修复约 0.1 人日) ### Task 5:会议 REST 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请) diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 5341a93..0aed296 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,7 +1,7 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-04-21(Phase 2e-2 Task 2 media-server 9 个内部 REST API 完成 + 58 个单测 + 80.89% 覆盖率) -> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-2 ✅ / Task 3-16 待执行) +> **最后更新**:2026-04-21(Phase 2e-2 Task 4 Go meeting 模块 service/controller/router 骨架完成,12 条 `/api/v1/meeting/*` 路由全部注册并通过 JWT 鉴权验证) +> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-4 ✅ / Task 5-16 待执行) > **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生) > **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单) > **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成) @@ -60,6 +60,116 @@ --- +## 🚀 2026-04-21 Phase 2e-2 Task 3 Go meeting 模块数据库 DDL + Model + DAO 完成 + +**交付**:Phase 2e-2 会议 MVP 的三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)完整落地到 PostgreSQL,配套 Go 侧 `app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`;DDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`(增量升级),在真实 postgres 容器跑通 CRUD + UNIQUE 约束 + CASCADE 级联删除 + 主持人转让事务。 + +### 产出文件 + +| 文件 | 行数 | 作用 | +|---|---|---| +| `deploy/docker/postgres/init.sql`(追加) | +119 | 3 张表 DDL + 9 个索引 + COMMENT 全量文档 | +| `deploy/docker/postgres/phase2e2_migration.sql` | 90 | 增量升级脚本(`IF NOT EXISTS` 幂等),用于已运行环境无损追加 | +| `backend/go-service/app/constants/meeting.go` | 110 | 8 组常量:会议类型/状态/角色/结束原因/离会原因/默认配置/WS 事件(与 group/notify 同构) | +| `backend/go-service/app/meeting/model/meeting_room.go` | 30 | `MeetingRoom` 结构体 + GORM 复合索引 tag + `TableName()` | +| `backend/go-service/app/meeting/model/meeting_participant.go` | 27 | `MeetingParticipant` 结构体 + `IsActive()` 辅助 + 联合唯一索引 tag | +| `backend/go-service/app/meeting/model/meeting_chat.go` | 18 | `MeetingChat` 结构体,纯文本 content + 房间聚合索引 | +| `backend/go-service/app/meeting/dao/meeting_room_dao.go` | 170 | 9 个方法:`Create/GetByID/GetByCode/ExistsCode/MarkStarted/MarkEnded/UpdateHost/UpdateSettings/ListByHost/ListExpiredForCleanup` | +| `backend/go-service/app/meeting/dao/meeting_participant_dao.go` | 235 | 11 个方法:`JoinRoom`(含重入复用)、`LeaveRoom`、`LeaveAllActive`、`TransferHost`(事务)、`FindActiveByUser`(JOIN 校验单点参会)、各类列表/计数/角色更新 | +| `backend/go-service/app/meeting/dao/meeting_chat_dao.go` | 85 | 4 个方法:`Create/ListByRoom`(游标分页)/`DeleteByRoomIDs`(清理任务)/`CountByRoom` | + +### 关键设计决策 + +1. **常量目录对齐项目风格(偏离实施计划草案)**:实施计划草案写的是 `app/meeting/constants/{meeting_status,meeting_role}.go`,但项目现有风格是"模块级常量统一放在 `app/constants/.go` 单文件"(见 `app/constants/group.go` / `notify.go`)。按 `project-context.mdc` 第 11 条「代码风格全局一致(最高优先级)」,本次采用 `app/constants/meeting.go` 单文件承载所有会议常量,同步修订实施计划。 +2. **时间字段统一 TIMESTAMP(0)**:设计文档草案用了 `TIMESTAMPTZ`,但项目所有表(`auth_users` / `im_messages` / `notify_notifications`)统一使用 `TIMESTAMP(0)`(见 init.sql),Go model 搭配 `gorm:"type:timestamp(0)"`。本次 DDL 改为 `TIMESTAMP(0)` 保持一致。 +3. **冗余索引移除**:设计文档草案写了 `idx_meeting_rooms_code`,但 `room_code UNIQUE NOT NULL` 已经自动建 B-tree 索引,冗余索引已移除避免双倍维护成本。 +4. **重入复用单条参与者记录**:`JoinRoom` 使用事务,若 (room_id, user_id) 已存在且 `left_at IS NOT NULL` → UPDATE 复用该行(`joined_at=NOW, left_at=NULL, duration=0`);仍活跃则返回 `ErrAlreadyInMeeting` 供上层转 409。避免每次重入写新记录污染审计数据。 +5. **`duration` 使用 SQL 表达式计算**:`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走数据库时间而非 Go 端 `time.Now()`,避免跨时区/NTP 漂移导致负 duration。 +6. **`MarkEnded` 乐观锁**:仅对 `status != ended` 的行 UPDATE,重复结束只保留首次原因,不被覆盖。 +7. **无 Go 单元测试(遵循项目现有风格)**:项目 Go 侧 0 个 `_test.go`,统一用"代码审查 + 真实 postgres psql 验证 + Playwright E2E"三层守护。本次 Task 3 验收用 psql 脚本跑通 8 类场景(创建、UNIQUE 约束 ×2、主持人转让事务、聊天写入、`duration` 精确匹配、CASCADE 清零),全部通过。 + +### 验证记录 + +- `go build ./...` ✅ 零报错 +- `go vet ./...` ✅ 零报错 +- `ReadLints app/meeting/ app/constants/meeting.go` ✅ 零 Lint 问题 +- `docker exec echochat-postgres psql ... < phase2e2_migration.sql` ✅ 全部 `CREATE TABLE/INDEX/COMMENT` 成功 +- `psql -c "\d meeting_*"` ✅ 3 张表结构、9 个索引、所有外键约束(含 `ON DELETE CASCADE`)正确生成 +- psql 集成测试 ✅ 场景汇总: + - `INSERT meeting_rooms` + 重复插 `room_code` → `unique_violation` 触发 + - `INSERT meeting_participants` + 重复 `(room_id,user_id)` → `unique_violation` 触发 + - `UPDATE role=0 WHERE role=1` / `UPDATE role=1 WHERE left_at IS NULL` 事务链 → 主持人转让成功 + - `INSERT meeting_chats ×2` → 2 行写入 + - `UPDATE left_at = NOW()+10s` + `duration = EXTRACT(EPOCH ...)` → `duration=10` 精确匹配 + - `DELETE meeting_rooms` → `participants` 残留 0 / `chats` 残留 0(CASCADE 生效) + +### 下一步 + +- **Task 4**(0.5 人日):Go 侧 `meeting` 模块的 service / controller / router 骨架,完成依赖注入 + 空实现占位,建立 `POST /api/meeting/create` 等路由的握手层。 + +--- + +## 🚀 2026-04-21 Phase 2e-2 Task 4 Go meeting 模块骨架(service / controller / router / wire)完成 + +**交付**:`app/meeting/` 模块 service 层 17 个空方法 + controller 层 12 个 Gin 处理器 + `/api/v1/meeting/*` 路由全局挂载 + Wire 依赖注入全局打通;附带修复 admin 模块 `MessageManageService/Controller` provider 缺失的存量问题;`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;实机启动 server 确认 12 条路由全部注册并通过 JWT 鉴权(未授权返回 401 `缺少认证信息`)。 + +### 产出文件 + +| 文件 | 行数 | 作用 | +|---|---|---| +| `backend/go-service/app/meeting/service/interfaces.go` | 25 | 外部依赖接口抽象:`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`,为后续 Task 5-15 解耦 notify/contact/ws 模块 | +| `backend/go-service/app/meeting/service/meeting_service.go` | 165 | `MeetingService` 结构体 + 8 个 sentinel error(`ErrMeetingNotFound` 等)+ 17 个空方法占位(全部返回 `ErrNotImplemented`),为 Task 5-10 业务逻辑预留挂载点 | +| `backend/go-service/app/meeting/controller/meeting_controller.go` | 150 | `MeetingController` + `responseNotImplemented`(返回 501)+ `requireUserID` 辅助 + 12 个 Gin 处理器,全部返回 501 占位 | +| `backend/go-service/app/meeting/router.go` | 35 | `RegisterRoutes()` 将 12 条路由按设计文档挂到 `/api/v1/meeting/*`,统一套用 `jwtAuth` 中间件 | +| `backend/go-service/app/meeting/provider.go` | 22 | `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`,与其他模块 `Set` 命名一致 | +| `backend/go-service/app/provider/wire.go`(改) | +10 | 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind`:`NotifyPusher→NotifyService`、`UserInfoResolver→FriendshipDAO`、`OnlineChecker→ws.OnlineService` | +| `backend/go-service/app/provider/provider.go`(改) | +6 | `App` struct 新增 `MeetingService` / `MeetingController` 字段 + `NewApp` 形参 | +| `backend/go-service/app/provider/wire_gen.go`(自动生成) | +30 | `wire` 命令自动重生成,按拓扑序串联 meeting 模块依赖 | +| `backend/go-service/router/router.go`(改) | +3 | `meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth)` 挂载 | +| `backend/go-service/app/admin/provider.go`(改) | +6 | **存量修复**:补齐 `MessageManageDAO/Service/Controller` 至 `AdminSet`,修复旧版 wire 未能发现 provider 的 bug | + +### 路由清单(12 条全部验证) + +| 方法 | 路径 | 处理器 | 当前行为 | +|---|---|---|---| +| POST | `/api/v1/meeting/rooms` | `CreateRoom` | 501 NotImplemented | +| GET | `/api/v1/meeting/rooms` | `ListMyMeetings` | 501 | +| GET | `/api/v1/meeting/rooms/:code` | `GetRoom` | 501 | +| POST | `/api/v1/meeting/rooms/:code/join` | `JoinRoom` | 501 | +| POST | `/api/v1/meeting/rooms/:code/leave` | `LeaveRoom` | 501 | +| POST | `/api/v1/meeting/rooms/:code/end` | `EndRoom` | 501 | +| POST | `/api/v1/meeting/rooms/:code/transfer-host` | `TransferHost` | 501 | +| POST | `/api/v1/meeting/rooms/:code/kick` | `KickMember` | 501 | +| POST | `/api/v1/meeting/rooms/:code/invite` | `InviteUsers` | 501 | +| POST | `/api/v1/meeting/invites/:token/redeem` | `RedeemInvite` | 501 | +| POST | `/api/v1/meeting/rooms/:code/chats` | `SendChat` | 501 | +| GET | `/api/v1/meeting/rooms/:code/chats` | `ListChats` | 501 | + +### 关键设计决策 + +- **接口隔离(`interfaces.go`)**:对 notify/contact/ws 只依赖接口而非具体类型,避免后续实现时出现循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单个 `bool`,内部吞噬 error),保持最小改动面。 +- **骨架返回 501(而非 404/200)**:`responseNotImplemented` 统一返回 501 + `ErrNotImplemented` 消息,前端联调/Postman 验证时能明确区分"未实现"与"路由缺失";与 group/contact 模块骨架风格保持一致。 +- **存量问题一并修复**:`admin/provider.go` 漏注册 `MessageManage{DAO,Service,Controller}` 是一个跟 Task 4 无关的 wire 老 bug,本轮顺手修掉,使 `wire ./app/provider` 重生成不再报错;已在 commit 描述中注明。 +- **Wire Bind 方向**:`wire.Bind(new(Interface), new(*ConcreteType))` 遵循"接口依赖指向具体类型"的惯例,与 Phase 2e-1 notify 模块的 Bind 写法保持一致。 +- **路由顺序**:`RegisterRoutes` 中 12 条路由按"会议生命周期 → 成员管理 → 邀请 → 聊天"的业务流排列,与设计文档 §5.3 的清单逐一对应。 + +### 验证执行 + +1. `go build ./...` → 无任何 warning/error +2. `go vet ./...` → 无提示 +3. `go run -mod=mod github.com/google/wire/cmd/wire ./app/provider` → `wire_gen.go` 成功重生成 +4. `GIN_MODE=debug go run cmd/server/main.go` 后台启动 → 日志打印 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).XxxRoom-fm (6 handlers)`,与路由表一一匹配 +5. `curl -X POST http://localhost:8085/api/v1/meeting/rooms`(无 token)→ **401** `{"code":401,"message":"缺少认证信息",...}`,JWT 中间件生效 +6. `curl -X GET http://localhost:8085/api/v1/meeting/rooms` → **401** +7. `curl -X POST http://localhost:8085/api/v1/meeting/invites/abc/redeem` → **401** +8. `pkill -f "go run cmd/server/main.go"` → 进程退出,端口 8085 释放 + +### 下一步 + +- **Task 5**(1.5 人日):`MeetingService.CreateRoom` + `JoinRoom` + `LeaveRoom` + `EndRoom` 核心业务逻辑(6 位会议号生成 + bcrypt 密码校验 + 人数上限 + Redis host 宽限期 Timer 骨架),替换当前 `ErrNotImplemented` 占位。 + +--- + ## 🚀 2026-04-21 Phase 2e-2 Task 2 Router/Transport/Producer/Consumer 核心内部 REST API 完成 **交付**:`media-server/` 的 9 个内部 REST API 全部落地 + zod 请求校验 + AppError 统一错误响应 + observer-close 自清理 + 58 个 vitest 单元/集成测试(覆盖率 **80.89%**),9 接口 happy-path + 6 类错误路径全部手动验证通过。