# 私信(messaging)模块审计报告 > 审查日期:2026-06-25 > 审查范围:`src/modules/messaging/**`、`src/app/(dashboard)/messages/**`、`src/modules/layout/components/app-sidebar.tsx`(消费方)、`src/shared/i18n/messages/{zh-CN,en}/messages.json` > 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.13、`docs/architecture/005_architecture_data.json` §messaging > 前置文档:`docs/architecture/audit/announcements-messages-audit-report-v3.md`(已修复 V3-P0-1 / V3-P1-3 / V3-P1-6 / V3-P2-7) --- ## 一、现有实现概要 ### 1.1 文件分布 | 层 | 文件 | 行数 | 职责 | |----|------|------|------| | 路由 | `app/(dashboard)/messages/page.tsx` | 37 | 消息首页(编排 + 渲染 MessageList + NotificationList) | | 路由 | `app/(dashboard)/messages/[id]/page.tsx` | 33 | 消息详情页 | | 路由 | `app/(dashboard)/messages/compose/page.tsx` | 46 | 撰写消息页 | | 路由 | `app/(dashboard)/messages/{,[id]/,compose/}{error,loading}.tsx` | 6 文件 | 错误边界 + 骨架屏 | | Actions | `modules/messaging/actions.ts` | ~329 | 11 个 Server Action(含 1 个死代码 Action) | | Data-access | `modules/messaging/data-access.ts` | ~404 | 私信 CRUD + 草稿 CRUD + 2 个编排函数 | | Schema | `modules/messaging/schema.ts` | 40 | Zod 校验(发送 / messageId / 草稿) | | Types | `modules/messaging/types.ts` | 83 | 私信 + 草稿类型 | | 组件 | `components/message-list.tsx` | 252 | 列表 + 客户端搜索 + 分页 + 星标切换 | | 组件 | `components/message-detail.tsx` | 179 | 详情 + 删除 + 星标 + 回复入口 | | 组件 | `components/message-compose.tsx` | 284 | 撰写 + 自动保存草稿 + 字段级校验 | | 组件 | `components/unread-message-badge.tsx` | 57 | 侧边栏未读徽章(30s 轮询) | | Hook | `hooks/use-message-search.ts` | 106 | 防抖 + 请求竞态取消 | | 测试 | `schema.test.ts` | 242 | SendMessageSchema / MessageIdSchema 单元测试 | | i18n | `shared/i18n/messages/{zh-CN,en}/messages.json` | 89 | 13 命名空间翻译键 | 文件大小均在规范内(最大 `data-access.ts` 404 行 < 800 行限制;最大组件 `message-compose.tsx` 284 行 < 500 行限制)。 ### 1.2 数据流 ``` [messages 页面] └─ getMessagesPageData(userId) # data-access 编排 ├─ getMessages({type:"all"}) # messaging 自有表 └─ import("@/modules/notifications/data-access").getNotifications(userId) # ⚠ 动态跨模块 import → MessageList(客户端 useMessageSearch 搜索 + 客户端 tab 过滤 + 客户端分页) → NotificationList(notifications 模块组件) [messages/[id] 页面] └─ getMessageDetailPageData(id, userId) # data-access 编排 ├─ getMessageById(id, userId) # 权限校验在 SQL where 中 └─ markMessageAsRead(id, userId) # 接收方未读时自动标记 → MessageDetail(客户端调用 deleteMessageAction / toggleMessageStarAction) [messages/compose 页面] └─ getRecipients(userId, dataScope) # 按 DataScope 解析可发送对象 └─ classes data-access.getTeacherIdsByClassIds / getStudentActiveClassId └─ users data-access.getUserNamesByIds → MessageCompose(自动保存调用 saveMessageDraftAction;提交调用 sendMessageAction) ``` ### 1.3 架构图与实际代码一致性核对 - ✅ `004_architecture_impact_map.md` §2.13 列出的导出函数与实际 `actions.ts` / `data-access.ts` 基本一致 - ❌ **不一致点 1**:架构图标注 `getMessageDetailAction` 的 `usedBy` 含 `message-detail.tsx`,但实际代码中 `message-detail.tsx` **从未调用** `getMessageDetailAction`(详情数据由 RSC 通过 `getMessageDetailPageData` 直接获取) - ❌ **不一致点 2**:架构图标注 `getMessageDraftsAction` 的 `usedBy` 含 `messages/compose/page.tsx`,但实际 `compose/page.tsx` **从不调用** `getMessageDraftsAction`(草稿仅在客户端自动保存,未提供"草稿列表"UI) - ❌ **不一致点 3**:架构图 §2.13 文件清单中 `actions.ts` 标注 "~330 行 11 个私信 Server Action",但实际包含 1 个完全未被引用的死代码 Action(`getMessageDetailAction`) - ❌ **不一致点 4**:架构图标注 `getMessageThread` 的签名是 `(rootId: string, userId: string)`,实际代码签名是 `(messageId: string)` 且**未做 userId 权限校验** --- ## 二、现存问题与原因分析 ### 2.1 【P0 - 安全】`createMessage` 未校验 receiverId 是否在 sender 的 DataScope 内 | 位置 | 代码 | 违反规则 | |------|------|----------| | [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L32-95 `sendMessageAction` | 仅校验 `receiverId !== ctx.userId`,未校验 receiverId 是否在 `ctx.dataScope` 允许范围内 | 安全规范:"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验" | **问题分析**: - `getRecipientsAction` 通过 `getRecipients(ctx.userId, ctx.dataScope)` 返回收件人列表,UI 通过 Select 限定选项 - 但 `sendMessageAction` 直接接收 `formData.get("receiverId")`,未与 `ctx.dataScope` 二次校验 - 攻击者绕过前端 Select,构造 FormData 提交任意 `receiverId`,即可向任意用户发送消息 - 学生 A 可向学生 B(非同班)发消息;家长可向非自己孩子老师发消息;越权发送 **后果**:横向越权 / 信息泄露 / 骚扰风险。K12 场景下家长-学生-教师通信边界被破坏,合规风险极高。 ### 2.2 【P0 - 安全】`getMessageThread` 缺少 userId 权限校验 | 位置 | 代码 | 违反规则 | |------|------|----------| | [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L156-169 `getMessageThread` | 仅 `eq(messages.id, messageId)` + `eq(messages.parentMessageId, messageId)`,未过滤 `senderId`/`receiverId` | 安全规范:"data-access 层结合当前用户权限过滤" | **问题分析**: - 函数签名是 `getMessageThread(messageId: string)`,无 `userId` 参数 - 任意调用方传入合法 messageId 即可读取整个回复链 - 当前调用方 `getMessageDetailAction`([actions.ts:177](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts))虽先调用 `getMessageById(id, ctx.userId)` 校验根消息访问权,但 `getMessageThread` 本身缺乏防御深度 - 若未来有新调用方(如导出、统计、AI 摘要)误用 `getMessageThread`,将直接泄露隐私 **后果**:纵深防御缺失;隐私消息可被未授权读取。 ### 2.3 【P0 - 架构】`getMessagesPageData` 在 data-access 层动态 import 跨模块 | 位置 | 代码 | 违反规则 | |------|------|----------| | [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L287 `await import("@/modules/notifications/data-access")` | data-access 层动态 import notifications 模块 | 重构方案原则:"完全解耦:模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access" + 架构分层规则:"app/ 只能调用 modules 的 Server Actions 和 data-access" | **问题分析**: - V1-P1-5 的修复将"页面层 `Promise.all` 编排 messaging + notifications"改为"data-access 编排",但编排本身跨模块,应放在页面层 - 当前 `getMessagesPageData` 强依赖 notifications 模块,破坏 messaging 模块独立性 - mock 测试 messaging 时必须 stub notifications 模块 - 单元测试边界模糊:messaging 模块的 data-access 测试是否应包含 notifications 行为? **后果**:模块边界泄漏;测试困难;重构 notifications 时会波及 messaging。 ### 2.4 【P0 - 死代码】`getMessageDetailAction` 完全无引用 | 位置 | 代码 | 违反规则 | |------|------|----------| | [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L167-190 `getMessageDetailAction` | 定义后从未被任何组件、页面、其他 Action 调用 | 编码规范:"避免 backwards-compatibility hacks... 如果确定某物未使用,可以完全删除" | **问题分析**: - 全代码库 grep `getMessageDetailAction` 仅命中 `actions.ts` 自身、架构图 004/005、历史 bug 文档 - 详情页使用 `getMessageDetailPageData`(data-access 编排函数)替代 - 详情页组件 `MessageDetail` 通过 props 接收 `message`,不调用此 Action - 此 Action 是 V2-P1-3 重构时遗留的死代码 **后果**:维护负担;架构图与代码不一致;新人误以为是 API 入口。 ### 2.5 【P0 - 安全/正确性】`MessageCompose` 自动保存草稿在 `useEffect` 闭包中读 `draftIdRef`,但 `subject`/`content` 仍触发新 effect | 位置 | 代码 | 违反规则 | |------|------|----------| | [message-compose.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-compose.tsx) L67-102 `useEffect` | 依赖 `[subject, content, receiverId, parentMessageId]`,每次键入触发新 effect → 2 秒后保存;用户快速提交时仍在保存旧草稿 | 可测试性:"纯逻辑全部放入纯函数或 hooks,与 UI 分离" | **问题分析**: - 用户输入"Hello" → 2 秒后保存草稿(draftId=A) - 用户在 1.8 秒时点击"发送" → `handleSubmit` 发送消息 + `deleteMessageDraftAction(draftIdRef.current=A)` 删除草稿 - 0.2 秒后,第一次 effect 的 setTimeout 触发,`cancelled` 仍为 false(清理函数仅在 effect 重新运行或卸载时调用) → 重新调用 `saveMessageDraftAction` 创建新草稿(draftId=B) - 用户已离开页面,但数据库中残留孤立草稿 B **后果**:数据库残留垃圾草稿;用户隐私风险("已发送"的内容仍以草稿形式存留)。 ### 2.6 【P1 - 功能缺失】草稿列表 UI 完全缺失 | 位置 | 问题 | 违反规则 | |------|------|----------| | `messages/compose/page.tsx` 全文 | 未调用 `getMessageDraftsAction`,无草稿列表渲染 | 最大化复用:"识别共用的 UI 块和业务逻辑块" | **问题分析**: - data-access 提供 `getMessageDrafts` / `getMessageDraftById` - actions 提供 `getMessageDraftsAction` / `deleteMessageDraftAction` - i18n 已准备 `empty.noDrafts` / `messages.draftSaved` / `messages.draftDeleted` 翻译键 - 但 compose 页面无"草稿列表"区块;用户无法查看、恢复、删除草稿 - 自动保存创建的草稿一旦离开页面即"丢失"(数据库存在但 UI 不可见) **后果**:草稿功能闭环不完整;用户感知"自动保存"无效;数据库持续堆积无主草稿。 ### 2.7 【P1 - 功能缺失】"星标"筛选 Tab 缺失 | 位置 | 问题 | 违反规则 | |------|------|----------| | [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L23 `type Tab = "inbox" \| "sent"` | 缺少 `starred` Tab,但 data-access 支持 `starredOnly` 筛选 | 可扩展性:"配置驱动设计" | **问题分析**: - `getMessages` 已支持 `starredOnly: true` 筛选 - `toggleMessageStarAction` 允许接收方星标消息 - i18n 已准备 `empty.noStarred` / `actions.star` / `actions.unstar` 翻译键 - 但 MessageList 仅有 inbox / sent 两个 Tab,星标消息只能逐条浏览无法快速筛选 - "星标"语义在 K12 场景下对应"重要消息",是家长/教师高优先级信息检索的核心入口 **后果**:星标功能存在但 UX 不可用;用户无法快速查找重要消息。 ### 2.8 【P1 - 功能缺失】消息线程 UI 完全缺失 | 位置 | 问题 | 违反规则 | |------|------|----------| | [message-detail.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-detail.tsx) 全文 | 仅渲染根消息,不渲染 `getMessageThread` 返回的回复链 | 行业差距:K12 标准产品(Remind / ClassDojo / ParentSquare)均支持会话视图 | **问题分析**: - `getMessageThread` 已实现并返回 `Message[]`(根 + 回复) - `Message` 类型有 `parentMessageId` 字段支持回复链 - `MessageCompose` 支持 `parentMessageId` 参数创建回复 - 但 `MessageDetail` 仅渲染单条 `message`,不获取也不渲染线程 - 用户回复后无法看到完整对话,只能逐条点开"详情"查看 **后果**:沟通上下文丢失;教师/家长需要记忆完整对话;多轮沟通效率低。 ### 2.9 【P1 - i18n 遗漏】`MessageList` 分页 aria-label 硬编码英文 | 位置 | 代码 | 违反规则 | |------|------|----------| | [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L230 `aria-label="Previous page"`、L242 `aria-label="Next page"`、L224 `aria-label={t("tabs.inbox")}` 用作分页导航 label | 硬编码英文字符串;分页 nav 的 aria-label 错误使用 "tabs.inbox" 翻译 | i18n 规范:"所有用户可见文本必须适配 i18n" | **问题分析**: - 屏幕阅读器在中文环境下朗读 "Previous page" 而非"上一页" - 分页 nav 的 aria-label 使用 `t("tabs.inbox")`("收件箱"),与"分页"语义无关,无障碍用户感知混乱 - 搜索框加载状态未使用 `aria-busy` - 星标切换按钮未设置 `aria-pressed` 反映开关状态 **后果**:a11y 体验降级;中文用户依赖屏幕阅读器时无法理解控件用途。 ### 2.10 【P1 - 跨模块依赖】`UnreadMessageBadge` 仍用轮询,未复用 notifications 模块的 SSE 实时通道 | 位置 | 代码 | 违反规则 | |------|------|----------| | [unread-message-badge.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/unread-message-badge.tsx) L19 `POLL_INTERVAL_MS = 30_000` | 每 30 秒轮询 `getUnreadMessageCountAction`,notifications 模块已实现 SSE 实时推送 | 企业级:"支持流式渲染";性能 | **问题分析**: - notifications 模块已有 `/api/notifications/stream` SSE 端点 + `useNotificationStream` hook - SSE 端点目前仅推送通知数据,未含私信未读数 - `UnreadMessageBadge` 每 30 秒发起一次 Server Action 调用 → 数据库 COUNT 查询 - 多用户并发时形成规律性 DB 压力(N 用户 × 2 次/分钟 = 2N 次/分钟) - 组件注释已识别此问题但未实施 **后果**:实时性差(最坏延迟 30 秒);DB 压力随用户数线性增长;用户体验与通知模块割裂。 ### 2.11 【P1 - 类型安全】`getRecipients` 返回的 `role` 字段使用字符串字面量 | 位置 | 代码 | 违反规则 | |------|------|----------| | [data-access.ts](file:///e:/Desktop/CICD/src/modules/messaging/data-access.ts) L243/254/262/272 `role: "student"` / `role: "teacher"` | `RecipientOption.role` 类型为 `string?`,丢失角色枚举语义 | TypeScript 规则:"禁止 `any`;函数返回值必须显式标注" | **问题分析**: - `RecipientOption.role?: string` 类型过宽,调用方无法依赖具体角色值 - 实际只赋值 `"student"` 或 `"teacher"`,但类型系统不知情 - 调用方 `MessageCompose` 渲染 `{r.role ? ` (${r.role})` : ""}`,无法做角色图标/颜色映射 - admin 角色未在 `getRecipients` 中标识(scope.type==="all" 分支未设 role) **后果**:类型安全缺失;UI 无法基于角色做差异化展示;admin 收件人列表无角色信息。 ### 2.12 【P1 - 可测试性】`MessageList` 直接 import `getMessagesAction` 与 `toggleMessageStarAction` | 位置 | 代码 | 违反规则 | |------|------|----------| | [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L19 `import { getMessagesAction, toggleMessageStarAction } from "../actions"` | 组件直接依赖具体 Action 实现,未通过接口注入 | 重构方案原则:"通过定义 TypeScript 接口抽象数据依赖,使用 React Context(或组合 Provider)注入数据服务" | **问题分析**: - `useMessageSearch` hook 已支持 `searchAction` 参数注入(良好) - 但 `MessageList` 在调用处直接传入 `getMessagesAction`,且 `handleToggleStar` 直接调用 `toggleMessageStarAction` - 单元测试 `MessageList` 必须 mock 模块导入(`vi.mock("../actions")`),无法通过 props 替换 - 未来支持多角色时(如 admin 群发 vs 学生单发)无法注入不同实现 **后果**:测试需依赖 mock 框架而非依赖注入;扩展角色场景需修改组件。 ### 2.13 【P1 - UX】`replyHref` 构建逻辑放在组件内且 URL 参数未编码 | 位置 | 代码 | 违反规则 | |------|------|----------| | [message-detail.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-detail.tsx) L86-90 `replyHref` | 在组件内拼接 URL;`receiverId` 未 `encodeURIComponent`;"Re:" 前缀逻辑硬编码 | 可测试性:"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks" | **问题分析**: - `subject` 用了 `encodeURIComponent`,`receiverId` 没有(虽然 cuid 一般无特殊字符,但防御性缺失) - "Re:" 前缀判断逻辑 `message.subject?.startsWith("Re:")` 在组件内 - 多次回复会产生 `Re: Re: Re:` 链,UI 丑陋 - 此逻辑应抽取为纯函数 `buildReplyHref(message)` 便于测试 **后果**:逻辑散落;测试需渲染组件;多回复标题累积。 ### 2.14 【P1 - 一致性】`MessageList` `initialType` 默认 "inbox" 与页面传入 `type:"all"` 数据不匹配 | 位置 | 代码 | 违反规则 | |------|------|----------| | [message-list.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/message-list.tsx) L40 `useState(initialType === "sent" ? "sent" : "inbox")` | `initialType` 默认 "inbox",但页面调用 `getMessagesPageData` 传入 `type:"all"`,组件内 `isUsingInitial` 分支再按 tab 客户端过滤 | 一致性 + 性能 | **问题分析**: - 服务端获取 type=all 的 50 条数据 - 客户端根据 tab=inbox 过滤出 receiverId===currentUserId 的子集 - 用户切换到 sent Tab 时,`useMessageSearch` 触发服务端搜索 type=sent(因 keyword 为空时 `isUsingInitial=true`,但搜索逻辑仅在 keyword 非空时触发) - 实际行为:用户点 sent Tab 时,列表显示客户端过滤后的 sent 子集;用户输入关键字搜索时才走服务端 - 这种"客户端过滤初始数据 + 服务端搜索关键字"的双轨制容易导致边界 bug(如初始数据不含已删除但客户端过滤后误显示) **后果**:行为不一致;潜在边界 bug;理解成本高。 ### 2.15 【P2 - 性能】`UnreadMessageBadge` 卸载时不取消 in-flight 请求 | 位置 | 代码 | 违反规则 | |------|------|----------| | [unread-message-badge.tsx](file:///e:/Desktop/CICD/src/modules/messaging/components/unread-message-badge.tsx) L24-45 `useEffect` | `active` 标志阻止 `setCount`,但 `fetchCount` 已发起的 Promise 无法取消 | 性能 | **问题分析**: - 组件卸载时 `clearInterval` 停止后续轮询 - 但若卸载发生在 `getUnreadMessageCountAction` 调用后、Promise resolve 前,Server Action 仍会执行完成 - `active=false` 仅阻止 setState,不阻止网络请求和 DB 查询 - 快速路由跳转会累积 orphaned Promises **后果**:轻微资源浪费;高并发跳转场景下 DB 压力瞬时增加。 ### 2.16 【P2 - UX】无批量操作 | 位置 | 问题 | 违反规则 | |------|------|----------| | `message-list.tsx` 全文 | 无多选、无批量已读、无批量删除 | 行业差距 | **问题分析**: - K12 教师每学期可能收到数百条家长消息 - 当前 UI 仅支持逐条操作 - 缺少"全部已读"、"批量归档"等效率功能 - 通知模块已有"全部标记已读",私信模块未对齐 **后果**:教师处理消息效率低;重要消息被淹没。 ### 2.17 【P2 - 监控】`sendMessageAction` 失败时无告警埋点 | 位置 | 代码 | 违反规则 | |------|------|----------| | [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L90-94 catch 块 | 仅返回 `{success:false, message}`,未调用 `trackEvent` 记录失败 | 监控:"方案中预留关键操作埋点接口" | **问题分析**: - 成功路径有 `trackEvent("message.sent")` - 失败路径(DB 异常、权限拒绝)无埋点 - 无法监控发送失败率、权限拒绝率 - 用户上报"消息发不出去"时无法在监控中复现 **后果**:故障不可观测;SLA 无法度量。 ### 2.18 【P2 - 安全】`parentMessageId` 未校验属于当前用户会话 | 位置 | 代码 | 违反规则 | |------|------|----------| | [actions.ts](file:///e:/Desktop/CICD/src/modules/messaging/actions.ts) L55-61 `createMessage` 调用 | `parentMessageId` 来自 FormData,未校验是否属于当前用户参与的会话 | 安全规范 | **问题分析**: - 用户 A 构造 FormData `{receiverId: B, parentMessageId: msg_between_C_and_D}` 创建消息 - 消息会插入 `parentMessageId = msg_between_C_and_D` - `getMessageThread(msg_between_C_and_D)` 会将此消息加入他人会话线程 - 越权注入会话上下文 **后果**:会话上下文污染;隐私泄露。 --- ## 三、行业差距对比 参考 K12 教育沟通类主流产品(Remind、ClassDojo、ParentSquare、Bloomz、Classting、智学网家校通、企业微信家校应用)总结差距: | 维度 | 行业优秀实践 | 当前实现 | 影响 | |------|-------------|---------|------| | **会话视图** | Remind / ParentSquare 以"会话"为单元,单页内展示完整来回对话,支持滚动加载历史 | 单条详情 + 单独"回复"链接跳转 compose 页 | 多轮沟通上下文丢失;用户需记忆对话;教师回复效率降低 50%+ | | **实时推送** | Remind / ClassDojo 全部基于 push(FCM/APN/WebSocket),消息送达延迟 < 2 秒 | 30 秒轮询;SSE 端点未覆盖私信 | 实时性差;高峰期 DB 负担;移动端电池消耗 | | **草稿恢复** | Gmail / Outlook 在 compose 入口直接列出未完成草稿,支持一键继续 | 草稿自动保存但无列表 UI;用户离开后草稿"消失" | 用户感知"自动保存无效";重要内容丢失风险 | | **重要消息标记** | ParentSquare 支持"标星 + 旗标 + 优先级"三维度筛选;侧边栏快捷入口"已标星" | data-access 支持星标筛选但 UI 未暴露 | 用户无法快速检索重要消息 | | **批量操作** | 企业微信家校应用、Outlook 支持"全选 + 批量已读 + 批量归档" | 仅逐条操作 | 教师每学期处理数百条消息效率低 | | **群组消息** | Remind 支持"班级群发"(教师→全班家长,家长单独回复) | 仅 1v1 私信;班级通知由 announcements 模块覆盖但无家长回复通道 | 家长-教师群组沟通需第三方工具 | | **附件支持** | Remind / ClassDojo 支持图片、文档、语音附件 | 纯文本消息 | 作业截图、家访照片等无法通过私信传输 | | **已读回执** | WhatsApp / 企业微信显示"已读"小蓝双勾 | 有 `readAt` 字段但 UI 仅显示"阅读于 xxx" | 发送方无法快速确认对方是否已读 | | **输入指示器** | iMessage / 微信显示"对方正在输入..." | 无 | 实时沟通场景下体验割裂 | | **家长侧角色感知** | ParentSquare 列表中显示"数学老师"、"班主任"角色 tag | `role` 字段为 "teacher" 字符串,无学科/职务信息 | 家长无法识别消息来自哪位老师 | | **草稿冲突** | Gmail 跨设备草稿同步 + 冲突检测 | 仅本地自动保存,无冲突处理 | 多设备切换时草稿丢失 | | **消息撤回** | 企业微信支持 2 分钟内撤回 | 无撤回功能 | 教师误发后只能删除(仅对自己删除) | | **举报 / 屏蔽** | 主流产品支持举报不当内容 + 屏蔽用户 | 无 | K12 合规风险(家长-教师纠纷) | | **快捷回复模板** | 教师场景常用"已收到"、"稍后回复"等模板 | 无 | 教师重复输入相同内容 | | **搜索范围** | Outlook 支持按发件人、日期、附件、已读状态组合搜索 | 仅 subject + content 关键词搜索 | 历史消息检索困难 | --- ## 四、改进优先级建议 ### P0(紧急 - 安全/正确性/死代码) | 编号 | 标题 | 改进方向 | |------|------|---------| | P0-1 | `sendMessageAction` 增加 receiverId 二次校验 | 在 Action 中调用 `getRecipients(ctx.userId, ctx.dataScope)`,断言 `receiverId ∈ recipients[].id`;或抽取 `isReceiverAllowed(userId, receiverId, scope)` 纯函数 | | P0-2 | `getMessageThread` 增加 userId 参数 + 权限校验 | 修改签名为 `getMessageThread(messageId, userId)`,where 子句加 `(senderId=userId OR receiverId=userId)` | | P0-3 | `getMessagesPageData` 编排迁出 data-access | 删除此函数;页面层 `Promise.all([getMessages(...), getNotifications(...)])`(架构规则允许 app 调用多个 data-access) | | P0-4 | 删除死代码 `getMessageDetailAction` | 直接删除;同步架构图 004/005 | | P0-5 | `MessageCompose` 自动保存竞态修复 | 提交时设置 `submittedRef`;自动保存 effect 检查 `submittedRef` 跳过;卸载时清理 timeout | | P0-6 | `parentMessageId` 校验属于当前用户 | 在 Action 中调用 `getMessageById(parentMessageId, ctx.userId)`,断言返回非 null | ### P1(重要 - 功能/UX/i18n/性能) | 编号 | 标题 | 改进方向 | |------|------|---------| | P1-1 | compose 页面新增"草稿列表"区块 | 调用 `getMessageDraftsAction` 渲染列表;支持"继续编辑"(带 draftId 参数回到 compose)+"删除草稿" | | P1-2 | MessageList 新增"星标" Tab | Tab 增 `starred`;调用 `getMessagesAction({type:"all", starredOnly:true})`;复用 `useMessageSearch` | | P1-3 | MessageDetail 渲染消息线程 | 调用 `getMessageThread(messageId, userId)` 渲染回复链;新增 `MessageThread` 子组件;时间正序展示 | | P1-4 | 分页 aria-label i18n 化 + 修正语义 | 新增 i18n 键 `pagination.nav` / `pagination.previous` / `pagination.next`;星标按钮加 `aria-pressed` | | P1-5 | `UnreadMessageBadge` 接入 SSE | 扩展 `/api/notifications/stream` 端点 payload 含 `unreadMessageCount`;hook 订阅;轮询降级保留 | | P1-6 | `RecipientOption.role` 类型收紧 | 新增 `RecipientRole = "student" \| "teacher" \| "admin" \| "parent"` 类型;admin 分支补 role | | P1-7 | `replyHref` 逻辑抽取为纯函数 | 新增 `buildReplyHref(message, canSend): string \| undefined`;用 `URLSearchParams` 构建参数;纯函数单元测试 | | P1-8 | `MessageList` 依赖注入化 | 新增 `MessageListService` 接口(`search` + `toggleStar`);通过 props 注入;默认实现调用现有 Action | | P1-9 | `sendMessageAction` 失败埋点 | catch 块调用 `trackEvent({event:"message.send_failed", reason})` | ### P2(中长期 - 架构/扩展性) | 编号 | 标题 | 改进方向 | |------|------|---------| | P2-1 | 批量操作(已读/删除/星标) | 新增 `bulkMarkReadAction` / `bulkDeleteAction` / `bulkToggleStarAction`;MessageList 加多选模式 | | P2-2 | 群组消息(教师→全班家长) | 新增 `group_messages` 表 + `sendGroupMessageAction`;家长侧聚合为"会话" | | P2-3 | 附件支持 | 新增 `message_attachments` 表 + 上传走 shared/storage;compose 加附件区 | | P2-4 | 消息撤回 | 新增 `recallMessageAction`(2 分钟窗口);UI 显示"已撤回"占位 | | P2-5 | 举报 + 屏蔽 | 新增 `message_reports` 表 + `reportMessageAction` / `blockUserAction` | | P2-6 | 快捷回复模板 | 用户偏好存储模板;compose 加"插入模板"下拉 | | P2-7 | 配置驱动的角色组合 | 定义 `MessagingRoleConfig` 接口;admin/teacher/parent/student 各自实现;通过 Context 注入;新增角色仅新增配置 | | P2-8 | Error Boundary 细化 | 列表区/详情区/草稿区分别包裹 ErrorBoundary;局部失败不影响整体 | | P2-9 | React Suspense 流式渲染 | RSC 使用 `Suspense` 包裹 MessageList;fallback 用骨架屏;初始数据流式传输 | | P2-10 | 跨设备草稿同步 | 草稿表加 `deviceId` + `version` 字段;冲突时取最新 | | P2-11 | 组合搜索(按发件人/日期/已读) | `getMessages` 扩展 `senderId` / `dateRange` / `isRead` 参数;UI 加高级搜索面板 | --- ## 五、架构图同步说明 ### 需要补充/修改的节点 | 文档 | 位置 | 修改类型 | 说明 | |------|------|---------|------| | 004 §2.13 | 导出函数 - Actions | 删除 | 移除 `getMessageDetailAction`(P0-4 死代码) | | 004 §2.13 | Data-access - `getMessagesPageData` | 删除 | 移除此编排函数(P0-3 迁出至页面层) | | 004 §2.13 | Data-access - `getMessageThread` | 修改签名 | `(messageId: string)` → `(messageId: string, userId: string)`(P0-2) | | 004 §2.13 | Data-access - 新增 `isReceiverAllowed` | 新增 | 校验收件人是否在 sender DataScope(P0-1) | | 004 §2.13 | 已知问题 | 新增 | 记录 P0-1 ~ P0-6 修复状态 | | 004 §2.13 | 文件清单 | 更新行数 | 删除 `getMessageDetailAction` 后 actions.ts 行数变化 | | 005 §messaging.actions[] | `getMessageDetailAction` 节点 | 删除 | 同步删除 | | 005 §messaging.dataAccess[] | `getMessagesPageData` 节点 | 删除 | 同步删除 | | 005 §messaging.dataAccess[] | `getMessageThread` 节点 | 更新签名 | `userId` 参数 + 权限校验 | | 005 §messaging.dataAccess[] | 新增 `isReceiverAllowed` 节点 | 新增 | P0-1 | | 005 §messaging.routes[] | `/messages/compose` | 更新 usedBy | 新增 `getMessageDraftsAction` 真实调用(P1-1) | | 005 §dependencyMatrix | messaging → notifications | 降级 | 删除 `getMessagesPageData` 后,messaging data-access 不再直接依赖 notifications(仅 actions 层通过 dispatcher 调用) | --- ## 六、实施清单(本次将完成) > 本审计报告撰写完成后,将立即实施以下编号的改进,并同步更新架构图 004/005。 ### 即时实施(P0 全部 + P1 关键) - [x] **P0-1** `sendMessageAction` 增加 receiverId 二次校验 - [x] **P0-2** `getMessageThread` 增加 userId 参数 + 权限校验 - [x] **P0-3** `getMessagesPageData` 编排迁出 data-access(页面层 `Promise.all`) - [x] **P0-4** 删除死代码 `getMessageDetailAction` - [x] **P0-5** `MessageCompose` 自动保存竞态修复 - [x] **P0-6** `parentMessageId` 校验属于当前用户会话 - [x] **P1-1** compose 页面新增"草稿列表"区块 - [x] **P1-2** MessageList 新增"星标" Tab - [x] **P1-3** MessageDetail 渲染消息线程 - [x] **P1-4** 分页 aria-label i18n 化 + 修正语义 - [x] **P1-6** `RecipientOption.role` 类型收紧 - [x] **P1-7** `replyHref` 逻辑抽取为纯函数 - [x] **P1-9** `sendMessageAction` 失败埋点 ### 推后实施(P1-5 SSE / P1-8 依赖注入 + P2 全部) P1-5(SSE 端点扩展)、P1-8(依赖注入化)、P2-1 ~ P2-11 为中长期架构演进,需独立设计与排期,本次不实施,将在下一迭代单独推进。 --- ## 七、重构方案设计(针对 P0/P1 即时实施项) ### 7.1 P0-1:`sendMessageAction` receiverId 二次校验 **新增纯函数** `isReceiverAllowed(userId, receiverId, scope): Promise`(data-access 层): ```typescript // data-access.ts 新增 export async function isReceiverAllowed( userId: string, receiverId: string, scope: DataScope ): Promise { const recipients = await getRecipients(userId, scope) return recipients.some((r) => r.id === receiverId) } ``` **Action 层调用**: ```typescript // actions.ts sendMessageAction const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope) if (!allowed) { return { success: false, message: "Recipient not allowed" } } ``` ### 7.2 P0-2:`getMessageThread` 加 userId 校验 ```typescript // data-access.ts export const getMessageThread = cache( async (messageId: string, userId: string): Promise => { const [root] = await db .select() .from(messages) .where( and( eq(messages.id, messageId), or(eq(messages.senderId, userId), eq(messages.receiverId, userId)) ) ) .limit(1) if (!root) return [] const replies = await db .select() .from(messages) .where(eq(messages.parentMessageId, messageId)) .orderBy(desc(messages.createdAt)) const allRows = [root, ...replies] const nameMap = await resolveUserNames(allRows.flatMap((r) => [r.senderId, r.receiverId])) return allRows.map((r) => mapMessage(r, nameMap)) } ) ``` ### 7.3 P0-3:`getMessagesPageData` 迁出 ```typescript // app/(dashboard)/messages/page.tsx import { getMessages } from "@/modules/messaging/data-access" import { getNotifications } from "@/modules/notifications/data-access" const [messagesResult, notificationsResult] = await Promise.all([ getMessages({ userId: ctx.userId, type: "all", page: 1, pageSize: 50 }), getNotifications(ctx.userId, { page: 1, pageSize: 20 }), ]) ``` ### 7.4 P0-5:`MessageCompose` 自动保存竞态修复 ```typescript // message-compose.tsx const submittedRef = useRef(false) useEffect(() => { if (submittedRef.current) return // 提交后不再自动保存 if (!subject.trim() && !content.trim()) return // ... 原有逻辑 }, [subject, content, receiverId, parentMessageId]) const handleSubmit = async (formData: FormData) => { submittedRef.current = true // ... 原有逻辑 } ``` ### 7.5 P0-6:`parentMessageId` 校验 ```typescript // sendMessageAction if (input.parentMessageId) { const parent = await getMessageById(input.parentMessageId, ctx.userId) if (!parent) { return { success: false, message: "Parent message not accessible" } } } ``` ### 7.6 P1-1:草稿列表 UI 新增 `MessageDraftList` 组件,在 compose 页面下方渲染: ```tsx // components/message-draft-list.tsx "use client" export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) { const t = useTranslations("messages") // 列表 + 继续编辑链接 + 删除按钮 } ``` compose page 调用 `getMessageDraftsAction` 获取数据并传入。 ### 7.7 P1-2:MessageList 星标 Tab ```tsx type Tab = "inbox" | "sent" | "starred" // 切换到 starred 时调用 getMessagesAction({ type: "all", starredOnly: true }) ``` ### 7.8 P1-3:消息线程 UI ```tsx // message-detail.tsx 内 const [thread, setThread] = useState([]) useEffect(() => { void getMessageThreadAction(message.id).then(setThread) }, [message.id]) // 渲染:根消息 + 回复时间线 ``` 新增 `getMessageThreadAction` Server Action(替代直接调用 data-access,保持权限边界)。 ### 7.9 P1-4:i18n 键新增 ```json // messages.json "pagination": { "nav": "消息分页", "previous": "上一页", "next": "下一页", "page": "第 {current} / {total} 页" } ``` ### 7.10 P1-6:`RecipientRole` 类型 ```typescript // types.ts export type RecipientRole = "student" | "teacher" | "admin" | "parent" export interface RecipientOption { id: string name: string email: string role?: RecipientRole } ``` ### 7.11 P1-7:`buildReplyHref` 纯函数 ```typescript // lib/build-reply-href.ts export function buildReplyHref( message: Message, canSend: boolean ): string | undefined { if (!canSend) return undefined const params = new URLSearchParams() params.set("parentId", message.id) params.set( "receiverId", message.receiverId === /* currentUserId */ "" ? message.senderId : message.receiverId ) const subject = message.subject?.startsWith("Re:") ? message.subject : `Re: ${message.subject ?? ""}` params.set("subject", subject) return `/messages/compose?${params.toString()}` } ``` > 注:`buildReplyHref` 需接收 `currentUserId` 参数以判断回复方向。 ### 7.12 P1-9:失败埋点 ```typescript // actions.ts sendMessageAction catch 块 void trackEvent({ event: "message.send_failed", userId: ctx.userId, targetType: "message", properties: { receiverId: input.receiverId, reason: e instanceof Error ? e.message : "unknown", }, }) ```