From d6fb7ce2913a836d80ee939d7a1aa9c9ee6d2b4a Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Wed, 22 Apr 2026 17:26:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(meeting):=20Task=2013=20=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20meeting=5Finvite=20=E9=80=9A=E7=9F=A5=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 后端 - 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 --- .cursor/rules/project-context.mdc | 3 +- .../app/meeting/service/meeting_service.go | 23 ++++-- ...026-04-21-phase2e-2-implementation.plan.md | 30 ++++---- docs/progress/CURRENT_STATUS.md | 44 +++++++++++- frontend/src/components/notify/NotifyItem.vue | 70 +++++++++++++++---- frontend/src/constants/notify.js | 24 ++++++- frontend/src/pages/notify/index.vue | 40 ++++++++++- 7 files changed, 193 insertions(+), 41 deletions(-) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 9ad8bf9..2b71104 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -27,7 +27,7 @@ alwaysApply: true * 单端 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` * 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) * 11 项关键决策已锁定(D01-D11),详见设计文档 §三 * **重要修订**:`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 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 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 自动给 ` - + @@ -69,6 +79,9 @@ import { NOTIFY_DEFAULT_ICON, NOTIFY_TYPE_LABEL, NOTIFY_CATEGORY_COLOR, + NOTIFY_TYPE_MEETING_INVITE, + NOTIFY_INLINE_ACTION_LABEL, + NOTIFY_INLINE_ACTION_DEFAULT, supportsInlineAction } from '@/constants/notify' @@ -110,6 +123,34 @@ const showActions = computed(() => { 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 = () => { emit('item-tap', props.notify) } @@ -284,4 +325,9 @@ const formatTime = (str) => { background-color: #F1F5F9; color: #1E293B; } + +/* 会议邀请过期态:合并成一个灰显按钮,占满整行 */ +.notify-btn--expired { + color: #94A3B8; +} diff --git a/frontend/src/constants/notify.js b/frontend/src/constants/notify.js index 4eee783..0441628 100644 --- a/frontend/src/constants/notify.js +++ b/frontend/src/constants/notify.js @@ -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 * @returns {boolean} */ 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: '拒绝' } diff --git a/frontend/src/pages/notify/index.vue b/frontend/src/pages/notify/index.vue index 00aa3b0..417ffb1 100644 --- a/frontend/src/pages/notify/index.vue +++ b/frontend/src/pages/notify/index.vue @@ -109,7 +109,8 @@ import { NOTIFY_TYPE_FRIEND_REQUEST, NOTIFY_TYPE_GROUP_INVITE, NOTIFY_TYPE_GROUP_JOIN_REQUEST, - NOTIFY_TYPE_GROUP_JOIN_APPROVED + NOTIFY_TYPE_GROUP_JOIN_APPROVED, + NOTIFY_TYPE_MEETING_INVITE } from '@/constants/notify' import NotifyItem from '@/components/notify/NotifyItem.vue' 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) => { try { @@ -240,6 +243,11 @@ const handleAccept = async (notify) => { const extra = _parseExtra(notify.extra) uni.showToast({ title: '已接受邀请', icon: 'success' }) _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(() => {}) } catch (e) { @@ -249,7 +257,9 @@ const handleAccept = async (notify) => { } /** - * 群邀请/入群申请 —— 内联“拒绝” + * 内联"拒绝/稍后"按钮 + * - group_invite / group_join_request:沿用原逻辑 + * - meeting_invite:仅标记已读,不弹 toast("稍后"是轻量操作) */ const handleReject = async (notify) => { try { @@ -290,6 +300,11 @@ const _navigateByNotify = (notify) => { case NOTIFY_TYPE_GROUP_JOIN_APPROVED: _navigateToGroup(extra) return + case NOTIFY_TYPE_MEETING_INVITE: + if (_navigateToMeetingInvite(extra)) return + // 过期或 extra 缺失时给用户提示 + uni.showToast({ title: '邀请已过期或信息缺失', icon: 'none' }) + return default: 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 字符串存储) */ const _parseExtra = (extra) => { if (!extra) return null