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:
@@ -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` 级联关闭下游 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 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+ 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 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)
|
||||||
|
|||||||
@@ -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_at:Unix 秒,与 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, ¬ifyService.PushPayload{
|
payloads = append(payloads, ¬ifyService.PushPayload{
|
||||||
UserID: invitee,
|
UserID: invitee,
|
||||||
|
|||||||
@@ -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-22(Task 10-12 ✅ 前端会议 UI 主链路打通;叠加一轮深度 UI 打磨:uni-button ::after 全视口遮罩修复、toolbar z-index 提升、面板 toggle、视频 HD、静默 stale 清理。下一步 Task 13 主持人权限全链路)
|
> **最后更新:** 2026-04-23(Task 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 TTL;extra 结构在原有 `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 14:docker-compose 扩展 + 环境变量双态开关
|
### Task 14:docker-compose 扩展 + 环境变量双态开关
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# EchoChat 项目开发进度
|
# EchoChat 项目开发进度
|
||||||
|
|
||||||
> **最后更新**:2026-04-22(Phase 2e-2 Task 10-12 前端会议 UI 主链路全部落地 + uni-button H5 遮罩深度修复 + 面板 toggle 交互打磨)
|
> **最后更新**:2026-04-23(Phase 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}` 的 TTL(600s)对齐。
|
||||||
|
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 的全部架构决策与实施拆分。
|
||||||
|
|||||||
@@ -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
|
<button
|
||||||
class="notify-btn notify-btn--primary"
|
v-if="isExpired"
|
||||||
:disabled="processing"
|
class="notify-btn notify-btn--ghost notify-btn--expired"
|
||||||
@tap.stop="handleAccept"
|
disabled
|
||||||
>
|
>
|
||||||
接受
|
邀请已过期
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="notify-btn notify-btn--ghost"
|
|
||||||
:disabled="processing"
|
|
||||||
@tap.stop="handleReject"
|
|
||||||
>
|
|
||||||
拒绝
|
|
||||||
</button>
|
</button>
|
||||||
|
<template v-else>
|
||||||
|
<button
|
||||||
|
class="notify-btn notify-btn--primary"
|
||||||
|
:disabled="processing"
|
||||||
|
@tap.stop="handleAccept"
|
||||||
|
>
|
||||||
|
{{ actionLabel.accept }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="notify-btn notify-btn--ghost"
|
||||||
|
:disabled="processing"
|
||||||
|
@tap.stop="handleReject"
|
||||||
|
>
|
||||||
|
{{ actionLabel.reject }}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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: '拒绝' }
|
||||||
|
|||||||
@@ -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 写入 600s,Token 保留冗余兑换
|
||||||
|
* 后端 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
|
||||||
|
|||||||
Reference in New Issue
Block a user