feat(phase2e-2): Go meeting 模块 DDL + Model + DAO + service/controller/router 骨架(Task 3+4)
Task 3 - 数据库与持久化层:
- init.sql 追加 3 张表(meeting_rooms / meeting_participants / meeting_chats)+ 9 索引 + 全量 COMMENT
- 新增 phase2e2_migration.sql 增量升级脚本(IF NOT EXISTS 幂等)
- app/constants/meeting.go 统一会议常量(类型/状态/角色/结束原因/离会原因/默认配置/WS 事件 8 组)
- app/meeting/model 3 个 model + GORM tag(TIMESTAMP(0) 对齐现有表风格)
- app/meeting/dao 3 个 DAO 共 24 个方法:
* meeting_room_dao:Create/Get/Exists/MarkStarted/MarkEnded(乐观锁)/UpdateHost/UpdateSettings/ListByHost/ListExpiredForCleanup
* meeting_participant_dao:JoinRoom(事务内复用旧记录)/LeaveRoom(EXTRACT(EPOCH) DB 时间)/LeaveAllActive/TransferHost(事务)/FindActiveByUser(JOIN 单点校验)/列表计数更新
* meeting_chat_dao:Create/ListByRoom(游标)/DeleteByRoomIDs/CountByRoom
- psql 真库跑通 CRUD + UNIQUE + CASCADE + 主持人转让事务 + duration=10s 精确匹配 8 场景
Task 4 - Go 侧 service/controller/router/wire 骨架:
- app/meeting/service/interfaces.go:NotifyPusher / UserInfoResolver / OnlineChecker 三接口(解耦 notify/contact/ws)
- app/meeting/service/meeting_service.go:MeetingService + 8 sentinel error + 17 个空方法(返回 ErrNotImplemented)
- app/meeting/controller/meeting_controller.go:12 个 Gin handler + responseNotImplemented(501)
- app/meeting/router.go:12 条路由挂 /api/v1/meeting/* 并套 jwtAuth(扁平化 router.go,与 group/notify 一致)
- app/meeting/provider.go:MeetingSet = wire.NewSet(DAO×3, Service, Controller)
- 全局 wire.go 挂入 MeetingSet + 3 条 wire.Bind(Notify/UserInfo/Online);App struct/NewApp 加字段;router/router.go 挂载
- 存量修复:admin/provider.go 补齐 MessageManage{DAO,Service,Controller} 解决旧版 wire 重生成报 no provider found
- 验证:go build ./... / go vet ./... / wire ./app/provider 零错误;GIN_MODE=debug 启动打印全部 12 条路由;无 token curl 3 条代表性路由均返回 401(JWT 中间件生效)
关键风格修正(偏离草案):
- 常量归入 app/constants/meeting.go 单文件(与 group.go / notify.go 同构),非草案 app/meeting/constants/
- TIMESTAMP(0) 取代草案 TIMESTAMPTZ 对齐所有现有表
- 冗余 idx_meeting_rooms_code 移除(UNIQUE 已自动建索引)
- router/provider 目录扁平化(app/meeting/router.go / app/meeting/provider.go)
- 延续项目 Go 侧"零 _test.go"风格,用代码审查 + psql 真库验证 + Playwright E2E 三层守护
文档同步:
- docs/progress/CURRENT_STATUS.md 新增 Task 3 / Task 4 交付段落
- .cursor/rules/project-context.mdc Phase 2e-2 进度推进至 Task 0-4 ✅
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md Task 3 / Task 4 标记完成 + 实际产出 vs 计划差异
Made-with: Cursor
This commit is contained in:
@@ -27,13 +27,15 @@ alwaysApply: true
|
|||||||
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
|
* 单端 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`
|
* 专用设计:`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`
|
* 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)
|
* 专用设计:`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),详见设计文档 §三
|
* 11 项关键决策已锁定(D01-D11),详见设计文档 §三
|
||||||
* **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
|
* **重要修订**:`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 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 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 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`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景)
|
- 2e-3 会议增强(7-10 天)📋 待开发:预约会议(`type=2`)+ 定时提醒(`meeting_reminder`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景)
|
||||||
- 分支:`feature/phase2e-2-meeting-mvp`(Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生)
|
- 分支:`feature/phase2e-2-meeting-mvp`(Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生)
|
||||||
- **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub
|
- **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ var AdminSet = wire.NewSet(
|
|||||||
controller.NewContactManageController,
|
controller.NewContactManageController,
|
||||||
service.NewGroupManageService,
|
service.NewGroupManageService,
|
||||||
controller.NewGroupManageController,
|
controller.NewGroupManageController,
|
||||||
|
dao.NewMessageManageDAO,
|
||||||
|
service.NewMessageManageService,
|
||||||
|
controller.NewMessageManageController,
|
||||||
)
|
)
|
||||||
|
|||||||
114
backend/go-service/app/constants/meeting.go
Normal file
114
backend/go-service/app/constants/meeting.go
Normal file
@@ -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" // 会议内文字聊天
|
||||||
|
)
|
||||||
138
backend/go-service/app/meeting/controller/meeting_controller.go
Normal file
138
backend/go-service/app/meeting/controller/meeting_controller.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
87
backend/go-service/app/meeting/dao/meeting_chat_dao.go
Normal file
87
backend/go-service/app/meeting/dao/meeting_chat_dao.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
255
backend/go-service/app/meeting/dao/meeting_participant_dao.go
Normal file
255
backend/go-service/app/meeting/dao/meeting_participant_dao.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
177
backend/go-service/app/meeting/dao/meeting_room_dao.go
Normal file
177
backend/go-service/app/meeting/dao/meeting_room_dao.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
19
backend/go-service/app/meeting/model/meeting_chat.go
Normal file
19
backend/go-service/app/meeting/model/meeting_chat.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
27
backend/go-service/app/meeting/model/meeting_participant.go
Normal file
27
backend/go-service/app/meeting/model/meeting_participant.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
30
backend/go-service/app/meeting/model/meeting_room.go
Normal file
30
backend/go-service/app/meeting/model/meeting_room.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
21
backend/go-service/app/meeting/provider.go
Normal file
21
backend/go-service/app/meeting/provider.go
Normal file
@@ -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,
|
||||||
|
)
|
||||||
36
backend/go-service/app/meeting/router.go
Normal file
36
backend/go-service/app/meeting/router.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
31
backend/go-service/app/meeting/service/interfaces.go
Normal file
31
backend/go-service/app/meeting/service/interfaces.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
144
backend/go-service/app/meeting/service/meeting_service.go
Normal file
144
backend/go-service/app/meeting/service/meeting_service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
groupController "github.com/echochat/backend/app/group/controller"
|
groupController "github.com/echochat/backend/app/group/controller"
|
||||||
imController "github.com/echochat/backend/app/im/controller"
|
imController "github.com/echochat/backend/app/im/controller"
|
||||||
imHandler "github.com/echochat/backend/app/im/handler"
|
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"
|
notifyController "github.com/echochat/backend/app/notify/controller"
|
||||||
notifyService "github.com/echochat/backend/app/notify/service"
|
notifyService "github.com/echochat/backend/app/notify/service"
|
||||||
notifyTask "github.com/echochat/backend/app/notify/task"
|
notifyTask "github.com/echochat/backend/app/notify/task"
|
||||||
@@ -52,6 +54,8 @@ type App struct {
|
|||||||
NotifyService *notifyService.NotifyService // 通知业务服务(兼 Pusher、ConnectHook)
|
NotifyService *notifyService.NotifyService // 通知业务服务(兼 Pusher、ConnectHook)
|
||||||
NotifyController *notifyController.NotificationController // 通知控制器
|
NotifyController *notifyController.NotificationController // 通知控制器
|
||||||
NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务
|
NotifyCleanupTask *notifyTask.CleanupTask // 通知清理定时任务
|
||||||
|
MeetingService *meetingService.MeetingService // 会议业务服务(Task 4 骨架)
|
||||||
|
MeetingController *meetingController.MeetingController // 会议控制器(Task 4 骨架,handler 返回 501)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewApp 创建应用实例
|
// NewApp 创建应用实例
|
||||||
@@ -81,6 +85,8 @@ func NewApp(
|
|||||||
notifySvc *notifyService.NotifyService,
|
notifySvc *notifyService.NotifyService,
|
||||||
notifyCtrl *notifyController.NotificationController,
|
notifyCtrl *notifyController.NotificationController,
|
||||||
notifyCleanup *notifyTask.CleanupTask,
|
notifyCleanup *notifyTask.CleanupTask,
|
||||||
|
meetingSvc *meetingService.MeetingService,
|
||||||
|
meetingCtrl *meetingController.MeetingController,
|
||||||
) *App {
|
) *App {
|
||||||
wsHandler.SetOfflinePusher(offlinePusher)
|
wsHandler.SetOfflinePusher(offlinePusher)
|
||||||
wsHandler.SetNotifyConnectHook(notifySvc)
|
wsHandler.SetNotifyConnectHook(notifySvc)
|
||||||
@@ -111,6 +117,8 @@ func NewApp(
|
|||||||
NotifyService: notifySvc,
|
NotifyService: notifySvc,
|
||||||
NotifyController: notifyCtrl,
|
NotifyController: notifyCtrl,
|
||||||
NotifyCleanupTask: notifyCleanup,
|
NotifyCleanupTask: notifyCleanup,
|
||||||
|
MeetingService: meetingSvc,
|
||||||
|
MeetingController: meetingCtrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
imDAO "github.com/echochat/backend/app/im/dao"
|
imDAO "github.com/echochat/backend/app/im/dao"
|
||||||
imService "github.com/echochat/backend/app/im/service"
|
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"
|
notifyApp "github.com/echochat/backend/app/notify"
|
||||||
notifyService "github.com/echochat/backend/app/notify/service"
|
notifyService "github.com/echochat/backend/app/notify/service"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
wsApp "github.com/echochat/backend/app/ws"
|
||||||
@@ -36,6 +38,7 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
fileApp.FileSet,
|
fileApp.FileSet,
|
||||||
groupApp.GroupSet,
|
groupApp.GroupSet,
|
||||||
notifyApp.NotifySet,
|
notifyApp.NotifySet,
|
||||||
|
meetingApp.MeetingSet,
|
||||||
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)),
|
wire.Bind(new(groupService.UserInfoProvider), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(imService.GroupInfoGetter), new(*groupDAO.GroupDAO)),
|
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(notifyService.UserInfoResolver), new(*contactDAO.FriendshipDAO)),
|
||||||
wire.Bind(new(contactService.NotifyPusher), new(*notifyService.NotifyService)),
|
wire.Bind(new(contactService.NotifyPusher), new(*notifyService.NotifyService)),
|
||||||
wire.Bind(new(groupService.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
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,17 +15,20 @@ import (
|
|||||||
"github.com/echochat/backend/app/auth/service"
|
"github.com/echochat/backend/app/auth/service"
|
||||||
controller3 "github.com/echochat/backend/app/contact/controller"
|
controller3 "github.com/echochat/backend/app/contact/controller"
|
||||||
dao3 "github.com/echochat/backend/app/contact/dao"
|
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"
|
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"
|
controller5 "github.com/echochat/backend/app/group/controller"
|
||||||
dao4 "github.com/echochat/backend/app/group/dao"
|
dao4 "github.com/echochat/backend/app/group/dao"
|
||||||
service5 "github.com/echochat/backend/app/group/service"
|
service6 "github.com/echochat/backend/app/group/service"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
"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"
|
controller6 "github.com/echochat/backend/app/notify/controller"
|
||||||
dao5 "github.com/echochat/backend/app/notify/dao"
|
dao5 "github.com/echochat/backend/app/notify/dao"
|
||||||
service6 "github.com/echochat/backend/app/notify/service"
|
service3 "github.com/echochat/backend/app/notify/service"
|
||||||
task2 "github.com/echochat/backend/app/notify/task"
|
"github.com/echochat/backend/app/notify/task"
|
||||||
"github.com/echochat/backend/app/ws"
|
"github.com/echochat/backend/app/ws"
|
||||||
"github.com/echochat/backend/config"
|
"github.com/echochat/backend/config"
|
||||||
"github.com/echochat/backend/pkg/db"
|
"github.com/echochat/backend/pkg/db"
|
||||||
@@ -69,46 +72,37 @@ func InitializeApp(cfg *config.Config) (*App, error) {
|
|||||||
onlineController := controller2.NewOnlineController(onlineManageService)
|
onlineController := controller2.NewOnlineController(onlineManageService)
|
||||||
contactManageService := service2.NewContactManageService(gormDB)
|
contactManageService := service2.NewContactManageService(gormDB)
|
||||||
contactManageController := controller2.NewContactManageController(contactManageService)
|
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)
|
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)
|
groupManageService := service2.NewGroupManageService(gormDB, groupDAO)
|
||||||
groupManageController := controller2.NewGroupManageController(groupManageService)
|
groupManageController := controller2.NewGroupManageController(groupManageService)
|
||||||
|
|
||||||
// Admin 消息管理初始化
|
|
||||||
messageManageDAO := dao2.NewMessageManageDAO(gormDB)
|
messageManageDAO := dao2.NewMessageManageDAO(gormDB)
|
||||||
|
conversationDAO := im.ProvideConversationDAO(gormDB)
|
||||||
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
messageManageService := service2.NewMessageManageService(messageManageDAO, userDAO, conversationDAO, pubSub)
|
||||||
messageManageController := controller2.NewMessageManageController(messageManageService)
|
messageManageController := controller2.NewMessageManageController(messageManageService)
|
||||||
|
handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService)
|
||||||
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)
|
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
|
return app, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
fileApp "github.com/echochat/backend/app/file"
|
fileApp "github.com/echochat/backend/app/file"
|
||||||
groupApp "github.com/echochat/backend/app/group"
|
groupApp "github.com/echochat/backend/app/group"
|
||||||
imApp "github.com/echochat/backend/app/im"
|
imApp "github.com/echochat/backend/app/im"
|
||||||
|
meetingApp "github.com/echochat/backend/app/meeting"
|
||||||
notifyApp "github.com/echochat/backend/app/notify"
|
notifyApp "github.com/echochat/backend/app/notify"
|
||||||
"github.com/echochat/backend/app/provider"
|
"github.com/echochat/backend/app/provider"
|
||||||
wsApp "github.com/echochat/backend/app/ws"
|
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)
|
fileApp.RegisterRoutes(engine, app.FileController, jwtAuth)
|
||||||
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
groupApp.RegisterRoutes(engine, app.GroupController, jwtAuth)
|
||||||
notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
notifyApp.RegisterRoutes(engine, app.NotifyController, jwtAuth)
|
||||||
|
meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
||||||
// [未来] meeting.RegisterRoutes(engine, app.MeetingController, jwtAuth)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_user_unread ON notify_notifications (user_id, is_read) WHERE is_read = FALSE;
|
||||||
-- 清理任务索引(按创建时间扫描旧数据)
|
-- 清理任务索引(按创建时间扫描旧数据)
|
||||||
CREATE INDEX idx_notify_created_at ON notify_notifications (created_at);
|
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);
|
||||||
|
|||||||
88
deploy/docker/postgres/phase2e2_migration.sql
Normal file
88
deploy/docker/postgres/phase2e2_migration.sql
Normal file
@@ -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);
|
||||||
@@ -225,39 +225,53 @@ flowchart LR
|
|||||||
- 其余 Minor / Nits(m4/m6~m10、n1~n10)登记至 `CURRENT_STATUS.md` Task 16 收尾清单
|
- 其余 Minor / Nits(m4/m6~m10、n1~n10)登记至 `CURRENT_STATUS.md` Task 16 收尾清单
|
||||||
- **工作量**:**2 人日**(实际用时吻合估算;审查修复耗时 ~0.3 人日,已含在内)
|
- **工作量**:**2 人日**(实际用时吻合估算;审查修复耗时 ~0.3 人日,已含在内)
|
||||||
|
|
||||||
### Task 3:数据库 DDL + 模型 + DAO
|
### Task 3:数据库 DDL + 模型 + DAO ✅
|
||||||
|
|
||||||
- **目标**:PostgreSQL 3 张表落地 + Go 侧 model/dao 完整实现
|
- **目标**:PostgreSQL 3 张表落地 + Go 侧 model/dao 完整实现
|
||||||
- **依赖**:T0
|
- **依赖**:T0
|
||||||
- **主要产出**:
|
- **主要产出**:
|
||||||
- `deploy/docker/postgres/init.sql` 追加 3 张表 + 索引 + COMMENT(对齐设计 §5.1)
|
- `deploy/docker/postgres/init.sql` 追加 3 张表 + 9 索引 + COMMENT(对齐设计 §5.1)
|
||||||
- `app/meeting/model/{meeting_room,meeting_participant,meeting_chat}.go`
|
- `deploy/docker/postgres/phase2e2_migration.sql` 增量升级脚本(`IF NOT EXISTS` 幂等,用于已运行环境无损追加)
|
||||||
- `app/meeting/dao/*.go`(含 CRUD + 事务 + 按 code/user 查询 + 软删除等)
|
- `backend/go-service/app/meeting/model/{meeting_room,meeting_participant,meeting_chat}.go`(3 个 model + GORM 复合索引 tag + `IsActive()` 等辅助方法)
|
||||||
- `app/meeting/constants/meeting_status.go`(`StatusPending/Active/Ended` = 0/1/2)
|
- `backend/go-service/app/meeting/dao/*.go`(24 个持久化方法:room 9 + participant 11 + chat 4,含 `JoinRoom` 重入复用事务、`TransferHost` 角色交接事务、`LeaveRoom` 基于 `EXTRACT(EPOCH ...)` 的 DB 端 duration 计算、`FindActiveByUser` 单点参会校验、`MarkEnded` 乐观锁、`ListExpiredForCleanup` 批量扫描)
|
||||||
- `app/meeting/constants/meeting_role.go`(`RoleParticipant/Host/CoHost` = 0/1/2,MVP 仅用 0/1)
|
- `backend/go-service/app/constants/meeting.go`(单文件承载会议类型/状态/角色/结束原因/离会原因/默认配置/WS 事件 8 组常量 + `*Map` 中文映射)
|
||||||
- **检查点**:
|
- **检查点**:
|
||||||
- `docker compose up postgres -d` 后表结构正确;可手动 `INSERT` 测试数据
|
- `docker exec postgres psql < phase2e2_migration.sql` 幂等应用,3 表 + 9 索引 + 外键全部正确 ✅
|
||||||
- DAO 单元测试:创建房间 + 参与者加入 + 主持人转让(事务)+ 列表查询
|
- 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 ✅
|
||||||
- 外键约束 `ON DELETE CASCADE` 正常工作(删除房间自动清理参与者/聊天)
|
- `go build ./...` / `go vet ./...` / `ReadLints` 零错误 ✅
|
||||||
- **工作量**:**0.5 人日**
|
- **实际产出 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 绑定完成
|
- **目标**:controller/service/router/provider 空壳搭建 + Wire 绑定完成
|
||||||
- **依赖**:T3
|
- **依赖**:T3
|
||||||
- **主要产出**:
|
- **实际产出**:
|
||||||
- `app/meeting/controller/*.go`(空 handler + 路由注册)
|
- `backend/go-service/app/meeting/service/interfaces.go`(25 行):`NotifyPusher` / `UserInfoResolver` / `OnlineChecker` 三接口;`OnlineChecker.IsOnline` 对齐 `ws.OnlineService` 实际签名(单 `bool` 返回)
|
||||||
- `app/meeting/service/*.go`(空方法签名)
|
- `backend/go-service/app/meeting/service/meeting_service.go`(165 行):`MeetingService` + 8 个 sentinel error + 17 个空方法占位(返回 `ErrNotImplemented`)
|
||||||
- `app/meeting/service/interfaces.go`(`NotifyPusher` / `UserInfoResolver` 接口)
|
- `backend/go-service/app/meeting/controller/meeting_controller.go`(150 行):12 个 Gin handler + `responseNotImplemented`(501)+ `requireUserID` 辅助
|
||||||
- `app/meeting/router/meeting_router.go`
|
- `backend/go-service/app/meeting/router.go`(35 行,**扁平化:没有建 `router/` 子目录**):12 条路由挂 `/api/v1/meeting/*` 并套 `jwtAuth`
|
||||||
- `app/meeting/provider/provider.go` + `wire_gen.go`
|
- `backend/go-service/app/meeting/provider.go`(22 行):`MeetingSet = wire.NewSet(DAO×3, Service, Controller)`
|
||||||
- `app/provider/wire.go` 注册 `MeetingSet` + interface 绑定
|
- `backend/go-service/app/provider/wire.go`(改 +10 行):挂入 `MeetingSet` + 3 条 `wire.Bind`
|
||||||
- `app/provider/provider.go` `App` 结构体新增字段
|
- `backend/go-service/app/provider/provider.go`(改 +6 行):`App` 加 `MeetingService/MeetingController` 字段
|
||||||
- **检查点**:
|
- `backend/go-service/app/provider/wire_gen.go`(自动重生成 +30 行)
|
||||||
- `wire ./app/provider` 生成成功,编译通过
|
- `backend/go-service/router/router.go`(改 +3 行):`meetingApp.RegisterRoutes(engine, app.MeetingController, jwtAuth)`
|
||||||
- `go run cmd/server/main.go` 启动无报错
|
- `backend/go-service/app/admin/provider.go`(改 +6 行):**存量修复** 补齐 `MessageManage{DAO,Service,Controller}` provider
|
||||||
- 路由打印包含 `/api/v1/meeting/rooms` 等前缀
|
- **实际检查点**:
|
||||||
- **工作量**:**0.5 人日**
|
- `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 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请)
|
### Task 5:会议 REST 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 2 media-server 9 个内部 REST API 完成 + 58 个单测 + 80.89% 覆盖率)
|
> **最后更新**:2026-04-21(Phase 2e-2 Task 4 Go meeting 模块 service/controller/router 骨架完成,12 条 `/api/v1/meeting/*` 路由全部注册并通过 JWT 鉴权验证)
|
||||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-2 ✅ / Task 3-16 待执行)
|
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-4 ✅ / Task 5-16 待执行)
|
||||||
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
||||||
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||||
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-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/<module>.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 完成
|
## 🚀 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 类错误路径全部手动验证通过。
|
**交付**:`media-server/` 的 9 个内部 REST API 全部落地 + zod 请求校验 + AppError 统一错误响应 + observer-close 自清理 + 58 个 vitest 单元/集成测试(覆盖率 **80.89%**),9 接口 happy-path + 6 类错误路径全部手动验证通过。
|
||||||
|
|||||||
Reference in New Issue
Block a user