Files
NextEdu/docs/architecture/audit/archive/messaging-audit-report.md

35 KiB
Raw Blame History

私信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 过滤 + 客户端分页)
  → NotificationListnotifications 模块组件)

[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:架构图标注 getMessageDetailActionusedBymessage-detail.tsx,但实际代码中 message-detail.tsx 从未调用 getMessageDetailAction(详情数据由 RSC 通过 getMessageDetailPageData 直接获取)
  • 不一致点 2:架构图标注 getMessageDraftsActionusedBymessages/compose/page.tsx,但实际 compose/page.tsx 从不调用 getMessageDraftsAction(草稿仅在客户端自动保存,未提供"草稿列表"UI
  • 不一致点 3:架构图 §2.13 文件清单中 actions.ts 标注 "~330 行 11 个私信 Server Action",但实际包含 1 个完全未被引用的死代码 ActiongetMessageDetailAction
  • 不一致点 4:架构图标注 getMessageThread 的签名是 (rootId: string, userId: string),实际代码签名是 (messageId: string)未做 userId 权限校验

二、现存问题与原因分析

2.1 【P0 - 安全】createMessage 未校验 receiverId 是否在 sender 的 DataScope 内

位置 代码 违反规则
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 L156-169 getMessageThread eq(messages.id, messageId) + eq(messages.parentMessageId, messageId),未过滤 senderId/receiverId 安全规范:"data-access 层结合当前用户权限过滤"

问题分析

  • 函数签名是 getMessageThread(messageId: string),无 userId 参数
  • 任意调用方传入合法 messageId 即可读取整个回复链
  • 当前调用方 getMessageDetailActionactions.ts:177)虽先调用 getMessageById(id, ctx.userId) 校验根消息访问权,但 getMessageThread 本身缺乏防御深度
  • 若未来有新调用方如导出、统计、AI 摘要)误用 getMessageThread,将直接泄露隐私

后果:纵深防御缺失;隐私消息可被未授权读取。

2.3 【P0 - 架构】getMessagesPageData 在 data-access 层动态 import 跨模块

位置 代码 违反规则
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 L167-190 getMessageDetailAction 定义后从未被任何组件、页面、其他 Action 调用 编码规范:"避免 backwards-compatibility hacks... 如果确定某物未使用,可以完全删除"

问题分析

  • 全代码库 grep getMessageDetailAction 仅命中 actions.ts 自身、架构图 004/005、历史 bug 文档
  • 详情页使用 getMessageDetailPageDatadata-access 编排函数)替代
  • 详情页组件 MessageDetail 通过 props 接收 message,不调用此 Action
  • 此 Action 是 V2-P1-3 重构时遗留的死代码

后果:维护负担;架构图与代码不一致;新人误以为是 API 入口。

2.5 【P0 - 安全/正确性】MessageCompose 自动保存草稿在 useEffect 闭包中读 draftIdRef,但 subject/content 仍触发新 effect

位置 代码 违反规则
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 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 全文 仅渲染根消息,不渲染 getMessageThread 返回的回复链 行业差距K12 标准产品Remind / ClassDojo / ParentSquare均支持会话视图

问题分析

  • getMessageThread 已实现并返回 Message[](根 + 回复)
  • Message 类型有 parentMessageId 字段支持回复链
  • MessageCompose 支持 parentMessageId 参数创建回复
  • MessageDetail 仅渲染单条 message,不获取也不渲染线程
  • 用户回复后无法看到完整对话,只能逐条点开"详情"查看

后果:沟通上下文丢失;教师/家长需要记忆完整对话;多轮沟通效率低。

2.9 【P1 - i18n 遗漏】MessageList 分页 aria-label 硬编码英文

位置 代码 违反规则
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 L19 POLL_INTERVAL_MS = 30_000 每 30 秒轮询 getUnreadMessageCountActionnotifications 模块已实现 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 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 getMessagesActiontoggleMessageStarAction

位置 代码 违反规则
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 L86-90 replyHref 在组件内拼接 URLreceiverIdencodeURIComponent"Re:" 前缀逻辑硬编码 可测试性:"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks"

问题分析

  • subject 用了 encodeURIComponentreceiverId 没有(虽然 cuid 一般无特殊字符,但防御性缺失)
  • "Re:" 前缀判断逻辑 message.subject?.startsWith("Re:") 在组件内
  • 多次回复会产生 Re: Re: Re:UI 丑陋
  • 此逻辑应抽取为纯函数 buildReplyHref(message) 便于测试

后果:逻辑散落;测试需渲染组件;多回复标题累积。

2.14 【P1 - 一致性】MessageList initialType 默认 "inbox" 与页面传入 type:"all" 数据不匹配

位置 代码 违反规则
message-list.tsx L40 useState<Tab>(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 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 L90-94 catch 块 仅返回 {success:false, message},未调用 trackEvent 记录失败 监控:"方案中预留关键操作埋点接口"

问题分析

  • 成功路径有 trackEvent("message.sent")
  • 失败路径DB 异常、权限拒绝)无埋点
  • 无法监控发送失败率、权限拒绝率
  • 用户上报"消息发不出去"时无法在监控中复现

