feat(meeting): Task 13 完成 meeting_invite 通知卡片对接

## 后端
- MeetingService.InviteUsers 的 PushPayload.Extra 补齐设计 §10.1 要求字段:
  inviter_id / inviter_name / inviter_avatar / expired_at(Unix 秒,
  与 Redis TTL MeetingInviteTokenTTL=600s 对齐)

## 前端
- constants/notify.js:supportsInlineAction 扩展支持 meeting_invite;
  新增 NOTIFY_INLINE_ACTION_LABEL 映射(meeting_invite → 立即加入/稍后)
  + NOTIFY_INLINE_ACTION_DEFAULT 兜底
- components/notify/NotifyItem.vue:
  * 按 type 动态渲染按钮文案(actionLabel 计算属性)
  * isExpired 计算属性:meeting_invite 比对 extra.expired_at * 1000
  * 过期态合并为单个 disabled 的"邀请已过期"按钮
  * 新增 .notify-btn--expired 灰显样式
- pages/notify/index.vue:
  * handleAccept / handleReject / _navigateByNotify 增加 meeting_invite 分支
  * 新增 _navigateToMeetingInvite 辅助:跳 /pages/meeting/preview?mode=join&code=xxx
  * 过期时 toast "邀请已过期"不跳转,"稍后"仅 markRead 不发接口

## 文档
- docs/plans/2026-04-21-phase2e-2-implementation.plan.md:Task 13 标  + 详细产出
- docs/progress/CURRENT_STATUS.md:新增 Task 13 专节(关键技术点 / 验证记录 / 下一步)
- .cursor/rules/project-context.mdc:2e-2 进度刷 Task 0-13 

## 验证
后端 curl 双角色端到端(testuser1 创会 → 邀请 testuser2 → 拉通知列表):
- type=meeting_invite,extra 8 字段齐全
- expired_at = invited_at + 600,与 Redis TTL 对齐

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-22 17:26:31 +08:00
parent 310ea03ce1
commit d6fb7ce291
7 changed files with 193 additions and 41 deletions

View File

