From df538e8df6fc752a215e447a24b1cd5a510b5d24 Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Tue, 21 Apr 2026 13:53:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=9B=B4=E6=96=B0---2e-2=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E7=9B=B8=E5=85=B3=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E5=92=8C=E5=AE=9E=E6=96=BD=E6=AD=A5=E9=AA=A4=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=BA=A7=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/project-context.mdc | 9 +- docs/api/README.md | 2 +- docs/api/admin/meeting.md | 8 + docs/api/frontend/meeting.md | 23 + docs/architecture/system-architecture.md | 15 +- .../2026-02-27-echochat-system-design.md | 48 +- docs/plans/2026-04-20-phase2e-design.md | 75 +- docs/plans/2026-04-21-phase2e-2-design.md | 1095 +++++++++++++++++ ...026-04-21-phase2e-2-implementation.plan.md | 539 ++++++++ docs/progress/CURRENT_STATUS.md | 60 +- 10 files changed, 1815 insertions(+), 59 deletions(-) create mode 100644 docs/plans/2026-04-21-phase2e-2-design.md create mode 100644 docs/plans/2026-04-21-phase2e-2-implementation.plan.md diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index ec199cb..f6361dc 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -27,9 +27,12 @@ alwaysApply: true * 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期) * 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md` * API 文档:`docs/api/frontend/notify.md` - - 2e-2 会议 MVP(10-14 天)📋 待开发:mediasoup Node.js 独立服务 + 即时会议(≤8 人)+ 音视频控制 - - 2e-3 会议增强(7-10 天)📋 待开发:预约会议 + 定时提醒 + 会议邀请(复用 2e-1) - - 分支:`feature/phase2e-meeting-notification`(基于 `origin/feature/phase2c-group-read-receipt`) + - 2e-2 会议 MVP(约 17 天)📋 **设计阶段完成,待进入代码开发**:mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机) + * 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task) + * 11 项关键决策已锁定(D01-D11),详见设计文档 §三 + * **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key + - 2e-3 会议增强(7-10 天)📋 待开发:预约会议(`type=2`)+ 定时提醒(`meeting_reminder`)+ 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景) + - 分支:`feature/phase2e-2-meeting-mvp`(Phase 2e-2 专用,从 `feature/phase2c-group-read-receipt` 衍生) - **关键技术锁定**:维持 mediasoup SFU 架构(非 Mesh),前端用 mediasoup-client,信令复用现有 WS Hub - **显式推迟清单**(必须留档,见设计文档 §九): * → Phase 2f:WS Hub 多端连接改造、会议管理后台、通知广播发布 UI、管理端仪表板、操作日志页、系统配置管理、通知分类开关 diff --git a/docs/api/README.md b/docs/api/README.md index e0535ad..ab1b292 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -17,7 +17,7 @@ | [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 | | [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 | | [frontend/group.md](frontend/group.md) | 群聊管理 | ✅ Phase 2c | 16 个 API:建群/管理/成员/角色/禁言/公告/搜索/入群审批 | -| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 | +| [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 Phase 2e-2 设计阶段 | 当前为总设计占位版本;最终 API 清单将由 Phase 2e-2 Task 5 产出(共 12 接口,含创建/加入/离开/结束/详情/列表/邀请/踢人/转让主持人/发起聊天/拉聊天历史/邀请链接兑换)。详见设计文档 `docs/plans/2026-04-21-phase2e-2-design.md` §6.2 | | [frontend/notify.md](frontend/notify.md) | 通知中心 | ✅ Phase 2e-1 | 5 个 API:通知列表(游标分页)/未读数/标记已读/全部已读/管理员广播 + 2 个 WS 事件(notify.new/notify.unread.total) | ### 后台管理端 (`admin/`) diff --git a/docs/api/admin/meeting.md b/docs/api/admin/meeting.md index 0c7de1e..3c7a4c8 100644 --- a/docs/api/admin/meeting.md +++ b/docs/api/admin/meeting.md @@ -5,6 +5,14 @@ --- +## ⚠️ 文档状态说明(2026-04-21 更新) + +**本模块整体推迟到 Phase 2f 实施**,Phase 2e-2(会议 MVP)不含管理端功能。以下旧占位接口清单会在 Phase 2f 启动时重新对齐 Phase 2e-2 的实际数据模型(如 `password_hash` / `ended_reason` / `role` 枚举等)再落地。 + +临时性参考:Phase 2e-2 专用设计 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 已为管理端接口预留命名(`/api/v1/admin/meetings` / `/:id` / `/:id/force-close`)。 + +--- + ## 接口列表 | 方法 | 路径 | 权限 | 说明 | diff --git a/docs/api/frontend/meeting.md b/docs/api/frontend/meeting.md index 45b9fd1..44c35f8 100644 --- a/docs/api/frontend/meeting.md +++ b/docs/api/frontend/meeting.md @@ -5,6 +5,29 @@ --- +## ⚠️ 文档状态说明(2026-04-21 更新) + +**本文档为系统总设计阶段的占位版本,下方所列接口清单尚未落地实现。** + +Phase 2e-2 已进入设计阶段,专用设计文档 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 锁定了 **MVP 最终 12 个接口**的路径与行为。与本文旧版接口清单的关键差异: + +| 维度 | 本文档旧版(总设计) | Phase 2e-2 MVP(即将实施) | +|---|---|---| +| 路径前缀 | `/api/v1/meetings/*` | `/api/v1/meeting/rooms/*`(更符合 REST 语义) | +| 范围 | 含预约会议 + 即将开始/历史会议 | MVP 仅即时会议;预约推迟到 Phase 2e-3 | +| 密码存储 | 明文 `password` | bcrypt `password_hash` | +| 主持人控制 | 仅 join/leave | 新增 `kick` / `transfer-host` / 结束会议 / 邀请 | +| 会议内聊天 | 无 | 新增 `POST /:code/chats` + `GET /:code/chats` | +| 邀请链接 | 无 | 新增 `POST /invite-tokens/:token/redeem` | + +**实施指引**: +- **Phase 2e-2 开发时**:请以 Phase 2e-2 专用设计文档 §6.2 为唯一实现口径;Task 5(会议 REST 接口)完成后**全量重写**本文档 +- **其他模块集成时**:请勿照搬本文旧接口路径;若需调用 meeting 模块 API 请先阅读 Phase 2e-2 设计文档 + +以下旧版占位内容保留供历史对照,直到 Phase 2e-2 实施完成后整体替换。 + +--- + ## 接口列表 | 方法 | 路径 | 权限 | 说明 | diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 3f12657..e1795fc 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -94,8 +94,8 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想 | im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b | | group | 群聊管理(建群/加入/退出/角色/禁言/@提醒/群公告/入群审批) | ✅ Phase 2c | | file | 文件上传(MinIO 对象存储 + 通用上传 API) | ✅ Phase 2c | -| meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 | -| notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 | +| meeting | 会议创建/管理、信令转发、mediasoup 资源编排、会议内聊天、主持人控制 | 📋 Phase 2e-2 设计阶段(专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`) | +| notify | 统一通知中心、好友/群聊事件 + 系统广播推送、11 种类型枚举、跨模块 Pusher 接口注入 | ✅ Phase 2e-1(10 种落地 + 2 种预留 `meeting_invite` / `meeting_reminder`) | | admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理 + 群聊管理、会议监控、系统配置) | ✅ Phase 1/2a/2c | **不负责的事情:** 不处理 RTP 媒体数据、不参与音视频转发、不做 WebRTC 协议协商。 @@ -241,11 +241,12 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒 ```yaml services: - go-service: # Go 后端 → :8085 ✅ 已实现(多阶段构建 Dockerfile) - media-server: # mediasoup Node → :3000 + :40000-40100/udp(Phase 3) - postgres: # PostgreSQL 17 → :5432 ✅ 已实现 - redis: # Redis 7 → :6379 ✅ 已实现 - nginx: # 反向代理 → :80/:443(生产环境部署) + go-service: # Go 后端 → :8085 ✅ 已实现(多阶段构建 Dockerfile) + media-server: # mediasoup Node → :3300 + :40000-40199/udp 📋 Phase 2e-2 Task 1/14(TypeScript + Fastify + mediasoup v3) + coturn: # TURN 服务(可选) → :3478/:5349 📋 Phase 2e-2 公网部署 profile=public + postgres: # PostgreSQL 17 → :5432 ✅ 已实现 + redis: # Redis 7 → :6379 ✅ 已实现 + nginx: # 反向代理 → :80/:443 📋 生产环境部署 ``` > Docker Compose 配置文件:`deploy/docker-compose.dev.yml` diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index 714431f..dc5eeeb 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -452,6 +452,12 @@ CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector #### meeting 模块 — 音视频会议 +> **📘 Phase 2e-2 实施修订说明**(2026-04-21):以下为总设计首版 DDL,Phase 2e-2 实施阶段将做 3 处修订,详见 [`2026-04-21-phase2e-2-design.md` §5.1](./2026-04-21-phase2e-2-design.md): +> - `password VARCHAR(50)` → `password_hash VARCHAR(255)`(bcrypt 哈希替换明文) +> - `meeting_rooms` 新增 `ended_reason VARCHAR(20)`、`meeting_participants` 新增 `left_reason VARCHAR(20)` +> - 新增独立表 `meeting_chats`(会议内聊天,24 小时后清理,不写入 `im_messages`) +> - MVP 阶段 `max_members` 应用层强制 8(schema 默认值保留 50 供后续扩展) + ```sql -- ============================================================ -- meeting_rooms: 会议房间表 @@ -1001,29 +1007,41 @@ services: - 设计文档:`docs/plans/2026-03-04-phase2c-design.md` - 实施计划:`docs/plans/2026-03-04-phase2c-implementation.plan.md`(14 个 Task + 10 项测试修复) -#### Phase 2d:消息类型扩展 📋 待规划 +#### Phase 2d:消息类型扩展 ✅ 已完成 - 消息类型扩展(图片/语音/文件消息) - 管理端消息管理功能 +- 详见 `docs/plans/2026-03-04-phase2d-design.md` -#### Phase 2e:会议与通知 🚧 规划完成,拆分为三子阶段(详见 `docs/plans/2026-04-20-phase2e-design.md`) +#### Phase 2e:会议与通知 🚧 拆分为三子阶段(详见 `docs/plans/2026-04-20-phase2e-design.md`) -- **Phase 2e-1:通知系统** 🔜 开发中 +- **Phase 2e-1:通知系统** ✅ 已完成 - 统一通知中心(好友/群聊事件 + 系统广播 + 会议通知类型预留) - - 双通道(持久化入库 + mini-toast) - - 入口:「我的」Tab 顶部铃铛 + 数字徽标 - - 11 种通知类型枚举;30 天保留期;多端已读同步 - - 跨模块 Pusher 接口(contact/group 模块解耦集成) + - 双通道(持久化入库 + WS `notify.new` 实时推送 + mini-toast)+ TabBar「我的」聚合未读红点 + - 入口:「我的」Tab 顶部铃铛 + 数字徽标;通知中心顶部 5 分类 Tab + - 11 种通知类型枚举(10 种本期落地 + `meeting_invite`/`meeting_reminder` 2 种预留);30 天自动清理 + - **单端 WS 架构**(不做多端已读同步,多端改造推迟到 Phase 2f) + - 跨模块 Pusher 接口(contact/group 模块解耦集成);后期接入 meeting 模块(Phase 2e-2) + - 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md` -- **Phase 2e-2:会议 MVP** 📋 待开发 - - mediasoup Node.js 独立媒体服务 + mediasoup-client 前端集成 - - 即时会议(≤ 8 人)、会议号/密码、音视频开关、主持人控制 - - WebSocket 信令复用现有 Hub - - 不含:录制、屏幕共享、预约 +- **Phase 2e-2:会议 MVP** 📋 设计阶段完成(2026-04-21),代码开发待启动 + - mediasoup Node.js 独立 `media-server/` 子项目 + mediasoup-client 前端集成 + - 即时会议(≤ 8 人)、会议号 `XXX-XXX-XXX`、可选密码(**bcrypt**)、音视频开关、主持人四件套(静音/移除/转让/结束) + - **入会前设备预览页**(设备选择 + 本地画面 + 音量检测) + - **三合一加入方式**:手输会议号 + 邀请链接 + 通知中心 `meeting_invite` 卡片 + - WebSocket 信令复用现有 Hub(11 个事件,3 组分类) + - **双态部署**:本机 Docker Compose + 公网 `announcedIp` + coturn(`--profile public`) + - **响应式**:桌面 3×3 / 平板 2×2 / 手机单列+抽屉 + - **会议内聊天**(独立 `meeting_chats` 表,会议结束 24 小时后清理) + - 主持人掉线 2 分钟宽限 + 自动转让(最早加入者)+ 空房 5 分钟 TTL + - 不含:录制、屏幕共享、预约、等候室、锁定会议 + - 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task ≈ 17 人日) + - 11 项关键决策(D01-D11)已锁定,见设计文档 §三 - **Phase 2e-3:会议增强** 📋 待开发 - - 预约会议 + 定时提醒 - - 会议邀请(走 2e-1 `meeting_invite` 通知) - - 入会前设备预览 + - 预约会议(`meeting_rooms.type=2`)+ 定时提醒(`meeting_reminder`) + - 等候室(waiting room)+ 锁定会议(lock) + - 设备预览高级参数(降噪 / 回声消除 / 虚拟背景) + - 注:原计划在 2e-3 的「会议邀请」与「入会前设备预览」已上移到 **Phase 2e-2** #### Phase 2f:管理端扩展(MVP 收尾)📋 待规划 diff --git a/docs/plans/2026-04-20-phase2e-design.md b/docs/plans/2026-04-20-phase2e-design.md index 079e152..dc679c7 100644 --- a/docs/plans/2026-04-20-phase2e-design.md +++ b/docs/plans/2026-04-20-phase2e-design.md @@ -1,9 +1,12 @@ # Phase 2e 设计文档:会议与通知系统 -> **状态:** 🚧 进行中(2e-1 ✅ 已完成,2e-2/2e-3 📋 待开发) -> **分支:** `feature/phase2e-meeting-notification`(基于 `origin/feature/phase2c-group-read-receipt`) +> **状态:** 🚧 进行中(2e-1 ✅ 已完成,2e-2 📋 **设计阶段完成待进入编码**,2e-3 📋 待开发) +> **分支:** `feature/phase2e-meeting-notification`(2e-1);`feature/phase2e-2-meeting-mvp`(2e-2,基于 `origin/feature/phase2c-group-read-receipt`) > **前置依赖:** Phase 2a(联系人 + WS)、Phase 2b(即时通讯)、Phase 2c(群聊+已读)、Phase 2d(消息类型扩展)全部完成 -> **最后更新:** 2026-04-20(2e-1 完成 + 单端架构说明同步) +> **最后更新:** 2026-04-21(§四精简为引用链 + §五缩小 2e-3 范围 + 2e-2 专用设计/实施计划落盘) +> **子阶段专用文档:** +> - 2e-1:`docs/plans/2026-04-20-phase2e-1-design.md` + `docs/plans/2026-04-20-phase2e-1-implementation.plan.md`(✅ 已完成,含 TabBar「我的」未读红点优化) +> - 2e-2:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节)+ `docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task,约 17 人日) --- @@ -15,9 +18,9 @@ 2. **多人音视频会议**:基于 mediasoup SFU 架构,支持即时会议(MVP)→ 预约会议 + 邀请(增强) **核心交付物(按子阶段):** -- **Phase 2e-1 通知系统**(3-4 人日):统一通知中心 + 11 种通知类型预留 + 跨模块 Pusher 接口 -- **Phase 2e-2 会议 MVP**(10-14 人日):mediasoup Node 媒体服务 + 即时会议 + 基础音视频控制(≤8 人) -- **Phase 2e-3 会议增强**(7-10 人日):预约会议 + 会议邀请 + 会议提醒 +- **Phase 2e-1 通知系统**(3-4 人日)✅ 已完成:统一通知中心 + 11 种通知类型预留 + 跨模块 Pusher 接口 + TabBar「我的」未读红点 +- **Phase 2e-2 会议 MVP**(约 17 人日)📋 设计阶段完成:mediasoup Node 媒体服务 + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + 双态部署(本机/公网 coturn)+ 桌面/手机响应式 +- **Phase 2e-3 会议增强**(7-10 人日):预约会议 + 定时提醒 + 等候室/锁定会议 + 设备预览高级参数(降噪/回声/虚拟背景) **不包含(明确推迟):** 见 [§九 后续规划清单](#九后续规划清单必须留档) @@ -228,43 +231,59 @@ export const useNotifyStore = defineStore('notify', () => { --- -## 四、Phase 2e-2 会议 MVP 范围锁定(详细设计待 2e-1 完成后展开) +## 四、Phase 2e-2 会议 MVP 范围锁定(摘要 + 引用专用设计) -### 4.1 范围(硬边界) -- ✅ 即时会议(无预约)、会议号自动生成(格式 `XXX-XXX-XXX`) -- ✅ ≤ 8 人同时参会 -- ✅ 音频 + 视频 开关 -- ✅ 主持人控制:静音他人、移除成员、结束会议 -- ✅ 密码保护(可选) -- ✅ **发起邀请**:仅发起方内嵌"复制会议号"(暂不接通知中心,通知邀请在 2e-3) -- ❌ 不做:录制、屏幕共享、虚拟背景、预约、提醒 +> **📘 详细设计已独立出文档:** +> - [Phase 2e-2 专用设计文档](./2026-04-21-phase2e-2-design.md) — 16 章节完整设计(架构 / 数据模型 / API / WS 信令 / UI/UX / 风险 / 验收) +> - [Phase 2e-2 实施计划](./2026-04-21-phase2e-2-implementation.plan.md) — 17 个 Task 共约 17 人日 +> +> 本节仅保留摘要,作为总路线图的衔接锚点。任何细节调整请以上述两份专用文档为准(本文不再跟随更新)。 + +### 4.1 范围摘要(与专用设计 §2.2 一致) + +- ✅ 即时会议(`XXX-XXX-XXX` 会议号)+ 可选密码(bcrypt)+ 邀请链接 + **通知中心 `meeting_invite`** +- ✅ ≤ 8 人音视频(mediasoup SFU + simulcast 三档) +- ✅ **入会前设备预览页**(摄像头/麦克风/扬声器选择 + 本地预览 + 音量检测) +- ✅ 主持人四件套:静音他人 / 移除成员 / **转让主持人** / 结束会议 +- ✅ 生命周期:host 掉线 2 分钟宽限 + 自动转让(最早加入者)+ 空房 5 分钟 TTL +- ✅ **会议内文字聊天**(独立 `meeting_chats` 表,24 小时后清理) +- ✅ **桌面 + 手机双端响应式**(3×3 / 2×2 / 单列+抽屉) +- ✅ **双态部署**:本机 Docker Compose + 公网 `announcedIp` + coturn(`--profile public`) +- ❌ 预约会议 / 提醒 / 等候室 / 锁定 → Phase 2e-3 +- ❌ 屏幕共享 / 录制 / 虚拟背景 → 第二期 +- ❌ 管理端会议列表 / 详情 / 强制关闭 → Phase 2f ### 4.2 技术选型(锁定) | 组件 | 选型 | 备注 | |---|---|---| -| 媒体服务 | `media-server/` 独立 Node.js 进程 + mediasoup v3 | 严格遵循原系统设计 | -| 客户端库 | `mediasoup-client` JS SDK | 与服务端强绑定 | -| 信令通道 | **复用现有 WebSocket Hub** | 不开新通道,复用 `Hub.RegisterEvent/DispatchEvent` | -| Go ↔ Node | HTTP REST(docker-compose 内网) | 9 个 API:Router/Transport/Producer/Consumer 生命周期 | -| 数据库表 | `meeting_rooms` + `meeting_participants` | 已在总设计文档定义 | -| Redis 键 | `echo:meeting:room:{code}` + `echo:meeting:members:{code}` + `echo:meeting:transport:{code}` | 已在总设计文档定义 | +| 媒体服务目录 | `media-server/` 根级子项目(与 `backend/` / `frontend/` / `admin/` 并列) | TypeScript + Fastify + mediasoup v3 | +| 客户端库 | `mediasoup-client` JS SDK | `markRaw` 包裹避免 Pinia 响应式代理 | +| 信令通道 | **复用现有 WebSocket Hub** | 新增 `MeetingSignalDispatcher` 接口注入 | +| Go ↔ Node | HTTP REST(容器内网,`X-Internal-Token` 鉴权) | 9 个 API:Router/Transport/Producer/Consumer 生命周期 | +| 数据库表 | `meeting_rooms` + `meeting_participants` + `meeting_chats` | 在总设计基础上修订:`password_hash`(bcrypt) + `ended_reason` + `meeting_chats` 新表 | +| Redis 键 | `echo:meeting:room:{code}` / `members:{code}` / `transport:{code}:{user_id}` / `invite:{token}` / `host_grace:{code}` | 后两个为新增 | -### 4.3 WebSocket 信令事件(11 个) +### 4.3 WS 信令事件(11 个,详见专用设计 §6.3) -**房间事件**:`meeting.room.join / leave / info` -**成员事件**:`meeting.member.join / leave / mute / video` -**媒体事件**:`meeting.transport.create / connect`、`meeting.produce.start / stop`、`meeting.consume.start / resume` +房间组 3 个 + 成员组 4 个 + 媒体组 5 个,命名统一 `meeting.*` 前缀。 + +### 4.4 架构变化关键点(衔接 Phase 2e-1) + +- **Go 主控 + Node 无状态包装**:所有权威状态在 Go,Node 仅做 mediasoup HTTP 封装 +- **跨模块通信模式延续**:`meeting.service.NotifyPusher` 接口 ← 由 `notify.service.NotifyService` 实现,Wire 注入(与 Phase 2a 的 `ws.FriendIDsGetter` / Phase 2e-1 的 `contact.service.NotifyPusher` 完全同构) +- **单端 WS 连接架构**:继续沿用,不因会议而改造;多端改造仍保留给 Phase 2f --- ## 五、Phase 2e-3 会议增强范围锁定 +> 注:原计划在 2e-3 的「会议邀请(`meeting_invite`)」与「入会前设备预览」已上移到 **Phase 2e-2**(见 [Phase 2e-2 专用设计 §2.2.1](./2026-04-21-phase2e-2-design.md) 与 §十)。Phase 2e-3 聚焦「预约 / 提醒 / 等候室 / 锁定」。 + - 预约会议(`meeting_rooms.type=2`)+ 前端预约表单 - 定时器:到预约时间前 N 分钟触发 `meeting_reminder` 通知 -- 会议邀请:从联系人/群聊发起 → 走 `meeting_invite` 通知类型 -- 入会前设备预览(本地摄像头/麦克风测试页) -- 可选:等候室 / 锁定会议(视时间余量决定) +- 等候室(waiting room)+ 锁定会议(lock) +- 设备预览高级参数:降噪 / 回声消除 / 虚拟背景(若浏览器支持) --- diff --git a/docs/plans/2026-04-21-phase2e-2-design.md b/docs/plans/2026-04-21-phase2e-2-design.md new file mode 100644 index 0000000..b7968f2 --- /dev/null +++ b/docs/plans/2026-04-21-phase2e-2-design.md @@ -0,0 +1,1095 @@ +# Phase 2e-2 设计文档:会议 MVP(多人音视频) + +> **状态:** 📋 设计阶段(待评审后进入代码开发) +> **上级设计:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)(本文档是其 §四「Phase 2e-2 会议 MVP」的专项展开版本,对齐 Phase 2e-1 的「总-分」设计文档结构) +> **实施计划:** [Phase 2e-2 实施计划](./2026-04-21-phase2e-2-implementation.plan.md) +> **前置依赖:** Phase 2a(联系人 + WS)、Phase 2b(即时通讯)、Phase 2c(群聊 + 已读)、Phase 2d(消息类型扩展)、Phase 2e-1(统一通知中心)均已完成 +> **分支:** `feature/phase2e-2-meeting-mvp` +> **最后更新:** 2026-04-21(设计文档首版落盘,待评审) + +--- + +## 一、文档定位说明 + +项目惯例:每个 Phase 子阶段对应一份 `{phase}-design.md`(设计)+ `{phase}-implementation.plan.md`(实施),双文档绑定协同。 + +**Phase 2e 三子阶段文档结构**: + +- [`2026-04-20-phase2e-design.md`](./2026-04-20-phase2e-design.md) — Phase 2e 大阶段路线图 + 三子阶段(2e-1 / 2e-2 / 2e-3)的总览与衔接 +- [`2026-04-20-phase2e-1-design.md`](./2026-04-20-phase2e-1-design.md) — Phase 2e-1 「统一通知中心」专用设计(✅ 已完成) +- [`2026-04-21-phase2e-2-design.md`](./2026-04-21-phase2e-2-design.md)(**本文档**) — Phase 2e-2 「会议 MVP」专用设计(📋 设计阶段) +- `2026-04-21-phase2e-3-design.md` — 待 2e-3 启动时补充(预约会议 / 提醒 / 等候室 / 会议预览高级参数) + +采用总-分结构的理由:三子阶段共享部分设计背景(跨模块通信、接口注入模式、通知类型枚举),保留总设计可避免重复;但每个子阶段需独立落档以承载具体的架构决策、状态机、数据模型与实施约束。 + +与 Phase 2e-1 不同的是:Phase 2e-2 引入了**全新技术栈**(Node.js + mediasoup + WebRTC)与**全新子项目目录**(`media-server/`),因此本文档对架构与风险描述比 Phase 2e-1 更详尽。 + +--- + +## 二、目标与范围 + +### 2.1 业务目标 + +在 Phase 2a-2e-1 建立的 IM 基础能力之上,**交付一个可独立运行、可对外演示、可被 Phase 2e-3 增强的多人音视频会议 MVP**,支持: + +1. **即时会议**:用户一键创建会议 → 分享会议号/邀请链接/通知邀请 → 其他用户加入 → 完成音视频通话 → 结束 +2. **端到端音视频**:基于 mediasoup SFU 架构,支持 ≤8 人同时音视频(1 路音频 + 1 路视频 per user) +3. **基础主持人控制**:静音他人 / 移除成员 / 转让主持人 / 结束会议 +4. **与通知中心的闭环**:会议中的「邀请」操作通过 Phase 2e-1 的 `notify.Pusher` 触发 `meeting_invite` 通知卡片 +5. **双态部署**:本机 Docker Compose 即装即用 + 公网 announcedIp/TURN 配置预留(上线前补 TURN 即可公网跑通) + +### 2.2 MVP 硬边界 + +#### 2.2.1 本期交付(✅) + +| 能力分类 | 项 | 说明 | +|---|---|---| +| 会议创建 | 即时会议(type=1) | 用户点击「立即开会」→ 自动生成会议号 → 进入会议室 | +| 会议加入 | 会议号手输 | 输入 `XXX-XXX-XXX` + 可选密码 | +| 会议加入 | 邀请链接 | `http(s)://{host}/#/meeting/join?code=XXX-XXX-XXX` 一键入会(含密码则弹密码框) | +| 会议加入 | 通知中心邀请 | 触发 `meeting_invite` 通知卡片,内联「立即加入 / 稍后」按钮 | +| 容量限制 | ≤ 8 人 | 超过返回错误码,不放行 | +| 音视频 | 麦克风开关 | mediasoup Producer audio 启停 | +| 音视频 | 摄像头开关 | mediasoup Producer video 启停 | +| 音视频 | 入会前设备预览 | 独立预览页:设备选择 + 本地画面 + 音量检测 + 降噪开关(可选) | +| 主持人权限 | 静音他人 | 通过 WS 指令通知对方静音,最终由客户端 Producer.pause 执行 | +| 主持人权限 | 移除成员 | 强制目标离会,关闭其 Transport 与 Producer | +| 主持人权限 | 转让主持人 | 主持人显式选择某成员为新 host | +| 主持人权限 | 结束会议 | 关闭 router + 广播 meeting.ended 事件 | +| 生命周期 | 主持人离开自动转让 | 转让给「剩余成员中最早加入者」 | +| 生命周期 | 全员退出 TTL | 5 分钟后自动销毁房间与 mediasoup router | +| 会议内聊天 | 文字聊天 | 独立 `meeting_chats` 表,会议结束自动清理(TTL 24h 或 endedAt 后批量删) | +| 前端适配 | 桌面浏览器 | ≥ 1024px,最大 3x3 视频网格 | +| 前端适配 | 手机浏览器 | < 768px 单列或 2 列,底部安全区适配 | +| 管理端 | **本期不做** | 管理端会议列表/详情/强制关闭推迟到 Phase 2f | +| 部署 | 双态配置 | `MEDIASOUP_ANNOUNCED_IP` 与 `TURN_ENABLED` 环境变量切换本机/公网 | + +#### 2.2.2 本期不做(❌,明确推迟) + +| 项 | 推迟去向 | +|---|---| +| 预约会议(type=2) + 定时触发 | Phase 2e-3 | +| 会议提醒(`meeting_reminder`) | Phase 2e-3 | +| 等候室(waiting room) | Phase 2e-3 | +| 锁定会议(lock) | Phase 2e-3 | +| 屏幕共享(screen share) | 第二期 | +| 会议录制与回放 | 第二期 | +| 虚拟背景 / 背景模糊 | 第二期 | +| 联合主持人(role=2) | 第二期(表中 role 字段预留枚举值) | +| 管理端会议列表 / 详情 / 强制结束 | Phase 2f | +| 管理端会议统计仪表板 | Phase 2f | +| 多 Worker / 多 Router 集群 | 第三期 | +| 跨服务器会议(Router Pipe) | 第三期 | + +--- + +## 三、关键决策记录(11 项,D01-D11) + +> 本节是 MVP 架构与工作量估算的**唯一真理来源**。后续任何偏离这些决策的修订都必须先在此处更新并记入 §十六 变更记录。 + +### D01 部署形态:双态可切 + +- **决策**:本机 Demo + 公网就绪两种形态通过**环境变量**切换,不做代码分支 +- **理由**:用户明确"本机和公网都要能部署成功和使用"。一套代码 + 配置分离符合 12-factor 原则 +- **关键环境变量**: + - `MEDIASOUP_LISTEN_IP`:mediasoup RTCTransport 监听 IP(本机 `0.0.0.0`,公网 `0.0.0.0`) + - `MEDIASOUP_ANNOUNCED_IP`:对端通告 IP(本机留空/`127.0.0.1`,公网填服务器公网 IP 或域名解析 A 记录) + - `MEDIASOUP_RTC_MIN_PORT` / `MEDIASOUP_RTC_MAX_PORT`:RTP UDP 端口范围(默认 `40000-49999`,Docker 需 host 网络或显式端口映射) + - `TURN_ENABLED`:是否启用 TURN fallback(公网建议 `true`) + - `TURN_URLS` / `TURN_USERNAME` / `TURN_CREDENTIAL`:TURN 服务器连接参数 +- **docker-compose 策略**:coturn 服务声明 `profiles: [public]`,默认不启动;运行 `docker compose --profile public up` 才拉起 TURN + +### D02 前端端形:仅 H5 + +- **决策**:MVP 仅支持 H5 浏览器端(Chrome / Edge / Safari / 移动浏览器),不支持微信小程序、不支持原生 App +- **理由**:mediasoup-client 依赖标准 WebRTC API,uni-app 小程序环境不可用(仅 live-pusher/live-player 私有协议);frontend/uni-app 当前主要构建目标就是 H5 +- **浏览器要求**:支持 WebRTC 1.0(`RTCPeerConnection`、`getUserMedia`);设备预览页对不支持的浏览器显示降级提示 + +### D03 Go-Node 协同:Go 主控 + Node 无状态包装 + +- **决策**:Go backend 承载所有权威状态(房间、成员、权限、鉴权),Node media-server 仅作为 mediasoup API 的 HTTP 封装层,自身不维护业务状态 +- **理由**:权威状态集中 + 鉴权统一 + 管理端后续扩展(强制关闭 / 踢人审计)易于在 Go 侧实现,Node 可按需扩容或替换 +- **通信形式**: + - 客户端 ↔ Go:WebSocket(信令) + HTTPS(REST CRUD) + - Go ↔ Node:**仅 HTTP REST**(9 个接口,容器内网 `http://media-server:3300`) + - 客户端 ↔ Node:**仅 WebRTC**(RTP 媒体流,通过 mediasoup DTLS/ICE 握手直连) +- **异常传播**:Node 返回非 2xx 或超时 → Go 映射为业务错误码 → 经 WS 通知客户端 + +### D04 设备预览:MVP 纳入 + +- **决策**:创建/加入会议后先进「设备预览页」,完成设备选择、本地预览、音量检测后才进会议室推流 +- **理由**:首次使用者需要明确的权限授权与设备选择窗口,不纳入会导致"打开会议 = 强制推流"的不良体验 +- **预览页核心能力**:摄像头 / 麦克风 / 扬声器设备选择(`enumerateDevices`)、本地视频预览(`getUserMedia` localStream)、麦克风音量条(AudioContext AnalyserNode)、显示名称输入/昵称确认、「加入会议」按钮 + +### D05 加入体验套餐 C:会议号 + 密码 + 链接 + 通知闭环 + +- **决策**:MVP 同时支持三种加入方式,并打通与 Phase 2e-1 通知中心的 `meeting_invite` 类型 +- **会议号格式**:9 位数字,分组展示 `XXX-XXX-XXX`,数据库存 `VARCHAR(20)`(允许后续扩展),唯一约束 +- **密码**:可选,bcrypt 哈希存储(**修订**总设计 `password VARCHAR(50)` 明文为 `password_hash VARCHAR(255)`),密码对手输/链接/通知三种入口同等生效 +- **邀请链接**:`{WEB_BASE_URL}/#/meeting/join?code={room_code}`(若有密码,加入时弹密码框) +- **通知邀请**:会议内「邀请联系人 / 群成员」→ 调用 `notify.Pusher.Push(ctx, receiverID, PushRequest{Type: "meeting_invite", ...})`,通知卡片内联「立即加入 / 稍后」 + +### D06 会议生命周期策略 A:主持人转让 + 空房 5 分 TTL + +- **决策**: + - **主持人主动离会**:系统自动从剩余成员中挑「最早加入(joined_at 最小)」的人升为 host,广播 `meeting.host.changed` + - **主持人掉线**:首次掉线**保留 host 身份 + 2 分钟宽限期**(可重连),宽限期内其他人不自动升 host;宽限期结束仍未重连,执行自动转让 + - **全员退出**:Redis `echo:meeting:room:{code}` TTL 设为 300 秒,5 分钟内有人重入则房间复用;过期后由定时任务销毁 mediasoup router 并将 `meeting_rooms.status` 置 2(已结束) +- **理由**:兼顾微信 / 腾讯会议的容错习惯,对断网重连友好 + +### D07 UI 风格:飞书简洁框架 + EchoChat 原创 + +- **决策**:整体视觉密度、导航层级参考飞书会议(干净、留白充分、功能入口前置);但**不照抄**,由 `ui-ux-pro-max` 技能包为 EchoChat 产出原创视觉语言 +- **EchoChat 创新特色候选**(在 §九 展开): + - 说话者画面外轮廓流光动效(替代主流的绿色静态外框) + - 柔性网格布局:2 人/3 人/4 人时采用非均分自适应布局(非强制 2x2 / 3x3) + - 自视频手势拖拽 + 多锚点吸附(四角 / 中央) + - 静音氛围色:底部工具栏在全员静音时呈现冷色调氛围提示 +- **落地**:设计产物存 `design-system/echochat/pages/meeting-*.md`,不在代码中硬编码样式 + +### D08 主持人权限范围 A:四件套 + +- **决策**:静音他人 / 移除成员 / 转让主持人 / 结束会议 +- **不做**:静音全员 / 锁定会议 / 等候室 / 邮件邀请 / 开麦申请 +- **理由**:MVP 用户少(≤8),不需要全员管控;其余功能推迟到 2e-3 + +### D09 媒体服务目录:根级 `media-server/` + +- **决策**:新建 [`media-server/`](../../media-server/) 根级子项目,与 `backend/` / `frontend/` / `admin/` 并列 +- **理由**:与系统总设计、Phase 2e 路线图保持一致;未来微服务拆分时无需迁移目录;独立 `package.json` / `Dockerfile` / tsconfig 易于管理 +- **技术栈**:TypeScript + Fastify + mediasoup v3 + pnpm(或 npm) + +### D10 移动端适配:桌面 + 手机浏览器双端 + +- **决策**:会议室页面全部采用响应式布局,断点为: + - 手机(`< 768px`):单列满宽视频,最多同屏 2 个视频块(其余缩略到抽屉),工具栏底部横排 + 底部安全区 + - 平板(`768px ~ 1024px`):2×2 视频网格,工具栏横排 + - 桌面(`≥ 1024px`):最多 3×3 视频网格,工具栏 + 侧边栏可同时展开 +- **理由**:uni-app H5 本身就需覆盖多端,MVP 若只做桌面会显著降低演示场景 + +### D11 会议内聊天:MVP 纳入 + 独立表 + +- **决策**:会议室支持文字聊天面板,消息存 `meeting_chats` 独立表,**不写入 `im_messages`**;会议结束后 TTL 24 小时批量清理 +- **理由**:会议聊天具有"会中强相关 / 会后弱价值"属性,与主消息系统解耦;避免一场会议污染 IM 消息流 + +--- + +## 四、整体架构 + +### 4.1 组件拓扑 + +```mermaid +flowchart LR + subgraph Clients [客户端层] + browserA[H5 浏览器 A] + browserB[H5 浏览器 B] + end + + subgraph GoBackend [Go Backend 服务] + wsHub[ws.Hub 信令] + meetingSvc[meeting 模块 Service/Controller] + notifyPusher[notify.Pusher] + nodeClient[Node HTTP Client] + end + + subgraph MediaServer [Node media-server 服务] + fastify[Fastify HTTP Server] + mediasoupWorker[mediasoup Worker] + router[Router 每房间 1 个] + transport[WebRtcTransport] + producer[Producer N 个] + consumer[Consumer N 个] + end + + subgraph Infra [基础设施] + postgres[(PostgreSQL)] + redis[(Redis)] + coturn[coturn TURN Server 可选] + end + + browserA -->|WebSocket 信令| wsHub + browserB -->|WebSocket 信令| wsHub + browserA -->|HTTPS REST| meetingSvc + browserB -->|HTTPS REST| meetingSvc + wsHub --> meetingSvc + meetingSvc --> notifyPusher + meetingSvc --> nodeClient + nodeClient -->|HTTP REST 9 接口| fastify + fastify --> mediasoupWorker + mediasoupWorker --> router + router --> transport + transport --> producer + transport --> consumer + + browserA -.->|WebRTC DTLS/ICE/RTP| transport + browserB -.->|WebRTC DTLS/ICE/RTP| transport + browserA -.->|TURN fallback| coturn + browserB -.->|TURN fallback| coturn + coturn -.-> transport + + meetingSvc --> postgres + meetingSvc --> redis + notifyPusher --> postgres +``` + +**关键说明**: +- **信令 / 媒体分离**:客户端与 Go 建立 WebSocket 走信令(低频、小包);客户端与 Node 建立 WebRTC 走媒体(高频、大包)。两条通道互不干扰,便于横向扩展 +- **Go 是媒体路径上的权威控制点**:所有 mediasoup 资源创建/销毁必须经过 Go 的鉴权与状态校验,不允许客户端直连 Node +- **coturn 可选**:本机 Demo 下未启用,公网部署时启用 `--profile public` + +### 4.2 Go-Node 协同时序(以 Produce 音频为例) + +```mermaid +sequenceDiagram + autonumber + participant C as H5 Client + participant G as Go Backend + participant N as Node media-server + participant MS as mediasoup Router + + C->>G: WS meeting.transport.create (direction=send) + G->>N: HTTP POST /transport (routerId, direction) + N->>MS: router.createWebRtcTransport + MS-->>N: transport {id, iceParameters, dtlsParameters} + N-->>G: 200 OK transport info + G-->>C: WS meeting.transport.created (transport info) + + C->>C: mediasoup-client Device.createSendTransport + transport.on connect + C->>G: WS meeting.transport.connect (transportId, dtlsParameters) + G->>N: HTTP POST /transport/:id/connect + N->>MS: transport.connect + MS-->>N: ok + N-->>G: 200 OK + G-->>C: WS meeting.transport.connected + + C->>G: WS meeting.produce.start (transportId, kind, rtpParameters) + G->>N: HTTP POST /producer (transportId, kind, rtpParameters) + N->>MS: transport.produce + MS-->>N: producer {id} + N-->>G: 200 OK producer id + G-->>C: WS meeting.produce.started (producerId) + G-->>G: 更新 echo:meeting:transport:{code}:{user_id} + G-->>全体成员: WS meeting.member.producer.new (userId, producerId, kind) +``` + +**要点**: +- Go 每次都是"WS 进 → HTTP 调 Node → WS 出",Node 不主动推 Go(无反向回调) +- Node 的 `producer.observer.on('close')` 等事件由 Go 轮询或通过定时健康检查感知(MVP 采用 Go 侧心跳 + 主动清理,不做 Node → Go 主动回调,降低复杂度) + +### 4.3 双态部署拓扑 + +```mermaid +flowchart TB + subgraph LocalDemo [本机 Demo MEDIASOUP_ANNOUNCED_IP 留空] + ld_go[Go Backend :8085] + ld_node[Node media-server :3300] + ld_fe[Frontend :5173] + ld_pg[(Postgres :5432)] + ld_redis[(Redis :6379)] + ld_browser1[浏览器标签 1] + ld_browser2[浏览器标签 2] + ld_browser1 --> ld_fe + ld_browser2 --> ld_fe + ld_fe --> ld_go + ld_go --> ld_node + ld_browser1 -.WebRTC 127.0.0.1.-> ld_node + ld_browser2 -.WebRTC 127.0.0.1.-> ld_node + ld_go --> ld_pg + ld_go --> ld_redis + end + + subgraph PublicProd [公网 MEDIASOUP_ANNOUNCED_IP=PUBLIC_IP TURN_ENABLED=true] + pp_go[Go Backend + Nginx :443] + pp_node[Node media-server :3300] + pp_coturn[coturn :3478/5349] + pp_user1[用户 A 家庭 NAT] + pp_user2[用户 B 企业 NAT] + pp_user1 -->|WSS / HTTPS| pp_go + pp_user2 -->|WSS / HTTPS| pp_go + pp_go --> pp_node + pp_user1 -.WebRTC 直连失败.-> pp_node + pp_user1 -.TURN relay.-> pp_coturn + pp_coturn -.-> pp_node + pp_user2 -.WebRTC 直连成功.-> pp_node + end +``` + +**区别**:本机 Demo 不需要 coturn、`announcedIp` 留空(浏览器通过 127.0.0.1 直连);公网环境必须配置 `announcedIp = 服务器公网 IP` 并开启 coturn 作为对称 NAT fallback。 + +--- + +## 五、数据模型 + +### 5.1 PostgreSQL 表结构 + +#### 5.1.1 `meeting_rooms`(会议房间) + +在原系统总设计草案基础上做 3 处修订: +- `password VARCHAR(50)` 改为 `password_hash VARCHAR(255)` —— bcrypt 哈希存储 +- `max_members` 默认值在 MVP 阶段应用层强制为 8(schema 默认值保留 50 供后续扩展) +- 新增 `ended_reason VARCHAR(20)` 字段,记录会议结束原因(`host_ended` / `empty_ttl` / `admin_force` / `system_error`) + +```sql +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 TIMESTAMPTZ DEFAULT NULL, + started_at TIMESTAMPTZ DEFAULT NULL, + ended_at TIMESTAMPTZ DEFAULT NULL, + ended_reason VARCHAR(20) DEFAULT NULL, + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_meeting_rooms_host_status ON meeting_rooms(host_id, status, created_at DESC); +CREATE INDEX idx_meeting_rooms_code ON meeting_rooms(room_code); + +COMMENT ON COLUMN meeting_rooms.type IS '会议类型:1=即时(MVP 仅此),2=预约(2e-3)'; +COMMENT ON COLUMN meeting_rooms.status IS '0=未开始(仅预约),1=进行中,2=已结束'; +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 等}'; +``` + +#### 5.1.2 `meeting_participants`(参与者) + +与总设计草案基本保持一致,仅修订:`joined_at` 默认值由 `NULL` 改为 `NOW()`(参与者记录被创建即代表已加入),新增 `left_reason VARCHAR(20)` 记录离会原因: + +```sql +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 TIMESTAMPTZ NOT NULL DEFAULT NOW(), + left_at TIMESTAMPTZ DEFAULT NULL, + left_reason VARCHAR(20) DEFAULT NULL, + duration INT DEFAULT 0, + UNIQUE (room_id, user_id) +); + +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); + +COMMENT ON COLUMN meeting_participants.role IS '0=普通,1=主持人(MVP 仅此两档),2=联合主持人(第二期)'; +COMMENT ON COLUMN meeting_participants.left_reason IS '离会原因:self/kicked/host_end/empty_ttl/disconnect'; +``` + +**MVP 说明**: +- `role=1`(主持人)同一房间同时最多 1 人;转让时先 `UPDATE ... SET role=0 WHERE role=1`,再 `UPDATE ... SET role=1 WHERE user_id=new_host`,用事务包裹 +- `duration` 在 `left_at` 写入时同步计算(`EXTRACT(EPOCH FROM (left_at - joined_at))`) + +#### 5.1.3 `meeting_chats`(会议内聊天) + +新表,独立于 `im_messages`: + +```sql +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 TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_meeting_chats_room_created ON meeting_chats(room_id, created_at ASC); +``` + +**清理策略**:定时任务(沿用 Phase 2e-1 的 task 包模式),每日扫描 `meeting_rooms.status=2 AND ended_at < NOW() - INTERVAL '24 hours'` 的房间,`DELETE FROM meeting_chats WHERE room_id IN (...)`;不保留到 IM 消息流。 + +### 5.2 Redis 键设计 + +沿用系统总设计已有 3 个 key,补充 2 个新 key: + +| 键 | 类型 | TTL | 内容 | 用途 | +|---|---|---|---|---| +| `echo:meeting:room:{code}` | STRING(JSON) | 房间活跃时无 TTL;全员退出后设 300s | `{id, host_id, title, router_id, status, started_at}` | 房间快照 + 空房销毁兜底 | +| `echo:meeting:members:{code}` | HASH | 同上 | `user_id` → `{joined_at, role, mic_on, camera_on, device_info}` | 在线成员实时状态 | +| `echo:meeting:transport:{code}:{user_id}` | HASH | 同上 | `send_transport_id / recv_transport_id / producer_audio_id / producer_video_id / consumer_*_id` | 用户在 Node 侧的 mediasoup 资源映射(Go 不直接持久化 Node 资源,仅持有 ID 引用) | +| `echo:meeting:invite:{token}` | STRING(JSON) | 600s | `{room_code, inviter_id, invitee_id, created_at}` | 邀请链接短 Token,用户点击链接时兑换为会议入会凭证 | +| `echo:meeting:host_grace:{code}` | STRING | 120s | `{host_id, grace_until}` | 主持人掉线宽限期标记(D06 实现支撑) | + +**命名说明**:`code` = 用户可见的会议号 `XXX-XXX-XXX`,与 `meeting_rooms.room_code` 一致;Go 侧对代码内部使用 `roomID`(主键),对外/Redis 使用 `roomCode`。 + +### 5.3 mediasoup 资源对应关系 + +```mermaid +flowchart LR + subgraph meetingRooms [meeting_rooms 1 行] + code[room_code] + routerId[router_id 存于 Redis room:] + end + subgraph nodeWorker [Node Worker 1 个] + worker[mediasoup Worker] + routerA[Router A] + routerB[Router B] + worker --> routerA + worker --> routerB + end + subgraph perUser [每个参会者] + sendT[SendTransport] + recvT[RecvTransport] + prodAudio[Producer Audio] + prodVideo[Producer Video] + consumers[Consumer N 个] + sendT --> prodAudio + sendT --> prodVideo + recvT --> consumers + end + + code -.-> routerA + routerA --> sendT + routerA --> recvT +``` + +每房间 1 个 Router(初期单 Worker 简化);每用户 2 个 Transport(send + recv)+ 2 个 Producer(audio + video)+ 最多 7 个 Consumer(8 人会议中订阅其他 7 人的音频/视频流,MVP 简化为 simulcast 单档)。 + +--- + +## 六、后端 Go 侧设计 + +### 6.1 模块目录 + +沿用项目 Clean Architecture 约定,在 `backend/go-service/app/` 下新建 `meeting/` 模块: + +``` +backend/go-service/app/meeting/ +├── controller/ +│ ├── meeting_controller.go # 创建/加入/离开/结束/列表/详情 6 个端点 +│ ├── meeting_chat_controller.go # 会议内聊天:发送/拉历史 2 个端点 +│ └── meeting_ws_handler.go # WS 事件处理(注册到 ws.Hub) +├── dao/ +│ ├── meeting_room_dao.go +│ ├── meeting_participant_dao.go +│ └── meeting_chat_dao.go +├── model/ +│ ├── meeting_room.go +│ ├── meeting_participant.go +│ └── meeting_chat.go +├── service/ +│ ├── meeting_service.go # 房间 CRUD + 生命周期 +│ ├── meeting_signal_service.go # WS 信令处理(mediasoup 资源编排) +│ ├── meeting_chat_service.go +│ ├── node_client.go # Go → Node HTTP 客户端 +│ └── interfaces.go # 向外部模块暴露的注入接口(NotifyPusher 等) +├── task/ +│ └── meeting_cleanup_task.go # 空房 TTL + meeting_chats 24h 清理 +├── router/ +│ └── meeting_router.go +├── provider/ +│ ├── provider.go +│ └── wire_gen.go +└── constants/ + ├── meeting_status.go + ├── meeting_role.go + └── ws_events.go +``` + +**接口注入模式(延续 Phase 2a / 2e-1 规范)**: +- `meeting.service.NotifyPusher`(接口,本模块定义)← 由 `notify.service.NotifyService` 实现,Wire 注入 +- `meeting.service.UserInfoResolver`(接口,本模块定义)← 由 `auth.service.UserService` 实现,Wire 注入 +- `ws.MeetingSignalDispatcher`(接口,`ws` 包定义,方法签名 `Dispatch(ctx, userID, event, payload) error`)← 由 `meeting.service.MeetingSignalService` 实现,Wire 注入到 `ws.Hub` + +### 6.2 REST API(用户端) + +统一前缀 `/api/v1/meeting`,Gin 路由注册,JWT + `clientType=frontend` 中间件鉴权。 + +| 方法 | 路径 | 用途 | 权限 | +|---|---|---|---| +| POST | `/api/v1/meeting/rooms` | 创建即时会议 | 已登录用户 | +| POST | `/api/v1/meeting/rooms/:code/join` | 加入会议(校验密码) | 已登录用户 | +| POST | `/api/v1/meeting/rooms/:code/leave` | 主动离会 | 当前参会者 | +| POST | `/api/v1/meeting/rooms/:code/end` | 结束会议(仅 host) | host | +| GET | `/api/v1/meeting/rooms/:code` | 会议详情(成员列表 + 状态) | 当前参会者 | +| GET | `/api/v1/meeting/rooms/mine` | 我发起/参与过的最近会议(30 天) | 已登录用户 | +| POST | `/api/v1/meeting/rooms/:code/invite` | 触发邀请通知(接收人ID 数组) | 当前参会者 | +| POST | `/api/v1/meeting/rooms/:code/kick` | 移除某成员 | host | +| POST | `/api/v1/meeting/rooms/:code/transfer-host` | 转让主持人 | host | +| POST | `/api/v1/meeting/rooms/:code/chats` | 会议内发送消息 | 当前参会者 | +| GET | `/api/v1/meeting/rooms/:code/chats` | 拉取会议内历史消息(分页) | 当前参会者 | +| POST | `/api/v1/meeting/invite-tokens/:token/redeem` | 邀请链接兑换入会凭证 | 已登录用户 | + +**管理端路由(Phase 2f 启用,本期不实现,但表结构 + DTO 为其预留)**: +- `GET /api/v1/admin/meetings` — 列表(按状态/host 筛选) +- `GET /api/v1/admin/meetings/:id` — 详情 +- `POST /api/v1/admin/meetings/:id/force-close` — 强制关闭 + +### 6.3 WS 信令事件(11 个) + +客户端 ↔ Go 的 WebSocket 事件(JSON `{event, seq, data}`),按三组分类: + +**房间组(3 个)**: +- `meeting.room.join` (C→S):加入会议成功后的"宣告在线",绑定 userID ↔ roomCode +- `meeting.room.leave`(C→S):主动离会 +- `meeting.room.ended`(S→C):会议已结束(广播给所有成员) + +**成员组(4 个)**: +- `meeting.member.joined`(S→C):新成员加入(广播) +- `meeting.member.left`(S→C):成员离开(广播) +- `meeting.member.state.changed`(双向):静音 / 开关摄像头等状态变更(发送方为 C→S,服务端向其他成员广播 S→C) +- `meeting.member.kicked`(S→C):成员被移除(定向发给被移除者 + 广播状态更新) +- `meeting.host.changed`(S→C):主持人变更(广播) + +**媒体组(5 个,mediasoup 信令)**: +- `meeting.transport.create`(C→S→Node→S→C):创建 Transport(参 §4.2) +- `meeting.transport.connect`(C→S→Node→S→C):Transport DTLS 握手 +- `meeting.produce.start`(C→S→Node→S→C):创建 Producer(广播 `meeting.member.producer.new` 给其他成员) +- `meeting.consume.start`(C→S→Node→S→C):为某远端 Producer 创建本地 Consumer +- `meeting.producer.close`(C→S→Node→S→C):关闭自己的 Producer(推流停止) + +**消息字段规范**:所有事件 `data` 必须包含 `room_code`(字符串),媒体事件额外带 `transport_id / producer_id / consumer_id` 等标识。响应 `seq` 对齐请求 `seq`,便于客户端配对。 + +### 6.4 权限校验矩阵 + +| 操作 | 允许角色 | 校验点 | +|---|---|---| +| 创建会议 | 任意登录用户 | controller | +| 加入会议 | 任意登录用户 | service(校验密码 + 容量) | +| 主动离会 | 当前参会者 | service(查 `meeting_participants`) | +| 静音他人 / 开关他人摄像头 | host | `meeting.member.state.changed` 带 `target_user_id` 时校验 | +| 移除成员 | host | controller + service 双层 | +| 转让主持人 | host | 同上 | +| 结束会议 | host | 同上 | +| 发起会议邀请 | 当前参会者 | controller | +| 会议内发送聊天 | 当前参会者 | controller | + +所有 Service 层权限校验统一走辅助函数 `assertIsHost(ctx, roomID, userID) error` / `assertIsParticipant(ctx, roomID, userID) error`。 + +### 6.5 生命周期状态机 + +```mermaid +stateDiagram-v2 + [*] --> Pending: 创建即时会议 + Pending --> Active: host 首次进入 推流 + Active --> HostGrace: host 掉线 + HostGrace --> Active: host 2 分钟内重连 + HostGrace --> Transferring: 2 分钟超时 + Active --> Transferring: host 主动离会 或 主持人转让 + Transferring --> Active: 新 host 就位 广播 + Active --> Idle: 全员退出 Redis TTL 300s + Idle --> Active: 有人重新加入 + Idle --> Ended: 5 分钟 TTL 到期 + Active --> Ended: host 结束会议 + Ended --> [*]: task 清理资源 +``` + +**状态持久化**: +- `meeting_rooms.status`:0 Pending / 1 Active / 2 Ended(HostGrace / Transferring / Idle 作为应用层内部状态,不入 DB 状态列,仅通过 Redis key `host_grace` 与 `room` TTL 表达) +- 每次状态跃迁由 `meeting_service.transitStatus(ctx, roomID, next)` 统一写入 + 记日志 + +### 6.6 Go → Node HTTP Client 封装 + +`service/node_client.go` 统一提供 9 个方法,对应 §七 的 Node REST API: + +```go +type NodeClient interface { + CreateRouter(ctx context.Context, roomCode string) (routerID string, err error) + CloseRouter(ctx context.Context, routerID string) error + CreateTransport(ctx context.Context, req CreateTransportReq) (*TransportInfo, error) + ConnectTransport(ctx context.Context, transportID string, dtls DtlsParameters) error + CreateProducer(ctx context.Context, req CreateProducerReq) (producerID string, err error) + CreateConsumer(ctx context.Context, req CreateConsumerReq) (*ConsumerInfo, error) + ResumeConsumer(ctx context.Context, consumerID string) error + CloseProducer(ctx context.Context, producerID string) error + CloseConsumer(ctx context.Context, consumerID string) error +} +``` + +- HTTP 封装细节:默认超时 5 秒;失败重试 2 次(仅幂等的关闭类操作);失败日志打印 `funcName` + `room_code` + `transport_id` 等上下文 +- 并发控制:创建类接口(Transport / Producer)单用户级串行(通过 `sync.Map[userID]sync.Mutex` 避免重复创建);关闭类接口并发安全 + +--- + +## 七、Node media-server 设计 + +### 7.1 目录与技术栈 + +``` +media-server/ +├── src/ +│ ├── app.ts # Fastify 入口 +│ ├── config.ts # 环境变量加载 + 默认值 +│ ├── mediasoup/ +│ │ ├── worker.ts # Worker 池 (MVP: 1 个) +│ │ ├── router.ts # Router 管理 +│ │ └── codecs.ts # RtpCapabilities 配置 +│ ├── routes/ +│ │ ├── router.route.ts +│ │ ├── transport.route.ts +│ │ ├── producer.route.ts +│ │ └── consumer.route.ts +│ ├── services/ +│ │ ├── router.service.ts +│ │ ├── transport.service.ts +│ │ ├── producer.service.ts +│ │ └── consumer.service.ts +│ ├── middlewares/ +│ │ └── internal-auth.ts # 共享密钥 校验 header X-Internal-Token +│ └── utils/ +│ └── logger.ts # pino +├── tests/ +│ └── *.spec.ts # vitest +├── package.json +├── tsconfig.json +├── Dockerfile +└── .env.example +``` + +**技术栈选型**: +- `fastify@^4` —— 高性能 HTTP,TypeScript 原生支持 +- `mediasoup@^3` —— WebRTC SFU 核心,v3 最新稳定 +- `pino@^8` —— 日志库 +- `typescript@^5`、`@types/node`、`tsx`(开发时热更) +- `vitest` —— 测试 +- `zod` —— 请求体 Schema 校验 + +**Node 版本**:`>=18 LTS`(mediasoup v3 要求) + +### 7.2 内部 REST API 契约(9 个) + +所有接口统一前缀 `/internal/v1`,要求 Header `X-Internal-Token: {MEDIA_INTERNAL_TOKEN}`(与 Go 侧共享密钥,来自 `.env`,默认关闭公网暴露)。 + +| 方法 | 路径 | Body | 响应 | +|---|---|---|---| +| POST | `/internal/v1/routers` | `{roomCode}` | `{routerId, rtpCapabilities}` | +| DELETE | `/internal/v1/routers/:routerId` | - | `{ok: true}` | +| POST | `/internal/v1/transports` | `{routerId, userId, direction}` | `{id, iceParameters, iceCandidates, dtlsParameters}` | +| POST | `/internal/v1/transports/:id/connect` | `{dtlsParameters}` | `{ok: true}` | +| POST | `/internal/v1/producers` | `{transportId, kind, rtpParameters, appData}` | `{id}` | +| POST | `/internal/v1/consumers` | `{routerId, transportId, producerId, rtpCapabilities}` | `{id, kind, rtpParameters, producerPaused}` | +| POST | `/internal/v1/consumers/:id/resume` | - | `{ok: true}` | +| DELETE | `/internal/v1/producers/:id` | - | `{ok: true}` | +| DELETE | `/internal/v1/consumers/:id` | - | `{ok: true}` | + +**错误码约定**: +- `400` 参数错误(zod 校验失败) +- `401` 缺 `X-Internal-Token` 或不匹配 +- `404` 资源不存在(router / transport / producer) +- `409` 状态冲突(如 transport 已 connect) +- `500` mediasoup 内部错误(附 `code` 与 `message`) + +### 7.3 Worker 与 Router 生命周期 + +- **MVP 阶段**:启动 1 个 Worker,按房间创建 Router(`worker.createRouter(...)`) +- **Router 上限**:Node 进程内维持 `Map`,达到 200 个时拒绝新 Router 创建(预留水位,实际按 CPU 压测再调) +- **资源释放**:`DELETE /routers/:routerId` 时递归释放 router 下所有 transport/producer/consumer,内部事件监听自动 GC 对应资源 +- **未来扩展点**:Worker 池、多 Worker 间 PipeTransport、Router 水平分片(第三期) + +### 7.4 编解码与 Simulcast + +Router 创建时注入 `mediaCodecs`: + +- Audio:`opus / 48000 / 2` +- Video:`VP8`(首选)+ `H264`(降级,兼容 Safari) + +**Simulcast 档位**(MVP 仅 Video,Producer 端由客户端上送 3 档 encodings,Consumer 端由 Node 根据订阅者网络状况切换): +- `high`:720p @ 25fps(maxBitrate 1 Mbps) +- `mid`:360p @ 25fps(maxBitrate 400 Kbps) +- `low`:180p @ 15fps(maxBitrate 150 Kbps) + +客户端 `Device.createSendTransport` 调用时 `encodings: [{maxBitrate: 150_000}, {maxBitrate: 400_000}, {maxBitrate: 1_000_000}]`。 + +### 7.5 Docker Compose 服务片段(示意) + +```yaml +services: + media-server: + build: ./media-server + ports: + - "3300:3300" + - "40000-40199:40000-40199/udp" # MVP 仅放 200 端口,足够 ~25 用户 + environment: + MEDIASOUP_LISTEN_IP: "0.0.0.0" + MEDIASOUP_ANNOUNCED_IP: "${MEDIASOUP_ANNOUNCED_IP:-}" + MEDIASOUP_RTC_MIN_PORT: "40000" + MEDIASOUP_RTC_MAX_PORT: "40199" + MEDIA_INTERNAL_TOKEN: "${MEDIA_INTERNAL_TOKEN}" + depends_on: + - redis + + coturn: + image: coturn/coturn:latest + profiles: ["public"] # 本机 Demo 不启动 + network_mode: host # TURN 通常需要 host 网络 + command: > + -n --log-file=stdout + --min-port=49160 --max-port=49200 + --realm=echochat + --user=${TURN_USERNAME:-echouser}:${TURN_PASSWORD:-echopass} +``` + +--- + +## 八、前端设计(uni-app / Vue 3) + +### 8.1 新增页面与路由 + +| 路径 | 组件 | 用途 | +|---|---|---| +| `/pages/meeting/index` | `MeetingHome.vue` | 会议入口(替换现有占位页)—— 入会输入 + 创建按钮 + 最近会议 | +| `/pages/meeting/create` | `MeetingCreate.vue` | 创建会议弹窗/页面(标题 + 密码 + 设置) | +| `/pages/meeting/join` | `MeetingJoin.vue` | 输入会议号/密码的加入页(同时承接邀请链接 `?code=xxx`) | +| `/pages/meeting/preview` | `MeetingPreview.vue` | 设备预览页(摄像头/麦克风/扬声器选择 + 本地画面) | +| `/pages/meeting/room` | `MeetingRoom.vue` | 会议室主页面(视频网格 + 工具栏 + 成员面板 + 聊天) | + +**路由说明**:uni-app pages.json 新增 5 项,`MeetingHome` 作为 Tab 首页(现有占位 index.vue 升级);`MeetingRoom` 不是 Tab 页,进入后隐藏 TabBar。 + +### 8.2 Pinia Store + +新建 `frontend/src/store/meeting.js`(与现有 `notify.js` / `contact.js` 并列)。 + +**State**: +``` +{ + currentRoom: null, // {code, title, hostId, mySelf: {...}, settings} + members: {}, // user_id → {id, nickname, avatar, role, micOn, cameraOn, speaking} + producers: {}, // user_id → {audio: producerId, video: producerId} + consumers: {}, // producerId → {id, kind, track, paused} + localTracks: { // 本地采集 + audioTrack: null, + videoTrack: null, + micOn: false, + cameraOn: false, + }, + devices: { // 设备预览结果 + selectedMic: '', + selectedCamera: '', + selectedSpeaker: '', + }, + chat: { + messages: [], // 仅内存,不落本地 + hasMore: false, + loading: false, + }, + mediasoup: { + device: null, // mediasoupClient.Device 实例(不通过 Pinia 序列化,用 markRaw) + sendTransport: null, + recvTransport: null, + }, + ui: { + panelMember: false, + panelChat: false, + networkWarning: false, + } +} +``` + +**Actions**(核心动作摘要): +- `createRoom(payload)` / `joinRoom(code, password)` / `leaveRoom()` / `endRoom()` +- `openDevicePreview()` / `pickDevices({mic, camera, speaker})` / `confirmDevices()` +- `toggleMic()` / `toggleCamera()` +- `kickMember(userId)` / `transferHost(userId)` / `muteMember(userId)` +- `sendChat(content)` / `loadChatHistory()` +- `_onWsEvent(event, payload)` —— WS 事件进入 store(由顶层 WS 管理器 dispatch) +- `_setupMediasoup()` / `_teardownMediasoup()` —— mediasoup-client 的 Device/Transport/Producer/Consumer 生命周期 + +**与 WS 的对接**:延续 Phase 2e-1 `notify.js` 的 `_onNotifyNew` 模式,由统一的 `utils/ws.js` 注册事件回调,路由到 meeting store 对应的 `_on*` 私有 action。 + +### 8.3 新增组件 + +`frontend/src/components/meeting/`: + +| 组件 | 职责 | +|---|---| +| `VideoTile.vue` | 单个成员视频块:视频/音频占位、昵称、静音图标、说话者流光边框 | +| `VideoGrid.vue` | 网格容器:按成员数自适应布局(1/2/3/4/5+ 分别不同) | +| `MeetingToolbar.vue` | 底部工具栏:麦/摄像头切换、成员、邀请、聊天、挂断 | +| `MemberPanel.vue` | 成员抽屉:列表 + 单成员静音/移除/转让主持操作菜单 | +| `ChatPanel.vue` | 聊天抽屉:消息列表 + 发送框 | +| `InviteDialog.vue` | 邀请弹窗:复制链接、复制会议号、选择联系人/群成员发送通知 | +| `DevicePreview.vue` | 设备预览内容区(被 MeetingPreview 页面使用) | +| `NetworkBadge.vue` | 网络质量徽标(好/中/差) | + +### 8.4 mediasoup-client 集成 + +关键调用序列(所有调用由 meeting store 统一封装): + +```text +1. joinRoom 成功后 +2. const device = new mediasoupClient.Device() +3. const rtpCaps = await fetchRtpCapabilities() // 来自 Go 创建 Router 时返回 +4. await device.load({routerRtpCapabilities: rtpCaps}) +5. sendTransport = device.createSendTransport(sendParams) +6. sendTransport.on('connect', (params, callback) => wsEmit('meeting.transport.connect', params, callback)) +7. sendTransport.on('produce', (params, callback) => wsEmit('meeting.produce.start', params, callback)) +8. recvTransport = device.createRecvTransport(recvParams) // 对称 connect 事件 +9. await sendTransport.produce({track: audioTrack, ...}) // 推音频 +10. await sendTransport.produce({track: videoTrack, encodings: [...], codecOptions: ...}) // 推视频 +11. 收到 meeting.member.producer.new 后 → createConsumer → recvTransport.consume +``` + +**说明**:`markRaw` 包裹 mediasoup-client 实例防止 Vue 响应式代理 + 内存膨胀;WS emit 采用 Promise 封装以适配 mediasoup-client callback 风格。 + +### 8.5 响应式断点 + +参考 D10: + +```scss +// 桌面 ≥ 1024px +.video-grid { grid-template-columns: repeat(3, 1fr); } +// 平板 768 ~ 1024 +@media (max-width: 1024px) { + .video-grid { grid-template-columns: repeat(2, 1fr); } +} +// 手机 < 768 +@media (max-width: 768px) { + .video-grid { grid-template-columns: 1fr; max-height: calc(100vh - 120px); overflow-y: auto; } + .meeting-toolbar { padding-bottom: env(safe-area-inset-bottom); } +} +``` + +手机端视频显示策略:主画面 + 缩略抽屉;手势上滑可切换主画面焦点。 + +--- + +## 九、UI/UX 设计约束 + +### 9.1 交付给 `ui-ux-pro-max` 的 4 个关键屏幕 brief + +| 屏幕 | 核心约束 | 创新要点候选 | +|---|---|---| +| 创建会议页 | 1 屏内完成(标题 + 密码 + 是否开启入会静音) | 柔和渐变背景 + 主 CTA 呼吸动效 | +| 设备预览页 | 左侧大预览区 + 右侧设备列表 | 音量检测条采用渐变波浪而非分段条 | +| 会议室主页面 | 视频区最大化,工具栏极简(5 个以内按钮) | 说话者画面「流光轮廓」动效;全员静音时工具栏冷色调氛围;自视频浮窗支持四角吸附 | +| 邀请弹窗 | 复制链接 / 复制会议号 / 选择联系人 3 选项并列 | 联系人选择器复用 Phase 2a 的组件,避免重复开发 | + +### 9.2 EchoChat 原创特色清单(候选) + +1. **说话者流光轮廓**:检测 Producer 音量 >阈值 时,VideoTile 外框渐变流转动画(非固定绿色框) +2. **柔性网格**:2 人布局采用左右非等分(主画面 65% / 次画面 35%),3 人采用大小不等的三角形布局 +3. **自视频手势拖拽**:PC 鼠标 + 移动端 touch 均支持拖拽,松手时吸附到最近锚点 +4. **静音氛围色**:工具栏底色随"当前房间是否全员静音"微调(正常=中性灰 / 全员静音=淡蓝冷色) +5. **入会动效**:新成员加入时视频块从网格外滑入 + 透明度渐变 +6. **网络质量可视化**:`NetworkBadge` 采用 3 条波浪线动效(而非传统格子信号) + +### 9.3 设计产物落地位置 + +- `design-system/echochat/pages/meeting-home.md` +- `design-system/echochat/pages/meeting-preview.md` +- `design-system/echochat/pages/meeting-room.md` +- `design-system/echochat/pages/meeting-invite.md` + +调用时机:Task 15「UI 打磨」执行阶段,通过 `npx openskills read ui-ux-pro-max` 调起技能包;文档阶段**不调用**。 + +--- + +## 十、通知中心对接(`meeting_invite`) + +### 10.1 通知类型扩展 + +延续 Phase 2e-1 定义的 `notify.NotifyType`,`meeting_invite` 类型已在 Phase 2e-1 作为"会议组"分类预留;本期仅需填入实际推送: + +```go +// Phase 2e-2 补充 extra 结构规范 +type MeetingInviteExtra struct { + RoomCode string `json:"room_code"` + RoomTitle string `json:"room_title"` + InviterID int64 `json:"inviter_id"` + InviterName string `json:"inviter_name"` + HasPassword bool `json:"has_password"` + ExpiredAt int64 `json:"expired_at"` // Unix 秒,用于客户端判断是否过期 +} +``` + +### 10.2 触发点代码片段(示意) + +```go +// backend/go-service/app/meeting/service/meeting_service.go +func (s *MeetingService) Invite(ctx context.Context, code string, inviterID int64, inviteeIDs []int64) error { + room, err := s.meetingRoomDAO.GetByCode(ctx, code) + if err != nil { return err } + inviter, _ := s.userInfoResolver.GetUserLite(ctx, inviterID) // 通过注入接口 + + extra := MeetingInviteExtra{ + RoomCode: room.RoomCode, + RoomTitle: room.Title, + InviterID: inviterID, + InviterName: inviter.Nickname, + HasPassword: room.PasswordHash != "", + ExpiredAt: time.Now().Add(10 * time.Minute).Unix(), + } + for _, receiverID := range inviteeIDs { + if err := s.notifyPusher.Push(ctx, receiverID, notifydto.PushRequest{ + Type: "meeting_invite", + Title: fmt.Sprintf("%s 邀请你加入会议", inviter.Nickname), + Content: room.Title, + Extra: mustMarshal(extra), + Category: "meeting", + }); err != nil { + logger.Warn("meeting invite push failed", zap.Error(err)) + } + } + return nil +} +``` + +(实际代码在实施阶段按 Go 规范完善,上面仅示意调用路径) + +### 10.3 前端通知卡片渲染 + +Phase 2e-1 `NotifyItem.vue` 已支持 `meeting_invite` 类型卡片;本期补齐: +- 卡片正文末尾增加「立即加入 / 稍后」双按钮 +- 点击「立即加入」→ 解析 `extra.room_code` → 路由跳 `/pages/meeting/preview?code=xxx` +- 点击「稍后」→ 仅标记通知为已读,不跳转 + +TabBar 「我的」红点逻辑不变(Phase 2e-1 已实现 `unreadTotal > 0` 聚合红点,`meeting_invite` 类型通知同样贡献红点计数)。 + +--- + +## 十一、安全与性能 + +### 11.1 会议 Token 策略 + +- **主通道鉴权**:REST / WS 一律复用 Phase 1 的 JWT(`clientType=frontend`);**不**新增业务 Token +- **邀请链接 Token**:Redis STRING `echo:meeting:invite:{token}` 存 600 秒,`POST /api/v1/meeting/invite-tokens/:token/redeem` 兑换后立刻删除。防止链接被转发后长期有效 +- **Node 内部 Token**:`X-Internal-Token` header,容器内网只开 `media-server` 的 3300 端口给 Go backend,避免公网可见 + +### 11.2 密码哈希 + +- 算法:bcrypt(Go `golang.org/x/crypto/bcrypt`,cost=10) +- 存储:`meeting_rooms.password_hash VARCHAR(255)` +- 校验:`POST /rooms/:code/join` 时 `bcrypt.CompareHashAndPassword`;连续 5 次错误 → 锁定该会议 ID 10 分钟(Redis `echo:meeting:lock:{code}:{user_id}`) + +### 11.3 幂等与防抖 + +- **踢人**:`POST /rooms/:code/kick` 请求体 `{target_user_id, request_id}`,Redis `SETNX echo:meeting:kick:{request_id} EX 60` 幂等保护 +- **结束会议**:仅幂等返回 `{status: "already_ended"}` 而非 400(避免客户端重试误报) +- **转让主持人**:DB 事务包裹 "`UPDATE role=0 WHERE role=1 AND room_id=... AND user_id=old` + `UPDATE role=1 WHERE room_id=... AND user_id=new`",避免竞态 + +### 11.4 mediasoup 资源回收 + +- 客户端断连 → WS `OnClose` 触发 → Go 清理该 user 在 `echo:meeting:transport:{code}:{user_id}` 中记录的所有 ID,通过 Node Client 批量关闭 → 广播 `meeting.member.left` +- Node 侧:`transport.observer.on('close')` → 自动级联关闭 producer/consumer(mediasoup v3 内建行为),无需 Go 显式发起 +- 定时兜底:`meeting_cleanup_task` 每分钟扫一次 Redis `echo:meeting:room:{code}` TTL 过期情况 + DB 未结束但超过 4 小时无心跳的房间 → 强制结束 + +### 11.5 Simulcast 与网络自适应 + +- 三档 encodings(见 §7.4),客户端默认上报三档;Node 侧 `consumer.setPreferredLayers({spatialLayer: 2, temporalLayer: 2})` 由 Go 在 WS `meeting.member.network.quality` 事件中指令客户端切换 +- 网络降级策略: + - 丢包率 ≥ 10% → 请求 Go 切到 mid + - 丢包率 ≥ 25% → 请求 Go 切到 low + - 恢复至 ≤ 5% 超过 30 秒 → 切回 high + +### 11.6 容量限制(MVP) + +- 单会议 ≤ 8 人(应用层硬校验) +- 单 Node 进程 ≤ 200 个活跃 router(水位报警,MVP 阶段不会触达) +- 单用户并发会议数 ≤ 1(同一用户同一时间仅能在一个会议中,加入新会议自动退出旧的) + +### 11.7 离会检测 + +- WS 断线:Hub 在 `OnClose` 触发 meeting 模块的 `OnUserDisconnect(ctx, userID)`,立即触发离会流程(主持人掉线走宽限期,普通成员立即离会) +- mediasoup transport 空闲:Node 侧 `transport.on('icestatechange')` 进入 `disconnected` 状态 60 秒无恢复 → 主动关闭 transport(Go 通过心跳 `/internal/v1/transports/:id/stats` 每 30 秒拉一次状态,感知并清理) + +--- + +## 十二、风险识别与应对 + +| 风险 | 概率 | 影响 | 缓解措施 | 归属 Task | +|---|---|---|---|---| +| mediasoup v3 TypeScript SDK 学习曲线陡 | 高 | 中 | Task 0 先做 PoC Spike,用 2 个浏览器 + 1 个 Worker 跑通单向推拉流再进入正式开发 | Task 0 | +| 公网 NAT 穿透失败(对称 NAT 场景) | 中 | 高 | 文档化 coturn 配置 + `announcedIp` 环境变量;提供公网 Demo 部署脚本 `scripts/deploy-public.sh` | Task 14 | +| uni-app H5 对 WebRTC API 兼容性(特别是 iOS Safari) | 中 | 中 | 设备预览页内置 `navigator.mediaDevices` 可用性检测,不支持时友好降级 | Task 10 | +| Docker Compose 新增 2 服务(media-server / 可选 coturn)启动时长与资源占用 | 中 | 低 | 分服务启动:`scripts/start.sh backend` / `scripts/start.sh media` / `scripts/start.sh frontend` | Task 14 | +| 前端 mediasoup-client 在 Pinia 响应式下内存膨胀 | 中 | 中 | `markRaw` 包裹 Device/Transport 实例;本地 tracks 用 useStorage 之外的内存字段 | Task 9 | +| 会议内聊天消息高峰时前端渲染卡顿 | 低 | 低 | 虚拟列表(MVP 可先不做,≤ 500 条/会议场景够用) | Task 12 | +| Go → Node HTTP 调用超时导致客户端长时间等待 | 中 | 中 | 固定 5 秒超时 + 客户端 WS 层显示"正在创建媒体通道…" loading | Task 7 | +| 主持人频繁断网导致 host 身份频繁转让,体验差 | 中 | 中 | 2 分钟宽限期(D06)+ 客户端"主持人重连中"状态提示 | Task 8 | +| 公网部署时 UDP 端口范围与防火墙/云安全组配置遗漏 | 高 | 高 | 文档化所需端口(40000-40199/UDP + 3478 TURN),`scripts/deploy-public.sh` 校验 iptables | Task 14 | +| simulcast 三档实际带宽与客户端 CPU 承受不住 | 低 | 中 | PoC 压测;允许运行时通过环境变量收紧到单档 | Task 0 | +| mediasoup Worker 异常退出(如 c++ 崩溃)导致所有房间失效 | 低 | 高 | Node 层监听 `worker.on('died')` 自动重启 Worker;Go 层收到 Node 5xx 时通知所有房间重建 | Task 1/2 | + +--- + +## 十三、验收标准(MVP P0 清单) + +### 13.1 功能验收 + +- [ ] 用户 A 可创建会议,获得 9 位会议号 `XXX-XXX-XXX` +- [ ] 用户 B 通过会议号加入会议(无密码与有密码均覆盖) +- [ ] 用户 C 通过邀请链接加入会议(链接 10 分钟有效,过期拒绝) +- [ ] 用户 D 通过「通知中心」卡片的「立即加入」按钮加入会议 +- [ ] 8 人同时在会,互相可看见对方视频 + 听见对方音频 +- [ ] 第 9 人加入时返回 `meeting_room_full` 错误码 +- [ ] 任意成员可开关自己的麦克风/摄像头 +- [ ] host 可静音指定成员(该成员客户端显示"被主持人静音") +- [ ] host 可移除指定成员(该成员 WS 收到 `meeting.member.kicked`,页面跳转到首页) +- [ ] host 可转让主持人给另一成员(双方 UI 角色标识同步更新) +- [ ] host 可结束会议(全员 WS 收到 `meeting.room.ended`,跳首页) +- [ ] host 主动离会后,最早加入者自动成为新 host +- [ ] host 掉线 2 分钟内重连,身份保留;超过 2 分钟,自动转让 +- [ ] 全员退出 5 分钟后房间自动销毁(DB `status=2`,Redis 键清空) +- [ ] 会议内聊天:发送 / 接收 / 历史拉取正常;会议结束后 24 小时聊天记录被清理 +- [ ] 桌面浏览器 3x3 网格显示正常;手机浏览器单列显示正常(响应式验证) + +### 13.2 非功能验收 + +- [ ] 本机 Demo:`scripts/start.sh backend && scripts/start.sh frontend && docker compose up -d media-server` 拉起后 1 分钟内可用 +- [ ] 公网部署:文档化流程可复现(包括 `announcedIp`、coturn、防火墙端口) +- [ ] E2E:Playwright 覆盖 4 个核心场景(创建、加入、主持人转让、邀请通知) +- [ ] 代码评审:`code-reviewer` 子代理通过;无 P0/P1 遗留 +- [ ] 文档:所有新建文件含包/函数注释;API 文档(`docs/api/frontend/meeting.md`)与代码同步 + +### 13.3 质量门槛 + +- 无 mediasoup 资源泄漏(会议结束后 Node 侧 `router.observer` 计数归零) +- 无 DB 事务遗留(转让主持人失败后回滚完整) +- 无 goroutine 泄漏(`pprof` 抓取会议结束 5 分钟后的 goroutine 数与无会议时基线差 < 10) + +--- + +## 十四、后续衔接点(Phase 2e-3 与之后) + +- **Phase 2e-3(预约会议与提醒)**: + - 启用 `meeting_rooms.type=2`、`scheduled_at` 字段 + - 新增定时任务:开始前 15 分钟 / 1 分钟推送 `meeting_reminder` 通知 + - 新增「我的预约会议」列表页 +- **Phase 2e-3(等候室 / 锁定会议)**: + - `meeting_rooms.settings` 追加 `waiting_room_enabled` / `locked` + - 等候室队列:Redis LIST `echo:meeting:waiting:{code}`,host 逐个放行 +- **Phase 2e-3(设备预览高级参数)**: + - 降噪 / 回声消除(`echoCancellation` / `noiseSuppression`) + - 视频美颜(若浏览器支持 `MediaStreamTrack` 的 processor) + - 虚拟背景(基于 WebCodecs + WebAssembly 的轻量分割模型) +- **Phase 2f(管理端)**: + - `/api/v1/admin/meetings` 三端点开发 + - 会议统计仪表板(按日/周/月会议数、平均时长、高峰并发) +- **第二期(屏幕共享 / 录制)**: + - mediasoup Producer `appData.screen=true` 路径 + - 录制走 Node 侧 `PlainTransport` + `ffmpeg` 进程 +- **第三期(集群化)**: + - 多 Worker、多 Node 实例、Router PipeTransport 跨机互通 + +--- + +## 十五、关联文档 + +- [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md) — 本阶段上级设计 +- [Phase 2e-1 通知中心设计](./2026-04-20-phase2e-1-design.md) — `meeting_invite` 通知类型来源 +- [Phase 2e-2 实施计划](./2026-04-21-phase2e-2-implementation.plan.md) — 本设计的执行拆分 +- [系统总设计](./2026-02-27-echochat-system-design.md) — `meeting_rooms` / `meeting_participants` 初版 DDL 与 Redis key 定义 +- [Phase 2a-2c 设计](./) — 接口注入模式、WS Hub 架构来源 +- [API 文档索引](../api/README.md) — `meeting` 模块 API 文档将新建于 `docs/api/frontend/meeting.md` +- [前后端联动规范](../conventions/frontend-backend-integration.md) — JWT / 错误码 / 响应格式约束 +- [项目规则](../../.cursor/rules/project-context.mdc) — 开发流程与文档自动同步规则 + +--- + +## 十六、变更记录 + +| 日期 | 作者 | 变更内容 | +|---|---|---| +| 2026-04-21 | Agent | 首版落盘。16 章节完整撰写,16 章节含 4 张 mermaid 图、3 张 DDL、1 份 REST API 清单、11 项关键决策记录 | + +--- + +**文档结束** + + + diff --git a/docs/plans/2026-04-21-phase2e-2-implementation.plan.md b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md new file mode 100644 index 0000000..48a1658 --- /dev/null +++ b/docs/plans/2026-04-21-phase2e-2-implementation.plan.md @@ -0,0 +1,539 @@ +# Phase 2e-2 实施计划:会议 MVP(多人音视频) + +> **状态:** 📋 待执行(设计文档定稿后进入代码开发) +> **设计文档:** [Phase 2e-2 设计文档](./2026-04-21-phase2e-2-design.md) +> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md) +> **分支:** `feature/phase2e-2-meeting-mvp` +> **预估总工时:** **约 17 人日**(17 个 Task,含 PoC 与 UI 打磨) +> **最后更新:** 2026-04-21(实施计划首版落盘) + +--- + +## 一、范围锁定 + +### 本期交付(与设计文档 §2.2.1 一一对应) + +- 即时会议创建 / 加入 / 离开 / 结束 / 列表 / 详情 +- 会议号 `XXX-XXX-XXX` + 可选密码(bcrypt)+ 邀请链接 + 通知中心邀请(`meeting_invite`) +- 入会前设备预览页(设备选择 + 本地预览 + 音量检测) +- ≤ 8 人音视频(mediasoup SFU + simulcast 3 档) +- 主持人四件套:静音他人 / 移除成员 / 转让主持人 / 结束会议 +- 会议生命周期:host 掉线 2 分钟宽限 + host 转让(最早加入者)+ 空房 5 分钟 TTL +- 会议内文字聊天(独立 `meeting_chats` 表,24 小时后清理) +- 响应式布局:桌面 3×3 / 平板 2×2 / 手机 单列+抽屉 +- 双态部署:本机 Docker Compose + 公网 `announcedIp` + coturn `--profile public` + +### 显式推迟(与设计文档 §2.2.2 一一对应) + +预约会议 / 提醒 / 等候室 / 锁定 / 屏幕共享 / 录制 / 虚拟背景 / 联合主持人 / 管理端会议管理 / 多 Worker 集群。 + +--- + +## 二、Task 依赖拓扑 + +```mermaid +flowchart LR + T0[Task 0 mediasoup PoC Spike] --> T1[Task 1 media-server 骨架] + T1 --> T2[Task 2 Node 9 个 REST API] + T0 --> T3[Task 3 数据库 DDL] + T3 --> T4[Task 4 Go meeting 模块骨架] + T4 --> T5[Task 5 会议 REST 接口] + T4 --> T6[Task 6 WS 信令处理器] + T2 --> T7[Task 7 Go-Node HTTP Client] + T6 --> T7 + T5 --> T8[Task 8 生命周期状态机] + T2 --> T9[Task 9 前端 mediasoup-client + Store] + T5 --> T10[Task 10 前端预览/创建/加入页] + T9 --> T11[Task 11 会议室主页] + T11 --> T12[Task 12 会议内聊天面板] + T5 --> T13[Task 13 meeting_invite 通知对接] + T11 --> T14[Task 14 docker-compose + 双态配置] + T11 --> T15[Task 15 UI/UX 打磨] + T15 --> T16[Task 16 E2E + 代码审查 + 文档同步] + T12 --> T16 + T13 --> T16 + T14 --> T16 + T8 --> T16 +``` + +**关键路径**:T0 → T1 → T2 → T9 → T11 → T15 → T16(≈ 9 人日) +**并行机会**:T3/T4 可与 T1/T2 并行;T10/T13 可与 T11 并行;T14 可与 T15 并行。 + +--- + +## 三、文件与变更清单概览 + +### 3.1 新建子项目 + +- `media-server/` —— 根级子项目(与 `backend/` / `frontend/` / `admin/` 并列) + - `src/app.ts` / `src/config.ts` + - `src/mediasoup/{worker.ts, router.ts, codecs.ts}` + - `src/routes/{router,transport,producer,consumer}.route.ts` + - `src/services/{router,transport,producer,consumer}.service.ts` + - `src/middlewares/internal-auth.ts` + - `src/utils/logger.ts` + - `tests/*.spec.ts` + - `package.json` / `tsconfig.json` / `Dockerfile` / `.env.example` + +### 3.2 后端新增(`backend/go-service/app/meeting/`) + +| 文件 | 作用 | +|---|---| +| `constants/{meeting_status,meeting_role,ws_events}.go` | 常量 | +| `model/{meeting_room,meeting_participant,meeting_chat}.go` | GORM 模型 | +| `dao/{meeting_room_dao,meeting_participant_dao,meeting_chat_dao}.go` | DAO | +| `service/meeting_service.go` | 房间 CRUD + 生命周期 | +| `service/meeting_signal_service.go` | WS 信令处理 | +| `service/meeting_chat_service.go` | 会议内聊天 | +| `service/node_client.go` | Go → Node HTTP 客户端 | +| `service/interfaces.go` | 对外注入接口(`NotifyPusher` / `UserInfoResolver`) | +| `controller/{meeting_controller,meeting_chat_controller}.go` | REST Controller | +| `controller/meeting_ws_handler.go` | WS 事件处理(注册到 ws.Hub) | +| `router/meeting_router.go` | 路由注册 | +| `task/meeting_cleanup_task.go` | 定时任务:空房 TTL 兜底 + chat 24h 清理 | +| `provider/{provider,wire_gen}.go` | Wire | + +### 3.3 后端改造 + +| 文件 | 改动 | +|---|---| +| `app/provider/wire.go` | 注册 `MeetingSet`;绑定 `meetingService.NotifyPusher = notifyService`、`meetingService.UserInfoResolver = userService`、`ws.Hub.MeetingSignalDispatcher = meetingSignalService` | +| `app/provider/provider.go` | `App` 新增 `MeetingController` / `MeetingChatController` / `MeetingCleanupTask` | +| `cmd/server/main.go` | 启动 `app.MeetingCleanupTask.Start()` + `defer Stop()` | +| `router/router.go` | 注册 meeting 路由 | +| `app/ws/hub.go` / `app/ws/handler.go` | 新增 `MeetingSignalDispatcher` 接口注入;WS 断线时触发 `meeting.OnUserDisconnect` | +| `deploy/docker/postgres/init.sql` | 追加 `meeting_rooms` / `meeting_participants` / `meeting_chats` DDL + 索引 | +| `app/dto/meeting_dto.go` | 请求 / 响应 DTO | + +### 3.4 前端新增(`frontend/src/`) + +| 文件 | 作用 | +|---|---| +| `api/meeting.js` | REST API 封装 | +| `constants/meeting.js` | 会议类型 / 角色 / WS 事件常量 | +| `store/meeting.js` | Pinia Store | +| `utils/mediasoup-client.js` | Device/Transport/Producer/Consumer 封装 | +| `pages/meeting/{index,create,join,preview,room}.vue` | 5 个页面 | +| `components/meeting/{VideoTile,VideoGrid,MeetingToolbar,MemberPanel,ChatPanel,InviteDialog,DevicePreview,NetworkBadge}.vue` | 8 个组件 | + +### 3.5 前端改造 + +| 文件 | 改动 | +|---|---| +| `pages.json` | 新增 5 条 page 注册 | +| `utils/ws.js` | 增加 meeting 事件分发 | +| `components/NotifyItem.vue` | `meeting_invite` 卡片补齐「立即加入 / 稍后」 | +| `store/notify.js` | `meeting_invite` 点击跳转 `/pages/meeting/preview?code=xxx` | + +### 3.6 Docker / 脚本 + +| 文件 | 改动 | +|---|---| +| `deploy/docker-compose.dev.yml` | 新增 `media-server` 服务;新增 `coturn` 服务(`profiles: [public]`) | +| `scripts/start.sh` | 增加 `media` 子命令(启动 media-server)与 `full` 子命令(全部服务) | +| `scripts/stop.sh` | 对应 stop | +| `scripts/status.sh` | 增加 media-server 进程/容器检测 | +| `scripts/deploy-public.sh`(新建) | 公网部署快捷脚本(校验 announcedIp、端口开放) | +| `.env.example` / `.env.local.example` | 新增 `MEDIASOUP_*` / `MEDIA_INTERNAL_TOKEN` / `TURN_*` | + +--- + +## 四、Task 明细 + +### Task 0:mediasoup PoC Spike + +- **目标**:在正式动工前用最小代码跑通"2 个浏览器 ↔ Node ↔ mediasoup ↔ 互相看见视频",验证技术栈可用性与关键参数 +- **依赖**:无(起点) +- **主要产出**: + - `media-server/poc/`(临时目录,PoC 结束后删除或保留为 tests/examples) + - Node 侧 Fastify + mediasoup 单 Router + 2 Transport + 2 Producer + 2 Consumer 跑通 + - 浏览器侧极简 HTML + mediasoup-client + 原生 WebSocket(不走 uni-app) + - 压测记录:1 Worker 下 4/6/8 人会议的 CPU / 带宽 baseline +- **检查点**: + - 本机 Chrome + Firefox 两个窗口可互相看见对方摄像头视频 + - 记录关键坑:`announcedIp` 在 localhost 的处理、DTLS 握手超时的排查方式 + - 输出 `media-server/docs/poc-notes.md`(含启动步骤 + 压测截图) +- **工作量**:**1 人日** +- **风险缓解**:若 PoC 过程中发现 mediasoup 学习曲线超预期,评估是否改用 `livekit-server`(本 Task 为决策关卡) + +### Task 1:media-server 项目骨架 + +- **目标**:创建正式 `media-server/` 目录,包含 TS 配置、Fastify 入口、日志、中间件、Dockerfile +- **依赖**:T0 +- **主要产出**: + - `media-server/package.json`(固定版本号,不做动态解析) + - `media-server/tsconfig.json`、`.eslintrc`、`.prettierrc` + - `media-server/src/app.ts`(Fastify 实例 + `/healthz` 端点) + - `media-server/src/config.ts`(zod 校验 + 环境变量加载) + - `media-server/src/mediasoup/worker.ts`(单 Worker 启动 + die 自动重启) + - `media-server/src/middlewares/internal-auth.ts`(`X-Internal-Token`) + - `media-server/src/utils/logger.ts`(pino) + - `media-server/Dockerfile`(多阶段构建) + - `media-server/.env.example` +- **检查点**: + - `pnpm install && pnpm dev` 本地可启动,`curl /healthz` 返回 `{ok: true, workerPid}` + - Docker 镜像构建成功,运行时打印 Worker PID +- **工作量**:**0.5 人日** + +### Task 2:Node 侧 9 个 REST API + +- **目标**:实现设计文档 §7.2 的 9 个接口,mediasoup 资源完整生命周期 +- **依赖**:T1 +- **主要产出**: + - `src/routes/router.route.ts`(POST/DELETE) + - `src/routes/transport.route.ts`(POST / POST connect) + - `src/routes/producer.route.ts`(POST / DELETE) + - `src/routes/consumer.route.ts`(POST / POST resume / DELETE) + - `src/services/*.service.ts`(对应的资源管理类,使用 `Map`) + - `tests/router.spec.ts` / `tests/transport.spec.ts`(vitest,覆盖创建+释放) +- **检查点**: + - 所有接口走 zod 请求 Schema 校验,非法参数返回 400 带字段错误 + - 缺 `X-Internal-Token` 返回 401 + - 资源释放:DELETE router 后,内部 `routerMap.size === 0` 且 mediasoup `router.closed === true` + - 单元测试覆盖率 ≥ 60% +- **工作量**:**2 人日** + +### Task 3:数据库 DDL + 模型 + DAO + +- **目标**:PostgreSQL 3 张表落地 + Go 侧 model/dao 完整实现 +- **依赖**:T0 +- **主要产出**: + - `deploy/docker/postgres/init.sql` 追加 3 张表 + 索引 + COMMENT(对齐设计 §5.1) + - `app/meeting/model/{meeting_room,meeting_participant,meeting_chat}.go` + - `app/meeting/dao/*.go`(含 CRUD + 事务 + 按 code/user 查询 + 软删除等) + - `app/meeting/constants/meeting_status.go`(`StatusPending/Active/Ended` = 0/1/2) + - `app/meeting/constants/meeting_role.go`(`RoleParticipant/Host/CoHost` = 0/1/2,MVP 仅用 0/1) +- **检查点**: + - `docker compose up postgres -d` 后表结构正确;可手动 `INSERT` 测试数据 + - DAO 单元测试:创建房间 + 参与者加入 + 主持人转让(事务)+ 列表查询 + - 外键约束 `ON DELETE CASCADE` 正常工作(删除房间自动清理参与者/聊天) +- **工作量**:**0.5 人日** + +### Task 4:Go 侧 meeting 模块骨架 + Wire + +- **目标**:controller/service/router/provider 空壳搭建 + Wire 绑定完成 +- **依赖**:T3 +- **主要产出**: + - `app/meeting/controller/*.go`(空 handler + 路由注册) + - `app/meeting/service/*.go`(空方法签名) + - `app/meeting/service/interfaces.go`(`NotifyPusher` / `UserInfoResolver` 接口) + - `app/meeting/router/meeting_router.go` + - `app/meeting/provider/provider.go` + `wire_gen.go` + - `app/provider/wire.go` 注册 `MeetingSet` + interface 绑定 + - `app/provider/provider.go` `App` 结构体新增字段 +- **检查点**: + - `wire ./app/provider` 生成成功,编译通过 + - `go run cmd/server/main.go` 启动无报错 + - 路由打印包含 `/api/v1/meeting/rooms` 等前缀 +- **工作量**:**0.5 人日** + +### Task 5:会议 REST 接口(创建/加入/离开/结束/列表/详情 + 邀请链接兑换 + 邀请) + +- **目标**:填充 Task 4 骨架中的业务逻辑,完整实现设计 §6.2 的 12 个接口 +- **依赖**:T4 +- **主要产出**: + - 会议号生成:`GenerateRoomCode()` 随机 9 位数字,冲突重试(< 3 次) + - 密码 bcrypt 存取(`service.setPassword / verifyPassword`) + - 加入会议时校验:会议存在、未结束、容量未满、密码正确、用户未在其他会议 + - 离开 / 结束:参与者表 `left_at` 填入 + duration 计算 + 触发 mediasoup 资源清理 + - 邀请链接 Token:`GenerateInviteToken() -> Redis SET EX 600`,`RedeemInviteToken(token)` 校验并删除 + - `POST /rooms/:code/invite` 接受 `{invitee_ids, group_ids}`,生成 Token 后调 `NotifyPusher` + - DTO:`app/dto/meeting_dto.go` 完整定义请求/响应结构 + - API 文档:`docs/api/frontend/meeting.md` 新建(全部 12 接口 + 示例) +- **检查点**: + - Postman 手测 12 接口全部 2xx;错误场景返回正确错误码(`meeting_not_found` / `meeting_full` / `password_incorrect` 等) + - 容量限制:第 9 人加入返回 `meeting_full` + - 密码连续错误 5 次锁定 10 分钟 +- **工作量**:**1.5 人日** + +### Task 6:WS 信令 11 事件处理器 + +- **目标**:实现设计 §6.3 的 11 个 WS 事件,完整对接到 `ws.Hub` +- **依赖**:T4 +- **主要产出**: + - `app/meeting/controller/meeting_ws_handler.go`:注册事件回调到 Hub + - `app/meeting/service/meeting_signal_service.go`:3 组事件(房间 / 成员 / 媒体)的业务逻辑 + - `app/ws/hub.go` 接口扩展:`MeetingSignalDispatcher` 接口注入 + `DispatchMeeting(event, payload)` 方法 + - `app/meeting/constants/ws_events.go`:11 个事件名常量 + - 权限校验:所有事件 handler 入口调用 `assertIsParticipant` / `assertIsHost` + - 广播:`Hub.BroadcastToMeeting(roomCode, event, payload, excludeUserID)` 辅助方法 +- **检查点**: + - 通过 `wscat` 或临时前端脚本连入 WS,逐个事件手测 + - 未授权事件(非参与者发 `meeting.member.state.changed`)被拒绝 + - 事件广播覆盖正确(excludeUserID 生效) +- **工作量**:**1.5 人日** + +### Task 7:Go → Node HTTP Client 封装 + +- **目标**:实现设计 §6.6 的 `NodeClient` 接口,挂接到 WS 信令流程 +- **依赖**:T2 + T6 +- **主要产出**: + - `app/meeting/service/node_client.go`:9 个方法完整实现 + - 配置:`config.NodeServiceURL` / `config.NodeInternalToken`(从 yaml + 环境变量) + - 超时:HTTP Client 5 秒超时;关闭类操作 2 秒超时 + - 重试:关闭类操作失败重试 2 次(指数退避 200ms/500ms) + - 日志:每次调用记录 `funcName + room_code + duration + status_code` + - 错误映射:Node 5xx → 业务错误码 `media_server_error`;404 → `media_resource_not_found`;超时 → `media_timeout` +- **检查点**: + - 单元测试:使用 `httptest.NewServer` 模拟 Node,覆盖成功/失败/超时三类 + - 集成测试:Go + Node 真实连通,创建 Router → Transport → Producer → 销毁链路 +- **工作量**:**0.5 人日** + +### Task 8:会议生命周期状态机(host 宽限期 + 自动转让 + 空房 TTL) + +- **目标**:实现设计 §6.5 的状态机完整逻辑 +- **依赖**:T5 +- **主要产出**: + - `app/meeting/service/meeting_service.go` 补充: + - `OnHostDisconnect(ctx, roomCode)` → 写 `echo:meeting:host_grace:{code}` EX 120s + - `OnHostReconnect(ctx, roomCode, userID)` → 清除宽限期键 + - `HandleHostGraceExpired(ctx, roomCode)` → 转让主持(挑最早加入者)或销毁会议 + - `OnAllMembersLeft(ctx, roomCode)` → 设置房间 Redis key TTL 300s + - `OnRoomTTLExpired(ctx, roomCode)` → `status=2, ended_reason=empty_ttl`,清理 Node 资源 + - `app/meeting/task/meeting_cleanup_task.go`:每 30 秒扫描 Redis TTL + DB 状态,兜底清理 + - 事务包裹主持人转让(见设计 §11.3) +- **检查点**: + - 手测:host 关闭浏览器 → 2 分钟内重连,身份保留 → 超过 2 分钟自动转让给另一成员 + - 手测:全员退出 → 5 分钟后 DB `status=2`、Redis key 清空 + - 单元测试覆盖:转让事务回滚、并发转让竞态 +- **工作量**:**0.5 人日** + +### Task 9:前端 mediasoup-client 集成 + Pinia Store + +- **目标**:实现设计 §8.2 / §8.4 的 Store 状态与 mediasoup-client 生命周期封装 +- **依赖**:T2 +- **主要产出**: + - `frontend/src/utils/mediasoup-client.js`: + - `createDevice(rtpCapabilities)` → `mediasoupClient.Device` + - `createSendTransport` / `createRecvTransport` 包装 + 事件桥接到 WS + - `produceAudio(track)` / `produceVideo(track, encodings)` + - `consume(producerId)` 全流程 + - 所有实例通过 `markRaw` 包裹 + - `frontend/src/store/meeting.js`: + - state 完整定义(见设计 §8.2) + - 10+ actions 与 WS `_on*` 事件响应 + - `frontend/src/constants/meeting.js`:事件名 / 类型 / 角色常量 + - `frontend/src/api/meeting.js`:REST 封装 + - `frontend/src/utils/ws.js` 增加 meeting 事件分发桥 +- **检查点**: + - 手动启动前端,在控制台调用 `useMeetingStore().createRoom({title: '测试'})` → 成功入会并推流 + - 两个浏览器标签互相看见视频 + - 关闭标签后另一端 1 秒内收到 `meeting.member.left` +- **工作量**:**1.5 人日** + +### Task 10:前端设备预览页 + 创建页 + 加入页 + +- **目标**:完成 `/pages/meeting/{create,join,preview}.vue` 3 个页面 +- **依赖**:T5 +- **主要产出**: + - `MeetingCreate.vue`:标题输入 + 密码 + 开关(入会静音 / 允许聊天) + 「立即开会」按钮 + - `MeetingJoin.vue`:会议号输入(3-3-3 分组) + 密码 + 承接 `?code=xxx` 参数 + - `MeetingPreview.vue`: + - 左侧视频预览(`