35 KiB
私信(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 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 即可读取整个回复链
- 当前调用方
getMessageDetailAction(actions.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 文档 - 详情页使用
getMessageDetailPageData(data-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 秒轮询 getUnreadMessageCountAction,notifications 模块已实现 SSE 实时推送 |
企业级:"支持流式渲染";性能 |
问题分析:
- notifications 模块已有
/api/notifications/streamSSE 端点 +useNotificationStreamhook - 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 getMessagesAction 与 toggleMessageStarAction
| 位置 | 代码 | 违反规则 |
|---|---|---|
message-list.tsx L19 import { getMessagesAction, toggleMessageStarAction } from "../actions" |
组件直接依赖具体 Action 实现,未通过接口注入 | 重构方案原则:"通过定义 TypeScript 接口抽象数据依赖,使用 React Context(或组合 Provider)注入数据服务" |
问题分析:
useMessageSearchhook 已支持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 |
在组件内拼接 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 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 全部基于 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 关键)
- 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-5(SSE 端点扩展)、P1-8(依赖注入化)、P2-1 ~ P2-11 为中长期架构演进,需独立设计与排期,本次不实施,将在下一迭代单独推进。
七、重构方案设计(针对 P0/P1 即时实施项)
7.1 P0-1:sendMessageAction 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-2:getMessageThread 加 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-3:getMessagesPageData 迁出
// 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 自动保存竞态修复
// 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 校验
// 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-2:MessageList 星标 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-4:i18n 键新增
// messages.json
"pagination": {
"nav": "消息分页",
"previous": "上一页",
"next": "下一页",
"page": "第 {current} / {total} 页"
}
7.10 P1-6:RecipientRole 类型
// 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 纯函数
// 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",
},
})