Files
EchoChat/docs/plans/2026-04-21-phase2e-2-design.md
bujinyuan f41e63ba6b docs(phase2e-2): 补齐顶层文档进度同步,Phase 2e-2 会议 MVP 全面切
用户准备开始真实用户测试,此 commit 对"六份文档同步"之外遗漏的上层/入口文档
做二次补齐,保证所有出现阶段状态描述的文档都与 CURRENT_STATUS.md 一致。

补齐范围:
- README.md
  · "MVP 功能规划" 原把 Phase 2 / Phase 3 标记为"待开始",与实际进度严重偏离
  · 整段重写为"开发进度"小节,按 Phase 1 / 2a-d / 2e-1 / 2e-2 分别列出 
    已完成状态与关键交付;2e-3 / Phase 3 / P2 推迟项独立列为"待启动(规划中)"
  · "文档导航"扩展 5 条新入口:Phase 2e 路线图 / 2e-2 设计 / 2e-2 实施 /
    2e-2 代码审查 / 2e-2 验收报告 / 会议 MVP 部署指南
- docs/plans/2026-02-27-echochat-system-design.md(整体系统设计方案)
  · §Phase 2e 行:追加"2e-1  / 2e-2  / 2e-3 📋 待启动"状态徽标
  · §Phase 2e-2:从"📋 设计阶段完成,代码开发待启动"改为
    " 已完成(2026-04-24,Task 0-16 全量落地 + 代码审查 4 P0 + 8 P1 + 7 P2 + 7 Nit 闭环)"
- docs/plans/2026-04-21-phase2e-2-design.md(Phase 2e-2 设计文档)
  · 顶部"状态 / 最后更新"由"📋 设计阶段(待评审后进入代码开发)"切 
  · §一 文档定位的"本文档"行同步切 
- docs/plans/2026-04-24-phase2e-2-task15-ui-polish.plan.md(Task 15 计划)
  · 顶部"状态"由"📋 设计阶段(等待用户 Review)"切 
  · 追加"交付记录"行指向 CURRENT_STATUS.md Task 15/16 交付条目
- docs/deployment/meeting-mvp.md(会议 MVP 部署指南)
  · 顶部元信息追加阶段  标记 + Task 16 追加的 REDIS_PASSWORD × redis.conf
    requirepass 联动校验指引

验证:workspace grep "📋 设计阶段|🚧 代码开发|待开始" 在顶层 README/docs 下
已无遗留进度描述与实际状态冲突(仅 README 新增的"Phase 2e-3 / Phase 3 — 待启动
(规划中)"章节为规划项,符合预期)。

Made-with: Cursor
2026-04-23 17:59:32 +08:00

1101 lines
58 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 2e-2 设计文档:会议 MVP多人音视频
> **状态:** ✅ 已完成Task 0-16 全部落地,代码审查 4 P0 + 8 P1 + 7 P2 + 7 Nit 闭环5 项推迟登记)
> **上级设计:** [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-24Task 16 收官E2E 总回归 + 代码审查修复 + 资源生命周期审计 + 文档同步,阶段切 ✅)
---
## 一、文档定位说明
项目惯例:每个 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 APIuni-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 可按需扩容或替换
- **通信形式**
- 客户端 GoWebSocket信令 + HTTPSREST 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 阶段应用层强制为 8schema 默认值保留 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 Transportsend + recv+ 2 Produceraudio + video+ 最多 7 Consumer8 人会议中订阅其他 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` CS加入会议成功后的"宣告在线"绑定 userID roomCode
- `meeting.room.leave`CS主动离会
- `meeting.room.ended`SC会议已结束广播给所有成员
**成员组4 个)**
- `meeting.member.joined`SC新成员加入广播
- `meeting.member.left`SC成员离开广播
- `meeting.member.state.changed`双向静音 / 开关摄像头等状态变更发送方为 CS服务端向其他成员广播 SC
- `meeting.member.kicked`SC成员被移除定向发给被移除者 + 广播状态更新
- `meeting.host.changed`SC主持人变更广播
**媒体组5 个mediasoup 信令)**
- `meeting.transport.create`CSNodeSC创建 Transport §4.2
- `meeting.transport.connect`CSNodeSCTransport DTLS 握手
- `meeting.produce.start`CSNodeSC创建 Producer广播 `meeting.member.producer.new` 给其他成员
- `meeting.consume.start`CSNodeSC为某远端 Producer 创建本地 Consumer
- `meeting.producer.close`CSNodeSC关闭自己的 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 EndedHostGrace / 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` —— 高性能 HTTPTypeScript 原生支持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 Schemazod 完美吻合
- `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<routerId, Router>`达到 200 个时拒绝新 Router 创建预留水位实际按 CPU 压测再调
- **资源释放**`DELETE /routers/:routerId` 时递归释放 router 下所有 transport/producer/consumer内部事件监听自动 GC 对应资源
- **未来扩展点**Worker Worker PipeTransportRouter 水平分片第三期
### 7.4 编解码与 Simulcast
Router 创建时注入 `mediaCodecs`
- Audio`opus / 48000 / 2`
- Video`VP8`首选+ `H264`降级兼容 Safari
**Simulcast 档位**MVP VideoProducer 端由客户端上送 3 encodingsConsumer 端由 Node 根据订阅者网络状况切换
- `high`720p @ 25fpsmaxBitrate 1 Mbps
- `mid`360p @ 25fpsmaxBitrate 400 Kbps
- `low`180p @ 15fpsmaxBitrate 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 密码哈希
- 算法bcryptGo `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/consumermediasoup 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 秒无恢复 → 主动关闭 transportGo 通过心跳 `/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')` 自动重启 WorkerGo 层收到 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、防火墙端口
- [ ] E2EPlaywright 覆盖 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 DDL1 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` 已覆盖CS 事件引入白名单常量 `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 CS 事件未暴露推迟到 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 补齐 |
---
**文档结束**