# 私信（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<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 全部基于 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<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-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<Message[]>([])
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",
  },
})
```