后果故障不可观测SLA 无法度量。

2.18 【P2 - 安全】parentMessageId 未校验属于当前用户会话

位置 代码 违反规则
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 全部基于 pushFCM/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 含 unreadMessageCounthook 订阅;轮询降级保留
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 / bulkToggleStarActionMessageList 加多选模式
P2-2 群组消息(教师→全班家长) 新增 group_messages 表 + sendGroupMessageAction;家长侧聚合为"会话"
P2-3 附件支持 新增 message_attachments 表 + 上传走 shared/storagecompose 加附件区
P2-4 消息撤回 新增 recallMessageAction2 分钟窗口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 包裹 MessageListfallback 用骨架屏;初始数据流式传输
P2-10 跨设备草稿同步 草稿表加 deviceId + version 字段;冲突时取最新
P2-11 组合搜索(按发件人/日期/已读) getMessages 扩展 senderId / dateRange / isRead 参数UI 加高级搜索面板

五、架构图同步说明

需要补充/修改的节点

文档 位置 修改类型 说明
004 §2.13 导出函数 - Actions 删除 移除 getMessageDetailActionP0-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 DataScopeP0-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 降级 删除 getMessagesPageDatamessaging data-access 不再直接依赖 notifications仅 actions 层通过 dispatcher 调用)

六、实施清单(本次将完成)

本审计报告撰写完成后,将立即实施以下编号的改进,并同步更新架构图 004/005。

即时实施P0 全部 + P1 关键)

  • P0-1 sendMessageAction 增加 receiverId 二次校验
  • P0-2 getMessageThread 增加 userId 参数 + 权限校验
  • P0-3 getMessagesPageData 编排迁出 data-access页面层 Promise.all
  • P0-4 删除死代码 getMessageDetailAction
  • P0-5 MessageCompose 自动保存竞态修复
  • P0-6 parentMessageId 校验属于当前用户会话
  • P1-1 compose 页面新增"草稿列表"区块
  • P1-2 MessageList 新增"星标" Tab
  • P1-3 MessageDetail 渲染消息线程
  • P1-4 分页 aria-label i18n 化 + 修正语义
  • P1-6 RecipientOption.role 类型收紧
  • P1-7 replyHref 逻辑抽取为纯函数
  • P1-9 sendMessageAction 失败埋点

推后实施P1-5 SSE / P1-8 依赖注入 + P2 全部)

P1-5SSE 端点扩展、P1-8依赖注入化、P2-1 ~ P2-11 为中长期架构演进,需独立设计与排期,本次不实施,将在下一迭代单独推进。


七、重构方案设计(针对 P0/P1 即时实施项)

7.1 P0-1sendMessageAction receiverId 二次校验

新增纯函数 isReceiverAllowed(userId, receiverId, scope): Promise<boolean>data-access 层):

// data-access.ts 新增
export async function isReceiverAllowed(
  userId: string,
  receiverId: string,
  scope: DataScope
): Promise<boolean> {
  const recipients = await getRecipients(userId, scope)
  return recipients.some((r) => r.id === receiverId)
}

Action 层调用

// 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-2getMessageThread 加 userId 校验

// data-access.ts
export const getMessageThread = cache(
  async (messageId: string, userId: string): Promise<Message[]> => {
    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-3getMessagesPageData 迁出

// 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-5MessageCompose 自动保存竞态修复

// 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-6parentMessageId 校验

// 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 页面下方渲染:

// components/message-draft-list.tsx
"use client"
export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
  const t = useTranslations("messages")
  // 列表 + 继续编辑链接 + 删除按钮
}

compose page 调用 getMessageDraftsAction 获取数据并传入。

7.7 P1-2MessageList 星标 Tab

type Tab = "inbox" | "sent" | "starred"

// 切换到 starred 时调用
getMessagesAction({ type: "all", starredOnly: true })

7.8 P1-3消息线程 UI

// message-detail.tsx 内
const [thread, setThread] = useState<Message[]>([])
useEffect(() => {
  void getMessageThreadAction(message.id).then(setThread)
}, [message.id])

// 渲染:根消息 + 回复时间线

新增 getMessageThreadAction Server Action替代直接调用 data-access保持权限边界

7.9 P1-4i18n 键新增

// messages.json
"pagination": {
  "nav": "消息分页",
  "previous": "上一页",
  "next": "下一页",
  "page": "第 {current} / {total} 页"
}

7.10 P1-6RecipientRole 类型

// types.ts
export type RecipientRole = "student" | "teacher" | "admin" | "parent"

export interface RecipientOption {
  id: string
  name: string
  email: string
  role?: RecipientRole
}

7.11 P1-7buildReplyHref 纯函数

// 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失败埋点

// 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",
  },
})