649 lines
35 KiB
Markdown
649 lines
35 KiB
Markdown
# 私信(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",
|
||
},
|
||
})
|
||
```
|