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

649 lines
35 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 私信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**:架构图标注 `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<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](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 全部基于 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 含 `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/storagecompose 加附件区 |
| 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` 包裹 MessageListfallback 用骨架屏;初始数据流式传输 |
| 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 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 | 降级 | 删除 `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-5SSE 端点扩展、P1-8依赖注入化、P2-1 ~ P2-11 为中长期架构演进,需独立设计与排期,本次不实施,将在下一迭代单独推进。
---
## 七、重构方案设计(针对 P0/P1 即时实施项)
### 7.1 P0-1`sendMessageAction` receiverId 二次校验
**新增纯函数** `isReceiverAllowed(userId, receiverId, scope): Promise<boolean>`data-access 层):
```typescript
// 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 层调用**
```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<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` 迁出
```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-2MessageList 星标 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<Message[]>([])
useEffect(() => {
void getMessageThreadAction(message.id).then(setThread)
}, [message.id])
// 渲染:根消息 + 回复时间线
```
新增 `getMessageThreadAction` Server Action替代直接调用 data-access保持权限边界
### 7.9 P1-4i18n 键新增
```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",
},
})
```