feat(phase2e-2): 前端 mediasoup-client 集成 + Pinia meeting Store(Task 9)
- frontend/src/api/meeting.js:12 个 REST 接口封装,统一 unwrap envelope.data - frontend/src/services/websocket.js:新增 sendWithAck(Promise 化 + 超时 + 序列号) - frontend/src/utils/mediasoup-client.js:MediaEngine 包装 Device/Transport/Producer/Consumer - frontend/src/store/meeting.js:Pinia 会议状态机,桥接 14 个 WS 事件 + cleanupStaleMeetings - frontend/src/constants/meeting.js:状态枚举 + 事件名集中管理 - frontend/src/pages/meeting/debug.vue:临时调试页(H5 原生 video/audio DOM 绕过 uni 组件限制) - backend:meeting.consume.resume WS 事件 + create/join 响应透传 router_id + rtp_capabilities - 文档:frontend/meeting.md、websocket.md、CURRENT_STATUS、plan 全部同步 Task 9 落地 Made-with: Cursor
This commit is contained in:
@@ -27,7 +27,7 @@ alwaysApply: true
|
|||||||
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
|
* 单端 WS 连接架构(沿用),不做多端已读同步(设计文档 §3.1/§3.5/§九 已修订,多端改造推迟到 Phase 2f/二期)
|
||||||
* 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md`
|
* 专用设计:`docs/plans/2026-04-20-phase2e-1-design.md`;实施计划:`docs/plans/2026-04-20-phase2e-1-implementation.plan.md`;验证报告:`test-report-phase2e-1-notification.md`
|
||||||
* API 文档:`docs/api/frontend/notify.md`
|
* API 文档:`docs/api/frontend/notify.md`
|
||||||
- 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-8 ✅ / Task 9-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + **会议生命周期状态机**(host 宽限期 + 自动转让 + 空房 TTL)+ 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机)
|
- 2e-2 会议 MVP(约 17 天)🚧 **代码开发中**(Task 0-9 ✅ / Task 10-16 待执行):mediasoup Node.js 独立 `media-server/` + 即时会议(≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + **会议生命周期状态机**(host 宽限期 + 自动转让 + 空房 TTL)+ 双态部署(本机 + 公网 coturn)+ 响应式(桌面/手机)
|
||||||
* 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task)
|
* 专用设计:`docs/plans/2026-04-21-phase2e-2-design.md`(16 章节);实施计划:`docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(17 个 Task)
|
||||||
* 11 项关键决策已锁定(D01-D11),详见设计文档 §三
|
* 11 项关键决策已锁定(D01-D11),详见设计文档 §三
|
||||||
* **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
|
* **重要修订**:`meeting_rooms.password` → `password_hash`(bcrypt),新增 `meeting_chats` 表 + `ended_reason` / `left_reason` 字段,新增 `echo:meeting:invite:{token}` / `host_grace:{code}` Redis key
|
||||||
@@ -36,6 +36,7 @@ alwaysApply: true
|
|||||||
* **Task 2 ✅ 9 个内部 REST API 完成(2026-04-21,含代码审查修复)**:`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`)+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**,`producerclose` 级联关闭下游 consumer;Consumer 强制 `paused:true` 创建 + `/resume` 独立接口;direction 强约束(recv transport 拒 produce、send transport 拒 consume)。**code-reviewer 子代理"有条件通过"**,2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验(mimeType/clockRate/payloadType 必填、codecs 数组 ≥1),消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await);(4) m2 改读 `consumer.producerPaused`;(5) m3 `producerclose` 改为 `once`;(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s)、覆盖率 82.87%/75.83%/91.3%/82.87%**(stmts/branches/funcs/lines,较首版 +2pp);9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回(201/200/400/401/404/409)。产出:`src/schemas/*`(6 文件,新增 `rtp.ts`)+ `src/services/*`(4 文件)+ `src/routes/*`(4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`(8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单
|
* **Task 2 ✅ 9 个内部 REST API 完成(2026-04-21,含代码审查修复)**:`media-server/` 落地 Router/Transport/Producer/Consumer 四类资源的 9 个接口,全部挂 `/internal/v1/*` 前缀;**zod 手动 parse + 全局 errorHandler** 方案(不引入 fastify-type-provider-zod 避免 zod v4 依赖冲突);`AppError` 统一错误码(`NOT_FOUND`/`CONFLICT`/`CAN_NOT_CONSUME`/`ROUTER_LIMIT_EXCEEDED`/`MEDIASOUP_ERROR`)+ `VALIDATION_ERROR`/`UNAUTHORIZED`/`INTERNAL_ERROR`;所有资源用 **Map + `observer.once('close')` 自清理**,`producerclose` 级联关闭下游 consumer;Consumer 强制 `paused:true` 创建 + `/resume` 独立接口;direction 强约束(recv transport 拒 produce、send transport 拒 consume)。**code-reviewer 子代理"有条件通过"**,2 Major + 4 高价值 Minor 当场修复:(1) M1 `_clearXxxMap` 新增 `assertTestOnly` 守卫(生产误调用直接抛错);(2) M2 新增 `src/schemas/rtp.ts` 对 `rtpParameters` / `rtpCapabilities` 做 codecs 浅层校验(mimeType/clockRate/payloadType 必填、codecs 数组 ≥1),消除 `as unknown as` 双跳断言;(3) m1 `connectTransport` 改乐观锁(先置位再 await);(4) m2 改读 `consumer.producerPaused`;(5) m3 `producerclose` 改为 `once`;(6) m5 `internal-auth` 改为反向白名单 `PRIVATE_PATH_PREFIXES = ['/internal/']`(默认开放)。**65 个 vitest 测试全过(~1s)、覆盖率 82.87%/75.83%/91.3%/82.87%**(stmts/branches/funcs/lines,较首版 +2pp);9 接口 happy path + 6 类错误路径人工 curl 全部按预期返回(201/200/400/401/404/409)。产出:`src/schemas/*`(6 文件,新增 `rtp.ts`)+ `src/services/*`(4 文件)+ `src/routes/*`(4 文件)+ `src/middlewares/{error-handler,internal-auth}.ts` + `src/utils/{errors,test-guard}.ts` + `src/mediasoup/codecs.ts` + `vitest.config.ts` + `tests/*`(8 spec 文件,含 `test-guard.spec.ts`)。余下 Minor/Nits(m4/m6~m10、n1~n10)登记至 Task 16 收尾清单
|
||||||
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`(幂等增量升级)。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`:3 个 model + 3 个 DAO(`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1`);`FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason`;`ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误;psql 集成脚本跑通 8 场景(CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护)
|
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成(2026-04-21)**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQL,DDL 同时写入 `init.sql`(全量初始化)与 `phase2e2_migration.sql`(幂等增量升级)。Go 侧 `backend/go-service/app/meeting/{model,dao}` + 统一常量 `app/constants/meeting.go`:3 个 model + 3 个 DAO(`meeting_room_dao.go` 9 方法 / `meeting_participant_dao.go` 11 方法 / `meeting_chat_dao.go` 4 方法),共 24 个持久化方法;`JoinRoom` 事务内复用离会后的旧记录(`left_at=NULL,joined_at=NOW,duration=0`)避免审计表污染;`LeaveRoom` 用 `EXTRACT(EPOCH FROM (? - joined_at))::INT` 走 DB 时间防跨时区漂移;`TransferHost` 事务链(`role=1→0` + `role=0→1`);`FindActiveByUser` 用 JOIN 校验用户单点参会;`MarkEnded` 乐观锁防重复覆盖 `ended_reason`;`ListExpiredForCleanup` 供后续清理任务批量扫描。**关键风格修正(偏离实施计划草案)**:按 `project-context` 第 11 条「代码风格全局一致(最高优先级)」,常量归入 `app/constants/meeting.go` 单文件(与 `group.go`/`notify.go` 同构),而非草案的 `app/meeting/constants/*.go`;时间字段统一 `TIMESTAMP(0)` 取代草案的 `TIMESTAMPTZ` 对齐项目所有现有表;冗余 `idx_meeting_rooms_code` 移除(`room_code UNIQUE` 已自动建索引)。验证:`go build ./...` / `go vet ./...` / `ReadLints` 零错误;psql 集成脚本跑通 8 场景(CRUD + 双 UNIQUE 约束 + 主持人转让事务 + `duration=10s` 精确匹配 + CASCADE 清零)。延续项目 Go 侧"零 `_test.go`"风格(用代码审查 + psql 真库验证 + Playwright E2E 三层守护)
|
||||||
* **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成(2026-04-21)**:`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`);(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`;(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`(501)+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`;(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind`(`NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`);(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**:`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider,解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`;curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`,JWT 中间件生效
|
* **Task 4 ✅ Go meeting 模块 service/controller/router 骨架完成(2026-04-21)**:`app/meeting/` 补齐 service/controller/router/provider 四件套,接口 → 实现按设计文档 §5.3 一一对齐。产出:(1) `service/interfaces.go` 定义 3 个外部依赖接口(`NotifyPusher` / `UserInfoResolver` / `OnlineChecker`),解耦 notify/contact/ws 模块避免循环依赖;`OnlineChecker.IsOnline` 签名与现存 `ws.OnlineService` 一致(返回单 `bool`);(2) `service/meeting_service.go` 声明 `MeetingService` + 8 个 sentinel error + 17 个业务方法空实现,全部返回 `ErrNotImplemented`;(3) `controller/meeting_controller.go` 12 个 Gin 处理器 + `responseNotImplemented`(501)+ `requireUserID` 辅助;(4) `router.go` 12 条路由挂载到 `/api/v1/meeting/*` 并统一套 `jwtAuth` 中间件;(5) `provider.go` 定义 `MeetingSet = wire.NewSet(DAO×3, Service, Controller)`;(6) 全局 `app/provider/wire.go` 挂入 `meetingApp.MeetingSet` + 3 条 `wire.Bind`(`NotifyPusher→NotifyService` / `UserInfoResolver→FriendshipDAO` / `OnlineChecker→ws.OnlineService`);(7) `app/provider/provider.go` `App` 加 `MeetingService/MeetingController` 字段;(8) `router/router.go` 调用 `meetingApp.RegisterRoutes`。**顺手修复存量 bug**:`admin/provider.go` 补齐 `MessageManage{DAO,Service,Controller}` 三个 provider,解决旧版 `wire` 重生成报"no provider found"的遗留问题。验证:`go build ./...` / `go vet ./...` / `wire ./app/provider` 全绿;`GIN_MODE=debug` 启动 server 日志打印全部 12 条 `[GIN-debug] ... meeting/controller.(*MeetingController).Xxx-fm`;curl 无 token 打 3 条代表性路由均返回 401 `缺少认证信息`,JWT 中间件生效
|
||||||
|
* **Task 9 ✅ 前端 mediasoup-client + Pinia Store 落地(2026-04-21)**:前端新增 `constants/meeting.js`(150 行,14 个 WS 事件 + 本地状态机常量)/ `api/meeting.js`(120 行,12 REST 全量封装)/ `utils/mediasoup-client.js`(270 行,`createMediaEngine` 封装 Device/Transport/Producer/Consumer 全生命周期 + `#ifdef H5` 平台隔离 + `markRaw` 防 Vue 深度代理)/ `store/meeting.js`(615 行,Pinia Store:本地状态机 + 8 个广播事件桥 + 20+ action,WS 监听进房注册/离房注销);扩展 `services/websocket.js` 新增 `sendWithAck(event, data, timeoutMS)` Promise 化接口 + `pendingAcks` Map + `_handleAck` 路由(自动识别 `*.ack` 后缀)+ `_rejectAllPendingAcks` 断线清理。**后端补齐**:新增 `meeting.consume.resume` WS 事件(`constants/meeting.go` + `MeetingSignalService.OnConsumeResume` + `MeetingWSHandler.handleConsumeResume` + `MediaOrchestrator.ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume`);`CreateMeetingRoomResponse` / `JoinMeetingRoomResponse` 新增 `router_id` / `rtp_capabilities`(`HTTPMediaOrchestrator` 用 `routerInfoCache{ID, RtpCapabilities}` 同时缓存两者 + `ResolveRouterInfo` 返回给前端供 `Device.load()`)。**临时调试页**:`pages/meeting/debug.vue`(280 行)会议状态面板 + 生命周期按钮 + 音/视频开关 + 远端参与者渲染 + 聊天面板,`<video>`/`<audio>` 用 `document.createElement` 原生 DOM 挂到 `<view>` 容器(绕过 uni-h5 Video/Audio 组件不支持 `srcObject` 的限制),`#ifdef H5` 保护非 H5 构建正常。**7 项决策锁定**:Q1=H5 Only 多端策略 / Q2=npm dependencies / Q3=进房注册 WS 监听 / Q4=12 接口全量封装 / Q5=services 层 sendWithAck / Q6=**WS 暴露 meeting.consume.resume**(而非 Go 自动 resume,理由:官方规范 + DOM 挂完 track 再 resume 避免首帧黑屏 + 未来 simulcast/订阅变更/后台节流扩展点)/ Q7=debug.vue 页手测。验证:`go vet ./...` + `go test ./app/meeting/...` 全绿;`npm run build:h5` 通过,无本次新增 warning(剩余 `chat.js/notify.js dynamic import` + Sass legacy API 告警均为 pre-existing)。**已知遗留**:(a) Consumer resume 当前在 store 层立即触发未等 `<video>.onloadedmetadata`,首帧可能 50-100ms 抖动,Task 11 会议室主页将下移到页面层;(b) `_cleanupRemoteProducer` 当前粗粒度关闭 slot 全部 Consumer,Task 11 重构为 `Map<producerId, consumer>` 精细索引;(c) Chrome 两 tab 手测 + WS 断线重入会需用户本地运行验证,Task 16 补 Playwright 自动化脚本回归
|
||||||
* **Task 8 ✅ 会议生命周期状态机 + Router 幂等双层防御(2026-04-21)**:设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期 / 宽限期内重连 / 自动转让 / 空房 TTL / TTL 过期销毁)全部落地;同步修复 Task 7 遗留的"CreateRoom + JoinRoom 重复调 `CreateRouter`"行为。产出:(1) `config/config.go` 新增 `MeetingConfig{HostGraceSeconds=120, EmptyRoomTTLSeconds=300, CleanupIntervalSeconds=30, StaleRoomHours=4}` + dev/docker yaml;(2) `app/meeting/service/meeting_lifecycle_service.go`(新 470 行)封装 `OnHostDisconnect` / `OnHostReconnect` / `HandleHostGraceExpired` / `OnAllMembersLeft` / `CancelEmptyTTL` / `HandleEmptyRoomExpired` 6 钩子,`sync.Map` 管理本地 `time.AfterFunc` timer + Redis key 双保险;`RescheduleFromRedis` 重启按剩余 PTTL 重建 timer;**Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer**(调试期实测发现若 Redis TTL 与本地 timer 同步到期,`DEL` 会因 key 已被 Redis 自动过期返回 0 被误判为"已被其他路径处理"而跳过业务逻辑,加 buffer 让本地 timer 先触发 DEL 命中);并发保护靠入口 `DEL` 返回值(1 = 本次处理,0 = 已被其他路径处理);(3) `app/meeting/dao/meeting_room_dao.go` 新增 `ListStaleActive(hoursAgo, limit)` 扫陈旧活跃房间;(4) `app/meeting/service/interfaces.go` `MediaOrchestrator` 新增 `ResolveRouterID(roomCode) (string, bool)` 让业务层直接查缓存不创建;(5) `app/meeting/service/http_media_orchestrator.go` `CreateRouter` 入口查 `sync.Map` 命中直接返回(HTTP 层幂等防御);(6) `app/meeting/service/meeting_service.go` `JoinRoom` **移除 `CreateRouter` 调用**改 `lifecycleSvc.CancelEmptyTTL` + `ResolveRouterID`;`LeaveRoom` 空房分支改调 `lifecycleSvc.OnAllMembersLeft` 不再立即销毁房间;(7) `app/meeting/service/meeting_signal_service.go` 新增 `OnWSDisconnect(userID)` 实现 `ws.MeetingDisconnectHook` 接口(host 掉线调宽限期 + 非 host 仅清 media 资源,决策 `q1_nonhost_disconnect=a1_keep_current` 不动 participant 表,长期不活跃由 4 小时后台兜底),`OnRoomJoin` 追加 host 重连钩子;(8) `app/ws/handler.go` 定义 `MeetingDisconnectHook` 接口 + `SetMeetingDisconnectHook` 注入,解耦 ws → meeting 反向依赖;(9) `app/meeting/task/meeting_cleanup_task.go`(新 220 行)启动时 `RescheduleFromRedis`;每 `CleanupIntervalSeconds` 秒 `ScanExpired` 兜底扫 `host_grace:*` / `empty_ttl:*` + 扫 stale active rooms → `MarkEnded(reason=system_error)`;每小时清理 Ended 会议旧聊天;(10) `app/provider/{provider,wire,wire_gen}.go` + `cmd/server/main.go` 注入新 service/task + `wire.Bind(new(ws.MeetingDisconnectHook), new(*service.MeetingSignalService))` + 启动/停止 cleanup task;(11) `media-server/src/app.ts` `/internal/info` 追加 `stats.routers` 供 E2E 断言 Router 幂等;(12) `docs/verify/meeting_t8_verify.mjs`(新 320 行)5 场景 E2E 脚本(用 env 加速 TTL 到 3s 跑测)。**关键设计决策**:(a) Redis TTL 加 buffer 避免 DEL 与自动过期并发误判(修复 bug 的核心);(b) 本地 timer 低延迟 + cleanup task 兜底,多实例由入口 DEL 保证唯一处理权;(c) Router 幂等双层防御(业务层 `JoinRoom` 不创建 + HTTP 层 `sync.Map` 命中直接返回,决策 `q2_router_dedup=a2_both`);(d) 全员 leave 不再立即销毁房间,由 `empty_ttl` + 本地 timer 实现"可复活"语义;(e) 普通成员 WS 断开仅清 media 资源不动 DB。验证:`go build`/`go vet`/`ReadLints` 全绿(保留 Task 7 遗留 pre-existing 警告);启动 media-server + go-service 跑 E2E **20/20 PASS**,5 场景全部验证(host 宽限期过期自动转让 + 宽限期内重连保留 + 空房 TTL 复活 + 空房 TTL 过期销毁 + JoinRoom 不再重复 Router)
|
* **Task 8 ✅ 会议生命周期状态机 + Router 幂等双层防御(2026-04-21)**:设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期 / 宽限期内重连 / 自动转让 / 空房 TTL / TTL 过期销毁)全部落地;同步修复 Task 7 遗留的"CreateRoom + JoinRoom 重复调 `CreateRouter`"行为。产出:(1) `config/config.go` 新增 `MeetingConfig{HostGraceSeconds=120, EmptyRoomTTLSeconds=300, CleanupIntervalSeconds=30, StaleRoomHours=4}` + dev/docker yaml;(2) `app/meeting/service/meeting_lifecycle_service.go`(新 470 行)封装 `OnHostDisconnect` / `OnHostReconnect` / `HandleHostGraceExpired` / `OnAllMembersLeft` / `CancelEmptyTTL` / `HandleEmptyRoomExpired` 6 钩子,`sync.Map` 管理本地 `time.AfterFunc` timer + Redis key 双保险;`RescheduleFromRedis` 重启按剩余 PTTL 重建 timer;**Redis key TTL = 业务时长 + max(CleanupIntervalSeconds*2, 30s) buffer**(调试期实测发现若 Redis TTL 与本地 timer 同步到期,`DEL` 会因 key 已被 Redis 自动过期返回 0 被误判为"已被其他路径处理"而跳过业务逻辑,加 buffer 让本地 timer 先触发 DEL 命中);并发保护靠入口 `DEL` 返回值(1 = 本次处理,0 = 已被其他路径处理);(3) `app/meeting/dao/meeting_room_dao.go` 新增 `ListStaleActive(hoursAgo, limit)` 扫陈旧活跃房间;(4) `app/meeting/service/interfaces.go` `MediaOrchestrator` 新增 `ResolveRouterID(roomCode) (string, bool)` 让业务层直接查缓存不创建;(5) `app/meeting/service/http_media_orchestrator.go` `CreateRouter` 入口查 `sync.Map` 命中直接返回(HTTP 层幂等防御);(6) `app/meeting/service/meeting_service.go` `JoinRoom` **移除 `CreateRouter` 调用**改 `lifecycleSvc.CancelEmptyTTL` + `ResolveRouterID`;`LeaveRoom` 空房分支改调 `lifecycleSvc.OnAllMembersLeft` 不再立即销毁房间;(7) `app/meeting/service/meeting_signal_service.go` 新增 `OnWSDisconnect(userID)` 实现 `ws.MeetingDisconnectHook` 接口(host 掉线调宽限期 + 非 host 仅清 media 资源,决策 `q1_nonhost_disconnect=a1_keep_current` 不动 participant 表,长期不活跃由 4 小时后台兜底),`OnRoomJoin` 追加 host 重连钩子;(8) `app/ws/handler.go` 定义 `MeetingDisconnectHook` 接口 + `SetMeetingDisconnectHook` 注入,解耦 ws → meeting 反向依赖;(9) `app/meeting/task/meeting_cleanup_task.go`(新 220 行)启动时 `RescheduleFromRedis`;每 `CleanupIntervalSeconds` 秒 `ScanExpired` 兜底扫 `host_grace:*` / `empty_ttl:*` + 扫 stale active rooms → `MarkEnded(reason=system_error)`;每小时清理 Ended 会议旧聊天;(10) `app/provider/{provider,wire,wire_gen}.go` + `cmd/server/main.go` 注入新 service/task + `wire.Bind(new(ws.MeetingDisconnectHook), new(*service.MeetingSignalService))` + 启动/停止 cleanup task;(11) `media-server/src/app.ts` `/internal/info` 追加 `stats.routers` 供 E2E 断言 Router 幂等;(12) `docs/verify/meeting_t8_verify.mjs`(新 320 行)5 场景 E2E 脚本(用 env 加速 TTL 到 3s 跑测)。**关键设计决策**:(a) Redis TTL 加 buffer 避免 DEL 与自动过期并发误判(修复 bug 的核心);(b) 本地 timer 低延迟 + cleanup task 兜底,多实例由入口 DEL 保证唯一处理权;(c) Router 幂等双层防御(业务层 `JoinRoom` 不创建 + HTTP 层 `sync.Map` 命中直接返回,决策 `q2_router_dedup=a2_both`);(d) 全员 leave 不再立即销毁房间,由 `empty_ttl` + 本地 timer 实现"可复活"语义;(e) 普通成员 WS 断开仅清 media 资源不动 DB。验证:`go build`/`go vet`/`ReadLints` 全绿(保留 Task 7 遗留 pre-existing 警告);启动 media-server + go-service 跑 E2E **20/20 PASS**,5 场景全部验证(host 宽限期过期自动转让 + 宽限期内重连保留 + 空房 TTL 复活 + 空房 TTL 过期销毁 + JoinRoom 不再重复 Router)
|
||||||
* **Task 7 ✅ HTTPMediaOrchestrator Go↔Node 媒体链路打通(2026-04-21)**:`NoopMediaOrchestrator` 替换为真实 HTTP 实现,`MeetingService` / `MeetingSignalService` 的 9 个媒体操作全部走 `POST/DELETE /internal/v1/*` 调到 Node media-server。产出:(1) `config/config.go` 新增 `MediaServerConfig{BaseURL, InternalToken, TimeoutMS(5000), CloseTimeoutMS(2000), CloseRetry(2)}` + dev/docker 两份 yaml 配置(dev 直连 `localhost:3300`,docker 走服务名 `media-server:3300`);(2) `app/meeting/service/http_media_orchestrator.go`(新 340 行)用 `net/http` 标准库实现 `MediaOrchestrator` 8 方法,`context.WithTimeout` 驱动差异化超时,创建类一次性透传,**关闭类指数退避 200ms→500ms 最多 CloseRetry+1 次**(幂等安全);`X-Internal-Token` header 与 `media-server/.env MEDIA_INTERNAL_TOKEN` 配对;错误类型 `ErrMediaResourceNotFound`(404)与 `ErrMediaServerError`(5xx/超时/网络错)可供 `errors.Is` 精准区分;**`sync.Map` 本地 `roomCode ↔ routerID` 缓存**兼容设计 §6.6 `CloseRouter(roomCode)` 签名但 Node 以 routerID 为主键的约束,go-service 重启缓存丢失与 Node 重启 Router 释放状态自然同步;(3) `app/meeting/provider.go` 切换 `wire.Bind` 到 `*HTTPMediaOrchestrator`,`wire_gen.go` 再生;(4) `docs/verify/meeting_t7_verify.mjs`(新 140 行)E2E 验证脚本。**关键设计决策**:(a) 关闭类幂等重试 vs 创建类一次性透传 —— 防孤儿资源;(b) 错误语义二分 —— 让 404 关闭转 nil、5xx 透传 WS ACK;(c) 接口维持 8 方法不含 `ResumeConsumer` —— Node REST 已就绪但 WS 契约未暴露,留给 Task 9 前端 mediasoup-client 接入时按需补齐。**已知遗留**:(i) CreateRoom + JoinRoom 各自调 `CreateRouter` 重复创建,Task 8 需改为"仅首次创建 Router 其余复用";(ii) `/transports/:id/stats` Node 暂未实现,Task 10 可观测性补齐。验证:`go build`/`go vet` 全绿;启动 media-server(Node 3300) + go-service(Go 8085),E2E **16/16 PASS** —— REST 创建/加入/结束会议触发 Router 创建/销毁,WS `transport.create` 返回真实 mediasoup `id`(非 noop- 前缀)+ 非空 `iceCandidates[]` + `dtlsParameters.fingerprints[]`,关闭虚构 producer 触发 Node 404 → Go 幂等转 ok;media-server 日志对齐 `router created × 2 / webrtc transport created / router closed explicitly`
|
* **Task 7 ✅ HTTPMediaOrchestrator Go↔Node 媒体链路打通(2026-04-21)**:`NoopMediaOrchestrator` 替换为真实 HTTP 实现,`MeetingService` / `MeetingSignalService` 的 9 个媒体操作全部走 `POST/DELETE /internal/v1/*` 调到 Node media-server。产出:(1) `config/config.go` 新增 `MediaServerConfig{BaseURL, InternalToken, TimeoutMS(5000), CloseTimeoutMS(2000), CloseRetry(2)}` + dev/docker 两份 yaml 配置(dev 直连 `localhost:3300`,docker 走服务名 `media-server:3300`);(2) `app/meeting/service/http_media_orchestrator.go`(新 340 行)用 `net/http` 标准库实现 `MediaOrchestrator` 8 方法,`context.WithTimeout` 驱动差异化超时,创建类一次性透传,**关闭类指数退避 200ms→500ms 最多 CloseRetry+1 次**(幂等安全);`X-Internal-Token` header 与 `media-server/.env MEDIA_INTERNAL_TOKEN` 配对;错误类型 `ErrMediaResourceNotFound`(404)与 `ErrMediaServerError`(5xx/超时/网络错)可供 `errors.Is` 精准区分;**`sync.Map` 本地 `roomCode ↔ routerID` 缓存**兼容设计 §6.6 `CloseRouter(roomCode)` 签名但 Node 以 routerID 为主键的约束,go-service 重启缓存丢失与 Node 重启 Router 释放状态自然同步;(3) `app/meeting/provider.go` 切换 `wire.Bind` 到 `*HTTPMediaOrchestrator`,`wire_gen.go` 再生;(4) `docs/verify/meeting_t7_verify.mjs`(新 140 行)E2E 验证脚本。**关键设计决策**:(a) 关闭类幂等重试 vs 创建类一次性透传 —— 防孤儿资源;(b) 错误语义二分 —— 让 404 关闭转 nil、5xx 透传 WS ACK;(c) 接口维持 8 方法不含 `ResumeConsumer` —— Node REST 已就绪但 WS 契约未暴露,留给 Task 9 前端 mediasoup-client 接入时按需补齐。**已知遗留**:(i) CreateRoom + JoinRoom 各自调 `CreateRouter` 重复创建,Task 8 需改为"仅首次创建 Router 其余复用";(ii) `/transports/:id/stats` Node 暂未实现,Task 10 可观测性补齐。验证:`go build`/`go vet` 全绿;启动 media-server(Node 3300) + go-service(Go 8085),E2E **16/16 PASS** —— REST 创建/加入/结束会议触发 Router 创建/销毁,WS `transport.create` 返回真实 mediasoup `id`(非 noop- 前缀)+ 非空 `iceCandidates[]` + `dtlsParameters.fingerprints[]`,关闭虚构 producer 触发 Node 404 → Go 幂等转 ok;media-server 日志对齐 `router created × 2 / webrtc transport created / router closed explicitly`
|
||||||
* **Task 6 ✅ WebSocket 信令协议 13 事件全量落地(2026-04-21)**:`meeting.*` 事件族从 Task 5 的 `PublishToUser` 循环升级为完整 WS 信令协议。产出:(1) `app/constants/meeting.go` WS 事件常量与设计 §6.3 对齐(3 房间 + 5 成员 + 5 媒体 + 1 聊天)+ `MeetingWSClientEvents` 白名单限制客户端仅能发起 8 个 C→S 事件(防恶意客户端伪造 `room.ended` 等广播);(2) `service/interfaces.go` 扩容 `MediaOrchestrator` 至 9 方法(Router/Transport/Producer/Consumer 全生命周期)+ 配套 5 个 DTO + `NoopMediaOrchestrator` 9 占位实现;(3) **新建 `service/meeting_broadcaster.go`**(75 行)抽离 `BroadcastToMeeting` / `PublishToUser` 统一广播层,REST + WS 共用;(4) `service/meeting_service.go` 12 方法改调 `broadcaster.*` 不再直连 `ws.PubSub`;(5) **新建 `service/meeting_signal_service.go`**(430 行)承载 8 C→S 事件业务:`OnRoomJoin`/`OnRoomLeave`/`OnMemberStateChanged`/`OnTransportCreate/Connect`/`OnProduceStart`/`OnConsumeStart`/`OnProducerClose`;**Redis 资源追踪** `echo:meeting:resources:{room_id}:{user_id}`(Set,TTL 1 小时)+ `cleanupUserResources`(WS 断开钩子遍历清理 transport/producer/consumer)+ host 权限校验(非 host 改他人状态返回 `-1 仅主持人可执行此操作`);(6) **新建 `controller/meeting_ws_handler.go`**(200 行)薄层:构造时 `hub.RegisterEvent` 注册 8 C→S 事件,每 handler 仅 JSON 反序列化 + 调 `signalSvc.On*` + 构造 ACK;(7) `provider.go` + `wire_gen.go` 扩充三个新 provider;(8) `docs/api/frontend/meeting.md` 追加 §WebSocket 信令协议(200 行):16 事件总览 + 8 C→S 完整契约 + S→C 广播契约 + 架构 + 错误处理。**关键设计决策**:(a) Broadcaster 单独抽层让 Task 5 REST 广播与 Task 6 WS 广播复用同一对象;(b) C→S 白名单防伪造;(c) Redis Set 追踪媒体资源即使 Go 进程崩溃也不泄漏 mediasoup 端;(d) MediaOrchestrator 接口完整化让 Task 7 只需替换绑定不改 signal service / handler 代码;(e) ACK `code=0/-1` 与 REST 领域错误口径完全一致;(f) `meeting.room.leave` 只清 WS 资源不改 participant 表(真正离会仍需 REST `/leave`),允许客户端 WS 重连刷新 transport 不退会。验证:`go build` / `go vet` / `wire` 全绿;端到端 WS 冒烟 `/tmp/meeting_ws_t6_test.mjs` **PASS=18 / FAIL=0**,覆盖 8 C→S 白名单事件 + 3 S→C 广播 + 3 错误路径(非 host 越权/不存在会议号/WS leave 资源清理)。测试期间修复 `waitEvent` 死循环 bug(pop→push 自循环)改为 stash 缓冲区归还模式
|
* **Task 6 ✅ WebSocket 信令协议 13 事件全量落地(2026-04-21)**:`meeting.*` 事件族从 Task 5 的 `PublishToUser` 循环升级为完整 WS 信令协议。产出:(1) `app/constants/meeting.go` WS 事件常量与设计 §6.3 对齐(3 房间 + 5 成员 + 5 媒体 + 1 聊天)+ `MeetingWSClientEvents` 白名单限制客户端仅能发起 8 个 C→S 事件(防恶意客户端伪造 `room.ended` 等广播);(2) `service/interfaces.go` 扩容 `MediaOrchestrator` 至 9 方法(Router/Transport/Producer/Consumer 全生命周期)+ 配套 5 个 DTO + `NoopMediaOrchestrator` 9 占位实现;(3) **新建 `service/meeting_broadcaster.go`**(75 行)抽离 `BroadcastToMeeting` / `PublishToUser` 统一广播层,REST + WS 共用;(4) `service/meeting_service.go` 12 方法改调 `broadcaster.*` 不再直连 `ws.PubSub`;(5) **新建 `service/meeting_signal_service.go`**(430 行)承载 8 C→S 事件业务:`OnRoomJoin`/`OnRoomLeave`/`OnMemberStateChanged`/`OnTransportCreate/Connect`/`OnProduceStart`/`OnConsumeStart`/`OnProducerClose`;**Redis 资源追踪** `echo:meeting:resources:{room_id}:{user_id}`(Set,TTL 1 小时)+ `cleanupUserResources`(WS 断开钩子遍历清理 transport/producer/consumer)+ host 权限校验(非 host 改他人状态返回 `-1 仅主持人可执行此操作`);(6) **新建 `controller/meeting_ws_handler.go`**(200 行)薄层:构造时 `hub.RegisterEvent` 注册 8 C→S 事件,每 handler 仅 JSON 反序列化 + 调 `signalSvc.On*` + 构造 ACK;(7) `provider.go` + `wire_gen.go` 扩充三个新 provider;(8) `docs/api/frontend/meeting.md` 追加 §WebSocket 信令协议(200 行):16 事件总览 + 8 C→S 完整契约 + S→C 广播契约 + 架构 + 错误处理。**关键设计决策**:(a) Broadcaster 单独抽层让 Task 5 REST 广播与 Task 6 WS 广播复用同一对象;(b) C→S 白名单防伪造;(c) Redis Set 追踪媒体资源即使 Go 进程崩溃也不泄漏 mediasoup 端;(d) MediaOrchestrator 接口完整化让 Task 7 只需替换绑定不改 signal service / handler 代码;(e) ACK `code=0/-1` 与 REST 领域错误口径完全一致;(f) `meeting.room.leave` 只清 WS 资源不改 participant 表(真正离会仍需 REST `/leave`),允许客户端 WS 重连刷新 transport 不退会。验证:`go build` / `go vet` / `wire` 全绿;端到端 WS 冒烟 `/tmp/meeting_ws_t6_test.mjs` **PASS=18 / FAIL=0**,覆盖 8 C→S 白名单事件 + 3 S→C 广播 + 3 错误路径(非 host 越权/不存在会议号/WS leave 资源清理)。测试期间修复 `waitEvent` 死循环 bug(pop→push 自循环)改为 stash 缓冲区归还模式
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ const (
|
|||||||
MeetingWSEventTransportConnect = "meeting.transport.connect" // C→S→Node:Transport DTLS 握手
|
MeetingWSEventTransportConnect = "meeting.transport.connect" // C→S→Node:Transport DTLS 握手
|
||||||
MeetingWSEventProduceStart = "meeting.produce.start" // C→S→Node:创建 Producer(广播 producer.new)
|
MeetingWSEventProduceStart = "meeting.produce.start" // C→S→Node:创建 Producer(广播 producer.new)
|
||||||
MeetingWSEventConsumeStart = "meeting.consume.start" // C→S→Node:订阅远端 Producer,创建本地 Consumer
|
MeetingWSEventConsumeStart = "meeting.consume.start" // C→S→Node:订阅远端 Producer,创建本地 Consumer
|
||||||
|
MeetingWSEventConsumeResume = "meeting.consume.resume" // C→S→Node:Consumer 订阅后由前端 track 挂载就绪再 resume(防止首帧丢失;Task 9 引入)
|
||||||
MeetingWSEventProducerClose = "meeting.producer.close" // C→S→Node:关闭自己的 Producer(推流停止)
|
MeetingWSEventProducerClose = "meeting.producer.close" // C→S→Node:关闭自己的 Producer(推流停止)
|
||||||
|
|
||||||
// ====== 补充事件(非 §6.3 核心 11 事件,但业务必须)======
|
// ====== 补充事件(非 §6.3 核心 11 事件,但业务必须)======
|
||||||
@@ -130,5 +131,6 @@ var MeetingWSClientEvents = []string{
|
|||||||
MeetingWSEventTransportConnect,
|
MeetingWSEventTransportConnect,
|
||||||
MeetingWSEventProduceStart,
|
MeetingWSEventProduceStart,
|
||||||
MeetingWSEventConsumeStart,
|
MeetingWSEventConsumeStart,
|
||||||
|
MeetingWSEventConsumeResume,
|
||||||
MeetingWSEventProducerClose,
|
MeetingWSEventProducerClose,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
// ====== 会议模块基础 DTO(Phase 2e-2)======
|
// ====== 会议模块基础 DTO(Phase 2e-2)======
|
||||||
|
|
||||||
// MeetingRoomDTO 会议房间传输对象
|
// MeetingRoomDTO 会议房间传输对象
|
||||||
@@ -66,8 +68,11 @@ type CreateMeetingRoomRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateMeetingRoomResponse 创建会议响应
|
// CreateMeetingRoomResponse 创建会议响应
|
||||||
|
// Task 9 起返回 router_id + rtp_capabilities,便于前端创建会议后立即 Device.load
|
||||||
type CreateMeetingRoomResponse struct {
|
type CreateMeetingRoomResponse struct {
|
||||||
Room MeetingRoomDTO `json:"room"`
|
Room MeetingRoomDTO `json:"room"`
|
||||||
|
RouterID string `json:"router_id,omitempty"`
|
||||||
|
RtpCapabilities json.RawMessage `json:"rtp_capabilities,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JoinMeetingRoomRequest 加入会议请求
|
// JoinMeetingRoomRequest 加入会议请求
|
||||||
@@ -78,9 +83,10 @@ type JoinMeetingRoomRequest struct {
|
|||||||
|
|
||||||
// JoinMeetingRoomResponse 加入会议响应
|
// JoinMeetingRoomResponse 加入会议响应
|
||||||
type JoinMeetingRoomResponse struct {
|
type JoinMeetingRoomResponse struct {
|
||||||
Room MeetingRoomDTO `json:"room"`
|
Room MeetingRoomDTO `json:"room"`
|
||||||
Participant MeetingParticipantDTO `json:"participant"`
|
Participant MeetingParticipantDTO `json:"participant"`
|
||||||
RouterID string `json:"router_id,omitempty"` // mediasoup Router ID(Task 7 接入 Node 后填充)
|
RouterID string `json:"router_id,omitempty"` // mediasoup Router ID(Task 7 接入 Node 后填充)
|
||||||
|
RtpCapabilities json.RawMessage `json:"rtp_capabilities,omitempty"` // Router RTP 能力(Task 9);前端 mediasoup-client Device.load 直接使用
|
||||||
}
|
}
|
||||||
|
|
||||||
// LeaveMeetingRoomResponse 离开会议响应
|
// LeaveMeetingRoomResponse 离开会议响应
|
||||||
|
|||||||
@@ -158,10 +158,13 @@ func (ctl *MeetingController) CreateRoom(c *gin.Context) {
|
|||||||
ctl.handleError(c, err, "创建会议失败")
|
ctl.handleError(c, err, "创建会议失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Task 9:CreateRoom 响应携带 router_id + rtpCapabilities,供前端 Device.load 使用
|
||||||
|
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(room.RoomCode)
|
||||||
resp := dto.CreateMeetingRoomResponse{
|
resp := dto.CreateMeetingRoomResponse{
|
||||||
Room: *roomToDTO(room, 1),
|
Room: *roomToDTO(room, 1),
|
||||||
|
RouterID: routerID,
|
||||||
|
RtpCapabilities: rtpCaps,
|
||||||
}
|
}
|
||||||
_ = routerID // 当前 Noop 返回占位 RouterID,Task 7 后可拼入响应供前端订阅使用
|
|
||||||
utils.ResponseCreated(c, resp)
|
utils.ResponseCreated(c, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,10 +217,13 @@ func (ctl *MeetingController) JoinRoom(c *gin.Context) {
|
|||||||
ctl.handleError(c, err, "加入会议失败")
|
ctl.handleError(c, err, "加入会议失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Task 9:JoinRoom 响应携带 rtpCapabilities,供前端 mediasoup-client Device.load 直接使用
|
||||||
|
_, rtpCaps, _ := ctl.meetingService.ResolveRouterInfo(code)
|
||||||
resp := dto.JoinMeetingRoomResponse{
|
resp := dto.JoinMeetingRoomResponse{
|
||||||
Room: *roomToDTO(room, 0),
|
Room: *roomToDTO(room, 0),
|
||||||
Participant: *participantToDTO(participant),
|
Participant: *participantToDTO(participant),
|
||||||
RouterID: routerID,
|
RouterID: routerID,
|
||||||
|
RtpCapabilities: rtpCaps,
|
||||||
}
|
}
|
||||||
utils.ResponseOK(c, resp)
|
utils.ResponseOK(c, resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func (h *MeetingWSHandler) registerEvents() {
|
|||||||
h.hub.RegisterEvent(constants.MeetingWSEventTransportConnect, h.handleTransportConnect)
|
h.hub.RegisterEvent(constants.MeetingWSEventTransportConnect, h.handleTransportConnect)
|
||||||
h.hub.RegisterEvent(constants.MeetingWSEventProduceStart, h.handleProduceStart)
|
h.hub.RegisterEvent(constants.MeetingWSEventProduceStart, h.handleProduceStart)
|
||||||
h.hub.RegisterEvent(constants.MeetingWSEventConsumeStart, h.handleConsumeStart)
|
h.hub.RegisterEvent(constants.MeetingWSEventConsumeStart, h.handleConsumeStart)
|
||||||
|
h.hub.RegisterEvent(constants.MeetingWSEventConsumeResume, h.handleConsumeResume)
|
||||||
h.hub.RegisterEvent(constants.MeetingWSEventProducerClose, h.handleProducerClose)
|
h.hub.RegisterEvent(constants.MeetingWSEventProducerClose, h.handleProducerClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +183,19 @@ func (h *MeetingWSHandler) handleConsumeStart(client *ws.Client, msg *ws.Message
|
|||||||
h.sendACK(client, msg, 0, "ok", info)
|
h.sendACK(client, msg, 0, "ok", info)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleConsumeResume 处理 meeting.consume.resume(Task 9)
|
||||||
|
func (h *MeetingWSHandler) handleConsumeResume(client *ws.Client, msg *ws.Message) {
|
||||||
|
var payload service.ConsumeResumePayload
|
||||||
|
if !h.unmarshal(client, msg, &payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.signalSvc.OnConsumeResume(context.Background(), client.UserID, &payload); err != nil {
|
||||||
|
h.sendACK(client, msg, -1, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.sendACK(client, msg, 0, "ok", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// handleProducerClose 处理 meeting.producer.close
|
// handleProducerClose 处理 meeting.producer.close
|
||||||
func (h *MeetingWSHandler) handleProducerClose(client *ws.Client, msg *ws.Message) {
|
func (h *MeetingWSHandler) handleProducerClose(client *ws.Client, msg *ws.Message) {
|
||||||
var payload service.ProducerClosePayload
|
var payload service.ProducerClosePayload
|
||||||
|
|||||||
@@ -43,12 +43,20 @@ var ErrMediaServerError = errors.New("media server error")
|
|||||||
// - X-Internal-Token header 与 media-server/.env 的 MEDIA_INTERNAL_TOKEN 配对,两端不匹配将被 Node 的 401 拒绝
|
// - X-Internal-Token header 与 media-server/.env 的 MEDIA_INTERNAL_TOKEN 配对,两端不匹配将被 Node 的 401 拒绝
|
||||||
//
|
//
|
||||||
// 并发:所有方法可安全并发调用;内部 http.Client 复用连接池
|
// 并发:所有方法可安全并发调用;内部 http.Client 复用连接池
|
||||||
|
// routerInfoCache Router 本地缓存条目:除 ID 外保存 Node 返回的 rtpCapabilities,
|
||||||
|
// 供前端 mediasoup-client Device.load 直接使用(Task 9 引入)。
|
||||||
|
type routerInfoCache struct {
|
||||||
|
ID string
|
||||||
|
RtpCapabilities json.RawMessage
|
||||||
|
}
|
||||||
|
|
||||||
type HTTPMediaOrchestrator struct {
|
type HTTPMediaOrchestrator struct {
|
||||||
cfg config.MediaServerConfig
|
cfg config.MediaServerConfig
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
// roomCode → routerID 本地缓存,服务重启后会丢失(此时 Node 侧的 Router 也会随 Node 重启而释放,状态一致)
|
// roomCode → *routerInfoCache 本地缓存,服务重启后会丢失(此时 Node 侧的 Router 也会随 Node 重启而释放,状态一致)
|
||||||
roomRouterIDs sync.Map
|
// Task 9 起由 sync.Map<string> 升级为 sync.Map<*routerInfoCache>
|
||||||
|
roomRouterInfos sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPMediaOrchestrator 构造真实 HTTP 客户端
|
// NewHTTPMediaOrchestrator 构造真实 HTTP 客户端
|
||||||
@@ -91,12 +99,12 @@ func NewHTTPMediaOrchestrator(cfg *config.Config) *HTTPMediaOrchestrator {
|
|||||||
func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode string) (string, error) {
|
func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode string) (string, error) {
|
||||||
funcName := "service.http_media_orchestrator.CreateRouter"
|
funcName := "service.http_media_orchestrator.CreateRouter"
|
||||||
|
|
||||||
if cached, ok := h.roomRouterIDs.Load(roomCode); ok {
|
if cached, ok := h.roomRouterInfos.Load(roomCode); ok {
|
||||||
if rid, _ := cached.(string); rid != "" {
|
if info, _ := cached.(*routerInfoCache); info != nil && info.ID != "" {
|
||||||
logs.Debug(ctx, funcName, "命中本地 Router 缓存,跳过 Node 调用",
|
logs.Debug(ctx, funcName, "命中本地 Router 缓存,跳过 Node 调用",
|
||||||
zap.String("room_code", roomCode),
|
zap.String("room_code", roomCode),
|
||||||
zap.String("router_id", rid))
|
zap.String("router_id", info.ID))
|
||||||
return rid, nil
|
return info.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,15 +124,19 @@ func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode strin
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info := &routerInfoCache{
|
||||||
|
ID: resp.RouterID,
|
||||||
|
RtpCapabilities: resp.RtpCapabilities,
|
||||||
|
}
|
||||||
// LoadOrStore 防止并发首次创建时重复覆盖:若他人已写入先到的值就用已存在的
|
// LoadOrStore 防止并发首次创建时重复覆盖:若他人已写入先到的值就用已存在的
|
||||||
actual, loaded := h.roomRouterIDs.LoadOrStore(roomCode, resp.RouterID)
|
actual, loaded := h.roomRouterInfos.LoadOrStore(roomCode, info)
|
||||||
if loaded {
|
if loaded {
|
||||||
if existing, _ := actual.(string); existing != "" && existing != resp.RouterID {
|
if existing, _ := actual.(*routerInfoCache); existing != nil && existing.ID != "" && existing.ID != resp.RouterID {
|
||||||
logs.Warn(ctx, funcName, "并发创建 Router 出现不一致,保留先到值",
|
logs.Warn(ctx, funcName, "并发创建 Router 出现不一致,保留先到值",
|
||||||
zap.String("room_code", roomCode),
|
zap.String("room_code", roomCode),
|
||||||
zap.String("existing", existing),
|
zap.String("existing", existing.ID),
|
||||||
zap.String("new", resp.RouterID))
|
zap.String("new", resp.RouterID))
|
||||||
return existing, nil
|
return existing.ID, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,15 +150,30 @@ func (h *HTTPMediaOrchestrator) CreateRouter(ctx context.Context, roomCode strin
|
|||||||
// 返回 (id, true) 表示命中;返回 (_, false) 表示缓存缺失(通常意味着该房间尚未创建 Router)
|
// 返回 (id, true) 表示命中;返回 (_, false) 表示缓存缺失(通常意味着该房间尚未创建 Router)
|
||||||
// 供 MeetingService.JoinRoom 等需复用已有 Router 的场景使用,避免重复调 Node
|
// 供 MeetingService.JoinRoom 等需复用已有 Router 的场景使用,避免重复调 Node
|
||||||
func (h *HTTPMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool) {
|
func (h *HTTPMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool) {
|
||||||
v, ok := h.roomRouterIDs.Load(roomCode)
|
v, ok := h.roomRouterInfos.Load(roomCode)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
rid, _ := v.(string)
|
info, _ := v.(*routerInfoCache)
|
||||||
if rid == "" {
|
if info == nil || info.ID == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
return rid, true
|
return info.ID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRouterInfo 读取 roomCode 对应的 Router 完整信息(routerID + rtpCapabilities)
|
||||||
|
// Task 9 引入:前端 mediasoup-client Device.load 需要 rtpCapabilities,JoinRoom 响应会回填此字段
|
||||||
|
// 返回 (id, caps, true) 命中;(_, _, false) 缺失
|
||||||
|
func (h *HTTPMediaOrchestrator) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
|
||||||
|
v, ok := h.roomRouterInfos.Load(roomCode)
|
||||||
|
if !ok {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
info, _ := v.(*routerInfoCache)
|
||||||
|
if info == nil || info.ID == "" {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return info.ID, info.RtpCapabilities, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseRouter 调用 DELETE /internal/v1/routers/:routerId
|
// CloseRouter 调用 DELETE /internal/v1/routers/:routerId
|
||||||
@@ -157,16 +184,17 @@ func (h *HTTPMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool)
|
|||||||
func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string) error {
|
func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string) error {
|
||||||
funcName := "service.http_media_orchestrator.CloseRouter"
|
funcName := "service.http_media_orchestrator.CloseRouter"
|
||||||
|
|
||||||
v, ok := h.roomRouterIDs.Load(roomCode)
|
v, ok := h.roomRouterInfos.Load(roomCode)
|
||||||
if !ok {
|
if !ok {
|
||||||
logs.Debug(ctx, funcName, "无本地映射,跳过 Close", zap.String("room_code", roomCode))
|
logs.Debug(ctx, funcName, "无本地映射,跳过 Close", zap.String("room_code", roomCode))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
routerID, _ := v.(string)
|
info, _ := v.(*routerInfoCache)
|
||||||
if routerID == "" {
|
if info == nil || info.ID == "" {
|
||||||
h.roomRouterIDs.Delete(roomCode)
|
h.roomRouterInfos.Delete(roomCode)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
routerID := info.ID
|
||||||
|
|
||||||
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/routers/%s", routerID), funcName, []zap.Field{
|
err := h.doCloseRequest(ctx, fmt.Sprintf("/internal/v1/routers/%s", routerID), funcName, []zap.Field{
|
||||||
zap.String("room_code", roomCode),
|
zap.String("room_code", roomCode),
|
||||||
@@ -174,7 +202,7 @@ func (h *HTTPMediaOrchestrator) CloseRouter(ctx context.Context, roomCode string
|
|||||||
})
|
})
|
||||||
// 成功或 ResourceNotFound 都从本地缓存删除(幂等)
|
// 成功或 ResourceNotFound 都从本地缓存删除(幂等)
|
||||||
if err == nil || errors.Is(err, ErrMediaResourceNotFound) {
|
if err == nil || errors.Is(err, ErrMediaResourceNotFound) {
|
||||||
h.roomRouterIDs.Delete(roomCode)
|
h.roomRouterInfos.Delete(roomCode)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
@@ -278,8 +306,8 @@ func (h *HTTPMediaOrchestrator) CloseProducer(ctx context.Context, producerID st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateConsumer 调用 POST /internal/v1/consumers
|
// CreateConsumer 调用 POST /internal/v1/consumers
|
||||||
// Node 侧 Consumer 强制 paused=true 创建,前端收到后需额外调 /resume(MediaOrchestrator 暂未暴露 ResumeConsumer,
|
// Node 侧 Consumer 强制 paused=true 创建,前端在 recv Transport 与 track 挂载完成后
|
||||||
// 将在 Task 8 前端接入 mediasoup-client 时按需补充)
|
// 需发送 meeting.consume.resume WS 事件触发 ResumeConsumer 才会真正收到 RTP(Task 9)
|
||||||
func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error) {
|
func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error) {
|
||||||
funcName := "service.http_media_orchestrator.CreateConsumer"
|
funcName := "service.http_media_orchestrator.CreateConsumer"
|
||||||
|
|
||||||
@@ -313,6 +341,22 @@ func (h *HTTPMediaOrchestrator) CreateConsumer(ctx context.Context, req *CreateC
|
|||||||
return &resp, nil
|
return &resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResumeConsumer 调用 POST /internal/v1/consumers/:id/resume(Task 9 引入)
|
||||||
|
// Node 侧会把 mediasoup Consumer 从 paused 切到 active,开始向 Transport 推 RTP
|
||||||
|
// 语义幂等:Node 端对已 active 的 Consumer 再次 resume 不报错;未找到 Consumer 返回 404 → ErrMediaResourceNotFound
|
||||||
|
func (h *HTTPMediaOrchestrator) ResumeConsumer(ctx context.Context, consumerID string) error {
|
||||||
|
funcName := "service.http_media_orchestrator.ResumeConsumer"
|
||||||
|
|
||||||
|
return h.doRequest(ctx, requestOptions{
|
||||||
|
method: http.MethodPost,
|
||||||
|
path: fmt.Sprintf("/internal/v1/consumers/%s/resume", consumerID),
|
||||||
|
body: nil,
|
||||||
|
timeoutMS: h.cfg.TimeoutMS,
|
||||||
|
funcName: funcName,
|
||||||
|
logFields: []zap.Field{zap.String("consumer_id", consumerID)},
|
||||||
|
}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
// CloseConsumer 调用 DELETE /internal/v1/consumers/:id
|
// CloseConsumer 调用 DELETE /internal/v1/consumers/:id
|
||||||
func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID string) error {
|
func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID string) error {
|
||||||
funcName := "service.http_media_orchestrator.CloseConsumer"
|
funcName := "service.http_media_orchestrator.CloseConsumer"
|
||||||
@@ -331,15 +375,15 @@ func (h *HTTPMediaOrchestrator) CloseConsumer(ctx context.Context, consumerID st
|
|||||||
// routerIDByRoomCode 从本地缓存反查 routerID,缺失时返回 ErrMediaResourceNotFound
|
// routerIDByRoomCode 从本地缓存反查 routerID,缺失时返回 ErrMediaResourceNotFound
|
||||||
// 此错误语义表达的是"meeting 业务侧尚未(或已清理了)为该房间创建 Router",调用方通常应转为"会议未开始/已结束"
|
// 此错误语义表达的是"meeting 业务侧尚未(或已清理了)为该房间创建 Router",调用方通常应转为"会议未开始/已结束"
|
||||||
func (h *HTTPMediaOrchestrator) routerIDByRoomCode(roomCode string) (string, error) {
|
func (h *HTTPMediaOrchestrator) routerIDByRoomCode(roomCode string) (string, error) {
|
||||||
v, ok := h.roomRouterIDs.Load(roomCode)
|
v, ok := h.roomRouterInfos.Load(roomCode)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", fmt.Errorf("%w: no router mapped for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
return "", fmt.Errorf("%w: no router mapped for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
||||||
}
|
}
|
||||||
routerID, _ := v.(string)
|
info, _ := v.(*routerInfoCache)
|
||||||
if routerID == "" {
|
if info == nil || info.ID == "" {
|
||||||
return "", fmt.Errorf("%w: empty router_id for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
return "", fmt.Errorf("%w: empty router_id for room_code=%s", ErrMediaResourceNotFound, roomCode)
|
||||||
}
|
}
|
||||||
return routerID, nil
|
return info.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// requestOptions 统一请求参数
|
// requestOptions 统一请求参数
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ type MediaOrchestrator interface {
|
|||||||
// Task 8 引入:用于 JoinRoom 等"仅需复用"的场景,避免重复调 CreateRouter
|
// Task 8 引入:用于 JoinRoom 等"仅需复用"的场景,避免重复调 CreateRouter
|
||||||
// 返回 (id, true) 命中;(_, false) 缺失(通常说明房间未创建 Router 或服务重启后未 reschedule)
|
// 返回 (id, true) 命中;(_, false) 缺失(通常说明房间未创建 Router 或服务重启后未 reschedule)
|
||||||
ResolveRouterID(roomCode string) (string, bool)
|
ResolveRouterID(roomCode string) (string, bool)
|
||||||
|
// ResolveRouterInfo 读取 roomCode 对应的 Router 完整信息(routerID + rtpCapabilities)
|
||||||
|
// Task 9 引入:前端 mediasoup-client Device.load 必须 rtpCapabilities,JoinRoom 响应由此填充
|
||||||
|
// 返回 (id, caps, true) 命中;(_, _, false) 缺失
|
||||||
|
ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool)
|
||||||
|
|
||||||
// CreateTransport 为用户创建 send/recv WebRTC Transport
|
// CreateTransport 为用户创建 send/recv WebRTC Transport
|
||||||
CreateTransport(ctx context.Context, req *CreateTransportReq) (*TransportInfo, error)
|
CreateTransport(ctx context.Context, req *CreateTransportReq) (*TransportInfo, error)
|
||||||
@@ -106,7 +110,12 @@ type MediaOrchestrator interface {
|
|||||||
CloseProducer(ctx context.Context, producerID string) error
|
CloseProducer(ctx context.Context, producerID string) error
|
||||||
|
|
||||||
// CreateConsumer 在指定 recv Transport 上创建 Consumer(订阅远端 Producer)
|
// CreateConsumer 在指定 recv Transport 上创建 Consumer(订阅远端 Producer)
|
||||||
|
// Node 端创建的 Consumer 默认 paused=true,需在前端 track 就绪后调用 ResumeConsumer 才会开始推流
|
||||||
CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error)
|
CreateConsumer(ctx context.Context, req *CreateConsumerReq) (*ConsumerInfo, error)
|
||||||
|
// ResumeConsumer 恢复 Consumer 开始接收 RTP(Task 9 引入)
|
||||||
|
// 前端 recv Transport 与 track 挂载完成后由 meeting.consume.resume WS 事件触发
|
||||||
|
// Node 未找到对应 Consumer 返回 ErrMediaResourceNotFound,调用方可按"已失效"处理
|
||||||
|
ResumeConsumer(ctx context.Context, consumerID string) error
|
||||||
// CloseConsumer 关闭指定 Consumer(幂等)
|
// CloseConsumer 关闭指定 Consumer(幂等)
|
||||||
CloseConsumer(ctx context.Context, consumerID string) error
|
CloseConsumer(ctx context.Context, consumerID string) error
|
||||||
}
|
}
|
||||||
@@ -137,6 +146,11 @@ func (n *NoopMediaOrchestrator) ResolveRouterID(roomCode string) (string, bool)
|
|||||||
return "noop-router-" + roomCode, true
|
return "noop-router-" + roomCode, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveRouterInfo 占位:返回空 rtpCapabilities 对象(保证前端 mediasoup-client 能通过 JSON 解析,但 Device.load 会失败;适合本地无 Node 调试)
|
||||||
|
func (n *NoopMediaOrchestrator) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
|
||||||
|
return "noop-router-" + roomCode, json.RawMessage(`{}`), true
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTransport 占位:返回以 "noop-transport-" 为前缀的伪造 ID,带最小合法 JSON 结构
|
// CreateTransport 占位:返回以 "noop-transport-" 为前缀的伪造 ID,带最小合法 JSON 结构
|
||||||
func (n *NoopMediaOrchestrator) CreateTransport(_ context.Context, req *CreateTransportReq) (*TransportInfo, error) {
|
func (n *NoopMediaOrchestrator) CreateTransport(_ context.Context, req *CreateTransportReq) (*TransportInfo, error) {
|
||||||
return &TransportInfo{
|
return &TransportInfo{
|
||||||
@@ -173,6 +187,11 @@ func (n *NoopMediaOrchestrator) CreateConsumer(_ context.Context, req *CreateCon
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResumeConsumer 占位:直接返回 nil
|
||||||
|
func (n *NoopMediaOrchestrator) ResumeConsumer(_ context.Context, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CloseConsumer 占位:直接返回 nil
|
// CloseConsumer 占位:直接返回 nil
|
||||||
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
|
func (n *NoopMediaOrchestrator) CloseConsumer(_ context.Context, _ string) error {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -823,3 +823,13 @@ func (s *MeetingService) ListChatMessages(ctx context.Context, userID int64, cod
|
|||||||
}
|
}
|
||||||
return chats, hasMore, nil
|
return chats, hasMore, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveRouterInfo 代理 mediaOrchestrator.ResolveRouterInfo
|
||||||
|
// Task 9 引入:JoinRoom 响应需把 rtpCapabilities 一并回给前端,避免前端再拉一次
|
||||||
|
// 返回 (routerID, rtpCapabilities, ok);未命中缓存返回 ("", nil, false)
|
||||||
|
func (s *MeetingService) ResolveRouterInfo(roomCode string) (string, json.RawMessage, bool) {
|
||||||
|
if s.mediaOrchestrator == nil {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
return s.mediaOrchestrator.ResolveRouterInfo(roomCode)
|
||||||
|
}
|
||||||
|
|||||||
@@ -368,6 +368,35 @@ func (s *MeetingSignalService) OnConsumeStart(ctx context.Context, userID int64,
|
|||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConsumeResumePayload meeting.consume.resume 请求载荷(Task 9)
|
||||||
|
// 前端 recv Transport 与 track 就绪后发送,用于把 Node 侧 paused Consumer 切到 active
|
||||||
|
type ConsumeResumePayload struct {
|
||||||
|
RoomCode string `json:"room_code"`
|
||||||
|
ConsumerID string `json:"consumer_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConsumeResume 处理 meeting.consume.resume 事件(Task 9)
|
||||||
|
// 语义:告知 media-server 把指定 Consumer 从 paused 切到 active
|
||||||
|
// 权限:仅当 userID 是会议活跃成员且 consumerID 归属该用户时允许
|
||||||
|
// 幂等:Node 对已 active Consumer 再次 resume 不报错;Consumer 不存在则 ACK 返回友好错误
|
||||||
|
func (s *MeetingSignalService) OnConsumeResume(ctx context.Context, userID int64, payload *ConsumeResumePayload) error {
|
||||||
|
if payload.ConsumerID == "" {
|
||||||
|
return fmt.Errorf("consumer_id 不能为空")
|
||||||
|
}
|
||||||
|
if _, err := s.loadRoomAndParticipant(ctx, payload.RoomCode, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.mediaOrchestrator.ResumeConsumer(ctx, payload.ConsumerID); err != nil {
|
||||||
|
logs.Warn(ctx, "service.meeting_signal_service.OnConsumeResume", "恢复 Consumer 失败",
|
||||||
|
zap.String("room_code", payload.RoomCode),
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("consumer_id", payload.ConsumerID),
|
||||||
|
zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ProducerClosePayload meeting.producer.close 请求载荷
|
// ProducerClosePayload meeting.producer.close 请求载荷
|
||||||
type ProducerClosePayload struct {
|
type ProducerClosePayload struct {
|
||||||
RoomCode string `json:"room_code"`
|
RoomCode string `json:"room_code"`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> 通用规范(认证、响应包络、通用错误码)见 [README.md](../README.md)
|
> 通用规范(认证、响应包络、通用错误码)见 [README.md](../README.md)
|
||||||
> 会议内实时信令(Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
|
> 会议内实时信令(Transport / Producer / Consumer / 控制事件)通过 WebSocket 完成,见 [websocket.md](../websocket.md)
|
||||||
|
|
||||||
**实施状态**:本文档对应 Phase 2e-2 Task 5 / Task 6 / Task 7 已落地的 12 个 REST 接口 + 13 个 WebSocket 信令事件,统一前缀 `/api/v1/meeting`(REST)与 `/ws`(WebSocket),全部需要 JWT 认证。Task 5/6/7 完成时间:2026-04-21。自 Task 7 起 **Go 后端直连 Node media-server**,`transport.id` / `iceCandidates` / `dtlsParameters.fingerprints` 等字段均由真实 mediasoup 返回(不再是 stub 占位)。
|
**实施状态**:本文档对应 Phase 2e-2 Task 5 / Task 6 / Task 7 / Task 9 已落地的 12 个 REST 接口 + **14 个** WebSocket 信令事件(Task 9 新增 `meeting.consume.resume`),统一前缀 `/api/v1/meeting`(REST)与 `/ws`(WebSocket),全部需要 JWT 认证。Task 5/6/7/9 完成时间:2026-04-21。自 Task 7 起 **Go 后端直连 Node media-server**,`transport.id` / `iceCandidates` / `dtlsParameters.fingerprints` 等字段均由真实 mediasoup 返回(不再是 stub 占位);自 Task 9 起 `CreateRoom` / `JoinRoom` 响应同时返回 `router_id` + `rtp_capabilities`,供前端 `mediasoupClient.Device.load()` 直接初始化。
|
||||||
|
|
||||||
**设计口径**:以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 为单一事实来源(SSOT)。
|
**设计口径**:以 [`docs/plans/2026-04-21-phase2e-2-design.md`](../../plans/2026-04-21-phase2e-2-design.md) §6.2 为单一事实来源(SSOT)。
|
||||||
|
|
||||||
@@ -193,12 +193,18 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"room": { ... },
|
"room": { ... },
|
||||||
"participant": { ... },
|
"participant": { ... },
|
||||||
"router_id": "stub-router-835-000-036"
|
"router_id": "abc-router-835-000-036",
|
||||||
|
"rtp_capabilities": {
|
||||||
|
"codecs": [ { "mimeType": "audio/opus", "clockRate": 48000, ... } ],
|
||||||
|
"headerExtensions": [ ... ]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`router_id` 自 Task 7 起为 Node media-server 返回的真实 mediasoup Router ID(Phase 2e-2 Task 7 完成,2026-04-21)。前端可据此建立 WebSocket 订阅,也可忽略,仅依赖 WS `meeting.transport.create` 的返回值创建 mediasoup-client Transport。
|
`router_id` 自 Task 7 起为 Node media-server 返回的真实 mediasoup Router ID(Phase 2e-2 Task 7 完成,2026-04-21);`rtp_capabilities` 自 Task 9 起随响应一并返回(Phase 2e-2 Task 9,2026-04-21),供前端 `mediasoupClient.Device.load({ routerRtpCapabilities })` 初始化媒体协商上下文。前端 MVP 推荐流程:`joinRoom → Device.load(rtp_capabilities) → Transport 双建 → produce/consume`,整个链路不再需要额外 REST 往返。
|
||||||
|
|
||||||
|
> 同形响应体在 `POST /rooms`(创建即时会议)中也会返回 `router_id` + `rtp_capabilities`,创建者无需额外请求即可 `Device.load`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -373,7 +379,7 @@
|
|||||||
|
|
||||||
## WebSocket 信令协议(Task 6)
|
## WebSocket 信令协议(Task 6)
|
||||||
|
|
||||||
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道,共 **13 个 `meeting.*` 事件**:8 个客户端→服务端(C→S)操作事件 + 5 个服务端→客户端(S→C)广播事件 + 2 个补充业务事件(聊天 + 被踢定向推送,与 REST 广播复用)。
|
全部会议相关实时信令走 `/ws?token=<access_token>` 统一通道,共 **14 个 `meeting.*` 事件**:**9 个** 客户端→服务端(C→S)操作事件(Task 9 新增 `meeting.consume.resume`)+ 5 个服务端→客户端(S→C)广播事件 + 2 个补充业务事件(聊天 + 被踢定向推送,与 REST 广播复用)。
|
||||||
|
|
||||||
### 帧格式
|
### 帧格式
|
||||||
|
|
||||||
@@ -404,16 +410,17 @@
|
|||||||
| 6 | C→S | `meeting.produce.start` | 创建 Producer(上行流) | `mediaOrchestrator.CreateProducer`,成功后广播 `meeting.member.producer.new` |
|
| 6 | C→S | `meeting.produce.start` | 创建 Producer(上行流) | `mediaOrchestrator.CreateProducer`,成功后广播 `meeting.member.producer.new` |
|
||||||
| 7 | C→S | `meeting.consume.start` | 创建 Consumer(订阅对端 Producer) | `mediaOrchestrator.CreateConsumer` |
|
| 7 | C→S | `meeting.consume.start` | 创建 Consumer(订阅对端 Producer) | `mediaOrchestrator.CreateConsumer` |
|
||||||
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer | `mediaOrchestrator.CloseProducer`,广播 `meeting.member.producer.new` (closed=true) |
|
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer | `mediaOrchestrator.CloseProducer`,广播 `meeting.member.producer.new` (closed=true) |
|
||||||
| 9 | S→C | `meeting.member.joined` | 新成员加入广播(复用 REST /join) | 房间广播 |
|
| 9 | C→S | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume Consumer(Task 9 新增) | `mediaOrchestrator.ResumeConsumer` → Node `POST /internal/v1/consumers/:id/resume`,不广播 |
|
||||||
| 10 | S→C | `meeting.member.left` | 成员离开广播(REST /leave /kick 或 WS leave) | 房间广播 |
|
| 10 | S→C | `meeting.member.joined` | 新成员加入广播(复用 REST /join) | 房间广播 |
|
||||||
| 11 | S→C | `meeting.member.kicked` | 定向通知被踢者(REST /kick) | `PublishToUser` |
|
| 11 | S→C | `meeting.member.left` | 成员离开广播(REST /leave /kick 或 WS leave) | 房间广播 |
|
||||||
| 12 | S→C | `meeting.host.changed` | 主持人变更 | 房间广播 |
|
| 12 | S→C | `meeting.member.kicked` | 定向通知被踢者(REST /kick) | `PublishToUser` |
|
||||||
| 13 | S→C | `meeting.room.ended` | 会议被结束(REST /end 或空房 TTL) | 房间广播 |
|
| 13 | S→C | `meeting.host.changed` | 主持人变更 | 房间广播 |
|
||||||
| 14 | S→C | `meeting.member.state.changed` | 成员状态变化(静音/关摄像头/举手) | 房间广播 |
|
| 14 | S→C | `meeting.room.ended` | 会议被结束(REST /end 或空房 TTL) | 房间广播 |
|
||||||
| 15 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流 | 房间广播,`closed=true` 表示关闭 |
|
| 15 | S→C | `meeting.member.state.changed` | 成员状态变化(静音/关摄像头/举手) | 房间广播 |
|
||||||
| 16 | S→C | `meeting.chat` | 会议内聊天(REST /chats) | 房间广播 |
|
| 16 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流 | 房间广播,`closed=true` 表示关闭 |
|
||||||
|
| 17 | S→C | `meeting.chat` | 会议内聊天(REST /chats) | 房间广播 |
|
||||||
|
|
||||||
> 说明:客户端仅注册 **C→S 白名单**中的 8 个事件(见 `app/constants/meeting.go:MeetingWSClientEvents`),其余 `meeting.*` 事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。
|
> 说明:客户端仅注册 **C→S 白名单**中的 9 个事件(见 `app/constants/meeting.go:MeetingWSClientEvents`),其余 `meeting.*` 事件若由客户端发送均被静默丢弃,防止恶意客户端伪造广播。
|
||||||
|
|
||||||
### 客户端白名单(C→S)详细契约
|
### 客户端白名单(C→S)详细契约
|
||||||
|
|
||||||
@@ -588,6 +595,31 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 9. `meeting.consume.resume`(Task 9 新增,2026-04-21)
|
||||||
|
|
||||||
|
**请求载荷**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "consumer_id": "consumer-def" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**ACK** `code=0`:`data` 为空。失败原因:`会议资源不存在`(Node 404 → Go 转译,HTTP 层归因 `ErrMediaResourceNotFound`)或 `会议媒体服务暂不可用`(5xx / 网络错)。
|
||||||
|
|
||||||
|
**调用时机**(前端推荐流程):
|
||||||
|
|
||||||
|
1. 收到广播 `meeting.member.producer.new { closed:false }`;
|
||||||
|
2. 前端创建 `recvTransport` + `consumer = device.consume()`,此时 Consumer 为 **paused**(后端创建时就强制 `paused:true`);
|
||||||
|
3. 前端把 `consumer.track` 挂载到 DOM 元素(`<video>.srcObject = new MediaStream([track])`);
|
||||||
|
4. 监听 `<video>.onloadedmetadata`(或图像解码首帧回调);
|
||||||
|
5. **回调中发送** `meeting.consume.resume { consumer_id }`;
|
||||||
|
6. 服务端 `ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume`,Node mediasoup 开始 forward RTP。
|
||||||
|
|
||||||
|
**为什么要显式 resume**:mediasoup 官方规范要求"DOM 挂完 track 再 resume",避免 RTP forward 时浏览器解码空帧造成首帧黑屏/抖动(50-200ms);同时保留未来 simulcast 层切换、订阅清单变更、后台节流等扩展点。
|
||||||
|
|
||||||
|
**不广播**:本事件是纯 C→S 调用,服务端不广播任何事件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 服务端广播(S→C)详细契约
|
### 服务端广播(S→C)详细契约
|
||||||
|
|
||||||
| 事件 | 载荷字段 | 说明 |
|
| 事件 | 载荷字段 | 说明 |
|
||||||
@@ -606,7 +638,7 @@
|
|||||||
- **MeetingWSHandler**(controller 层):thin adapter,仅负责 ws.Hub 事件注册 + JSON 反序列化 + ACK 回写;位于 `app/meeting/controller/meeting_ws_handler.go`。
|
- **MeetingWSHandler**(controller 层):thin adapter,仅负责 ws.Hub 事件注册 + JSON 反序列化 + ACK 回写;位于 `app/meeting/controller/meeting_ws_handler.go`。
|
||||||
- **MeetingSignalService**(service 层):承载 8 个 C→S 事件的业务逻辑(活跃参会校验、host 权限校验、mediaOrchestrator 调用、Redis 资源追踪、广播),位于 `app/meeting/service/meeting_signal_service.go`。
|
- **MeetingSignalService**(service 层):承载 8 个 C→S 事件的业务逻辑(活跃参会校验、host 权限校验、mediaOrchestrator 调用、Redis 资源追踪、广播),位于 `app/meeting/service/meeting_signal_service.go`。
|
||||||
- **MeetingBroadcaster**(service 层):封装 `BroadcastToMeeting`(查询活跃 participant 列表 → 逐个 `PubSub.PublishToUser`)与 `PublishToUser`,供 REST / WS 两个入口统一使用,位于 `app/meeting/service/meeting_broadcaster.go`。
|
- **MeetingBroadcaster**(service 层):封装 `BroadcastToMeeting`(查询活跃 participant 列表 → 逐个 `PubSub.PublishToUser`)与 `PublishToUser`,供 REST / WS 两个入口统一使用,位于 `app/meeting/service/meeting_broadcaster.go`。
|
||||||
- **MediaOrchestrator**(interface):定义 8 个 mediasoup 操作方法(第 9 个 `ResumeConsumer` 推迟到 Task 9);Task 6 曾用 `NoopMediaOrchestrator` 占位,**Task 7 (2026-04-21) 已替换为 `HTTPMediaOrchestrator`**,通过 `X-Internal-Token` 鉴权直连 Node media-server 的 `/internal/v1/*` 接口;错误类型 `ErrMediaResourceNotFound`(Node 404 → 关闭类幂等转 nil)与 `ErrMediaServerError`(5xx / 超时 / 网络错)可供上层 `errors.Is` 区分;关闭类操作指数退避 200ms→500ms 最多 `CloseRetry+1` 次。
|
- **MediaOrchestrator**(interface):定义 **10 个** mediasoup 操作方法(Task 9 新增 `ResumeConsumer` + `ResolveRouterInfo(roomCode) → (routerID, rtpCapabilities, ok)`);Task 6 曾用 `NoopMediaOrchestrator` 占位,**Task 7 (2026-04-21) 已替换为 `HTTPMediaOrchestrator`**,通过 `X-Internal-Token` 鉴权直连 Node media-server 的 `/internal/v1/*` 接口;错误类型 `ErrMediaResourceNotFound`(Node 404 → 关闭类幂等转 nil)与 `ErrMediaServerError`(5xx / 超时 / 网络错)可供上层 `errors.Is` 区分;关闭类操作指数退避 200ms→500ms 最多 `CloseRetry+1` 次。Router 信息缓存升级:`sync.Map[roomCode]*routerInfoCache{ID, RtpCapabilities}` 同时缓存 Router ID 与 RTP Capabilities(Task 9 变更,供 REST `rtp_capabilities` 字段透传)。
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
|
|||||||
@@ -217,18 +217,19 @@
|
|||||||
| 6 | C→S | `meeting.produce.start` | 创建 Producer |
|
| 6 | C→S | `meeting.produce.start` | 创建 Producer |
|
||||||
| 7 | C→S | `meeting.consume.start` | 创建 Consumer |
|
| 7 | C→S | `meeting.consume.start` | 创建 Consumer |
|
||||||
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer |
|
| 8 | C→S | `meeting.producer.close` | 关闭自己的 Producer |
|
||||||
| 9 | S→C | `meeting.member.joined` | 新成员加入广播(REST /join 触发)|
|
| 9 | C→S | `meeting.consume.resume` | 客户端完成 track 挂载后请求 resume Consumer(Task 9 新增,不广播)|
|
||||||
| 10 | S→C | `meeting.member.left` | 成员离开广播(REST /leave /kick 或 WS 资源清理)|
|
| 10 | S→C | `meeting.member.joined` | 新成员加入广播(REST /join 触发)|
|
||||||
| 11 | S→C | `meeting.member.kicked` | 定向通知被踢者 |
|
| 11 | S→C | `meeting.member.left` | 成员离开广播(REST /leave /kick 或 WS 资源清理)|
|
||||||
| 12 | S→C | `meeting.host.changed` | 主持人变更 |
|
| 12 | S→C | `meeting.member.kicked` | 定向通知被踢者 |
|
||||||
| 13 | S→C | `meeting.room.ended` | 会议被结束 |
|
| 13 | S→C | `meeting.host.changed` | 主持人变更 |
|
||||||
| 14 | S→C | `meeting.member.state.changed` | 成员状态变化广播 |
|
| 14 | S→C | `meeting.room.ended` | 会议被结束 |
|
||||||
| 15 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流(`closed=true` 表示关闭)|
|
| 15 | S→C | `meeting.member.state.changed` | 成员状态变化广播 |
|
||||||
| 16 | S→C | `meeting.chat` | 会议内聊天(REST /chats 触发)|
|
| 16 | S→C | `meeting.member.producer.new` | 成员开启/关闭媒体流(`closed=true` 表示关闭)|
|
||||||
|
| 17 | S→C | `meeting.chat` | 会议内聊天(REST /chats 触发)|
|
||||||
|
|
||||||
### 协议约定
|
### 协议约定
|
||||||
|
|
||||||
- **C→S 白名单**:客户端仅可发起上表 C→S 列的 8 个事件,其余 `meeting.*` 事件若由客户端发送被静默丢弃。
|
- **C→S 白名单**:客户端仅可发起上表 C→S 列的 9 个事件(Task 9 扩到 9),其余 `meeting.*` 事件若由客户端发送被静默丢弃。
|
||||||
- **ACK 规则**:每个 C→S 事件服务端必回 `<event>.ack`;成功 `code=0`,业务失败 `code=-1` + 中文 `message`(与 REST 领域错误口径一致)。
|
- **ACK 规则**:每个 C→S 事件服务端必回 `<event>.ack`;成功 `code=0`,业务失败 `code=-1` + 中文 `message`(与 REST 领域错误口径一致)。
|
||||||
- **错误码示例**:`会议不存在` / `你当前未在会议中` / `仅主持人可执行此操作` / `会议已结束`。
|
- **错误码示例**:`会议不存在` / `你当前未在会议中` / `仅主持人可执行此操作` / `会议已结束`。
|
||||||
- **资源追踪**:服务端对每用户在每会议的 transport/producer/consumer 用 Redis Set `echo:meeting:resources:{room_id}:{user_id}` 记录,WS 断开或 `room.leave` 时自动清理。
|
- **资源追踪**:服务端对每用户在每会议的 transport/producer/consumer 用 Redis Set `echo:meeting:resources:{room_id}:{user_id}` 记录,WS 断开或 `room.leave` 时自动清理。
|
||||||
|
|||||||
@@ -628,6 +628,7 @@ flowchart LR
|
|||||||
| 2026-04-21 | Agent | Task 0 mediasoup PoC Spike 完成:`media-server/poc/` 跑通 2 浏览器互通,`media-server/docs/poc-notes.md` 归档压测数据与 7 项关键坑,技术栈选型锁定 |
|
| 2026-04-21 | Agent | Task 0 mediasoup PoC Spike 完成:`media-server/poc/` 跑通 2 浏览器互通,`media-server/docs/poc-notes.md` 归档压测数据与 7 项关键坑,技术栈选型锁定 |
|
||||||
| 2026-04-21 | Agent | Task 1 media-server 项目骨架完成:src 五件套(app/config/logger/worker/internal-auth)落盘;`/healthz` + `/readyz` + `/internal/info` 全部验收通过;Worker died 自动重启通过 `kill -9` 实测;Dockerfile 多阶段 + 非 root + HEALTHCHECK |
|
| 2026-04-21 | Agent | Task 1 media-server 项目骨架完成:src 五件套(app/config/logger/worker/internal-auth)落盘;`/healthz` + `/readyz` + `/internal/info` 全部验收通过;Worker died 自动重启通过 `kill -9` 实测;Dockerfile 多阶段 + 非 root + HEALTHCHECK |
|
||||||
| 2026-04-21 | Agent | Task 1 Fastify 4 → 5 同日升级:fastify@4.28.1→5.8.5、fastify-plugin@4→5.1.0、@fastify/sensible@5→6.0.4、@fastify/websocket@10→11.2.0;`src/app.ts` 改用 `loggerInstance: logger` 消灭 pino 双实例;理由:Fastify 4 已过 2025-06-30 LTS 支持、v5 生态 GA、单实例回归;回归测试全部通过 |
|
| 2026-04-21 | Agent | Task 1 Fastify 4 → 5 同日升级:fastify@4.28.1→5.8.5、fastify-plugin@4→5.1.0、@fastify/sensible@5→6.0.4、@fastify/websocket@10→11.2.0;`src/app.ts` 改用 `loggerInstance: logger` 消灭 pino 双实例;理由:Fastify 4 已过 2025-06-30 LTS 支持、v5 生态 GA、单实例回归;回归测试全部通过 |
|
||||||
|
| 2026-04-21 | Agent | Task 9 前端 mediasoup-client + Pinia Store 完成:新增 `constants/meeting.js`(14 个 WS 事件)/ `api/meeting.js`(12 REST)/ `utils/mediasoup-client.js`(Device/Transport/Producer/Consumer 全生命周期封装)/ `store/meeting.js`(615 行,20+ action + 8 个广播事件桥)+ 扩展 `services/websocket.js` 的 `sendWithAck` Promise 化;后端补齐 `meeting.consume.resume` WS 事件 + `RouterRtpCapabilities` 透传到 `CreateMeetingRoomResponse` / `JoinMeetingRoomResponse`;新增 `pages/meeting/debug.vue` 临时调试页(原生 DOM 挂 `<video>`/`<audio>` 绕过 uni-h5 组件限制);`npm run build:h5` 通过无新增 warning;Q1-Q7 七项决策全部锁定(H5 Only / npm dep / 进房注册 WS 监听 / 12 接口全量封装 / services 层 sendWithAck / WS 暴露 consume.resume / debug 页手测) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-04-21(Phase 2e-2 Task 8 会议生命周期状态机落地,host 宽限期 + 自动转让 + 空房 TTL + Router 幂等,E2E 20/20 PASS)
|
> **最后更新**:2026-04-21(Phase 2e-2 Task 9 前端 mediasoup-client + Pinia Store 落地,REST/WS/媒体三链路打通,`npm run build:h5` 通过)
|
||||||
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-8 ✅ / Task 9-16 待执行)
|
> **当前阶段**:Phase 2e-2 会议 MVP **代码开发阶段** 🚧(Task 0-9 ✅ / Task 10-16 待执行)
|
||||||
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
> **当前分支**:`feature/phase2e-2-meeting-mvp`(从 `feature/phase2c-group-read-receipt` 衍生)
|
||||||
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
> **Phase 2e 整体设计**:`docs/plans/2026-04-20-phase2e-design.md`(三子阶段路线图 + 后续规划清单)
|
||||||
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
|
> **Phase 2e-1 专用设计**:`docs/plans/2026-04-20-phase2e-1-design.md`(✅ 已完成)
|
||||||
@@ -170,6 +170,86 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🎯 2026-04-21 Phase 2e-2 Task 9 前端 mediasoup-client + Pinia Store 落地
|
||||||
|
|
||||||
|
**交付**:前端新增 5 个模块(`constants/meeting.js` / `api/meeting.js` / `services/websocket.js` 的 `sendWithAck` 扩展 / `utils/mediasoup-client.js` / `store/meeting.js`)+ 1 个临时调试页(`pages/meeting/debug.vue`),把 REST(12 接口)/ WebSocket(14 事件)/ mediasoup 媒体三链路全部汇聚到 Pinia Store 对外提供 ~20 个 action;后端补齐 `meeting.consume.resume` WS 事件 + `RouterRtpCapabilities` 透传到 REST 响应;`npm run build:h5` 通过(无本次新增 warning)。
|
||||||
|
|
||||||
|
### 产出文件
|
||||||
|
|
||||||
|
| 文件 | 行数 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `frontend/package.json`(改) | +1 | `dependencies` 增加 `mediasoup-client@^3.9`(Q2=`a2_npm_dep`) |
|
||||||
|
| `frontend/src/constants/meeting.js`(新) | 150 | 会议类型/状态/角色/结束原因/离会原因/host 自动转让原因/14 个 WS 事件名(含 `meeting.consume.resume`)/前端本地状态机常量,对齐后端 `app/constants/meeting.go` |
|
||||||
|
| `frontend/src/api/meeting.js`(新) | 120 | 12 个 REST API 封装(`createRoom` / `getRoom` / `joinRoom` / `leaveRoom` / `endRoom` / `listMyMeetings` / `transferHost` / `kickMember` / `inviteUsers` / `redeemInvite` / `sendChat` / `listChats`),复用 `utils/request.js`(Q4=`a4_full_12`) |
|
||||||
|
| `frontend/src/services/websocket.js`(改) | +90 | 新增 `sendWithAck(event, data, timeoutMS)` Promise 接口 + `pendingAcks` Map + `_handleAck` 路由 + `_rejectAllPendingAcks` 断线清理;自动识别 `*.ack` 后缀消息路由到 Promise(Q5=`a5_services_layer`) |
|
||||||
|
| `frontend/src/utils/mediasoup-client.js`(新) | 270 | `createMediaEngine` 工厂:封装 Device / SendTransport / RecvTransport / Producer / Consumer 全生命周期;`Transport` 的 `connect`/`produce` 事件桥接到 `wsService.sendWithAck`;`#ifdef H5` 条件编译隔离非 H5 平台(Q1=`a1_h5_only`);所有 mediasoup 实例 `markRaw` 包裹避免 Vue 深度代理 |
|
||||||
|
| `frontend/src/store/meeting.js`(新) | 615 | Pinia Store:本地状态机(idle/joining/connecting/connected/reconnecting/leaving/ended)+ 当前 room/participant + routerID + participants/chatMessages + localProducers / remoteConsumers;监听 8 个 `meeting.*` 广播事件(room.ended / member.joined / member.left / member.state.changed / member.kicked / member.producer.new / host.changed / chat.message);生命周期 4 个 action(createAndEnter / joinAndEnter / leave / endMeeting)+ 媒体 4 个(startLocalAudio/Video / stopLocalAudio/Video)+ 读取 2 个(getLocalTrack / getRemoteTrack)+ 聊天/管理 5 个(sendChat / loadChatHistory / transferHost / kickMember / inviteUsers);WS 监听在进房时注册、离房时注销(Q3=`a3_on_enter_room`) |
|
||||||
|
| `frontend/src/pages/meeting/debug.vue`(新) | 280 | 临时调试页:会议状态面板 + 生命周期按钮 + 本地音/视频开关 + 远端参与者视频/音频动态渲染 + 聊天面板;`<video>`/`<audio>` 通过 `document.createElement` 原生 DOM 挂载到 `<view>` 容器(绕过 uni-h5 的 Video/Audio 组件不支持 `srcObject` 的限制);`#ifdef H5` 保护确保非 H5 平台构建正常(Q7=`a7_debug_page`) |
|
||||||
|
| `frontend/src/pages.json`(改) | +6 | 注册 `pages/meeting/debug` 路由(未进入 tabBar,仅供手测) |
|
||||||
|
| `backend/go-service/app/constants/meeting.go`(改) | +2 | `MeetingWSEventConsumeResume = "meeting.consume.resume"` + 加入 `MeetingWSClientEvents` |
|
||||||
|
| `backend/go-service/app/meeting/service/interfaces.go`(改) | +10 | `MediaOrchestrator` 新增 `ResumeConsumer(ctx, consumerID) error` + `ResolveRouterInfo(roomCode) (routerID, rtpCapabilities, bool)`;`NoopMediaOrchestrator` 同步实现 |
|
||||||
|
| `backend/go-service/app/meeting/service/http_media_orchestrator.go`(改) | ±40 | `sync.Map[roomCode]*routerInfoCache` 替换原 `roomRouterIDs`,同时缓存 `ID` + `RtpCapabilities`;`CreateRouter` 缓存 `rtpCapabilities` → `ResolveRouterInfo` 返回两者;新增 `ResumeConsumer` 调 Node `POST /internal/v1/consumers/:id/resume` |
|
||||||
|
| `backend/go-service/app/meeting/service/meeting_signal_service.go`(改) | +25 | `OnConsumeResume` 处理函数 + `ConsumeResumePayload` 结构体 |
|
||||||
|
| `backend/go-service/app/meeting/controller/meeting_ws_handler.go`(改) | +10 | 注册 `MeetingWSEventConsumeResume` + `handleConsumeResume` 分发 |
|
||||||
|
| `backend/go-service/app/dto/meeting_dto.go`(改) | +4 | `CreateMeetingRoomResponse` / `JoinMeetingRoomResponse` 新增 `router_id` / `rtp_capabilities`(供 `mediasoup-client.Device.load()` 使用) |
|
||||||
|
| `backend/go-service/app/meeting/service/meeting_service.go`(改) | +6 | 新增 `ResolveRouterInfo` 代理方法 |
|
||||||
|
| `backend/go-service/app/meeting/controller/meeting_controller.go`(改) | ±15 | `CreateRoom` / `JoinRoom` 响应组装时填充 `RtpCapabilities` |
|
||||||
|
|
||||||
|
### 关键设计决策(7 项)
|
||||||
|
|
||||||
|
| 编号 | 决策 | 选择 |
|
||||||
|
|---|---|---|
|
||||||
|
| Q1 | 多端策略 | 仅 H5(mediasoup-client 代码 `#ifdef H5` 包裹,其他平台 action 抛出"仅支持 H5"提示) |
|
||||||
|
| Q2 | mediasoup-client 安装 | `dependencies`(生产依赖,走 npm registry) |
|
||||||
|
| Q3 | Store 与 WS 监听挂载 | 进入会议室时注册 8 个广播监听,离会时注销(避免侵入其他页面) |
|
||||||
|
| Q4 | REST 封装范围 | 12 个接口一次性全量封装(避免后续 UI 阶段反复改 api 层) |
|
||||||
|
| Q5 | WS seq→ack 处理 | 封装在 `services/websocket.js` 层,对外 Promise 化 `sendWithAck` |
|
||||||
|
| Q6 | Consumer 自动 resume | **WS 事件暴露**(`meeting.consume.resume`)而非 Go 自动 resume:符合 mediasoup 官方规范(DOM 挂完 track 再 resume),为未来订阅/分辨率自适应/退会场景保留扩展点 |
|
||||||
|
| Q7 | E2E 验证方式 | 临时 `debug.vue` 页面 + Chrome 两 tab 手测(比 Playwright 脚本更适合 MVP 快速验证阶段) |
|
||||||
|
|
||||||
|
### Q6 深入:为什么是 WS 暴露 resume 而不是 Go 自动 resume
|
||||||
|
|
||||||
|
- **Go 自动 resume 的隐藏代价**:Consumer 创建时若立即 resume,客户端 DOM 还没挂 `<video.srcObject = track>`,mediasoup 已经开始 forward RTP 但浏览器解码出空帧 ~50-200ms,造成首帧黑屏/抖动。
|
||||||
|
- **WS 暴露方案的正确流程**:`Go createConsumer`(默认 paused)→ 下发 `consumer.created` 事件给客户端 → 客户端 `consumer = device.consume()` → `attach track to <video>` → **`video.onloadedmetadata`** 触发 → 客户端发 `meeting.consume.resume` → Go 转给 Node → RTP 开始 forward。全链路可观测,每一步都能打点。
|
||||||
|
- **长期扩展点**:未来 simulcast 层切换 / 订阅清单变更 / 进后台节流都只需要在客户端决策何时 resume/pause,不需要改 Go 业务层。
|
||||||
|
|
||||||
|
### sendWithAck 语义
|
||||||
|
|
||||||
|
```text
|
||||||
|
发送:client.send({ event: "meeting.transport.create", seq: "t_xxx", data: {...} })
|
||||||
|
回执:server.send({ event: "meeting.transport.create.ack", seq: "t_xxx", code: 0, data: {...} })
|
||||||
|
```
|
||||||
|
|
||||||
|
- **seq**:`${timestamp}_${random}` 本地生成,纯前端跟踪
|
||||||
|
- **超时**:默认 `MEETING_WS_ACK_TIMEOUT_MS=10s`(可通过第 3 参数覆盖)
|
||||||
|
- **断线保护**:`_rejectAllPendingAcks('ws_closed')` 在 `onclose` / `onerror` 回调里被调用,所有未完成 Promise 一次性 reject,避免"悬挂" Promise 导致 store action 卡住
|
||||||
|
- **消息识别**:只要 `event.endsWith('.ack')` 且 `seq` 匹配,就路由到 ACK 分发器,不再 `_emit` 到业务监听器
|
||||||
|
|
||||||
|
### 构建验证
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ npm run build:h5
|
||||||
|
> uni-preset-vue@0.0.0 build:h5
|
||||||
|
> uni build
|
||||||
|
编译器版本:4.87(vue3)
|
||||||
|
正在编译中...
|
||||||
|
DONE Build complete.
|
||||||
|
```
|
||||||
|
|
||||||
|
剩余 warning(`chat.js` / `notify.js` dynamic import 提示、Sass legacy JS API)**均为 Task 9 之前既有**,非本次引入。
|
||||||
|
|
||||||
|
### 已知待改进项(留给后续 Task)
|
||||||
|
|
||||||
|
1. **Consumer resume 流程**:当前 Store `_onProducerNew` 内部立即调 `resumeConsumer`,未等页面 `<video>.onloadedmetadata` 再 resume,MVP 阶段首帧可能有 50-100ms 抖动;Task 11 会议室主页将把 resume 时机下移到页面层 `loadedmetadata` 事件。
|
||||||
|
2. **`_cleanupRemoteProducer` 粒度粗**:当前实现是"该用户 slot 内的所有 Consumer 一并关掉",未按 producerId→consumerId 精准关;Task 11 重构时改为 `Map<producerId, consumer>` 精细索引。
|
||||||
|
3. **E2E 手测待执行**:Chrome 两 tab 跨 tab 互看 + WS 断线重入会场景需要用户在本地运行后手动验证;Task 16 会补 Playwright 自动化脚本回归。
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
|
||||||
|
进入 **Task 10:前端会议预览/创建/加入页**,依赖 Task 9 的 Pinia Store + REST 封装;预估 1 人日。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎯 2026-04-21 Phase 2e-2 Task 8 会议生命周期状态机落地(host 宽限期 + 自动转让 + 空房 TTL + Router 幂等)
|
## 🎯 2026-04-21 Phase 2e-2 Task 8 会议生命周期状态机落地(host 宽限期 + 自动转让 + 空房 TTL + Router 幂等)
|
||||||
|
|
||||||
**交付**:新增 `MeetingLifecycleService` + `MeetingCleanupTask`,落地设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期、宽限期内重连、自动转让、空房 TTL、TTL 过期销毁);同步修复 Task 7 遗留的 "CreateRoom/JoinRoom 各自调一次 `CreateRouter`" 行为,业务层 `JoinRoom` 不再触发 `CreateRouter` + HTTP 层 `HTTPMediaOrchestrator.CreateRouter` 增加 `sync.Map` 幂等防御;端到端验证脚本 `docs/verify/meeting_t8_verify.mjs` **20/20 PASS**,覆盖 5 个核心场景(含 Router 数量断言)。
|
**交付**:新增 `MeetingLifecycleService` + `MeetingCleanupTask`,落地设计 §6.5 的 5 类状态跃迁副作用(host 掉线宽限期、宽限期内重连、自动转让、空房 TTL、TTL 过期销毁);同步修复 Task 7 遗留的 "CreateRoom/JoinRoom 各自调一次 `CreateRouter`" 行为,业务层 `JoinRoom` 不再触发 `CreateRouter` + HTTP 层 `HTTPMediaOrchestrator.CreateRouter` 增加 `sync.Map` 幂等防御;端到端验证脚本 `docs/verify/meeting_t8_verify.mjs` **20/20 PASS**,覆盖 5 个核心场景(含 Router 数量断言)。
|
||||||
|
|||||||
194
frontend/package-lock.json
generated
194
frontend/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-ui": "^1.5.11",
|
"@dcloudio/uni-ui": "^1.5.11",
|
||||||
|
"mediasoup-client": "^3.19.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.3",
|
"pinia-plugin-persistedstate": "^3.2.3",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
@@ -3662,6 +3663,27 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lukeed/csprng": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lukeed/uuid": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lukeed/uuid/-/uuid-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/csprng": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@@ -4393,12 +4415,34 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
|
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/events-alias": {
|
||||||
|
"name": "@types/events",
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-legacy": {
|
"node_modules/@vitejs/plugin-legacy": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz",
|
||||||
@@ -4937,6 +4981,39 @@
|
|||||||
"postcss": "^8.1.0"
|
"postcss": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/awaitqueue": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/awaitqueue/-/awaitqueue-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-zLxDhzQbzHmOyvxi7g3OlfR7jLrcmElStPxfLPpJkrFSDm71RSrY/MvsDA8Btlx8X1nOHUzGhQvc6bdUjL2f2w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mediasoup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/awaitqueue/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.15",
|
"version": "0.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
|
||||||
@@ -5756,6 +5833,16 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/events-alias": {
|
||||||
|
"name": "events",
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -5851,6 +5938,18 @@
|
|||||||
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
"integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fake-mediastreamtrack": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fake-mediastreamtrack/-/fake-mediastreamtrack-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-SITLc7UTDirSdgLGORrlmqjWLJtbtfIz/xO7DwVbJH3f0ds+NQok4ccl/WEzz49NSgiUlXf2wDW0+y5C+TO6EA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@lukeed/uuid": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -6144,6 +6243,39 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/h264-profile-level-id": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/h264-profile-level-id/-/h264-profile-level-id-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-hnq1UDlw7WGJV6GCr/g7wnkHYUjdAY2bis9rgn2JqSdQS2WfVvnt1ZE9g8nTguracodf5LLKZOwURsDN49YtBQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.4.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mediasoup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/h264-profile-level-id/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
@@ -6647,6 +6779,59 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mediasoup-client": {
|
||||||
|
"version": "3.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mediasoup-client/-/mediasoup-client-3.19.0.tgz",
|
||||||
|
"integrity": "sha512-XU4Y7yOe0FqUXwu/Mf8Wgrp5AOde1l60FEFNvSSrR2nNzbw3+jAcHQV35Ii9bXb21PT5XaW4SkI4dhxhwFXR3Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/debug": "^4.1.13",
|
||||||
|
"@types/events-alias": "npm:@types/events@^3.0.3",
|
||||||
|
"awaitqueue": "^3.3.0",
|
||||||
|
"debug": "^4.4.3",
|
||||||
|
"events-alias": "npm:events@^3.3.0",
|
||||||
|
"fake-mediastreamtrack": "^2.2.1",
|
||||||
|
"h264-profile-level-id": "^2.3.2",
|
||||||
|
"sdp-transform": "^3.0.0",
|
||||||
|
"supports-color": "^10.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/mediasoup"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mediasoup-client/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mediasoup-client/node_modules/supports-color": {
|
||||||
|
"version": "10.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
|
||||||
|
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/meow": {
|
"node_modules/meow": {
|
||||||
"version": "13.2.0",
|
"version": "13.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
|
||||||
@@ -7736,6 +7921,15 @@
|
|||||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sdp-transform": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"sdp-verify": "checker.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
"@dcloudio/uni-mp-xhs": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4080720251210001",
|
||||||
"@dcloudio/uni-ui": "^1.5.11",
|
"@dcloudio/uni-ui": "^1.5.11",
|
||||||
|
"mediasoup-client": "^3.19.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.3",
|
"pinia-plugin-persistedstate": "^3.2.3",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
|
|||||||
160
frontend/src/api/meeting.js
Normal file
160
frontend/src/api/meeting.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
/**
|
||||||
|
* 会议模块 REST API
|
||||||
|
*
|
||||||
|
* 对应后端路由:/api/v1/meeting/*(见 backend/go-service/app/meeting/router.go)
|
||||||
|
*
|
||||||
|
* 设计原则:
|
||||||
|
* - 本文件仅做 REST 封装,业务层(store/meeting.js)统一调本文件方法
|
||||||
|
* - WebRTC 媒体层信令通过 WS(services/websocket.js + sendWithAck)发送,不在此处
|
||||||
|
* - 响应中的 DTO 字段命名与后端 DTO 完全一致(下划线),前端按需再转换
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { get, post } from '@/utils/request'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一拆包:utils/request.js 返回完整 envelope { code, message, data, trace_id, time },
|
||||||
|
* 业务层只关心 data 负载;这里一次性拆包,避免每个 store/页面都写 .data.xxx
|
||||||
|
* 对无 body 的响应(如 leave/end)返回 null 而非 undefined,方便调用方判空
|
||||||
|
*/
|
||||||
|
const unwrap = (promise) => promise.then((resp) => (resp && 'data' in resp ? (resp.data ?? null) : (resp ?? null)))
|
||||||
|
|
||||||
|
// ==================== 会议房间(6 接口) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建即时会议
|
||||||
|
* @param {Object} payload - { title, password?, max_members? }
|
||||||
|
* @returns {Promise<{ room: MeetingRoomDTO }>}
|
||||||
|
*/
|
||||||
|
const createRoom = (payload) => {
|
||||||
|
return unwrap(post('/api/v1/meeting/rooms', payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会议详情(含参与者列表)
|
||||||
|
* @param {string} roomCode - 会议号(XXX-XXX-XXX 或 9 位纯数字)
|
||||||
|
* @returns {Promise<{ room, participants, online_count }>}
|
||||||
|
*/
|
||||||
|
const getRoom = (roomCode) => {
|
||||||
|
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入会议
|
||||||
|
* @param {string} roomCode - 会议号
|
||||||
|
* @param {Object} [payload] - { password? }
|
||||||
|
* @returns {Promise<{ room, participant, router_id }>}
|
||||||
|
*/
|
||||||
|
const joinRoom = (roomCode, payload = {}) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/join`, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 离开会议
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @returns {Promise<{ duration }>}
|
||||||
|
*/
|
||||||
|
const leaveRoom = (roomCode) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/leave`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束会议(仅主持人可调)
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const endRoom = (roomCode) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/end`))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的会议列表(历史 + 进行中)
|
||||||
|
* @param {Object} [params] - { status?, before_id?, limit? }
|
||||||
|
* @returns {Promise<{ list, has_more }>}
|
||||||
|
*/
|
||||||
|
const listMyMeetings = (params = {}) => {
|
||||||
|
return unwrap(get('/api/v1/meeting/rooms/mine', params))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 会议成员管理(3 接口) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转让主持人
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {number} targetUserID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const transferHost = (roomCode, targetUserID) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/transfer-host`, {
|
||||||
|
target_user_id: targetUserID
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除成员(踢人,仅主持人)
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {number} userID
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const kickMember = (roomCode, userID) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/kick`, { user_id: userID }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请用户(批量)
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {number[]} inviteeIDs - 被邀请用户 ID 数组(1~50)
|
||||||
|
* @returns {Promise<{ pushed, skipped }>}
|
||||||
|
*/
|
||||||
|
const inviteUsers = (roomCode, inviteeIDs) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/invite`, {
|
||||||
|
invitee_ids: inviteeIDs
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 邀请兑换(1 接口) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兑换邀请 Token(从通知点进链接时使用)
|
||||||
|
* @param {string} token - 邀请链接中的 token 字符串
|
||||||
|
* @returns {Promise<{ room_code, inviter_id, has_password }>}
|
||||||
|
*/
|
||||||
|
const redeemInvite = (token) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/invite-tokens/${token}/redeem`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 会议内聊天(2 接口) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送会议内聊天消息
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {string} content - 1~500 字
|
||||||
|
* @returns {Promise<{ message }>}
|
||||||
|
*/
|
||||||
|
const sendChat = (roomCode, content) => {
|
||||||
|
return unwrap(post(`/api/v1/meeting/rooms/${roomCode}/chats`, { content }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询会议聊天历史(游标分页)
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {Object} [params] - { before_id?, limit? }
|
||||||
|
* @returns {Promise<{ list, has_more }>}
|
||||||
|
*/
|
||||||
|
const listChats = (roomCode, params = {}) => {
|
||||||
|
return unwrap(get(`/api/v1/meeting/rooms/${roomCode}/chats`, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createRoom,
|
||||||
|
getRoom,
|
||||||
|
joinRoom,
|
||||||
|
leaveRoom,
|
||||||
|
endRoom,
|
||||||
|
listMyMeetings,
|
||||||
|
transferHost,
|
||||||
|
kickMember,
|
||||||
|
inviteUsers,
|
||||||
|
redeemInvite,
|
||||||
|
sendChat,
|
||||||
|
listChats
|
||||||
|
}
|
||||||
163
frontend/src/constants/meeting.js
Normal file
163
frontend/src/constants/meeting.js
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* 会议模块前端常量
|
||||||
|
*
|
||||||
|
* 与后端 backend/go-service/app/constants/meeting.go 严格一致
|
||||||
|
* 新增/修改任一常量时必须同步维护两端,否则运行时会因事件名不匹配而静默失败
|
||||||
|
*
|
||||||
|
* 版本说明:
|
||||||
|
* - Task 6(2026-04-20)落地 13 个 WS 事件
|
||||||
|
* - Task 9(2026-04-22)新增 meeting.consume.resume,总计 14 个事件
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ==================== 会议类型 ====================
|
||||||
|
|
||||||
|
export const MEETING_TYPE_INSTANT = 1 // 即时会议(MVP 仅此)
|
||||||
|
export const MEETING_TYPE_SCHEDULED = 2 // 预约会议(Phase 2e-3)
|
||||||
|
|
||||||
|
export const MEETING_TYPE_LABEL = {
|
||||||
|
[MEETING_TYPE_INSTANT]: '即时会议',
|
||||||
|
[MEETING_TYPE_SCHEDULED]: '预约会议'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 会议状态 ====================
|
||||||
|
|
||||||
|
export const MEETING_STATUS_PENDING = 0 // 未开始(仅预约会议)
|
||||||
|
export const MEETING_STATUS_ACTIVE = 1 // 进行中
|
||||||
|
export const MEETING_STATUS_ENDED = 2 // 已结束
|
||||||
|
|
||||||
|
export const MEETING_STATUS_LABEL = {
|
||||||
|
[MEETING_STATUS_PENDING]: '未开始',
|
||||||
|
[MEETING_STATUS_ACTIVE]: '进行中',
|
||||||
|
[MEETING_STATUS_ENDED]: '已结束'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 参与者角色 ====================
|
||||||
|
|
||||||
|
export const MEETING_ROLE_PARTICIPANT = 0 // 普通参会人
|
||||||
|
export const MEETING_ROLE_HOST = 1 // 主持人
|
||||||
|
export const MEETING_ROLE_COHOST = 2 // 联合主持人(保留,MVP 不用)
|
||||||
|
|
||||||
|
export const MEETING_ROLE_LABEL = {
|
||||||
|
[MEETING_ROLE_PARTICIPANT]: '参会人',
|
||||||
|
[MEETING_ROLE_HOST]: '主持人',
|
||||||
|
[MEETING_ROLE_COHOST]: '联合主持人'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 会议结束原因 ====================
|
||||||
|
|
||||||
|
export const MEETING_ENDED_REASON_HOST_ENDED = 'host_ended'
|
||||||
|
export const MEETING_ENDED_REASON_EMPTY_TTL = 'empty_ttl'
|
||||||
|
export const MEETING_ENDED_REASON_ADMIN_FORCE = 'admin_force'
|
||||||
|
export const MEETING_ENDED_REASON_SYSTEM_ERROR = 'system_error'
|
||||||
|
|
||||||
|
export const MEETING_ENDED_REASON_LABEL = {
|
||||||
|
[MEETING_ENDED_REASON_HOST_ENDED]: '主持人结束',
|
||||||
|
[MEETING_ENDED_REASON_EMPTY_TTL]: '空房超时',
|
||||||
|
[MEETING_ENDED_REASON_ADMIN_FORCE]: '管理员强制结束',
|
||||||
|
[MEETING_ENDED_REASON_SYSTEM_ERROR]: '系统异常'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 离会原因 ====================
|
||||||
|
|
||||||
|
export const MEETING_LEFT_REASON_SELF = 'self'
|
||||||
|
export const MEETING_LEFT_REASON_KICKED = 'kicked'
|
||||||
|
export const MEETING_LEFT_REASON_HOST_END = 'host_end'
|
||||||
|
export const MEETING_LEFT_REASON_EMPTY_TTL = 'empty_ttl'
|
||||||
|
export const MEETING_LEFT_REASON_DISCONNECT = 'disconnect'
|
||||||
|
|
||||||
|
export const MEETING_LEFT_REASON_LABEL = {
|
||||||
|
[MEETING_LEFT_REASON_SELF]: '主动离开',
|
||||||
|
[MEETING_LEFT_REASON_KICKED]: '被移除',
|
||||||
|
[MEETING_LEFT_REASON_HOST_END]: '会议结束',
|
||||||
|
[MEETING_LEFT_REASON_EMPTY_TTL]: '空房超时',
|
||||||
|
[MEETING_LEFT_REASON_DISCONNECT]: '网络断开'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 主持人变更自动转让原因(Task 8 引入) ====================
|
||||||
|
|
||||||
|
export const MEETING_HOST_AUTO_REASON_GRACE_EXPIRED = 'host_grace_expired' // 主持人宽限期到期自动转让
|
||||||
|
export const MEETING_HOST_AUTO_REASON_HOST_KICKED = 'host_kicked' // (保留)主持人被管理员踢出自动转让
|
||||||
|
|
||||||
|
export const MEETING_HOST_AUTO_REASON_LABEL = {
|
||||||
|
[MEETING_HOST_AUTO_REASON_GRACE_EXPIRED]: '主持人掉线超时自动转让',
|
||||||
|
[MEETING_HOST_AUTO_REASON_HOST_KICKED]: '主持人被移除自动转让'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 默认配置常量 ====================
|
||||||
|
|
||||||
|
export const MEETING_MVP_MAX_MEMBERS = 8 // MVP 单会议硬上限
|
||||||
|
export const MEETING_HOST_GRACE_SECONDS = 120 // 主持人掉线宽限期(Task 8)
|
||||||
|
export const MEETING_EMPTY_ROOM_TTL_SECONDS = 300 // 空房销毁 TTL(Task 8)
|
||||||
|
export const MEETING_WS_ACK_TIMEOUT_MS = 10000 // WS 事件 ACK 等待超时(Task 9)
|
||||||
|
|
||||||
|
// ==================== WS 事件名(14 个,与后端 constants/meeting.go 一一对应) ====================
|
||||||
|
|
||||||
|
// 房间组(3 个)
|
||||||
|
export const MEETING_WS_ROOM_JOIN = 'meeting.room.join' // C→S
|
||||||
|
export const MEETING_WS_ROOM_LEAVE = 'meeting.room.leave' // C→S
|
||||||
|
export const MEETING_WS_ROOM_ENDED = 'meeting.room.ended' // S→C
|
||||||
|
|
||||||
|
// 成员组(6 个)
|
||||||
|
export const MEETING_WS_MEMBER_JOINED = 'meeting.member.joined' // S→C
|
||||||
|
export const MEETING_WS_MEMBER_LEFT = 'meeting.member.left' // S→C
|
||||||
|
export const MEETING_WS_MEMBER_STATE_CHANGED = 'meeting.member.state.changed' // 双向
|
||||||
|
export const MEETING_WS_MEMBER_KICKED = 'meeting.member.kicked' // S→C(定向)
|
||||||
|
export const MEETING_WS_MEMBER_PRODUCER_NEW = 'meeting.member.producer.new' // S→C
|
||||||
|
export const MEETING_WS_HOST_CHANGED = 'meeting.host.changed' // S→C
|
||||||
|
|
||||||
|
// 媒体组(6 个,含 Task 9 新增 consume.resume)
|
||||||
|
export const MEETING_WS_TRANSPORT_CREATE = 'meeting.transport.create' // C→S
|
||||||
|
export const MEETING_WS_TRANSPORT_CONNECT = 'meeting.transport.connect' // C→S
|
||||||
|
export const MEETING_WS_PRODUCE_START = 'meeting.produce.start' // C→S
|
||||||
|
export const MEETING_WS_CONSUME_START = 'meeting.consume.start' // C→S
|
||||||
|
export const MEETING_WS_CONSUME_RESUME = 'meeting.consume.resume' // C→S(Task 9 新增)
|
||||||
|
export const MEETING_WS_PRODUCER_CLOSE = 'meeting.producer.close' // C→S
|
||||||
|
|
||||||
|
// 聊天(1 个)
|
||||||
|
export const MEETING_WS_CHAT_MESSAGE = 'meeting.chat.message' // S→C
|
||||||
|
|
||||||
|
/** C→S 客户端可主动发起的事件白名单 */
|
||||||
|
export const MEETING_WS_CLIENT_EVENTS = [
|
||||||
|
MEETING_WS_ROOM_JOIN,
|
||||||
|
MEETING_WS_ROOM_LEAVE,
|
||||||
|
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||||
|
MEETING_WS_TRANSPORT_CREATE,
|
||||||
|
MEETING_WS_TRANSPORT_CONNECT,
|
||||||
|
MEETING_WS_PRODUCE_START,
|
||||||
|
MEETING_WS_CONSUME_START,
|
||||||
|
MEETING_WS_CONSUME_RESUME,
|
||||||
|
MEETING_WS_PRODUCER_CLOSE
|
||||||
|
]
|
||||||
|
|
||||||
|
/** S→C 服务端广播事件白名单(用于 Store 注册监听时统一处理) */
|
||||||
|
export const MEETING_WS_SERVER_EVENTS = [
|
||||||
|
MEETING_WS_ROOM_ENDED,
|
||||||
|
MEETING_WS_MEMBER_JOINED,
|
||||||
|
MEETING_WS_MEMBER_LEFT,
|
||||||
|
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||||
|
MEETING_WS_MEMBER_KICKED,
|
||||||
|
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||||
|
MEETING_WS_HOST_CHANGED,
|
||||||
|
MEETING_WS_CHAT_MESSAGE
|
||||||
|
]
|
||||||
|
|
||||||
|
// ==================== 本地会议状态机(前端专用,与后端 meeting_rooms.status 无关) ====================
|
||||||
|
|
||||||
|
// Task 9 用于 store/meeting.js 驱动 UI 的本地状态
|
||||||
|
export const MEETING_LOCAL_STATE_IDLE = 'idle' // 未进入会议
|
||||||
|
export const MEETING_LOCAL_STATE_JOINING = 'joining' // REST JoinRoom 中
|
||||||
|
export const MEETING_LOCAL_STATE_CONNECTING = 'connecting' // Device load + Transport 创建中
|
||||||
|
export const MEETING_LOCAL_STATE_CONNECTED = 'connected' // 媒体层就绪,可推流/收流
|
||||||
|
export const MEETING_LOCAL_STATE_RECONNECTING = 'reconnecting' // WS 断线重连期间(Task 8 host 宽限期共用)
|
||||||
|
export const MEETING_LOCAL_STATE_LEAVING = 'leaving' // REST LeaveRoom 中
|
||||||
|
export const MEETING_LOCAL_STATE_ENDED = 'ended' // 会议已结束(room.ended 收到后进入)
|
||||||
|
|
||||||
|
export const MEETING_LOCAL_STATE_LABEL = {
|
||||||
|
[MEETING_LOCAL_STATE_IDLE]: '未入会',
|
||||||
|
[MEETING_LOCAL_STATE_JOINING]: '加入中',
|
||||||
|
[MEETING_LOCAL_STATE_CONNECTING]: '连接中',
|
||||||
|
[MEETING_LOCAL_STATE_CONNECTED]: '已连接',
|
||||||
|
[MEETING_LOCAL_STATE_RECONNECTING]: '重连中',
|
||||||
|
[MEETING_LOCAL_STATE_LEAVING]: '离开中',
|
||||||
|
[MEETING_LOCAL_STATE_ENDED]: '已结束'
|
||||||
|
}
|
||||||
@@ -142,6 +142,12 @@
|
|||||||
"navigationBarTitleText": "会议"
|
"navigationBarTitleText": "会议"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/meeting/debug",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会议调试(Task 9)"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/notify/index",
|
"path": "pages/notify/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
340
frontend/src/pages/meeting/debug.vue
Normal file
340
frontend/src/pages/meeting/debug.vue
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
<!--
|
||||||
|
会议调试页(Task 9 临时产物)
|
||||||
|
|
||||||
|
目的:
|
||||||
|
- 在没有正式 UI 的情况下,手工验证 frontend/src/store/meeting.js 各 action 通链路
|
||||||
|
- 仅 H5 端可用(mediasoup-client 平台限制,见 Task 9 决策 Q1)
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- uni-app H5 的 <video>/<audio> 标签会被编译成 uni 自定义组件(不支持 srcObject)
|
||||||
|
- 因此本页用 <view> 作为容器,在 H5 环境用原生 document.createElement('video'/'audio') 挂 WebRTC track
|
||||||
|
|
||||||
|
Task 10 开始后此页可删除或重构为正式 UI,路由信息仅在 pages.json 的 debug 块中保留
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header">
|
||||||
|
<text class="title">会议调试(Task 9)</text>
|
||||||
|
<text class="state">状态:{{ localStateLabel }}</text>
|
||||||
|
<text class="state">房号:{{ currentRoom?.room_code || '-' }} ({{ currentRoom?.title || '-' }})</text>
|
||||||
|
<text class="state">是否主持人:{{ isHost ? '是' : '否' }}</text>
|
||||||
|
<text class="state" v-if="lastEndedReason">结束原因:{{ lastEndedReason }}</text>
|
||||||
|
<text class="state" v-if="lastAutoHostReason">自动转让:{{ lastAutoHostReason }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="subtitle">1. 会议生命周期</text>
|
||||||
|
<view class="row">
|
||||||
|
<input v-model="form.title" class="input" placeholder="会议标题" />
|
||||||
|
<button class="btn primary" :disabled="isInMeeting" @click="onCreate">创建并入会</button>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<input v-model="form.roomCode" class="input" placeholder="会议号(XXX-XXX-XXX 或 9 位)" />
|
||||||
|
<input v-model="form.password" class="input password" placeholder="密码(可选)" />
|
||||||
|
<button class="btn primary" :disabled="isInMeeting" @click="onJoin">加入会议</button>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<button class="btn" :disabled="!isInMeeting" @click="onLeave">离开会议</button>
|
||||||
|
<button class="btn danger" :disabled="!isHost || !isInMeeting" @click="onEnd">结束会议(host)</button>
|
||||||
|
<button class="btn" @click="onCleanupStale">清理遗留会议</button>
|
||||||
|
</view>
|
||||||
|
<text class="hint" v-if="lastHint">{{ lastHint }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="subtitle">2. 本地推流</text>
|
||||||
|
<view class="row">
|
||||||
|
<button class="btn" :disabled="!isConnected" @click="onToggleAudio">
|
||||||
|
{{ localAudioEnabled ? '关闭麦克风' : '开启麦克风' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn" :disabled="!isConnected" @click="onToggleVideo">
|
||||||
|
{{ localVideoEnabled ? '关闭摄像头' : '开启摄像头' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
<view class="video-wrap">
|
||||||
|
<text class="caption">本地预览</text>
|
||||||
|
<view ref="localVideoBox" class="video-slot"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="subtitle">3. 远端参与者({{ activeParticipants.length }} 人活跃)</text>
|
||||||
|
<view v-for="p in activeParticipants" :key="p.user_id" class="participant">
|
||||||
|
<text class="pname">userId={{ p.user_id }} role={{ p.role }} joined={{ p.joined_at }}</text>
|
||||||
|
<view class="video-wrap">
|
||||||
|
<view :ref="el => setRemoteBox(p.user_id, el)" class="video-slot small"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<text class="subtitle">4. 会议内聊天</text>
|
||||||
|
<view class="chat-box">
|
||||||
|
<view v-for="(m, i) in chatMessages" :key="i" class="chat-msg">
|
||||||
|
<text>{{ m.user_name || m.user_id }}:{{ m.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<input v-model="chatInput" class="input" placeholder="输入聊天内容" />
|
||||||
|
<button class="btn" :disabled="!isInMeeting" @click="onSendChat">发送</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { useMeetingStore } from '@/store/meeting'
|
||||||
|
import { MEETING_LOCAL_STATE_LABEL } from '@/constants/meeting'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
|
const meetingStore = useMeetingStore()
|
||||||
|
const {
|
||||||
|
localState,
|
||||||
|
currentRoom,
|
||||||
|
chatMessages,
|
||||||
|
lastEndedReason,
|
||||||
|
lastAutoHostReason,
|
||||||
|
localAudioEnabled,
|
||||||
|
localVideoEnabled,
|
||||||
|
isInMeeting,
|
||||||
|
isConnected,
|
||||||
|
isHost,
|
||||||
|
activeParticipants,
|
||||||
|
remoteConsumers
|
||||||
|
} = storeToRefs(meetingStore)
|
||||||
|
|
||||||
|
const form = reactive({ title: '调试会议', roomCode: '', password: '' })
|
||||||
|
const chatInput = ref('')
|
||||||
|
const lastHint = ref('')
|
||||||
|
|
||||||
|
// 容器 ref(保存 Vue 组件实例或真实 DOM)
|
||||||
|
const localVideoBox = ref(null)
|
||||||
|
const remoteBoxes = reactive({})
|
||||||
|
|
||||||
|
const setRemoteBox = (userId, el) => {
|
||||||
|
if (el) remoteBoxes[userId] = el
|
||||||
|
else delete remoteBoxes[userId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStateLabel = computed(() => {
|
||||||
|
const label = MEETING_LOCAL_STATE_LABEL[localState.value] || localState.value
|
||||||
|
return `${localState.value} (${label})`
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 原生 DOM 辅助(仅 H5)====================
|
||||||
|
|
||||||
|
/** 把 uni <view> 的 ref 统一转成真实 DOM 元素 */
|
||||||
|
const resolveDom = (refValue) => {
|
||||||
|
if (!refValue) return null
|
||||||
|
// #ifdef H5
|
||||||
|
if (refValue instanceof HTMLElement) return refValue
|
||||||
|
if (refValue.$el) return refValue.$el
|
||||||
|
if (refValue.exposed && refValue.exposed.$el) return refValue.exposed.$el
|
||||||
|
// #endif
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在容器内确保存在一个 <video> / <audio> 原生元素,并返回它 */
|
||||||
|
const ensureMediaEl = (container, tagName, className = '') => {
|
||||||
|
// #ifdef H5
|
||||||
|
const parent = resolveDom(container)
|
||||||
|
if (!parent) return null
|
||||||
|
let el = parent.querySelector(`:scope > ${tagName}`)
|
||||||
|
if (!el) {
|
||||||
|
el = document.createElement(tagName)
|
||||||
|
el.autoplay = true
|
||||||
|
el.playsInline = true
|
||||||
|
if (tagName === 'video') {
|
||||||
|
el.style.width = '100%'
|
||||||
|
el.style.maxWidth = '240px'
|
||||||
|
el.style.background = '#000'
|
||||||
|
el.style.borderRadius = '4px'
|
||||||
|
}
|
||||||
|
if (className) el.className = className
|
||||||
|
parent.appendChild(el)
|
||||||
|
}
|
||||||
|
return el
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return null
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeMediaEl = (container, tagName) => {
|
||||||
|
// #ifdef H5
|
||||||
|
const parent = resolveDom(container)
|
||||||
|
if (!parent) return
|
||||||
|
const el = parent.querySelector(`:scope > ${tagName}`)
|
||||||
|
if (el) {
|
||||||
|
try { el.srcObject = null } catch {}
|
||||||
|
parent.removeChild(el)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 本地/远端 track 挂载 ====================
|
||||||
|
|
||||||
|
const attachLocalTrack = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
const track = meetingStore.getLocalTrack('video')
|
||||||
|
if (track) {
|
||||||
|
const el = ensureMediaEl(localVideoBox.value, 'video')
|
||||||
|
if (el) {
|
||||||
|
el.muted = true
|
||||||
|
el.srcObject = new MediaStream([track])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removeMediaEl(localVideoBox.value, 'video')
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachRemoteTracks = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
Object.keys(remoteConsumers.value).forEach((uid) => {
|
||||||
|
const box = remoteBoxes[uid]
|
||||||
|
if (!box) return
|
||||||
|
const videoTrack = meetingStore.getRemoteTrack(Number(uid), 'video')
|
||||||
|
const audioTrack = meetingStore.getRemoteTrack(Number(uid), 'audio')
|
||||||
|
|
||||||
|
if (videoTrack) {
|
||||||
|
const videoEl = ensureMediaEl(box, 'video', 'remote-video')
|
||||||
|
if (videoEl) videoEl.srcObject = new MediaStream([videoTrack])
|
||||||
|
} else {
|
||||||
|
removeMediaEl(box, 'video')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioTrack) {
|
||||||
|
const audioEl = ensureMediaEl(box, 'audio', 'remote-audio')
|
||||||
|
if (audioEl) audioEl.srcObject = new MediaStream([audioTrack])
|
||||||
|
} else {
|
||||||
|
removeMediaEl(box, 'audio')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(localVideoEnabled, () => nextTick(attachLocalTrack))
|
||||||
|
watch(remoteConsumers, () => nextTick(attachRemoteTracks), { deep: true })
|
||||||
|
watch(activeParticipants, () => nextTick(attachRemoteTracks))
|
||||||
|
|
||||||
|
// ==================== Actions ====================
|
||||||
|
|
||||||
|
const handleStaleHint = (err) => {
|
||||||
|
if (err && err.staleMeetingHint) {
|
||||||
|
lastHint.value = '检测到僵尸参会残留:请点击"清理遗留会议"按钮后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCreate = async () => {
|
||||||
|
lastHint.value = ''
|
||||||
|
try {
|
||||||
|
await meetingStore.createAndEnter({ title: form.title })
|
||||||
|
uni.showToast({ title: '已创建并入会', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
handleStaleHint(err)
|
||||||
|
uni.showToast({ title: `创建失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onJoin = async () => {
|
||||||
|
lastHint.value = ''
|
||||||
|
try {
|
||||||
|
await meetingStore.joinAndEnter(form.roomCode, form.password)
|
||||||
|
uni.showToast({ title: '已加入会议', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
handleStaleHint(err)
|
||||||
|
uni.showToast({ title: `加入失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCleanupStale = async () => {
|
||||||
|
lastHint.value = ''
|
||||||
|
try {
|
||||||
|
const { cleanedCodes } = await meetingStore.cleanupStaleMeetings()
|
||||||
|
if (cleanedCodes.length === 0) {
|
||||||
|
lastHint.value = '无遗留活跃会议'
|
||||||
|
uni.showToast({ title: '无遗留活跃会议', icon: 'none' })
|
||||||
|
} else {
|
||||||
|
lastHint.value = `已清理 ${cleanedCodes.length} 条:${cleanedCodes.join('、')}`
|
||||||
|
uni.showToast({ title: `已清理 ${cleanedCodes.length} 条`, icon: 'success' })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: `清理失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLeave = async () => {
|
||||||
|
await meetingStore.leave()
|
||||||
|
uni.showToast({ title: '已离会', icon: 'success' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnd = async () => {
|
||||||
|
try {
|
||||||
|
await meetingStore.endMeeting()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: `结束失败:${err.message || JSON.stringify(err)}`, icon: 'none', duration: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleAudio = async () => {
|
||||||
|
try {
|
||||||
|
if (localAudioEnabled.value) await meetingStore.stopLocalAudio()
|
||||||
|
else await meetingStore.startLocalAudio()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: `音频切换失败:${err.message}`, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onToggleVideo = async () => {
|
||||||
|
try {
|
||||||
|
if (localVideoEnabled.value) await meetingStore.stopLocalVideo()
|
||||||
|
else await meetingStore.startLocalVideo()
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: `视频切换失败:${err.message}`, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSendChat = async () => {
|
||||||
|
const content = chatInput.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
try {
|
||||||
|
await meetingStore.sendChat(content)
|
||||||
|
chatInput.value = ''
|
||||||
|
} catch (err) {
|
||||||
|
uni.showToast({ title: `发送失败:${err.message || JSON.stringify(err)}`, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (isInMeeting.value) {
|
||||||
|
meetingStore.leave().catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { padding: 24rpx; background: #F8FAFC; min-height: 100vh; }
|
||||||
|
.header { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
|
||||||
|
.title { font-size: 36rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 12rpx; }
|
||||||
|
.state { font-size: 26rpx; color: #475569; display: block; margin-top: 4rpx; }
|
||||||
|
.section { background: #fff; padding: 24rpx; border-radius: 12rpx; margin-bottom: 24rpx; }
|
||||||
|
.subtitle { font-size: 30rpx; font-weight: 600; color: #1E293B; display: block; margin-bottom: 16rpx; }
|
||||||
|
.row { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; flex-wrap: wrap; }
|
||||||
|
.input { flex: 1; min-width: 200rpx; border: 1px solid #CBD5E1; padding: 12rpx 16rpx; border-radius: 8rpx; font-size: 28rpx; }
|
||||||
|
.input.password { max-width: 200rpx; }
|
||||||
|
.btn { padding: 12rpx 24rpx; border-radius: 8rpx; background: #E2E8F0; color: #1E293B; font-size: 26rpx; }
|
||||||
|
.btn.primary { background: #3B82F6; color: #fff; }
|
||||||
|
.btn.danger { background: #EF4444; color: #fff; }
|
||||||
|
.btn[disabled] { opacity: 0.5; }
|
||||||
|
.video-wrap { display: flex; flex-direction: column; gap: 8rpx; margin-top: 16rpx; }
|
||||||
|
.caption { font-size: 22rpx; color: #64748B; }
|
||||||
|
.video-slot { width: 100%; min-height: 180rpx; background: #0B1220; border-radius: 8rpx; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.video-slot.small { min-height: 140rpx; }
|
||||||
|
.participant { padding: 16rpx 0; border-bottom: 1px dashed #E2E8F0; }
|
||||||
|
.pname { font-size: 24rpx; color: #475569; }
|
||||||
|
.chat-box { max-height: 300rpx; overflow-y: auto; background: #F1F5F9; padding: 12rpx; border-radius: 8rpx; margin-bottom: 12rpx; }
|
||||||
|
.chat-msg { font-size: 26rpx; color: #1E293B; padding: 4rpx 0; }
|
||||||
|
.hint { font-size: 24rpx; color: #F59E0B; margin-top: 12rpx; display: block; }
|
||||||
|
</style>
|
||||||
@@ -18,6 +18,7 @@ const HEARTBEAT_INTERVAL = 30000
|
|||||||
const RECONNECT_BASE_DELAY = 1000
|
const RECONNECT_BASE_DELAY = 1000
|
||||||
const RECONNECT_MAX_DELAY = 30000
|
const RECONNECT_MAX_DELAY = 30000
|
||||||
const MAX_RECONNECT_ATTEMPTS = Infinity
|
const MAX_RECONNECT_ATTEMPTS = Infinity
|
||||||
|
const ACK_DEFAULT_TIMEOUT_MS = 10000
|
||||||
|
|
||||||
class WebSocketService {
|
class WebSocketService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -28,6 +29,11 @@ class WebSocketService {
|
|||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
this.isManualClose = false
|
this.isManualClose = false
|
||||||
this.seq = 0
|
this.seq = 0
|
||||||
|
/**
|
||||||
|
* 等待 ACK 的请求索引:Map<seq, { resolve, reject, timer, event }>
|
||||||
|
* Task 9 引入,为 meeting.* 事件提供 Promise 化 ACK 等待
|
||||||
|
*/
|
||||||
|
this.pendingAcks = new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +126,41 @@ class WebSocketService {
|
|||||||
return this.seq
|
return this.seq
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息并等待服务端 ACK(Task 9 引入)
|
||||||
|
*
|
||||||
|
* 服务端对 C→S 事件以相同 event 名 + 对应 seq 返回 ACK 报文:
|
||||||
|
* { event, seq, data: { code, message, data? } }
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* - code === 0 表示成功,Promise resolve 为 data.data(业务 payload)
|
||||||
|
* - code !== 0 表示业务失败,Promise reject 为 { code, message }
|
||||||
|
* - 超时 reject 为 { code: -1, message: 'ACK 超时' }
|
||||||
|
* - 连接未就绪 reject 为 { code: -1, message: '连接未就绪' }
|
||||||
|
*
|
||||||
|
* 典型用法:
|
||||||
|
* const info = await wsService.sendWithAck('meeting.transport.create', { room_code, direction: 'send' })
|
||||||
|
*
|
||||||
|
* @param {string} event - 事件名
|
||||||
|
* @param {Object} [data] - 业务 payload
|
||||||
|
* @param {number} [timeoutMS=10000] - 超时时间
|
||||||
|
* @returns {Promise<any>} ACK 的 data 字段
|
||||||
|
*/
|
||||||
|
sendWithAck(event, data = {}, timeoutMS = ACK_DEFAULT_TIMEOUT_MS) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const seq = this.send(event, data)
|
||||||
|
if (seq === -1) {
|
||||||
|
reject({ code: -1, message: '连接未就绪' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingAcks.delete(seq)
|
||||||
|
reject({ code: -1, message: `ACK 超时 (${event})` })
|
||||||
|
}, timeoutMS)
|
||||||
|
this.pendingAcks.set(seq, { resolve, reject, timer, event })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册事件监听
|
* 注册事件监听
|
||||||
* @param {string} event - 事件类型
|
* @param {string} event - 事件类型
|
||||||
@@ -171,6 +212,7 @@ class WebSocketService {
|
|||||||
_onClose(e) {
|
_onClose(e) {
|
||||||
console.log('[WS] 连接关闭', e)
|
console.log('[WS] 连接关闭', e)
|
||||||
this._clearTimers()
|
this._clearTimers()
|
||||||
|
this._rejectAllPendingAcks('连接已断开')
|
||||||
this._emit('_disconnected')
|
this._emit('_disconnected')
|
||||||
|
|
||||||
if (!this.isManualClose) {
|
if (!this.isManualClose) {
|
||||||
@@ -178,6 +220,19 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接断开时统一拒绝所有未完成的 ACK 等待(Task 9)
|
||||||
|
* 避免业务层 await sendWithAck() 永久挂起
|
||||||
|
*/
|
||||||
|
_rejectAllPendingAcks(reason) {
|
||||||
|
if (this.pendingAcks.size === 0) return
|
||||||
|
this.pendingAcks.forEach((pending, seq) => {
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
pending.reject({ code: -1, message: reason, event: pending.event })
|
||||||
|
})
|
||||||
|
this.pendingAcks.clear()
|
||||||
|
}
|
||||||
|
|
||||||
_onError(e) {
|
_onError(e) {
|
||||||
console.error('[WS] 连接错误', e)
|
console.error('[WS] 连接错误', e)
|
||||||
}
|
}
|
||||||
@@ -185,12 +240,36 @@ class WebSocketService {
|
|||||||
_onMessage(data) {
|
_onMessage(data) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data)
|
const msg = JSON.parse(data)
|
||||||
|
// Task 9:ACK 报文(event 以 ".ack" 结尾)走 pendingAcks 处理
|
||||||
|
// 后端 ws.NewResponse 固定在原 event 后追加 ".ack"
|
||||||
|
if (typeof msg.event === 'string' && msg.event.endsWith('.ack')) {
|
||||||
|
this._handleAck(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
this._emit(msg.event, msg)
|
this._emit(msg.event, msg)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[WS] 消息解析失败', data)
|
console.warn('[WS] 消息解析失败', data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 ACK 报文,触发 pendingAcks 中对应 seq 的 resolve/reject
|
||||||
|
* @param {Object} msg - { event: 'xxx.ack', seq, code, message, data? }
|
||||||
|
*/
|
||||||
|
_handleAck(msg) {
|
||||||
|
const pending = this.pendingAcks.get(msg.seq)
|
||||||
|
if (!pending) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.pendingAcks.delete(msg.seq)
|
||||||
|
clearTimeout(pending.timer)
|
||||||
|
if (msg.code === 0) {
|
||||||
|
pending.resolve(msg.data)
|
||||||
|
} else {
|
||||||
|
pending.reject({ code: msg.code, message: msg.message || 'WS 请求失败', event: pending.event })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 分发事件给注册的监听器 */
|
/** 分发事件给注册的监听器 */
|
||||||
_emit(event, data) {
|
_emit(event, data) {
|
||||||
const cbs = this.listeners.get(event)
|
const cbs = this.listeners.get(event)
|
||||||
|
|||||||
658
frontend/src/store/meeting.js
Normal file
658
frontend/src/store/meeting.js
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
/**
|
||||||
|
* 会议模块 Pinia Store
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 管理当前入会状态(本地状态机:idle/joining/connecting/connected/reconnecting/leaving/ended)
|
||||||
|
* - 维护房间信息、参与者列表、本地/远端媒体 Producer/Consumer
|
||||||
|
* - 串联 REST API + WebSocket 信令 + mediasoup-client
|
||||||
|
* - 监听 8 个服务端广播事件,实时更新 state
|
||||||
|
*
|
||||||
|
* 设计要点(Task 9):
|
||||||
|
* - WS 事件监听仅在进入会议室时注册、离会时注销(决策 Q3=a3_on_enter_room)
|
||||||
|
* - mediasoup 的 Device/Transport/Producer/Consumer 用 markRaw 避免 Vue 代理
|
||||||
|
* - mediasoup-client 仅 H5 可用,其他平台调用 enterRoom 会抛错
|
||||||
|
*
|
||||||
|
* 上层调用模式(极简):
|
||||||
|
* const meetingStore = useMeetingStore()
|
||||||
|
* await meetingStore.createAndEnter({ title }) // 创建并入会
|
||||||
|
* await meetingStore.joinAndEnter(roomCode, password) // 加入已有会议
|
||||||
|
* await meetingStore.startLocalAudio()
|
||||||
|
* await meetingStore.startLocalVideo()
|
||||||
|
* await meetingStore.leave() // 离会
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed, markRaw, reactive } from 'vue'
|
||||||
|
|
||||||
|
import meetingApi from '@/api/meeting'
|
||||||
|
import wsService from '@/services/websocket'
|
||||||
|
import { createMediaEngine } from '@/utils/mediasoup-client'
|
||||||
|
import { useUserStore } from '@/store/user'
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEETING_LOCAL_STATE_IDLE,
|
||||||
|
MEETING_LOCAL_STATE_JOINING,
|
||||||
|
MEETING_LOCAL_STATE_CONNECTING,
|
||||||
|
MEETING_LOCAL_STATE_CONNECTED,
|
||||||
|
MEETING_LOCAL_STATE_RECONNECTING,
|
||||||
|
MEETING_LOCAL_STATE_LEAVING,
|
||||||
|
MEETING_LOCAL_STATE_ENDED,
|
||||||
|
MEETING_WS_ROOM_JOIN,
|
||||||
|
MEETING_WS_ROOM_ENDED,
|
||||||
|
MEETING_WS_MEMBER_JOINED,
|
||||||
|
MEETING_WS_MEMBER_LEFT,
|
||||||
|
MEETING_WS_MEMBER_STATE_CHANGED,
|
||||||
|
MEETING_WS_MEMBER_KICKED,
|
||||||
|
MEETING_WS_MEMBER_PRODUCER_NEW,
|
||||||
|
MEETING_WS_HOST_CHANGED,
|
||||||
|
MEETING_WS_CHAT_MESSAGE,
|
||||||
|
MEETING_ROLE_HOST,
|
||||||
|
MEETING_ENDED_REASON_LABEL,
|
||||||
|
MEETING_HOST_AUTO_REASON_LABEL
|
||||||
|
} from '@/constants/meeting'
|
||||||
|
|
||||||
|
/** 判断当前平台是否支持 mediasoup-client(仅 H5) */
|
||||||
|
const isMediaSupported = () => {
|
||||||
|
// #ifdef H5
|
||||||
|
return true
|
||||||
|
// #endif
|
||||||
|
// #ifndef H5
|
||||||
|
return false
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMeetingStore = defineStore('meeting', () => {
|
||||||
|
// ==================== State ====================
|
||||||
|
|
||||||
|
/** 本地状态机:MEETING_LOCAL_STATE_* */
|
||||||
|
const localState = ref(MEETING_LOCAL_STATE_IDLE)
|
||||||
|
|
||||||
|
/** 当前会议房间 DTO(REST 响应中的 room 字段) */
|
||||||
|
const currentRoom = ref(null)
|
||||||
|
|
||||||
|
/** 当前用户的 participant DTO */
|
||||||
|
const currentParticipant = ref(null)
|
||||||
|
|
||||||
|
/** Router ID(Node mediasoup Router)—— 仅日志/调试用 */
|
||||||
|
const routerID = ref('')
|
||||||
|
|
||||||
|
/** 活跃参与者列表(含当前用户,按加入时间正序) */
|
||||||
|
const participants = ref([])
|
||||||
|
|
||||||
|
/** 会议聊天消息列表(最新在尾部) */
|
||||||
|
const chatMessages = ref([])
|
||||||
|
|
||||||
|
/** 最近一次会议结束原因(room.ended_reason 或 room.ended.reason) */
|
||||||
|
const lastEndedReason = ref('')
|
||||||
|
|
||||||
|
/** 最近一次自动主持人转让原因(auto_reason) */
|
||||||
|
const lastAutoHostReason = ref('')
|
||||||
|
|
||||||
|
/** 本地媒体状态 */
|
||||||
|
const localAudioEnabled = ref(false)
|
||||||
|
const localVideoEnabled = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户的本地 Producer 索引:{ audio: producerId?, video: producerId? }
|
||||||
|
* 用于启停麦克风/摄像头
|
||||||
|
*/
|
||||||
|
const localProducers = reactive({ audio: null, video: null })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端 Consumer 索引:{ [userId]: { audio?: Consumer, video?: Consumer, producerIds: Set } }
|
||||||
|
* 使用 reactive 但 Consumer 实例本身是 markRaw,不会被深度代理
|
||||||
|
*/
|
||||||
|
const remoteConsumers = reactive({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记:当前在 Store 内的事件监听器集合(进房注册,离房注销)
|
||||||
|
* Key 是事件名,Value 是监听函数引用
|
||||||
|
*/
|
||||||
|
const _listeners = new Map()
|
||||||
|
|
||||||
|
/** MediaEngine 实例(H5 才有值),用 markRaw 包裹避免 Vue 深度代理 */
|
||||||
|
let _engine = null
|
||||||
|
|
||||||
|
// ==================== Getters ====================
|
||||||
|
|
||||||
|
const isInMeeting = computed(() => {
|
||||||
|
return localState.value !== MEETING_LOCAL_STATE_IDLE &&
|
||||||
|
localState.value !== MEETING_LOCAL_STATE_ENDED
|
||||||
|
})
|
||||||
|
|
||||||
|
const isConnected = computed(() => localState.value === MEETING_LOCAL_STATE_CONNECTED)
|
||||||
|
|
||||||
|
const isHost = computed(() => {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const uid = userStore.userInfo?.id
|
||||||
|
return currentRoom.value && uid && currentRoom.value.host_id === uid
|
||||||
|
})
|
||||||
|
|
||||||
|
const activeParticipants = computed(() => {
|
||||||
|
return participants.value.filter(p => p.is_active)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ==================== 私有工具 ====================
|
||||||
|
|
||||||
|
const _log = (level, ...args) => {
|
||||||
|
const fn = console[level] || console.log
|
||||||
|
fn.call(console, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识别"已在其他会议中"这类僵尸残留错误,加上提示前端可调用 cleanupStaleMeetings 的 hint
|
||||||
|
* 保留原始 err 对象引用,仅附加 staleMeetingHint 字段供上层 UI 判定
|
||||||
|
*/
|
||||||
|
const _enrichStaleMeetingError = (err) => {
|
||||||
|
if (!err) return err
|
||||||
|
const msg = err.message || err.data?.message || ''
|
||||||
|
if (msg.includes('已在其他会议中') || msg.includes('已在此会议中')) {
|
||||||
|
err.staleMeetingHint = true
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const _reset = () => {
|
||||||
|
localState.value = MEETING_LOCAL_STATE_IDLE
|
||||||
|
currentRoom.value = null
|
||||||
|
currentParticipant.value = null
|
||||||
|
routerID.value = ''
|
||||||
|
participants.value = []
|
||||||
|
chatMessages.value = []
|
||||||
|
localAudioEnabled.value = false
|
||||||
|
localVideoEnabled.value = false
|
||||||
|
localProducers.audio = null
|
||||||
|
localProducers.video = null
|
||||||
|
Object.keys(remoteConsumers).forEach(k => delete remoteConsumers[k])
|
||||||
|
_engine = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一注册 WS 监听(进房调用一次) */
|
||||||
|
const _registerListeners = () => {
|
||||||
|
const handlers = {
|
||||||
|
[MEETING_WS_ROOM_ENDED]: _onRoomEnded,
|
||||||
|
[MEETING_WS_MEMBER_JOINED]: _onMemberJoined,
|
||||||
|
[MEETING_WS_MEMBER_LEFT]: _onMemberLeft,
|
||||||
|
[MEETING_WS_MEMBER_STATE_CHANGED]: _onMemberStateChanged,
|
||||||
|
[MEETING_WS_MEMBER_KICKED]: _onMemberKicked,
|
||||||
|
[MEETING_WS_MEMBER_PRODUCER_NEW]: _onProducerNew,
|
||||||
|
[MEETING_WS_HOST_CHANGED]: _onHostChanged,
|
||||||
|
[MEETING_WS_CHAT_MESSAGE]: _onChatMessage
|
||||||
|
}
|
||||||
|
Object.entries(handlers).forEach(([event, handler]) => {
|
||||||
|
wsService.on(event, handler)
|
||||||
|
_listeners.set(event, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const _unregisterListeners = () => {
|
||||||
|
_listeners.forEach((handler, event) => {
|
||||||
|
wsService.off(event, handler)
|
||||||
|
})
|
||||||
|
_listeners.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WS 事件回调 ====================
|
||||||
|
|
||||||
|
const _onRoomEnded = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
lastEndedReason.value = data.reason || ''
|
||||||
|
_log('info', '[Meeting] 会议已结束', data.reason, MEETING_ENDED_REASON_LABEL[data.reason])
|
||||||
|
_cleanupMedia()
|
||||||
|
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||||
|
_unregisterListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onMemberJoined = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
// 后端广播目前只带基础字段,完整 DTO 通过 GET /rooms/:code 二次拉取
|
||||||
|
if (!participants.value.find(p => p.user_id === data.user_id)) {
|
||||||
|
participants.value.push({
|
||||||
|
user_id: data.user_id,
|
||||||
|
joined_at: data.joined_at,
|
||||||
|
is_active: true,
|
||||||
|
role: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onMemberLeft = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
participants.value[idx].is_active = false
|
||||||
|
participants.value[idx].left_reason = data.reason
|
||||||
|
}
|
||||||
|
_cleanupRemoteUser(data.user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onMemberStateChanged = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
const p = participants.value.find(p => p.user_id === data.user_id)
|
||||||
|
if (p) {
|
||||||
|
if (typeof data.audio_enabled === 'boolean') p.audio_enabled = data.audio_enabled
|
||||||
|
if (typeof data.video_enabled === 'boolean') p.video_enabled = data.video_enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onMemberKicked = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (data.user_id === userStore.userInfo?.id) {
|
||||||
|
_log('warn', '[Meeting] 当前用户被踢出会议')
|
||||||
|
lastEndedReason.value = 'kicked'
|
||||||
|
_cleanupMedia()
|
||||||
|
localState.value = MEETING_LOCAL_STATE_ENDED
|
||||||
|
_unregisterListeners()
|
||||||
|
} else {
|
||||||
|
const idx = participants.value.findIndex(p => p.user_id === data.user_id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
participants.value[idx].is_active = false
|
||||||
|
participants.value[idx].left_reason = 'kicked'
|
||||||
|
}
|
||||||
|
_cleanupRemoteUser(data.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收到 meeting.member.producer.new 广播 → 自动订阅
|
||||||
|
* 后端在 Producer 创建 / 关闭时都广播本事件,closed=true 表示关闭
|
||||||
|
*/
|
||||||
|
const _onProducerNew = async (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
const userStore = useUserStore()
|
||||||
|
if (data.user_id === userStore.userInfo?.id) return
|
||||||
|
|
||||||
|
if (data.closed) {
|
||||||
|
_cleanupRemoteProducer(data.user_id, data.producer_id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_engine) {
|
||||||
|
_log('warn', '[Meeting] producer.new 收到但 MediaEngine 未就绪,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const consumer = await _engine.consume({ producerId: data.producer_id })
|
||||||
|
if (!remoteConsumers[data.user_id]) {
|
||||||
|
remoteConsumers[data.user_id] = markRaw({
|
||||||
|
audio: null,
|
||||||
|
video: null,
|
||||||
|
producerIds: new Set()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const slot = remoteConsumers[data.user_id]
|
||||||
|
slot[consumer.kind] = markRaw(consumer)
|
||||||
|
slot.producerIds.add(data.producer_id)
|
||||||
|
|
||||||
|
// track 已在 consumer.track 中,调用方(页面)可从 store 拿 consumer.track 挂到 DOM
|
||||||
|
// 这里先做最简化的 resume:页面渲染后若还需等 DOM 就绪,可改为在页面层调 resumeConsumer
|
||||||
|
await _engine.resumeConsumer(consumer.id)
|
||||||
|
} catch (err) {
|
||||||
|
_log('error', '[Meeting] 订阅远端 Producer 失败', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onHostChanged = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
currentRoom.value.host_id = data.new_host_id
|
||||||
|
lastAutoHostReason.value = data.auto_reason || ''
|
||||||
|
_log('info', '[Meeting] 主持人变更',
|
||||||
|
data.new_host_id,
|
||||||
|
data.auto_reason ? `(auto: ${MEETING_HOST_AUTO_REASON_LABEL[data.auto_reason]})` : '(manual)')
|
||||||
|
|
||||||
|
const p = participants.value.find(p => p.user_id === data.new_host_id)
|
||||||
|
if (p) p.role = MEETING_ROLE_HOST
|
||||||
|
const old = participants.value.find(p => p.user_id === data.old_host_id)
|
||||||
|
if (old) old.role = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const _onChatMessage = (msg) => {
|
||||||
|
const data = msg.data || {}
|
||||||
|
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
||||||
|
chatMessages.value.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 远端媒体清理 ====================
|
||||||
|
|
||||||
|
const _cleanupRemoteProducer = (userId, producerId) => {
|
||||||
|
const slot = remoteConsumers[userId]
|
||||||
|
if (!slot) return
|
||||||
|
slot.producerIds.delete(producerId)
|
||||||
|
// 无法从 producerId 直接反查 consumer;简单策略:关 slot 内所有 consumer 然后重建(MVP)
|
||||||
|
// 但更稳的:把 Consumer 索引按 producerId 存一遍
|
||||||
|
;['audio', 'video'].forEach(kind => {
|
||||||
|
const consumer = slot[kind]
|
||||||
|
if (consumer && !consumer.closed) {
|
||||||
|
try { consumer.close() } catch {}
|
||||||
|
slot[kind] = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (slot.producerIds.size === 0) {
|
||||||
|
delete remoteConsumers[userId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _cleanupRemoteUser = (userId) => {
|
||||||
|
const slot = remoteConsumers[userId]
|
||||||
|
if (!slot) return
|
||||||
|
;['audio', 'video'].forEach(kind => {
|
||||||
|
if (slot[kind] && !slot[kind].closed) {
|
||||||
|
try { slot[kind].close() } catch {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
delete remoteConsumers[userId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const _cleanupMedia = () => {
|
||||||
|
if (_engine) {
|
||||||
|
try { _engine.close() } catch (e) { _log('warn', '_cleanupMedia engine.close', e) }
|
||||||
|
_engine = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 进入会议(公共私有) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入会后的共同后置流程:加载 Device、创建两条 Transport、发 room.join 信令、订阅在线 Producer
|
||||||
|
*/
|
||||||
|
const _afterJoined = async ({ rtpCapabilities }) => {
|
||||||
|
if (!isMediaSupported()) {
|
||||||
|
throw new Error('当前平台不支持会议(仅支持 H5 浏览器)')
|
||||||
|
}
|
||||||
|
localState.value = MEETING_LOCAL_STATE_CONNECTING
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const uid = userStore.userInfo?.id
|
||||||
|
if (!uid) throw new Error('当前用户未登录')
|
||||||
|
_engine = markRaw(createMediaEngine({
|
||||||
|
roomCode: currentRoom.value.room_code,
|
||||||
|
userId: uid,
|
||||||
|
sendWithAck: wsService.sendWithAck.bind(wsService),
|
||||||
|
logger: _log
|
||||||
|
}))
|
||||||
|
await _engine.loadDevice(rtpCapabilities)
|
||||||
|
|
||||||
|
_registerListeners()
|
||||||
|
|
||||||
|
// 告知后端 WS 我已绑定本房间(用于 WS 连接 ↔ roomCode 映射)
|
||||||
|
await wsService.sendWithAck(MEETING_WS_ROOM_JOIN, {
|
||||||
|
room_code: currentRoom.value.room_code
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按需先创建 sendTransport + recvTransport,避免页面点"开麦"时首帧有额外抖动
|
||||||
|
await _engine.ensureSendTransport()
|
||||||
|
await _engine.ensureRecvTransport()
|
||||||
|
|
||||||
|
// 拉取最新参与者列表(广播里只有 ID,这里补齐完整 DTO)
|
||||||
|
try {
|
||||||
|
const detail = await meetingApi.getRoom(currentRoom.value.room_code)
|
||||||
|
if (detail && Array.isArray(detail.participants)) {
|
||||||
|
participants.value = detail.participants
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log('warn', '[Meeting] 拉取参与者详情失败,保留广播缓存', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
localState.value = MEETING_LOCAL_STATE_CONNECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:会议生命周期 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建即时会议并立即入会
|
||||||
|
* @param {Object} payload - { title, password?, max_members? }
|
||||||
|
*/
|
||||||
|
const createAndEnter = async (payload) => {
|
||||||
|
if (isInMeeting.value) {
|
||||||
|
throw new Error('你当前已在其他会议中')
|
||||||
|
}
|
||||||
|
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||||
|
try {
|
||||||
|
const resp = await meetingApi.createRoom(payload)
|
||||||
|
currentRoom.value = resp.room
|
||||||
|
routerID.value = resp.router_id || ''
|
||||||
|
// 创建者 host participant 尚未通过 REST 返回,用本地 userStore 占位
|
||||||
|
const userStore = useUserStore()
|
||||||
|
currentParticipant.value = {
|
||||||
|
user_id: userStore.userInfo?.id,
|
||||||
|
role: MEETING_ROLE_HOST,
|
||||||
|
is_active: true,
|
||||||
|
joined_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||||
|
return currentRoom.value
|
||||||
|
} catch (err) {
|
||||||
|
_log('error', '[Meeting] 创建并入会失败', err)
|
||||||
|
_reset()
|
||||||
|
throw _enrichStaleMeetingError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加入已有会议
|
||||||
|
* @param {string} roomCode
|
||||||
|
* @param {string} [password]
|
||||||
|
*/
|
||||||
|
const joinAndEnter = async (roomCode, password = '') => {
|
||||||
|
if (isInMeeting.value) {
|
||||||
|
throw new Error('你当前已在其他会议中')
|
||||||
|
}
|
||||||
|
localState.value = MEETING_LOCAL_STATE_JOINING
|
||||||
|
try {
|
||||||
|
const resp = await meetingApi.joinRoom(roomCode, { password })
|
||||||
|
currentRoom.value = resp.room
|
||||||
|
currentParticipant.value = resp.participant
|
||||||
|
routerID.value = resp.router_id || ''
|
||||||
|
await _afterJoined({ rtpCapabilities: resp.rtp_capabilities })
|
||||||
|
return currentRoom.value
|
||||||
|
} catch (err) {
|
||||||
|
_log('error', '[Meeting] 加入会议失败', err)
|
||||||
|
_reset()
|
||||||
|
throw _enrichStaleMeetingError(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主动离会(关闭媒体 + REST leave) */
|
||||||
|
const leave = async () => {
|
||||||
|
if (!isInMeeting.value || !currentRoom.value) return
|
||||||
|
localState.value = MEETING_LOCAL_STATE_LEAVING
|
||||||
|
const code = currentRoom.value.room_code
|
||||||
|
try {
|
||||||
|
_cleanupMedia()
|
||||||
|
_unregisterListeners()
|
||||||
|
await meetingApi.leaveRoom(code)
|
||||||
|
} catch (err) {
|
||||||
|
_log('warn', '[Meeting] leave API 失败(忽略)', err)
|
||||||
|
} finally {
|
||||||
|
_reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主持人结束会议(会广播 room.ended 给所有人) */
|
||||||
|
const endMeeting = async () => {
|
||||||
|
if (!currentRoom.value) return
|
||||||
|
const code = currentRoom.value.room_code
|
||||||
|
try {
|
||||||
|
await meetingApi.endRoom(code)
|
||||||
|
} catch (err) {
|
||||||
|
_log('warn', '[Meeting] end API 失败', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
// room.ended WS 广播会触发 _onRoomEnded 清理
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:本地推流 ====================
|
||||||
|
|
||||||
|
/** 开启本地音频 */
|
||||||
|
const startLocalAudio = async () => {
|
||||||
|
if (!_engine) throw new Error('未连接')
|
||||||
|
if (localProducers.audio) return
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
const track = stream.getAudioTracks()[0]
|
||||||
|
const producer = await _engine.produce({ kind: 'audio', track })
|
||||||
|
localProducers.audio = producer.id
|
||||||
|
localAudioEnabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开启本地视频 */
|
||||||
|
const startLocalVideo = async () => {
|
||||||
|
if (!_engine) throw new Error('未连接')
|
||||||
|
if (localProducers.video) return
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } })
|
||||||
|
const track = stream.getVideoTracks()[0]
|
||||||
|
const producer = await _engine.produce({ kind: 'video', track })
|
||||||
|
localProducers.video = producer.id
|
||||||
|
localVideoEnabled.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭本地音频 */
|
||||||
|
const stopLocalAudio = async () => {
|
||||||
|
if (!_engine || !localProducers.audio) return
|
||||||
|
await _engine.closeProducer(localProducers.audio)
|
||||||
|
localProducers.audio = null
|
||||||
|
localAudioEnabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭本地视频 */
|
||||||
|
const stopLocalVideo = async () => {
|
||||||
|
if (!_engine || !localProducers.video) return
|
||||||
|
await _engine.closeProducer(localProducers.video)
|
||||||
|
localProducers.video = null
|
||||||
|
localVideoEnabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得某个远端用户的 track(供页面挂到 <video>/<audio>)
|
||||||
|
* @param {number} userId
|
||||||
|
* @param {'audio'|'video'} kind
|
||||||
|
* @returns {MediaStreamTrack|null}
|
||||||
|
*/
|
||||||
|
const getRemoteTrack = (userId, kind) => {
|
||||||
|
const slot = remoteConsumers[userId]
|
||||||
|
if (!slot || !slot[kind]) return null
|
||||||
|
return slot[kind].track
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得本地 Producer 对应的 track(预览用) */
|
||||||
|
const getLocalTrack = (kind) => {
|
||||||
|
if (!_engine) return null
|
||||||
|
const producerId = localProducers[kind]
|
||||||
|
if (!producerId) return null
|
||||||
|
const producer = _engine.getProducer(producerId)
|
||||||
|
return producer ? producer.track : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Actions:聊天/管理 ====================
|
||||||
|
|
||||||
|
const sendChat = async (content) => {
|
||||||
|
if (!currentRoom.value) throw new Error('未入会')
|
||||||
|
await meetingApi.sendChat(currentRoom.value.room_code, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChatHistory = async (beforeId = 0, limit = 30) => {
|
||||||
|
if (!currentRoom.value) return { list: [], has_more: false }
|
||||||
|
return meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
|
||||||
|
}
|
||||||
|
|
||||||
|
const transferHost = async (targetUserID) => {
|
||||||
|
if (!currentRoom.value) throw new Error('未入会')
|
||||||
|
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickMember = async (userId) => {
|
||||||
|
if (!currentRoom.value) throw new Error('未入会')
|
||||||
|
await meetingApi.kickMember(currentRoom.value.room_code, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteUsers = async (inviteeIds) => {
|
||||||
|
if (!currentRoom.value) throw new Error('未入会')
|
||||||
|
return meetingApi.inviteUsers(currentRoom.value.room_code, inviteeIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理遗留的僵尸活跃会议
|
||||||
|
*
|
||||||
|
* 使用场景:前端崩溃 / 强制刷新 / 关闭标签页未调 `/leave` 时,后端
|
||||||
|
* `meeting_participants` 表仍标记用户为活跃参会者,再次创建/加入会议会
|
||||||
|
* 被后端以"你当前已在其他会议中"拒绝。本方法拉取用户全部 `status=Active`
|
||||||
|
* 会议并依次调用 `/leave`,顺带处理可能多条的僵尸记录(比如 DAO 异常)。
|
||||||
|
*
|
||||||
|
* @returns {Promise<{ cleanedCodes: string[] }>}
|
||||||
|
*/
|
||||||
|
const cleanupStaleMeetings = async () => {
|
||||||
|
const resp = await meetingApi.listMyMeetings({ status: 1, limit: 50 })
|
||||||
|
const list = (resp && resp.list) || []
|
||||||
|
const cleanedCodes = []
|
||||||
|
for (const room of list) {
|
||||||
|
try {
|
||||||
|
await meetingApi.leaveRoom(room.room_code)
|
||||||
|
cleanedCodes.push(room.room_code)
|
||||||
|
} catch (err) {
|
||||||
|
_log('warn', '[Meeting] 清理遗留会议失败(忽略继续)', room.room_code, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isInMeeting.value) {
|
||||||
|
// 本地状态可能因 leave 网络错误等处于脏状态,一并 reset
|
||||||
|
_reset()
|
||||||
|
}
|
||||||
|
return { cleanedCodes }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 导出 ====================
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
localState,
|
||||||
|
currentRoom,
|
||||||
|
currentParticipant,
|
||||||
|
routerID,
|
||||||
|
participants,
|
||||||
|
chatMessages,
|
||||||
|
lastEndedReason,
|
||||||
|
lastAutoHostReason,
|
||||||
|
localAudioEnabled,
|
||||||
|
localVideoEnabled,
|
||||||
|
localProducers,
|
||||||
|
remoteConsumers,
|
||||||
|
|
||||||
|
// getters
|
||||||
|
isInMeeting,
|
||||||
|
isConnected,
|
||||||
|
isHost,
|
||||||
|
activeParticipants,
|
||||||
|
|
||||||
|
// lifecycle
|
||||||
|
createAndEnter,
|
||||||
|
joinAndEnter,
|
||||||
|
leave,
|
||||||
|
endMeeting,
|
||||||
|
|
||||||
|
// media
|
||||||
|
startLocalAudio,
|
||||||
|
startLocalVideo,
|
||||||
|
stopLocalAudio,
|
||||||
|
stopLocalVideo,
|
||||||
|
getRemoteTrack,
|
||||||
|
getLocalTrack,
|
||||||
|
|
||||||
|
// chat & admin
|
||||||
|
sendChat,
|
||||||
|
loadChatHistory,
|
||||||
|
transferHost,
|
||||||
|
kickMember,
|
||||||
|
inviteUsers,
|
||||||
|
cleanupStaleMeetings,
|
||||||
|
|
||||||
|
// util
|
||||||
|
isMediaSupported
|
||||||
|
}
|
||||||
|
})
|
||||||
408
frontend/src/utils/mediasoup-client.js
Normal file
408
frontend/src/utils/mediasoup-client.js
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* mediasoup-client 封装层
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* - 封装 Device/sendTransport/recvTransport/Producer/Consumer 的生命周期
|
||||||
|
* - 把 Transport 的 connect/produce 回调桥接到 WS 信令(sendWithAck)
|
||||||
|
* - 对上层(store/meeting.js)暴露同步友好的异步 API
|
||||||
|
*
|
||||||
|
* 平台约束(Task 9 决策 Q1=a1_h5_only):
|
||||||
|
* - mediasoup-client 仅在 H5 端可用;非 H5 平台调用时会在构造时抛 ERR_PLATFORM
|
||||||
|
* - import 语句通过 uni-app 条件编译注释限定在 H5 构建
|
||||||
|
*
|
||||||
|
* 使用方式(仅限 store/meeting.js):
|
||||||
|
* import { createMediaEngine } from '@/utils/mediasoup-client'
|
||||||
|
* const engine = markRaw(createMediaEngine({ roomCode, userId, sendWithAck }))
|
||||||
|
* await engine.loadDevice(rtpCapabilities)
|
||||||
|
* const sendTransport = await engine.ensureSendTransport()
|
||||||
|
* const producer = await engine.produce({ kind: 'audio', track })
|
||||||
|
*/
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
import { Device } from 'mediasoup-client'
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
import {
|
||||||
|
MEETING_WS_TRANSPORT_CREATE,
|
||||||
|
MEETING_WS_TRANSPORT_CONNECT,
|
||||||
|
MEETING_WS_PRODUCE_START,
|
||||||
|
MEETING_WS_CONSUME_START,
|
||||||
|
MEETING_WS_CONSUME_RESUME,
|
||||||
|
MEETING_WS_PRODUCER_CLOSE
|
||||||
|
} from '@/constants/meeting'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 MediaEngine 实例
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {string} options.roomCode - 会议号
|
||||||
|
* @param {number} options.userId - 当前用户 ID
|
||||||
|
* @param {Function} options.sendWithAck - WS 发送带 ACK 等待的方法(由 services/websocket.js 提供)
|
||||||
|
* @param {Function} [options.logger] - 日志函数,默认 console.log
|
||||||
|
* @returns {Object} MediaEngine 实例
|
||||||
|
*/
|
||||||
|
export function createMediaEngine({ roomCode, userId, sendWithAck, logger = defaultLogger }) {
|
||||||
|
if (!roomCode) throw new Error('[MediaEngine] roomCode 不能为空')
|
||||||
|
if (!userId) throw new Error('[MediaEngine] userId 不能为空')
|
||||||
|
if (typeof sendWithAck !== 'function') {
|
||||||
|
throw new Error('[MediaEngine] sendWithAck 必须是函数')
|
||||||
|
}
|
||||||
|
|
||||||
|
// #ifndef H5
|
||||||
|
throw new Error('[MediaEngine] 当前平台不支持 mediasoup-client(仅支持 H5)')
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef H5
|
||||||
|
/** mediasoup Device 实例;Device.load 后持有 Router rtpCapabilities */
|
||||||
|
let device = null
|
||||||
|
|
||||||
|
/** 发送 Transport(本地 Producer 用),延迟创建,复用 */
|
||||||
|
let sendTransport = null
|
||||||
|
|
||||||
|
/** 接收 Transport(本地 Consumer 用),延迟创建,复用 */
|
||||||
|
let recvTransport = null
|
||||||
|
|
||||||
|
/** 本地 Producer 索引 Map<producerId, Producer> */
|
||||||
|
const producers = new Map()
|
||||||
|
|
||||||
|
/** 本地 Consumer 索引 Map<consumerId, Consumer> */
|
||||||
|
const consumers = new Map()
|
||||||
|
|
||||||
|
/** 是否已关闭,防止重复调用 close */
|
||||||
|
let closed = false
|
||||||
|
|
||||||
|
const ensureNotClosed = () => {
|
||||||
|
if (closed) {
|
||||||
|
throw new Error('[MediaEngine] 已关闭')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDeviceLoaded = () => {
|
||||||
|
ensureNotClosed()
|
||||||
|
if (!device || !device.loaded) {
|
||||||
|
throw new Error('[MediaEngine] Device 尚未 load')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 Device
|
||||||
|
* @param {Object} routerRtpCapabilities - 后端 JoinRoom 响应中的 rtp_capabilities
|
||||||
|
*/
|
||||||
|
const loadDevice = async (routerRtpCapabilities) => {
|
||||||
|
ensureNotClosed()
|
||||||
|
if (device && device.loaded) {
|
||||||
|
logger('debug', '[MediaEngine] Device 已 load,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!routerRtpCapabilities) {
|
||||||
|
throw new Error('[MediaEngine] routerRtpCapabilities 不能为空')
|
||||||
|
}
|
||||||
|
device = new Device()
|
||||||
|
await device.load({ routerRtpCapabilities })
|
||||||
|
logger('info', '[MediaEngine] Device load 成功', { canProduceAudio: device.canProduce('audio'), canProduceVideo: device.canProduce('video') })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 Device.rtpCapabilities(用于后端 CreateConsumer) */
|
||||||
|
const getRtpCapabilities = () => {
|
||||||
|
ensureDeviceLoaded()
|
||||||
|
return device.rtpCapabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过 WS 请求后端创建 Transport,返回 mediasoup-client 可用的 Transport 元信息
|
||||||
|
* @param {'send'|'recv'} direction
|
||||||
|
*/
|
||||||
|
const requestTransportInfo = async (direction) => {
|
||||||
|
const info = await sendWithAck(MEETING_WS_TRANSPORT_CREATE, {
|
||||||
|
room_code: roomCode,
|
||||||
|
direction
|
||||||
|
})
|
||||||
|
if (!info || !info.id) {
|
||||||
|
throw new Error(`[MediaEngine] 创建 ${direction} Transport 失败:后端返回为空`)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定 sendTransport 的 connect + produce 回调(把本地事件桥到 WS 信令)
|
||||||
|
* mediasoup-client 约定:
|
||||||
|
* - transport.on('connect', ({ dtlsParameters }, callback, errback))
|
||||||
|
* 需要在 DTLS 握手完成前告知远端 DTLS 参数
|
||||||
|
* - transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback))
|
||||||
|
* 需要在 Producer 创建前把 RTP 参数发给远端,拿到远端分配的 producerId 回调 callback({ id })
|
||||||
|
*/
|
||||||
|
const bindSendTransportEvents = (transport) => {
|
||||||
|
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
||||||
|
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
|
||||||
|
room_code: roomCode,
|
||||||
|
transport_id: transport.id,
|
||||||
|
dtls_parameters: dtlsParameters
|
||||||
|
}).then(() => callback()).catch((err) => {
|
||||||
|
logger('error', '[MediaEngine] sendTransport connect 失败', err)
|
||||||
|
errback(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
transport.on('produce', ({ kind, rtpParameters, appData }, callback, errback) => {
|
||||||
|
sendWithAck(MEETING_WS_PRODUCE_START, {
|
||||||
|
room_code: roomCode,
|
||||||
|
transport_id: transport.id,
|
||||||
|
kind,
|
||||||
|
rtp_parameters: rtpParameters,
|
||||||
|
app_data: appData || {}
|
||||||
|
}).then((resp) => {
|
||||||
|
if (!resp || !resp.producer_id) {
|
||||||
|
errback(new Error('后端返回 producer_id 为空'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback({ id: resp.producer_id })
|
||||||
|
}).catch((err) => {
|
||||||
|
logger('error', '[MediaEngine] sendTransport produce 失败', err)
|
||||||
|
errback(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
transport.on('connectionstatechange', (state) => {
|
||||||
|
logger('debug', `[MediaEngine] sendTransport state=${state}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** recvTransport 只需要桥接 connect 回调(consume 由 store 主动调起) */
|
||||||
|
const bindRecvTransportEvents = (transport) => {
|
||||||
|
transport.on('connect', ({ dtlsParameters }, callback, errback) => {
|
||||||
|
sendWithAck(MEETING_WS_TRANSPORT_CONNECT, {
|
||||||
|
room_code: roomCode,
|
||||||
|
transport_id: transport.id,
|
||||||
|
dtls_parameters: dtlsParameters
|
||||||
|
}).then(() => callback()).catch((err) => {
|
||||||
|
logger('error', '[MediaEngine] recvTransport connect 失败', err)
|
||||||
|
errback(err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
transport.on('connectionstatechange', (state) => {
|
||||||
|
logger('debug', `[MediaEngine] recvTransport state=${state}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按需创建 sendTransport(已存在则复用) */
|
||||||
|
const ensureSendTransport = async () => {
|
||||||
|
ensureDeviceLoaded()
|
||||||
|
if (sendTransport && !sendTransport.closed) {
|
||||||
|
return sendTransport
|
||||||
|
}
|
||||||
|
const info = await requestTransportInfo('send')
|
||||||
|
sendTransport = device.createSendTransport({
|
||||||
|
id: info.id,
|
||||||
|
iceParameters: info.iceParameters,
|
||||||
|
iceCandidates: info.iceCandidates,
|
||||||
|
dtlsParameters: info.dtlsParameters,
|
||||||
|
sctpParameters: info.sctpParameters
|
||||||
|
})
|
||||||
|
bindSendTransportEvents(sendTransport)
|
||||||
|
logger('info', '[MediaEngine] sendTransport 创建成功', { id: sendTransport.id })
|
||||||
|
return sendTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按需创建 recvTransport(已存在则复用) */
|
||||||
|
const ensureRecvTransport = async () => {
|
||||||
|
ensureDeviceLoaded()
|
||||||
|
if (recvTransport && !recvTransport.closed) {
|
||||||
|
return recvTransport
|
||||||
|
}
|
||||||
|
const info = await requestTransportInfo('recv')
|
||||||
|
recvTransport = device.createRecvTransport({
|
||||||
|
id: info.id,
|
||||||
|
iceParameters: info.iceParameters,
|
||||||
|
iceCandidates: info.iceCandidates,
|
||||||
|
dtlsParameters: info.dtlsParameters,
|
||||||
|
sctpParameters: info.sctpParameters
|
||||||
|
})
|
||||||
|
bindRecvTransportEvents(recvTransport)
|
||||||
|
logger('info', '[MediaEngine] recvTransport 创建成功', { id: recvTransport.id })
|
||||||
|
return recvTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 sendTransport 上创建 Producer(推本地音/视频)
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {'audio'|'video'} opts.kind
|
||||||
|
* @param {MediaStreamTrack} opts.track
|
||||||
|
* @param {Object} [opts.encodings]
|
||||||
|
* @param {Object} [opts.codecOptions]
|
||||||
|
* @param {Object} [opts.appData]
|
||||||
|
* @returns {Promise<Producer>}
|
||||||
|
*/
|
||||||
|
const produce = async ({ kind, track, encodings, codecOptions, appData }) => {
|
||||||
|
ensureDeviceLoaded()
|
||||||
|
if (!device.canProduce(kind)) {
|
||||||
|
throw new Error(`[MediaEngine] Device 不支持 produce kind=${kind}`)
|
||||||
|
}
|
||||||
|
const transport = await ensureSendTransport()
|
||||||
|
const produceOpts = { track }
|
||||||
|
if (encodings) produceOpts.encodings = encodings
|
||||||
|
if (codecOptions) produceOpts.codecOptions = codecOptions
|
||||||
|
produceOpts.appData = { user_id: userId, ...(appData || {}) }
|
||||||
|
|
||||||
|
const producer = await transport.produce(produceOpts)
|
||||||
|
producers.set(producer.id, producer)
|
||||||
|
|
||||||
|
// 监听 Producer 关闭事件,清理本地索引;外部可见的关闭由 closeProducer 触发 WS
|
||||||
|
producer.on('transportclose', () => {
|
||||||
|
producers.delete(producer.id)
|
||||||
|
logger('debug', `[MediaEngine] producer ${producer.id} transportclose`)
|
||||||
|
})
|
||||||
|
producer.on('trackended', () => {
|
||||||
|
logger('warn', `[MediaEngine] producer ${producer.id} trackended,将触发关闭`)
|
||||||
|
closeProducer(producer.id).catch((err) => logger('error', '关闭 Producer 失败', err))
|
||||||
|
})
|
||||||
|
|
||||||
|
logger('info', `[MediaEngine] producer 创建成功 kind=${kind} id=${producer.id}`)
|
||||||
|
return producer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅远端 Producer:请求后端创建 Consumer → 本地 consume → 等 track 挂好后 resume
|
||||||
|
*
|
||||||
|
* 与 Task 9 决策 Q6=B 对齐的规范流程:
|
||||||
|
* 1. WS consume.start → 拿到 { id, producerId, kind, rtpParameters } (Node 侧 paused)
|
||||||
|
* 2. recvTransport.consume(...) 得到本地 Consumer(track 可用)
|
||||||
|
* 3. 调用方把 track 挂到 <video>/<audio> 元素(DOM 就绪)
|
||||||
|
* 4. 调用 consumer.resume()(此方法返回的 consumer 暴露 resume())
|
||||||
|
*
|
||||||
|
* 本函数执行完 1+2 后返回 consumer,调用方挂 track 后必须调一次 engine.resumeConsumer(consumerId)
|
||||||
|
* 以告知后端 → Node 把 Consumer 从 paused 切到 active
|
||||||
|
*
|
||||||
|
* @param {Object} opts
|
||||||
|
* @param {string} opts.producerId - 要订阅的远端 Producer ID
|
||||||
|
* @returns {Promise<Consumer>} 本地 Consumer 实例(此时仍 paused)
|
||||||
|
*/
|
||||||
|
const consume = async ({ producerId }) => {
|
||||||
|
ensureDeviceLoaded()
|
||||||
|
if (!producerId) throw new Error('[MediaEngine] producerId 不能为空')
|
||||||
|
|
||||||
|
const transport = await ensureRecvTransport()
|
||||||
|
const info = await sendWithAck(MEETING_WS_CONSUME_START, {
|
||||||
|
room_code: roomCode,
|
||||||
|
transport_id: transport.id,
|
||||||
|
producer_id: producerId,
|
||||||
|
rtp_capabilities: device.rtpCapabilities
|
||||||
|
})
|
||||||
|
if (!info || !info.id) {
|
||||||
|
throw new Error('[MediaEngine] 后端未返回 Consumer 元信息')
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumer = await transport.consume({
|
||||||
|
id: info.id,
|
||||||
|
producerId: info.producerId || info.producer_id || producerId,
|
||||||
|
kind: info.kind,
|
||||||
|
rtpParameters: info.rtpParameters
|
||||||
|
})
|
||||||
|
consumers.set(consumer.id, consumer)
|
||||||
|
|
||||||
|
consumer.on('transportclose', () => {
|
||||||
|
consumers.delete(consumer.id)
|
||||||
|
logger('debug', `[MediaEngine] consumer ${consumer.id} transportclose`)
|
||||||
|
})
|
||||||
|
|
||||||
|
logger('info', `[MediaEngine] consumer 创建成功 id=${consumer.id} kind=${consumer.kind}`)
|
||||||
|
return consumer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知后端 resume Consumer(track 已挂载到 DOM 后调用)
|
||||||
|
* 对应 Task 9 决策 Q6=B 的 meeting.consume.resume WS 事件
|
||||||
|
* @param {string} consumerId
|
||||||
|
*/
|
||||||
|
const resumeConsumer = async (consumerId) => {
|
||||||
|
ensureNotClosed()
|
||||||
|
await sendWithAck(MEETING_WS_CONSUME_RESUME, {
|
||||||
|
room_code: roomCode,
|
||||||
|
consumer_id: consumerId
|
||||||
|
})
|
||||||
|
const local = consumers.get(consumerId)
|
||||||
|
if (local && typeof local.resume === 'function') {
|
||||||
|
await local.resume()
|
||||||
|
}
|
||||||
|
logger('info', `[MediaEngine] consumer ${consumerId} resumed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭指定 Producer(本地 close + WS 通知后端)
|
||||||
|
* 幂等:producerId 不存在时静默返回
|
||||||
|
*/
|
||||||
|
const closeProducer = async (producerId) => {
|
||||||
|
if (closed) return
|
||||||
|
const producer = producers.get(producerId)
|
||||||
|
if (!producer) return
|
||||||
|
try {
|
||||||
|
if (!producer.closed) producer.close()
|
||||||
|
} catch (e) {
|
||||||
|
logger('warn', '[MediaEngine] producer.close 抛错', e)
|
||||||
|
}
|
||||||
|
producers.delete(producerId)
|
||||||
|
try {
|
||||||
|
await sendWithAck(MEETING_WS_PRODUCER_CLOSE, {
|
||||||
|
room_code: roomCode,
|
||||||
|
producer_id: producerId
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// 后端已清理也视为成功(幂等);只记日志
|
||||||
|
logger('warn', `[MediaEngine] producer.close WS 通知失败 ${producerId}`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本地 Consumer 关闭(通常由 producer.new closed=true 广播触发,无需额外 WS) */
|
||||||
|
const closeConsumer = (consumerId) => {
|
||||||
|
const consumer = consumers.get(consumerId)
|
||||||
|
if (!consumer) return
|
||||||
|
try {
|
||||||
|
if (!consumer.closed) consumer.close()
|
||||||
|
} catch (e) {
|
||||||
|
logger('warn', '[MediaEngine] consumer.close 抛错', e)
|
||||||
|
}
|
||||||
|
consumers.delete(consumerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放所有资源(本地 close,不触发 WS;由 store 在离会时调用) */
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return
|
||||||
|
closed = true
|
||||||
|
try {
|
||||||
|
producers.forEach((p) => { try { if (!p.closed) p.close() } catch {} })
|
||||||
|
producers.clear()
|
||||||
|
consumers.forEach((c) => { try { if (!c.closed) c.close() } catch {} })
|
||||||
|
consumers.clear()
|
||||||
|
if (sendTransport && !sendTransport.closed) sendTransport.close()
|
||||||
|
if (recvTransport && !recvTransport.closed) recvTransport.close()
|
||||||
|
} finally {
|
||||||
|
sendTransport = null
|
||||||
|
recvTransport = null
|
||||||
|
device = null
|
||||||
|
}
|
||||||
|
logger('info', '[MediaEngine] 已关闭')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadDevice,
|
||||||
|
getRtpCapabilities,
|
||||||
|
ensureSendTransport,
|
||||||
|
ensureRecvTransport,
|
||||||
|
produce,
|
||||||
|
consume,
|
||||||
|
resumeConsumer,
|
||||||
|
closeProducer,
|
||||||
|
closeConsumer,
|
||||||
|
close,
|
||||||
|
getDevice: () => device,
|
||||||
|
getSendTransport: () => sendTransport,
|
||||||
|
getRecvTransport: () => recvTransport,
|
||||||
|
getProducer: (id) => producers.get(id),
|
||||||
|
getConsumer: (id) => consumers.get(id)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultLogger(level, ...args) {
|
||||||
|
const fn = console[level] || console.log
|
||||||
|
fn.call(console, ...args)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user