@@ -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-12 ✅ / Task 13-16 待执行mediasoup Node.js 独立 `media-server/` + 即时会议≤8 人)+ 密码/邀请链接/通知邀请三合一 + 设备预览页 + 主持人四件套 + 会议内聊天 + **会议生命周期状态机**host 宽限期 + 自动转让 + 空房 TTL+ 双态部署(本机 + 公网 coturn+ 响应式(桌面/手机) - 2e-2 会议 MVP约 17 天)🚧 **代码开发中**Task 0-13 ✅ / Task 14-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` 级联关闭下游 consumerConsumer 强制 `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较首版 +2pp9 接口 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/Nitsm4/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` 级联关闭下游 consumerConsumer 强制 `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较首版 +2pp9 接口 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/Nitsm4/m6~m10、n1~n10登记至 Task 16 收尾清单
* **Task 3 ✅ Go meeting 模块数据库 DDL + Model + DAO 完成2026-04-21**:三张持久化表(`meeting_rooms` / `meeting_participants` / `meeting_chats`)落地 PostgreSQLDDL 同时写入 `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`)落地 PostgreSQLDDL 同时写入 `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 13 ✅ meeting_invite 通知卡片对接完成2026-04-23**:后端 `MeetingService.InviteUsers` 的 `PushPayload.Extra` 补齐设计 §10.1 要求的 `inviter_id / inviter_name / inviter_avatar / expired_at`Unix 秒,与 Redis TTL `MeetingInviteTokenTTL=600s` 一致,取代之前只有 `room_code/room_title/has_password/invite_token` 的简化版本);前端 `constants/notify.js` 把 `supportsInlineAction` 扩展支持 `meeting_invite`,新增 `NOTIFY_INLINE_ACTION_LABEL` 映射表让按钮文案按 type 动态变化meeting_invite → `立即加入 / 稍后`friend_request → `同意 / 拒绝``NotifyItem.vue` 新增 `isExpired` 计算属性(纯客户端比对 `extra.expired_at * 1000 < Date.now()`),过期态合并为单个 disabled 的"邀请已过期"按钮 + `.notify-btn--expired` 灰显样式;`pages/notify/index.vue` 在 `handleAccept`/`handleReject`/`_navigateByNotify` 均增加 `NOTIFY_TYPE_MEETING_INVITE` 分支,新增 `_navigateToMeetingInvite(extra)` 跳 `/pages/meeting/preview?mode=join&code=xxx`,过期时 toast 提示不跳转,`handleReject`"稍后")只 `markRead` 不发接口。**附带修复commit 310ea03一个隐藏很深的 WS `.ack` 回归 bug**`frontend/src/services/websocket.js` 早期的 `_onMessage` 在命中 `.ack` 事件后 `_handleAck(msg); return;`,直接吞掉了 `_emit`Phase 2a 以后 `chat.js`/`meeting.js` 都在 `wsService.on('xxx.ack', ...)` 订阅 ACK 做"临时消息替换为真实消息",这条关键事件从未触发 → 发送方消息永远 `_sending=true`、前面显示 loading 圆圈、下方「已读/未读」标签永不渲染。修复:移除 `return`,改为同时走 `_handleAck`Promise `pendingAcks` 通路)+ `_emit`(订阅通路)。补强:`chat.js._appendMessage` 命中 `client_msg_id` 的临时消息时不再 dedup 丢弃,而是就地合并服务端字段并显式清 `_sending:false/_failed:false`,作为"丢 ACK 但不丢广播"场景的兜底。验证curl 双角色端到端(登录 testuser1/testuser2 → 创会 → 邀请 → 拉通知列表)返回 `type=meeting_invite` 且 `extra` 8 字段齐全,`expired_at = invited_at + 600` 与 Redis TTL 对齐用户手测「消息卡圈」bug 确认修复。Playwright UI 回归因多用户上下文环境限制暂缓到 Task 16 总回归统一做。下一步 Task 14 docker-compose 扩展 + 本机/公网双态部署
* **Task 10-12 ✅ + 2026-04-22 UI 打磨:前端会议主链路全部打通(含三轮 uni-button 遮罩 / z-index / toggle 深度修复)**:前端 `/pages/meeting/{create,join,preview,room,index}.vue` 五页全量落地;组件 `MeetingToolbar` / `VideoGrid` / `VideoTile` / `MemberPanel` / `InviteDialog` / `ChatPanel` 全部完工;后端 `SendChatMessage` + `chatToDTO` 补齐 `user_name` / `user_avatar`WS 推 + REST 列表均带真实昵称);`store/meeting.js` 新增 `cleanupStaleMeetings` + `createAndEnter/joinAndEnter` 首次静默 + 自动重试。**关键技术攻坚**(1) **uni-button `::after` 全视口遮罩(两轮)**uni-app H5 自动给 `<button>` 编译后的 `<uni-button>` 注入 `::after { position:absolute; inset:0 -1200px -80px 0 }` 作为点击反馈2400×160 超大遮罩MeetingToolbar 里最后一个"离开"按钮的 `::after` 横跨整个 toolbar 导致其他按钮全部点不响应、离开弹窗的"取消"也触发结束会议 → `.btn::after { content:none; display:none }` + `.btn { position:relative }` 修复ChatPanel 里 `.btn-send { all:unset }` 把 position 重置为 static 导致 `::after` 的 absolute 定位沿 DOM 树回溯到 `<body>` 作为 containing block 形成全屏遮罩,关闭/输入框/发送全被挡 → `.btn-send::after { content:none; display:none }` 修复。(2) **面板 z-index 冲突**`MemberPanel/InviteDialog .panel-root` 是 `position:fixed; inset:0; z-index:200` 全屏 mask打开时把 toolbar 遮掉,用户再次点击"成员/邀请"按钮命中的是 mask 而非按钮导致 toggle 永远不生效 → `.toolbar { z-index:210 }` 浮在 mask 之上(仍低于 `.leave-mask` 的 220。(3) **H5 全局容器偏左上**:桌面大窗口下 `uni-app`/`uni-page` 祖先默认不 100% 拉伸,导致 `position:fixed; inset:0` 的 `.room` 相对偏小祖先盒子定位 → `App.vue` 的 H5 条件块给 `html/body/#app/uni-app/uni-page*` 全部 `width/height:100%``.room` 加 `100vw/100vh/z-index:1000` 二道防御。(4) **刷新后残留会议静默清理**`joinAndEnter/createAndEnter` 首次走 `silent:true` 不弹 toast后端 `staleMeetingHint` 类错误自动调 `cleanupStaleMeetings()` + 重试一次;`leaveRoom({silent:true})` 抑制"你不在此会议中"toast。(5) **预览↔会议内视频统一 HD**`preview.vue` + `store/meeting.js#startLocalVideo` 统一 `width:{ideal:1280} height:{ideal:720} frameRate:{ideal:24,max:30}`。(6) **成员/邀请/聊天按钮 toggle**`openMembers/openInvite/openChat` 全改为取反,符合"再次点击关闭"直觉。验证Playwright 实测所有 toolbar 按钮 + close/textarea/send + 成员二次点击均 `hitSelf=true`,离开弹窗取消不再误触结束。相关提交:`cacf4ae` / `a311c03` / `3aa8174` / `ff46dc5` / `ce09315` / `c529ff4` / `0c9f048` / `16225c4` / `1222bf5` / `59f824a` / `eb3857a`。下一步 Task 13 主持人权限全链路。 * **Task 10-12 ✅ + 2026-04-22 UI 打磨:前端会议主链路全部打通(含三轮 uni-button 遮罩 / z-index / toggle 深度修复)**:前端 `/pages/meeting/{create,join,preview,room,index}.vue` 五页全量落地;组件 `MeetingToolbar` / `VideoGrid` / `VideoTile` / `MemberPanel` / `InviteDialog` / `ChatPanel` 全部完工;后端 `SendChatMessage` + `chatToDTO` 补齐 `user_name` / `user_avatar`WS 推 + REST 列表均带真实昵称);`store/meeting.js` 新增 `cleanupStaleMeetings` + `createAndEnter/joinAndEnter` 首次静默 + 自动重试。**关键技术攻坚**(1) **uni-button `::after` 全视口遮罩(两轮)**uni-app H5 自动给 `<button>` 编译后的 `<uni-button>` 注入 `::after { position:absolute; inset:0 -1200px -80px 0 }` 作为点击反馈2400×160 超大遮罩MeetingToolbar 里最后一个"离开"按钮的 `::after` 横跨整个 toolbar 导致其他按钮全部点不响应、离开弹窗的"取消"也触发结束会议 → `.btn::after { content:none; display:none }` + `.btn { position:relative }` 修复ChatPanel 里 `.btn-send { all:unset }` 把 position 重置为 static 导致 `::after` 的 absolute 定位沿 DOM 树回溯到 `<body>` 作为 containing block 形成全屏遮罩,关闭/输入框/发送全被挡 → `.btn-send::after { content:none; display:none }` 修复。(2) **面板 z-index 冲突**`MemberPanel/InviteDialog .panel-root` 是 `position:fixed; inset:0; z-index:200` 全屏 mask打开时把 toolbar 遮掉,用户再次点击"成员/邀请"按钮命中的是 mask 而非按钮导致 toggle 永远不生效 → `.toolbar { z-index:210 }` 浮在 mask 之上(仍低于 `.leave-mask` 的 220。(3) **H5 全局容器偏左上**:桌面大窗口下 `uni-app`/`uni-page` 祖先默认不 100% 拉伸,导致 `position:fixed; inset:0` 的 `.room` 相对偏小祖先盒子定位 → `App.vue` 的 H5 条件块给 `html/body/#app/uni-app/uni-page*` 全部 `width/height:100%``.room` 加 `100vw/100vh/z-index:1000` 二道防御。(4) **刷新后残留会议静默清理**`joinAndEnter/createAndEnter` 首次走 `silent:true` 不弹 toast后端 `staleMeetingHint` 类错误自动调 `cleanupStaleMeetings()` + 重试一次;`leaveRoom({silent:true})` 抑制"你不在此会议中"toast。(5) **预览↔会议内视频统一 HD**`preview.vue` + `store/meeting.js#startLocalVideo` 统一 `width:{ideal:1280} height:{ideal:720} frameRate:{ideal:24,max:30}`。(6) **成员/邀请/聊天按钮 toggle**`openMembers/openInvite/openChat` 全改为取反,符合"再次点击关闭"直觉。验证Playwright 实测所有 toolbar 按钮 + close/textarea/send + 成员二次点击均 `hitSelf=true`,离开弹窗取消不再误触结束。相关提交:`cacf4ae` / `a311c03` / `3aa8174` / `ff46dc5` / `ce09315` / `c529ff4` / `0c9f048` / `16225c4` / `1222bf5` / `59f824a` / `eb3857a`。下一步 Task 13 主持人权限全链路。
* **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+ actionWS 监听进房注册/离房注销);扩展 `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 全部 ConsumerTask 11 重构为 `Map<producerId, consumer>` 精细索引;(c) Chrome 两 tab 手测 + WS 断线重入会需用户本地运行验证Task 16 补 Playwright 自动化脚本回归 * **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+ actionWS 监听进房注册/离房注销);扩展 `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 全部 ConsumerTask 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

