# 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 ``` **技术栈选型**(2026-04-21 Task 1 落盘时的实际锁定版本): - `fastify@5.8.5` —— 高性能 HTTP,TypeScript 原生支持;Fastify 4 已于 2025-06-30 结束 LTS 支持,选用 v5 回归官方维护窗口 - `fastify-plugin@5.1.0` / `@fastify/sensible@6.0.4` / `@fastify/websocket@11.2.0` —— Fastify 5 兼容版本 - `mediasoup@3.19.0` —— WebRTC SFU 核心(v3 最新稳定版,较 Task 0 PoC 的 3.14.11 进一步升级) - `pino@9.3.2` + `pino-pretty@11.2.2` —— 日志库(Fastify 5 原生支持 pino 9/10) - `zod@3.23.8` —— 请求体 Schema 校验(Fastify 5 要求完整 JSON Schema,zod 完美吻合) - `typescript@5.5.4`、`@types/node@20.x`、`tsx@4.16.5`(开发时热更) - `vitest@1.6.0` —— 单元测试 - `eslint@8.57` + `prettier@3.3.3` —— 代码规范 **Node 版本**:`>=20 LTS`(Fastify 5 要求;Dockerfile 使用 `node:20-bookworm-slim`) ### 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 项关键决策记录 | | 2026-04-21 | Agent | Task 5 落地偏离记录:`meeting_rooms.password` 字段改名为 `password_hash`(bcrypt 哈希);DAO `GetByID/GetByCode` 对 `ErrRecordNotFound` 统一返回 `(nil, nil)`;路径微调 `GET /rooms/mine` + `POST /invite-tokens/:token/redeem`;`kick` 请求体字段统一为 `user_id`(而非 `target_user_id`);新增 `MediaOrchestrator` 接口(Task 5 用 Noop 占位,Task 7 真实实现);WS 广播 Task 5 阶段暂用 `PublishToUser` 循环(Task 6 已替换为 `BroadcastToMeeting`) | | 2026-04-21 | Agent | Task 6 落地偏离记录:§6.3 的 11 事件扩展为 **13 事件**(实际 16 含广播回包):新增 `meeting.chat`(REST 聊天广播)+ `meeting.member.producer.new`(produce.start / producer.close 的统一广播);`meeting.member.mute` + `meeting.member.video` 合并为 `meeting.member.state.changed`(加 `hand_raised` 举手字段 + `target_user_id` host 操作字段 + `actor_id`);`meeting.produce.stop` 重命名为 `meeting.producer.close`;`meeting.consume.resume` 暂时不落地(Consumer 创建时 `paused=true`,前端自己调 `/resume` 内部 REST);`meeting.room.info` 不落地(REST `/rooms/:code` 已覆盖);C→S 事件引入白名单常量 `MeetingWSClientEvents` 防伪造;新增 Redis 资源追踪 `echo:meeting:resources:{room_id}:{user_id}`(Set, TTL 1h)用于 WS 断开时自动清理 mediasoup 资源;新增 `MeetingBroadcaster` 统一广播层供 REST / WS 共用 | | 2026-04-21 | Agent | Task 7 落地偏离记录:§6.6 `NodeClient` 文件改名为 `HTTPMediaOrchestrator`(与 Task 6 抽的 `MediaOrchestrator` 接口语义一致);接口方法数保持 **8 个**(设计列 9 个含 `ResumeConsumer`,暂不落地,Node REST 已就绪,但 WS C→S 事件未暴露,推迟到 Task 9 前端 mediasoup-client 集成时补齐);新增错误类型 `ErrMediaResourceNotFound`(Node 404 → 关闭类幂等转 nil)和 `ErrMediaServerError`(5xx / 超时 / 网络错)替代设计中较泛的 `media_timeout` / `media_server_error` 字符串枚举,便于 `errors.Is` 精准区分;**`roomCode ↔ routerID` `sync.Map` 本地缓存**:设计 §6.6 `CloseRouter(ctx, roomCode)` 入参是 `roomCode`,但 Node 以 `routerID` 为资源主键,通过缓存反查达成两端契约融合;go-service 重启缓存丢失与 Node 重启 Router 释放的状态自然同步;单元测试(`httptest.NewServer` 模拟)本次未落地,用"真实 Go + 真 Node + E2E Node.js 脚本 16/16 PASS"替代,证据链更强;新增配置段 `media_server: {base_url, internal_token, timeout_ms, close_timeout_ms, close_retry}`,dev 与 docker 两套 yaml 差异化 base_url;遗留项:`CreateRoom` 与 `JoinRoom` 重复调 `CreateRouter`(Task 8 修复),`/transports/:id/stats` Node 未实现(Task 10 补齐) | --- **文档结束**