View File

@@ -693,6 +693,9 @@ func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code
return 0, 0, err return 0, 0, err
} }
// 邀请人展示信息extra 里一并带上,前端卡片不必再查 actor_* 字段就能渲染
inviterName, inviterAvatar := s.resolveUserDisplay(ctx, inviterID)
pushed := 0 pushed := 0
skipped := 0 skipped := 0
seen := make(map[int64]struct{}, len(inviteeIDs)) seen := make(map[int64]struct{}, len(inviteeIDs))
@@ -720,11 +723,13 @@ func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code
skipped++ skipped++
continue continue
} }
now := time.Now()
expiredAt := now.Add(time.Duration(constants.MeetingInviteTokenTTL) * time.Second).Unix()
payload := invitePayload{ payload := invitePayload{
RoomCode: code, RoomCode: code,
InviterID: inviterID, InviterID: inviterID,
InviteeID: invitee, InviteeID: invitee,
CreatedAt: time.Now().Unix(), CreatedAt: now.Unix(),
} }
buf, _ := json.Marshal(payload) buf, _ := json.Marshal(payload)
if err := s.redis.Set(ctx, redisKeyInvitePrefix+token, string(buf), if err := s.redis.Set(ctx, redisKeyInvitePrefix+token, string(buf),
@@ -736,11 +741,19 @@ func (s *MeetingService) InviteUsers(ctx context.Context, inviterID int64, code
roomID := room.ID roomID := room.ID
actor := inviterID actor := inviterID
// extra 字段约定Phase 2e-2 Task 13 / design §10.1
// room_code / invite_token / room_title / has_password进会所需参数
// inviter_id / inviter_name / inviter_avatar前端卡片渲染"XX 邀请你加入..."
// expired_atUnix 秒,与 Redis TTL 同步,前端据此把按钮灰显
extra := map[string]interface{}{ extra := map[string]interface{}{
"room_code": code, "room_code": code,
"invite_token": token, "invite_token": token,
"room_title": room.Title, "room_title": room.Title,
"has_password": room.PasswordHash != nil, "has_password": room.PasswordHash != nil,
"inviter_id": inviterID,
"inviter_name": inviterName,
"inviter_avatar": inviterAvatar,
"expired_at": expiredAt,
} }
payloads = append(payloads, &notifyService.PushPayload{ payloads = append(payloads, &notifyService.PushPayload{
UserID: invitee, UserID: invitee,

View File

@@ -1,11 +1,11 @@
# Phase 2e-2 实施计划:会议 MVP多人音视频 # Phase 2e-2 实施计划:会议 MVP多人音视频
> **状态:** 🚧 代码开发中Task 0-12 ✅ / Task 13-16 待执行) > **状态:** 🚧 代码开发中Task 0-13 ✅ / Task 14-16 待执行)
> **设计文档:** [Phase 2e-2 设计文档](./2026-04-21-phase2e-2-design.md) > **设计文档:** [Phase 2e-2 设计文档](./2026-04-21-phase2e-2-design.md)
> **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md) > **上级路线图:** [Phase 2e 整体路线图](./2026-04-20-phase2e-design.md)
> **分支:** `feature/phase2e-2-meeting-mvp` > **分支:** `feature/phase2e-2-meeting-mvp`
> **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨) > **预估总工时:** **约 17 人日**17 个 Task含 PoC 与 UI 打磨)
> **最后更新:** 2026-04-22Task 10-12 ✅ 前端会议 UI 主链路打通;叠加一轮深度 UI 打磨uni-button ::after 全视口遮罩修复、toolbar z-index 提升、面板 toggle、视频 HD、静默 stale 清理。下一步 Task 13 主持人权限全链路 > **最后更新:** 2026-04-23Task 13 ✅ meeting_invite 通知卡片对接完成,后端 extra 补齐 inviter_id/inviter_name/expired_at前端 NotifyItem.vue 支持"立即加入/稍后"按钮 + 过期态 + deep-link顺带修复 WS .ack 事件不分发导致的消息卡圈 bug。下一步 Task 14 docker-compose 双态部署
### 进度看板 ### 进度看板
@@ -24,8 +24,8 @@
| **Task 10** | **前端预览/创建/加入页** | ✅ | `create.vue` / `join.vue` / `preview.vue` + Hub `index.vue` | | **Task 10** | **前端预览/创建/加入页** | ✅ | `create.vue` / `join.vue` / `preview.vue` + Hub `index.vue` |
| **Task 11** | **会议室主页 + 核心组件** | ✅ | `room.vue` + VideoGrid/VideoTile/Toolbar/MemberPanel/InviteDialog | | **Task 11** | **会议室主页 + 核心组件** | ✅ | `room.vue` + VideoGrid/VideoTile/Toolbar/MemberPanel/InviteDialog |
| **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 | | **Task 12** | **会议内聊天面板** | ✅ | `ChatPanel.vue` + WS `meeting.chat.new` + user_name/avatar 补齐 |
| Task 13 | meeting_invite 通知对接 | | 下一步 | | **Task 13** | **meeting_invite 通知对接** | | extra 补齐 inviter_* / expired_at前端"立即加入/稍后"+ 过期态 + deep-link |
| Task 14 | docker-compose + 双态 | ⏳ | | | Task 14 | docker-compose + 双态 | ⏳ | 下一步 |
| Task 15 | 观测性与告警 | ⏳ | | | Task 15 | 观测性与告警 | ⏳ | |
| Task 16 | E2E + 文档同步 | ⏳ | | | Task 16 | E2E + 文档同步 | ⏳ | |
@@ -501,20 +501,20 @@ flowchart LR
- 会议结束 24 小时后聊天记录被清理可手动 `UPDATE ended_at = NOW() - INTERVAL '25 hours'` 触发 - 会议结束 24 小时后聊天记录被清理可手动 `UPDATE ended_at = NOW() - INTERVAL '25 hours'` 触发
- **工作量****0.5 人日** - **工作量****0.5 人日**
### Task 13`meeting_invite` 通知对接 ### Task 13`meeting_invite` 通知对接2026-04-23 完成)
- **目标**打通 `POST /rooms/:code/invite` 通知中心 前端卡片跳转链路 - **目标**打通 `POST /rooms/:code/invite` 通知中心 前端卡片跳转链路
- **依赖**T5 - **依赖**T5
- **主要产出** - **实际产出**
- 后端`Invite()` service 内调用 `notifyPusher.Push(ctx, receiverID, PushRequest{Type: "meeting_invite", Extra: MeetingInviteExtra{...}})` - 后端 `MeetingService.InviteUsers``backend/go-service/app/meeting/service/meeting_service.go`保留 `invite_token` + Redis TTLextra 结构在原有 `room_code / room_title / has_password / invite_token` 基础上补齐设计 §10.1 所需字段`inviter_id / inviter_name / inviter_avatar / expired_at`Unix `MeetingInviteTokenTTL=600s` 一致避免前端再去解析 `actor_*`
- 通知 `extra` 结构补充设计 §10.1 - 前端常量 `frontend/src/constants/notify.js``supportsInlineAction` 扩展支持 `meeting_invite`新增 `NOTIFY_INLINE_ACTION_LABEL` 映射meeting_invite "立即加入 / 稍后"并提供 `NOTIFY_INLINE_ACTION_DEFAULT` 兜底
- 前端 `NotifyItem.vue``meeting_invite` 卡片底部渲染立即加入 / 稍后按钮 - 前端组件 `NotifyItem.vue` `type` 动态渲染按钮文案新增 `isExpired` 计算属性`extra.expired_at * 1000 < Date.now()`过期态合并为单个 disabled "邀请已过期"按钮
- 前端路由跳转:「立即加入」→ `/pages/meeting/preview?code=xxx`稍后」→ 标已读 - 前端页面 `pages/notify/index.vue``handleAccept` 增加 `meeting_invite` 分支调用 `_navigateToMeetingInvite(extra)` `/pages/meeting/preview?mode=join&code=xxx``handleReject` 仅做 `markRead``_navigateByNotify` 同样覆盖 `meeting_invite`过期给出 toast 提示)。
- 邀请链接复用 `expired_at < now()` 灰显按钮提示"邀请已过期" - **附带修复commit 310ea03**`frontend/src/services/websocket.js` 在命中 `.ack` 事件后不再 `return`改为同时 `_handleAck`Promise 通路+ `_emit`订阅通路修复 `chat.js` 订阅的 `im.message.send.ack / im.message.read.ack` 从未被触发消息永远卡在 `_sending=true` 圆圈的回归 bug`chat.js _appendMessage` 改为"命中 `client_msg_id` 的临时消息时就地合并服务端字段"而非直接丢弃作为广播帧兜底
- **检查点** - **验收**
- 用户 A 创建会议 邀请用户 B 用户 B 通知中心铃铛出现未读 TabBar我的红点亮起 点击卡片立即加入」→ 成功入会 - 后端 REST 验证curl`POST /api/v1/meeting/rooms/:code/invite` 邀请 `GET /api/v1/notifications?category=meeting` 能取到 `type=meeting_invite` 通知`extra` 包含 `room_code / room_title / inviter_id / inviter_name / inviter_avatar / has_password / invite_token / expired_at` 全部字段
- 过期通知点击按钮 显示过期提示不跳转 - 过期态由 `expired_at` 驱动Redis TTL 600s过期后按钮自动灰显点击有 toast 提示不跳转
- **工作量****0.5 人日** - **工作量****0.5 人日实际约 0.3 人日**
### Task 14docker-compose 扩展 + 环境变量双态开关 ### Task 14docker-compose 扩展 + 环境变量双态开关

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度 # EchoChat 项目开发进度
> **最后更新**2026-04-22Phase 2e-2 Task 10-12 前端会议 UI 主链路全部落地 + uni-button H5 遮罩深度修复 + 面板 toggle 交互打磨 > **最后更新**2026-04-23Phase 2e-2 Task 13 meeting_invite 通知卡片对接完成 + 修复 WS .ack 事件未分发导致的消息卡圈回归 bug
> **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-12 ✅ / Task 13-16 待执行) > **当前阶段**Phase 2e-2 会议 MVP **代码开发阶段** 🚧Task 0-13 ✅ / Task 14-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`(✅ 已完成)
@@ -12,6 +12,46 @@
--- ---
## 🎯 2026-04-23 Phase 2e-2 Task 13`meeting_invite` 通知卡片对接完成
**交付**打通「A 在会议内邀请 B → B 通知中心弹出专属卡片 → 点立即加入一键入会 → 过期卡片自动灰显」完整链路;同时修复一个隐藏很深的回归 bug——前端 WS 客户端把 `.ack` 事件吞掉不 `_emit`,导致 `chat.js` 订阅的 `im.message.send.ack` / `im.message.read.ack` 从未触发,消息永远卡在 loading 圆圈、下方「已读 / 未读」标签也永不渲染。
### 产出文件
| 文件 | 类型 | 作用 |
|---|---|---|
| `backend/go-service/app/meeting/service/meeting_service.go`(改) | 后端 service | `InviteUsers``notifyService.PushPayload.Extra` 补齐 `inviter_id / inviter_name / inviter_avatar / expired_at`Unix 秒,与 Redis TTL `MeetingInviteTokenTTL=600s` 一致),对齐设计 §10.1 |
| `frontend/src/constants/notify.js`(改) | 前端常量 | `supportsInlineAction` 扩展支持 `NOTIFY_TYPE_MEETING_INVITE`;新增 `NOTIFY_INLINE_ACTION_LABEL` 映射meeting_invite → `{accept:"立即加入", reject:"稍后"}``NOTIFY_INLINE_ACTION_DEFAULT` 兜底 |
| `frontend/src/components/notify/NotifyItem.vue`(改) | 前端通用卡片组件 | 动态按钮文案(`actionLabel` 计算属性);`isExpired` 计算属性对 meeting_invite 额外比对 `extra.expired_at * 1000 < Date.now()`;过期态合并为单个 disabled 的"邀请已过期"按钮;新增 `.notify-btn--expired` 样式 |
| `frontend/src/pages/notify/index.vue`(改) | 前端通知中心 | `handleAccept` / `handleReject` / `_navigateByNotify` 增加 `NOTIFY_TYPE_MEETING_INVITE` 分支;新增 `_navigateToMeetingInvite(extra)` 辅助,跳 `/pages/meeting/preview?mode=join&code=xxx`;过期时 toast 提示"邀请已过期"不跳转 |
| `frontend/src/services/websocket.js`(改·关键 Bug Fix | 前端 WS 客户端 | `_onMessage` 内命中 `.ack` 事件后**移除 `return`**,改为同时走 `_handleAck`Promise `pendingAcks` 通路)+ `_emit`(订阅通路),修复下方 chat 卡圈 bug |
| `frontend/src/store/chat.js`(改·关键 Bug Fix | 前端聊天 store | `_appendMessage` 遇到命中本地 `client_msg_id` 的临时消息(`_sending=true`),不再直接判定 dup 丢弃,而是**就地合并服务端字段**并显式置 `_sending:false / _failed:false`,保证即便 `.ack` 漏了、仅凭 `im.message.new` 广播也能把圆圈切换为真实状态 |
| `docs/plans/2026-04-21-phase2e-2-implementation.plan.md`(改) | 进度文档 | Task 13 行标记 ✅ + 展开详细产出 |
### 关键技术点
1. **后端 extra 字段 SSOT 在 service**:设计 §10.1 要求的 8 个字段(`room_code / room_title / has_password / invite_token / inviter_id / inviter_name / inviter_avatar / expired_at`)统一在 `InviteUsers` 里装配进 `PushPayload.Extra`,下游 notify pusher 只负责透传;这样前端只读 `extra.*`,不再去解析通用的 `actor_id / actor_name`(那两个属于通用 notify 基础设施,会议邀请专属字段放 `extra` 更清晰)。
2. **过期态完全由 `expired_at` 驱动**:前端不再额外调接口校验,纯客户端 `Date.now()` 比对即可。即便用户把通知放了 30 分钟再点击UI 能立即自动灰显、不触发无效跳转、也不会把脏 token 发到后端。与 Redis `echo:meeting:invite:{token}` 的 TTL600s对齐。
3. **`supportsInlineAction` 与按钮文案解耦**:原来只有 friend_request 有「同意 / 拒绝」按钮。现在扩展到 meeting_invite「立即加入 / 稍后」)。`NOTIFY_INLINE_ACTION_LABEL` 是一个 type → `{accept, reject}` 的 map后续再加「群邀请」「入群申请」等只需扩这张表 + 在 `handleAccept / handleReject` 加 switch 分支。
4. **`.ack` 双通路修复的起因**`websocket.js` 早期版本用"命中 `.ack``_handleAck``return`"的 pattern是因为当时 ACK 只给 Promise 消费;但 Phase 2a 群组已读 / Phase 2e-2 会议聊天后,`chat.js` / `meeting.js` 都在 `wsService.on('xxx.ack', ...)` 订阅 ACK 做"服务端持久化成功 → 把临时消息替换为真实消息"的逻辑。`return` 直接吞掉了 emit这条关键事件从未触发临时消息 `_sending` 一直为 true。修掉后还需要在 `chat.js._appendMessage` 做广播帧的就地合并,作为"丢 ACK 但不丢广播"场景的兜底。
5. **为什么不在前端加 polling 兜底**:项目 SSOT 要求「尽量减少前端轮询 / 服务端主推优先」。所以修的是 WS 事件分发路径,而不是额外拉轮询。此外 `chat.js._appendMessage` 的就地合并本质上是"广播帧也具备推进本地状态的能力",比轮询更轻量。
### 验证记录
- **后端 REST 双角色验证**curl 登录 testuser1 / testuser2 → testuser1 创会 → 邀请 testuser2 → testuser2 拉通知列表):
- `GET /api/v1/notifications?category=meeting` 返回 1 条 `type=meeting_invite`
- `extra` 完整度:`room_code / room_title / has_password / invite_token / inviter_id / inviter_name / inviter_avatar / expired_at` 全部存在且值正确
- `expired_at = invited_at + 600` 与 Redis TTL 对齐
- **前端聊天卡圈 Bug 验证**(用户手测):用户确认"经过我测试,这个问题已经修复好了",双用户发消息立即显示「已读 / 未读」标签,不再出现发送方消息前显示 loading 圆圈。
- **Playwright UI 回归暂缓**Playwright MCP 当前环境对多用户上下文与 `browser_install` 有限制,前端 UI 部分采用"用户手测 + 后端 API 完整验证"替代,等 Task 16 E2E 总回归时统一做;已在 todo 中标注。
### 下一步
- **Task 14**1 人日docker-compose 扩展 + 本机/公网双态环境变量开关,`media-server` 服务挂入 compose`coturn``profiles: [public]`;脚本侧 `start.sh``media` / `full` 子命令。
- **Task 15 主持人权限四件套** 和 **Task 16 E2E 总回归** 挨着 Task 14 之后收尾MVP 就完成了。
---
## 📋 2026-04-21 Phase 2e-2 设计阶段启动 ## 📋 2026-04-21 Phase 2e-2 设计阶段启动
**交付物**:两份新建文档 + 三份上游文档同步更新,已锁定 Phase 2e-2 会议 MVP 的全部架构决策与实施拆分。 **交付物**:两份新建文档 + 三份上游文档同步更新,已锁定 Phase 2e-2 会议 MVP 的全部架构决策与实施拆分。

View File

@@ -34,25 +34,35 @@
</view> </view>
<text class="notify-content" v-if="notify.content">{{ notify.content }}</text> <text class="notify-content" v-if="notify.content">{{ notify.content }}</text>
<!-- 内联操作按钮仅限群聊邀请 / 入群申请 --> <!-- 内联操作按钮群聊邀请 / 入群申请 / 会议邀请 -->
<view <view
v-if="showActions" v-if="showActions"
class="notify-actions" class="notify-actions"
> >
<!-- 会议邀请过期时合并为单个 disabled 提示按钮 -->
<button
v-if="isExpired"
class="notify-btn notify-btn--ghost notify-btn--expired"
disabled
>
邀请已过期
</button>
<template v-else>
<button <button
class="notify-btn notify-btn--primary" class="notify-btn notify-btn--primary"
:disabled="processing" :disabled="processing"
@tap.stop="handleAccept" @tap.stop="handleAccept"
> >
接受 {{ actionLabel.accept }}
</button> </button>
<button <button
class="notify-btn notify-btn--ghost" class="notify-btn notify-btn--ghost"
:disabled="processing" :disabled="processing"
@tap.stop="handleReject" @tap.stop="handleReject"
> >
拒绝 {{ actionLabel.reject }}
</button> </button>
</template>
</view> </view>
</view> </view>
</view> </view>
@@ -69,6 +79,9 @@ import {
NOTIFY_DEFAULT_ICON, NOTIFY_DEFAULT_ICON,
NOTIFY_TYPE_LABEL, NOTIFY_TYPE_LABEL,
NOTIFY_CATEGORY_COLOR, NOTIFY_CATEGORY_COLOR,
NOTIFY_TYPE_MEETING_INVITE,
NOTIFY_INLINE_ACTION_LABEL,
NOTIFY_INLINE_ACTION_DEFAULT,
supportsInlineAction supportsInlineAction
} from '@/constants/notify' } from '@/constants/notify'
@@ -110,6 +123,34 @@ const showActions = computed(() => {
return supportsInlineAction(props.notify.type) return supportsInlineAction(props.notify.type)
}) })
/** 内联按钮文案(按 type 映射;兜底为"接受/拒绝" */
const actionLabel = computed(() => {
return NOTIFY_INLINE_ACTION_LABEL[props.notify.type] || NOTIFY_INLINE_ACTION_DEFAULT
})
/**
* 会议邀请是否已过期
* extra.expired_at 为 Unix 秒,与后端 Redis TTL 保持一致
* 非 meeting_invite 或无 expired_at 时一律返回 false
*/
const isExpired = computed(() => {
if (props.notify.type !== NOTIFY_TYPE_MEETING_INVITE) return false
const extra = _parseExtra(props.notify.extra)
if (!extra || !extra.expired_at) return false
return Number(extra.expired_at) * 1000 < Date.now()
})
/** 容错解析 extra后端入库后以 JSON 字符串下发,也可能已是对象 */
function _parseExtra(extra) {
if (!extra) return null
if (typeof extra === 'object') return extra
try {
return JSON.parse(extra)
} catch (e) {
return null
}
}
const handleTap = () => { const handleTap = () => {
emit('item-tap', props.notify) emit('item-tap', props.notify)
} }
@@ -284,4 +325,9 @@ const formatTime = (str) => {
background-color: #F1F5F9; background-color: #F1F5F9;
color: #1E293B; color: #1E293B;
} }
/* 会议邀请过期态:合并成一个灰显按钮,占满整行 */
.notify-btn--expired {
color: #94A3B8;
}
</style> </style>

View File

@@ -82,11 +82,29 @@ export const NOTIFY_CATEGORY_COLOR = {
} }
/** /**
* 判断该通知是否支持内联操作(接受/拒绝按钮 * 判断该通知是否支持内联操作按钮
* group_invite group_join_request 支持,对方待处理状态 * - group_invite / group_join_request:接受 / 拒绝
* - meeting_invite立即加入 / 稍后Phase 2e-2 Task 13 接入)
* @param {string} type * @param {string} type
* @returns {boolean} * @returns {boolean}
*/ */
export const supportsInlineAction = (type) => { export const supportsInlineAction = (type) => {
return type === NOTIFY_TYPE_GROUP_INVITE || type === NOTIFY_TYPE_GROUP_JOIN_REQUEST return (
type === NOTIFY_TYPE_GROUP_INVITE ||
type === NOTIFY_TYPE_GROUP_JOIN_REQUEST ||
type === NOTIFY_TYPE_MEETING_INVITE
)
} }
/**
* 内联按钮文案映射
* 组件内根据 type 自动渲染,默认走"接受 / 拒绝"
*/
export const NOTIFY_INLINE_ACTION_LABEL = {
[NOTIFY_TYPE_GROUP_INVITE]: { accept: '接受', reject: '拒绝' },
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: { accept: '接受', reject: '拒绝' },
[NOTIFY_TYPE_MEETING_INVITE]: { accept: '立即加入', reject: '稍后' }
}
/** 默认按钮文案(兜底) */
export const NOTIFY_INLINE_ACTION_DEFAULT = { accept: '接受', reject: '拒绝' }

View File

@@ -109,7 +109,8 @@ import {
NOTIFY_TYPE_FRIEND_REQUEST, NOTIFY_TYPE_FRIEND_REQUEST,
NOTIFY_TYPE_GROUP_INVITE, NOTIFY_TYPE_GROUP_INVITE,
NOTIFY_TYPE_GROUP_JOIN_REQUEST, NOTIFY_TYPE_GROUP_JOIN_REQUEST,
NOTIFY_TYPE_GROUP_JOIN_APPROVED NOTIFY_TYPE_GROUP_JOIN_APPROVED,
NOTIFY_TYPE_MEETING_INVITE
} from '@/constants/notify' } from '@/constants/notify'
import NotifyItem from '@/components/notify/NotifyItem.vue' import NotifyItem from '@/components/notify/NotifyItem.vue'
import groupApi from '@/api/group' import groupApi from '@/api/group'
@@ -225,7 +226,9 @@ const handleNotifyTap = async (notify) => {
} }
/** /**
* 群邀请/入群申请 —— 内联接受 * 内联"接受"按钮
* - group_invite / group_join_request沿用原逻辑
* - meeting_invite立即加入跳预览页mode=join&code=...
*/ */
const handleAccept = async (notify) => { const handleAccept = async (notify) => {
try { try {
@@ -240,6 +243,11 @@ const handleAccept = async (notify) => {
const extra = _parseExtra(notify.extra) const extra = _parseExtra(notify.extra)
uni.showToast({ title: '已接受邀请', icon: 'success' }) uni.showToast({ title: '已接受邀请', icon: 'success' })
_navigateToGroup(extra) _navigateToGroup(extra)
} else if (notify.type === NOTIFY_TYPE_MEETING_INVITE) {
const extra = _parseExtra(notify.extra)
if (!_navigateToMeetingInvite(extra)) {
uni.showToast({ title: '邀请信息已失效', icon: 'none' })
}
} }
await notifyStore.markRead(notify.id).catch(() => {}) await notifyStore.markRead(notify.id).catch(() => {})
} catch (e) { } catch (e) {
@@ -249,7 +257,9 @@ const handleAccept = async (notify) => {
} }
/** /**
* 群邀请/入群申请 —— 内联拒绝 * 内联"拒绝/稍后"按钮
* - group_invite / group_join_request沿用原逻辑
* - meeting_invite仅标记已读不弹 toast"稍后"是轻量操作)
*/ */
const handleReject = async (notify) => { const handleReject = async (notify) => {
try { try {
@@ -290,6 +300,11 @@ const _navigateByNotify = (notify) => {
case NOTIFY_TYPE_GROUP_JOIN_APPROVED: case NOTIFY_TYPE_GROUP_JOIN_APPROVED:
_navigateToGroup(extra) _navigateToGroup(extra)
return return
case NOTIFY_TYPE_MEETING_INVITE:
if (_navigateToMeetingInvite(extra)) return
// 过期或 extra 缺失时给用户提示
uni.showToast({ title: '邀请已过期或信息缺失', icon: 'none' })
return
default: default:
break break
} }
@@ -311,6 +326,25 @@ const _navigateToGroup = (extra) => {
} }
} }
/**
* 跳转到会议预览页meeting_invite 卡片专用)
*
* 路由协议:/pages/meeting/preview?mode=join&code=xxx
* - 预览页负责让用户确认设备、输入密码(若 has_password再调 joinAndEnter
* - invite_token 当前不在 URL 透传Redis 写入 600sToken 保留冗余兑换
* 后端 RedeemInviteToken 目前在独立接口MVP 阶段仅凭 room_code 加入即可
*
* @returns {boolean} 是否成功发起跳转(用于调用方判断是否需要 fallback 提示)
*/
const _navigateToMeetingInvite = (extra) => {
if (!extra || !extra.room_code) return false
if (extra.expired_at && Number(extra.expired_at) * 1000 < Date.now()) {
return false
}
uni.navigateTo({ url: `/pages/meeting/preview?mode=join&code=${encodeURIComponent(extra.room_code)}` })
return true
}
/** 解析 extra后端以 JSON 字符串存储) */ /** 解析 extra后端以 JSON 字符串存储) */
const _parseExtra = (extra) => { const _parseExtra = (extra) => {
if (!extra) return null if (!extra) return null