docs(audit): add audit reports for grades, homework, lesson-preparation, messaging, permissions, question-bank, settings, textbooks

- Add grades-audit-report

- Add homework-audit-report and homework-exams-audit-report

- Add lesson-preparation-audit-report-v3 and v4

- Add messaging-audit-report

- Add permissions-audit-report

- Add question-bank-audit-report

- Add settings-profile-audit-report-v3

- Add textbooks-audit-report-v3
This commit is contained in:
SpecialX
2026-07-03 10:23:34 +08:00
parent 365c36d97b
commit 89b9e181d2
29 changed files with 13009 additions and 230 deletions

View File

@@ -0,0 +1,392 @@
# 公告announcements模块审计报告
> 审查日期2026-06-25
> 审查范围:`src/modules/announcements/**`、`src/app/(dashboard)/announcements/**`、`src/app/(dashboard)/admin/announcements/**`、`src/shared/i18n/messages/{zh-CN,en}/announcements.json`
> 架构图参考:`docs/architecture/004_architecture_impact_map.md` §2.16、`docs/architecture/005_architecture_data.json#announcements`
> 关联报告:`announcements-messages-audit-report.md`合并版2026-06-22已不再维护本文为公告模块的独立深度审计
---
## 一、现有实现概要
### 1.1 文件分布
| 层 | 路径 | 文件 | 行数 | 说明 |
|----|------|------|------|------|
| 路由 · 用户端 | `src/app/(dashboard)/announcements/page.tsx` | 1 | 36 | 列表页(所有非管理角色共用) |
| 路由 · 用户端 | `src/app/(dashboard)/announcements/[id]/page.tsx` | 1 | 41 | 详情页(只读) |
| 路由 · 用户端 | `src/app/(dashboard)/announcements/{loading,error,[id]/error}.tsx` | 3 | 60 | 骨架屏 + 错误边界 |
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/page.tsx` | 1 | 45 | 管理列表页 |
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/[id]/page.tsx` | 1 | 47 | 编辑页(直接渲染表单,无详情视图) |
| 路由 · 管理端 | `src/app/(dashboard)/admin/announcements/{loading,error,[id]/error}.tsx` | 3 | 64 | 骨架屏 + 错误边界 |
| 模块 | `src/modules/announcements/actions.ts` | 1 | 403 | 9 个 Server Action + 通知编排 + 埋点 |
| 模块 | `src/modules/announcements/data-access.ts` | 1 | 413 | CRUD + 发布/归档 + 置顶/已读 + 3 个页面编排函数 |
| 模块 | `src/modules/announcements/types.ts` | 1 | 75 | 类型定义 |
| 模块 | `src/modules/announcements/schema.ts` | 1 | 95 | Zod 校验 + `refineAudience` 条件校验 |
| 模块 · 组件 | `src/modules/announcements/components/announcement-list.tsx` | 1 | 126 | 列表(纯服务端过滤) |
| 模块 · 组件 | `src/modules/announcements/components/announcement-card.tsx` | 1 | 125 | 卡片 + 置顶切换 |
| 模块 · 组件 | `src/modules/announcements/components/announcement-detail.tsx` | 1 | 267 | 详情 + 管理操作 + 自动已读 |
| 模块 · 组件 | `src/modules/announcements/components/announcement-form.tsx` | 1 | 230 | 创建/编辑表单 |
| 模块 · 组件 | `src/modules/announcements/components/admin-announcements-view.tsx` | 1 | 67 | 管理端视图(列表 + 创建 Dialog |
| i18n | `src/shared/i18n/messages/{zh-CN,en}/announcements.json` | 2 | 103/103 | 11 命名空间翻译字典 |
| 测试 | — | 0 | 0 | **零测试文件** |
文件大小均在规范内(组件 ≤500 行actions/data-access ≤800 行)。
### 1.2 数据流
```
[Route] /announcements/page.tsx
└─▶ announcements/data-access.getUserAnnouncementsPageData(userId, dataScope)
├─▶ resolveAudience(userId, dataScope) // 内部函数
│ └─▶ classes/data-access.{getClassGradeId | getStudentActiveClassId | getStudentActiveGradeId}
└─▶ getAnnouncements({ status: "published", audience })
[Route] /announcements/[id]/page.tsx ⚠️ 未做受众/状态过滤
├─▶ announcements/data-access.getAnnouncementById(id)
└─▶ announcements/data-access.isAnnouncementReadByUser(id, userId)
[Route] /admin/announcements/page.tsx
└─▶ announcements/data-access.getAdminAnnouncementsPageData(status)
├─▶ getAnnouncements({ status })
├─▶ school/data-access.getGrades()
└─▶ classes/data-access.getAdminClasses()
[Route] /admin/announcements/[id]/page.tsx
└─▶ announcements/data-access.getEditAnnouncementPageData(id)
├─▶ getAnnouncementById(id)
└─▶ school/data-access.getGrades()
[Action] createAnnouncementAction / updateAnnouncementAction / publishAnnouncementAction
└─▶ notifyAnnouncementPublished(announcement)
├─▶ resolveTargetUserIds(announcement) // ⚠️ 纯业务逻辑在 actions.ts
│ ├─▶ users/data-access.{getAllUserIds | getUserIdsByGradeId}
│ └─▶ classes/data-access.{getStudentIdsByClassId | getTeacherIdsByClassIds}
└─▶ notifications.sendBatchNotifications(payloads)
```
### 1.3 架构图记录情况
`004_architecture_impact_map.md` §2.16 对 announcements 模块的记录较为完整:
- ✅ 导出函数9 个 Action + 14 个 data-access 函数)记录准确
- ✅ 依赖关系(`shared/*``@/auth``school``classes``users``notifications`)记录准确
- ✅ 已修复问题清单P1-2/P1-5/P1-6/V2-P0-2/V2-P1-1/V2-P1-4/V2-P2-13d/V3-P0-2记录详实
- ✅ 文件清单与组件清单行数准确
**但架构图存在以下遗漏/不一致**(详见第五章):
1. 未记录 `AnnouncementDetail` 组件中 `canManage=true` 分支为**死代码**(无任何页面使用)
2. 未记录 `getAnnouncementReadStatusAction` 为**死代码**(无任何调用方)
3. 未记录 `/announcements/[id]` 路由层存在的**安全越权风险**(无受众过滤)
4. 未记录 `resolveAudience` 内部函数的**多孩子/多年级数据截断 Bug**
5. 未记录 actions 返回的英文字符串未走 i18n 的问题
---
## 二、现存问题与原因分析
### 2.1 【P0 · 安全越权】用户端详情页无受众/状态过滤
- **位置**[src/app/(dashboard)/announcements/[id]/page.tsx:26-27](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx)
- **问题**:详情页直接调用 `getAnnouncementById(id)`,未传入 `audience` 也未校验 `status === "published"`
- **后果**:任意持有 `ANNOUNCEMENT_READ` 权限的登录用户,只要知道/猜到公告 IDcuid2即可读取
- 草稿(`status="draft"`)公告——提前泄露未发布内容
- 已归档(`status="archived"`)公告——绕过归档语义
- 其他年级/班级的定向公告——跨班级信息泄露(如某班处分通知被外班学生读到)
- **违反规则**:项目规则"安全性:所有敏感数据查询必须在 data-access 层结合当前用户权限过滤" + "Parent routes must include permission checks with both `parentId` and `studentId` to prevent information leakage"。
- **根因**`getAnnouncementById` 设计为通用读取函数,未提供"按受众过滤"重载;路由层也未在读取后做二次校验。
### 2.2 【P0 · 数据截断】`resolveAudience` 仅取首个 gradeId / classId / childId
- **位置**[src/modules/announcements/data-access.ts:351-395](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
- **问题**
```ts
if (dataScope.type === "grade_managed") {
const gradeId = dataScope.gradeIds[0] // ⚠️ 仅取第一个
}
if (dataScope.type === "class_members" || dataScope.type === "class_taught") {
const classId = dataScope.classIds[0] // ⚠️ 仅取第一个
}
if (dataScope.type === "children") {
const childId = dataScope.childrenIds[0] // ⚠️ 仅取第一个孩子
}
```
- **后果**
- **家长**有多个孩子在不同班级/年级时,只能看到第一个孩子的定向公告,第二个孩子的班主任通知完全不可见——直接违反 K12 家长端核心诉求。
- **年级主任**管理多个年级时,只能看到第一个年级的公告。
- **教师**任课多个班级时,只能看到第一个班级的公告。
- **违反规则**:项目规则"Parent routes must include permission checks with both `parentId` and `studentId`" 与"data-access 层结合当前用户权限过滤"。
- **根因**`getAnnouncements` 的 `audience` 参数设计为单值 `{ gradeId?, classId? }`,不支持多值;`resolveAudience` 为迁就该签名做了截断。
### 2.3 【P0 · 越权写】置顶/已读 Action 缺少资源所有权二次校验
- **位置**[src/modules/announcements/actions.ts:335-376](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
- **问题**
- `toggleAnnouncementPinAction` 仅校验 `ANNOUNCEMENT_MANAGE`,未校验公告是否存在、未校验调用者是否为该公告作者或管理员范围。
- `markAnnouncementAsReadAction` 仅校验 `ANNOUNCEMENT_READ`,未校验该公告是否对当前用户可见(即未结合 2.1 的受众过滤)。任意用户可对任意公告 ID包括草稿、他人班级公告写入已读记录污染 `announcement_reads` 表。
- **后果**:数据库完整性被破坏;统计 `readCount` 失真;为后续基于已读率的分析埋下错误数据。
- **违反规则**:项目规则"Server Action 二次校验"。
- **根因**Action 层信任了 `requirePermission` 的角色校验未做资源级resource-level授权。
### 2.4 【P1 · i18n 违规】Actions 返回英文硬编码消息
- **位置**[src/modules/announcements/actions.ts](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts) 全文
- **问题**:所有 Action 返回的 `ActionState.message` 均为英文字符串:
- `"Announcement created"` / `"Announcement updated"` / `"Announcement deleted"`
- `"Announcement published"` / `"Announcement archived"`
- `"Announcement not found"` / `"Invalid form data"` / `"Unexpected error"`
- `"Pin status toggled"` / `"Announcement marked as read"`
- 这些 message 通过 `toast.success(res.message)` / `toast.error(res.message)` 直接展示给用户(见 [announcement-detail.tsx:80,100,115](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx) 与 [announcement-form.tsx:80,88](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx))。
- **后果**:中文用户在创建/发布/删除公告后看到英文 Toasti18n 字典中已定义的 `messages.created` / `messages.updated` 等翻译键完全未使用。
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n" + "Server Action 返回值统一采用 `ActionState<T>` 类型"(隐含 message 应可本地化)。
- **根因**Actions 在 try 块内同步返回字符串,未通过 `getTranslations("announcements")` 获取本地化文案i18n 字典定义了键但 Action 未消费。
### 2.5 【P1 · 死代码】`AnnouncementDetail` 管理分支与 `getAnnouncementReadStatusAction` 无调用方
- **位置**
- [src/modules/announcements/components/announcement-detail.tsx:165-200](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx)`canManage` 为 true 时的发布/归档/删除/置顶/编辑按钮组)
- [src/modules/announcements/actions.ts:381-391](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)`getAnnouncementReadStatusAction`
- **问题**
- 全仓搜索 `AnnouncementDetail` 的使用方,仅 [src/app/(dashboard)/announcements/[id]/page.tsx:34-38](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/[id]/page.tsx) 一处,且 `canManage={false}`。管理端 `/admin/announcements/[id]` 直接渲染 `AnnouncementForm`(编辑模式),**没有管理端详情页**。
- 全仓搜索 `getAnnouncementReadStatusAction`**零调用方**。该 Action 返回 `Record<string,boolean>`,本应用于列表页批量标记已读/未读,但列表页从未调用。
- **后果**
- 管理员无法在 UI 中执行发布/归档/删除/置顶操作(除非进入编辑表单),严重限制了管理端可用性。
- `announcement.readCount` 字段在 `AnnouncementDetail` 中展示,但因 `canManage` 永远为 false**用户永远看不到已读人数**——已读统计功能在 UI 层完全不可见。
- 列表页公告卡片没有"已读/未读"视觉区分,已读回执的数据无法驱动 UI。
- **违反规则**:项目规则"避免 backwards-compatibility hacks ... 如果确定未使用,应完全删除" + "识别四个角色共用的 UI 块"。
- **根因**:管理端路由设计遗漏了详情视图;已读状态查询 Action 未被列表组件消费。
### 2.6 【P1 · 耦合】组件直接 import actions未通过 Context/Provider 注入
- **位置**
- [announcement-card.tsx:13](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx)`import { toggleAnnouncementPinAction } from "../actions"`
- [announcement-detail.tsx:25-31](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-detail.tsx)`import { archiveAnnouncementAction, deleteAnnouncementAction, markAnnouncementAsReadAction, publishAnnouncementAction, toggleAnnouncementPinAction } from "../actions"`
- [announcement-form.tsx:21](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx)`import { createAnnouncementAction, updateAnnouncementAction } from "../actions"`
- **问题**:组件硬编码依赖具体 Server Action无法在不修改组件代码的前提下替换为 mock 实现。
- **后果**
- 组件不可单元测试(必须 mock 整个 `../actions` 模块)。
- 无法为不同角色注入不同实现(如家长端只读、教师端可编辑班级公告)。
- 未来若要将公告组件复用于"班级空间"或"家长端聚合页",必须重写组件。
- **违反规则**:用户要求"完全解耦:通过定义 TypeScript 接口抽象数据依赖,使用 React Context 注入数据服务,模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access"。
- **根因**:组件设计未遵循依赖注入原则。
### 2.7 【P1 · 耦合】`AnnouncementForm` 硬编码路由跳转
- **位置**[announcement-form.tsx:82,217](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-form.tsx)
- **问题**:表单提交成功后 `router.push("/admin/announcements")`,取消按钮也跳转到 `/admin/announcements`。
- **后果**:表单无法在管理端以外的场景复用(如教师端发布班级公告、嵌入到班级详情页的快速发布公告入口)。
- **违反规则**:用户要求"组合优先 ... 逻辑复用一律抽取为自定义 hooks" + "最大化复用"。
- **根因**:表单未通过 `onSuccess` / `onCancel` 回调或 `successHref` prop 解耦导航。
### 2.8 【P1 · 业务逻辑位置】`resolveTargetUserIds` 放在 actions.ts
- **位置**[src/modules/announcements/actions.ts:48-66](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
- **问题**:受众解析 + 用户 ID 聚合是纯业务逻辑(无 I/O 副作用之外的逻辑),却放在 Server Action 文件中,与 Action 编排逻辑混杂。
- **后果**
- 无法独立单元测试(必须 mock `getAllUserIds` / `getStudentIdsByClassId` 等跨模块 data-access
- 与 `data-access.ts` 中的 `resolveAudience` 形成两套受众解析逻辑,职责重叠。
- **违反规则**:项目规则"可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks与 UI 分离" + "Server Actions / Data Access 模块:建议 ≤ 800 行 ... 超过应考虑拆分"。
- **根因**actions.ts 既承担 HTTP 编排又承担业务规则,未分离 service 层。
### 2.9 【P1 · 性能】`toggleAnnouncementPin` 与 `markAnnouncementAsRead` 非原子操作
- **位置**[src/modules/announcements/data-access.ts:210-224,234-250](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
- **问题**
- `toggleAnnouncementPin`:先 `SELECT isPinned`,再 `UPDATE`。两次 DB 往返,且在并发场景下存在 lost update两个管理员同时切换会得到错误结果
- `markAnnouncementAsRead`:先 `SELECT id`,再 `INSERT`。已有唯一索引保证幂等,但多一次 SELECT 浪费往返。
- **后果**高并发时数据不一致DB 负载翻倍。
- **违反规则**:项目规则"性能:优先使用 React Server Components"(隐含高效数据访问)。
- **根因**:未使用 Drizzle 的 `sql` 表达式或 `onDuplicateKeyUpdate`/`INSERT IGNORE` 语义。
### 2.10 【P1 · 错误处理】`handleActionError` 吞错误上下文
- **位置**[src/modules/announcements/actions.ts:34-40](file:///e:/Desktop/CICD/src/modules/announcements/actions.ts)
- **问题**
```ts
function handleActionError(e: unknown): ActionState<never> {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
```
- 未 `console.error` 记录错误堆栈,生产环境无法定位故障。
- 直接把 `e.message` 返回给前端,可能泄露内部错误信息(如 SQL 错误)。
- "Unexpected error" 为英文硬编码。
- **后果**:可观测性差;安全信息泄露风险。
- **违反规则**:项目规则"错误与边界处理" + "i18n 就绪"。
- **根因**:错误处理未与日志/埋点/i18n 集成。
### 2.11 【P2 · a11y】置顶按钮嵌套在 `<Link>` 内的键盘交互问题
- **位置**[announcement-card.tsx:80-92,116-121](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx)
- **问题**`AnnouncementCard` 在有 `href` 时用 `<Link>` 包裹整个卡片,同时卡片内的"置顶"按钮是一个 `<button>`。`handleTogglePin` 调用 `e.preventDefault()` + `e.stopPropagation()` 处理鼠标点击,但:
- 键盘聚焦到置顶按钮后按 `Enter`,部分浏览器会同时触发外层 `<a>` 的导航。
- 屏幕阅读器会朗读"链接 标题",但置顶按钮的 `aria-label` 在链接上下文中语义模糊。
- **后果**键盘用户可能误跳转a11y 不达标。
- **违反规则**:项目规则"可访问性a11y语义化标签、ARIA 属性、键盘导航"。
- **根因**:交互按钮不应嵌套在导航链接内;应使用"卡片头部可点击 + 操作按钮独立"的布局。
### 2.12 【P2 · 类型不安全】`mapRow` 内联对象类型与 schema 脱钩
- **位置**[src/modules/announcements/data-access.ts:23-53](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts)
- **问题**`mapRow` 的参数类型是手写的内联对象,未使用 Drizzle 推导类型 `typeof announcements.$inferSelect`。
- **后果**schema 变更(如新增字段)时,`mapRow` 不会在编译期报错,导致类型漂移。
- **违反规则**:项目规则"TypeScript 严格模式 ... 函数返回值必须显式标注"。
- **根因**:未利用 Drizzle 的类型推导能力。
### 2.13 【P2 · i18n 字典冗余/缺失并存】
- **位置**[src/shared/i18n/messages/zh-CN/announcements.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/announcements.json)
- **问题**
- 已定义但未使用的键:`messages.created` / `messages.updated` / `messages.deleted` / `messages.published` / `messages.archived` / `messages.notFound` / `messages.createFailed` / `messages.invalidForm` / `messages.markedRead`(共 9 个死键,因 actions 未消费)。
- 缺失的键:`description.detail`(详情页描述)、`description.create`(创建 Dialog 描述)。
- **后果**i18n 字典维护成本上升;新增页面时找不到对应键。
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
- **根因**i18n 键与代码未做同步校验。
### 2.14 【P2 · 死分支】`detailHrefBuilder` prop 未被使用
- **位置**[announcement-list.tsx:39,70-74](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx)
- **问题**`AnnouncementList` 同时支持 `detailHrefPrefix`(字符串前缀)和 `detailHrefBuilder`(函数)两种 prop但全仓搜索 `detailHrefBuilder` 的传入方为零(所有调用方都使用 `detailHrefPrefix`)。
- **后果**:死代码增加维护负担。
- **违反规则**:项目规则"避免 backwards-compatibility hacks"。
- **根因**V3 重构引入 `detailHrefPrefix` 后未清理旧 prop。
### 2.15 【P2 · 重复骨架屏】用户端与管理端 loading.tsx 完全重复
- **位置**
- [src/app/(dashboard)/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/announcements/loading.tsx)
- [src/app/(dashboard)/admin/announcements/loading.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/announcements/loading.tsx)
- **问题**:两个文件几乎逐行重复(仅管理端多一个"新建公告"按钮骨架),未抽取共享骨架屏组件。
- **后果**UI 调整需改两处。
- **违反规则**:项目规则"Shared components must be extracted when page duplication exceeds 90%"。
- **根因**:未识别到骨架屏也是可复用 UI 块。
### 2.16 【P2 · 无分页 UI】`getAnnouncements` 支持分页但 UI 未消费
- **位置**[data-access.ts:55-112](file:///e:/Desktop/CICD/src/modules/announcements/data-access.ts) 支持 `page` / `pageSize`[announcement-list.tsx](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-list.tsx) 无分页控件。
- **问题**:列表页默认 `pageSize=20`,超过 20 条公告时静默截断,用户无法翻页。
- **后果**历史公告不可访问K12 学校一学期公告数通常 > 20。
- **违反规则**:用户要求"可扩展性:配置驱动设计"。
- **根因**:分页参数未贯穿到 UI。
### 2.17 【P2 · 表单与 Dialog 行为冲突】
- **位置**[admin-announcements-view.tsx:57-64](file:///e:/Desktop/CICD/src/modules/announcements/components/admin-announcements-view.tsx)
- **问题**`AdminAnnouncementsView` 在 Dialog 中渲染 `AnnouncementForm`(创建模式)。但 `AnnouncementForm` 的 Cancel 按钮 `router.push("/admin/announcements")` 会触发整页跳转,而不是关闭 Dialog。提交成功后也是 `router.push` 而非 `onSuccess` 回调。
- **后果**用户体验割裂Dialog 内按钮触发路由跳转);`handleOpenChange` 中的 `router.refresh()` 与表单跳转重复。
- **违反规则**:项目规则"组合优先 ... 严禁使用继承或深层嵌套 HOC"。
- **根因**:表单未与容器解耦。
---
## 三、行业差距对比
参考 Google Classroom、钉钉教育、企业微信家校通、飞书校园版、PowerSchool 等主流 K12 产品的公告/通知模块,对比差距如下:
| 维度 | 行业主流实践 | 当前实现 | 差距影响 |
|------|------------|---------|---------|
| **多受众定向** | 支持多班级/多年级/多角色组合发布(如"高三1班+2班家长" | 仅支持单年级或单班级 | 年级组长需重复发布 N 次 |
| **富文本/附件** | 富文本编辑器 + 附件PDF 通知、图片) | 纯文本 `whitespace-pre-wrap` | 学校正式通知无法排版、无法附带 PDF |
| **分类/标签** | 学科、活动、安全、家长信等分类筛选 | 仅按 status 筛选 | 家长在海量公告中找不到关注项 |
| **定时发布** | 选择未来时间自动发布 | schema 有 `publishedAt` 但 UI 未消费 | 管理员需手动踩点发布 |
| **到期/置顶** | 自动到期 + 多级优先级(紧急/普通) | 仅 pinned 布尔 | 紧急通知与普通通知无差异 |
| **已读统计仪表盘** | 管理端列表展示每条公告已读率、未读名单、可一键催读 | `readCount` 字段存在但 UI 未展示 | 管理员无法评估公告触达效果 |
| **草稿预览** | 编辑时预览发布后效果 | 无预览 | 发布前无法验证排版 |
| **批量操作** | 列表多选 + 批量归档/删除 | 逐条操作 | 学期末清理 50 条公告需 50 次点击 |
| **搜索** | 标题/正文全文搜索 | 无搜索 | 历史公告无法检索 |
| **Dashboard 集成** | 首页"最新公告"Widget + 未读红点 | 仅家长端有快速入口链接 | 用户必须主动进入公告页 |
| **通知点击回跳** | 点击通知直达公告详情并自动已读 | 通知 actionUrl 指向详情页,但详情页无受众校验 | 通知点击可能触发越权 |
| **多语言/多角色文案** | 同一公告对家长/学生/教师展示不同侧重点 | 同一文案对所有角色 | 家长看到教师内部用语 |
| **无障碍** | 列表语义化 `<ul>`/`<li>`、键盘可达 | `<div>` + `<Link>` 包裹按钮 | 屏幕阅读器用户导航困难 |
| **错误恢复** | 失败自动重试 + 离线草稿 | 失败仅 Toast 提示 | 网络波动时内容丢失 |
**核心差距**:当前实现停留在"CRUD + 状态机"的最小可用形态,缺少 K12 公告模块的"触达-反馈-统计"闭环。其中"已读统计不可见"和"无富文本/附件"是 K12 学校最痛的两个缺口。
---
## 四、改进优先级建议
### P0必须立即修复 · 安全与数据正确性)
| # | 问题 | 改进方向 |
|---|------|---------|
| P0-1 | 详情页越权读取 | 新增 `getAnnouncementByIdForUser(id, userId, dataScope)` data-access 函数,结合 `status="published"` 与受众过滤;路由层调用此函数,未命中返回 `notFound()` |
| P0-2 | `resolveAudience` 多孩子/多年级截断 | 将 `audience` 参数升级为 `{ gradeIds: string[]; classIds: string[] }``getAnnouncements` 用 `inArray` 查询;`resolveAudience` 返回完整数组而非首个 |
| P0-3 | 置顶/已读 Action 缺资源级校验 | `toggleAnnouncementPinAction` 校验公告存在;`markAnnouncementAsReadAction` 调用新增的 `getAnnouncementByIdForUser` 校验可见性后再写入 |
### P1高优先级 · 架构与可维护性)
| # | 问题 | 改进方向 |
|---|------|---------|
| P1-1 | Actions 返回英文硬编码 | 引入 `getTranslations("announcements")`,所有 `ActionState.message` 改用 i18n 键;新增 `messageKey` 字段或直接返回本地化字符串 |
| P1-2 | 死代码:管理端详情分支 / `getAnnouncementReadStatusAction` | 新增 `/admin/announcements/[id]/view` 详情页消费 `AnnouncementDetail canManage=true`;列表组件调用 `getAnnouncementReadStatusAction` 展示已读/未读角标;或删除死分支 |
| P1-3 | 组件直接 import actions | 新建 `announcements-service-context.tsx`,定义 `AnnouncementsService` 接口(含 `togglePin` / `publish` / `archive` / `delete` / `markRead` / `create` / `update` 方法签名),用 Provider 注入默认实现;组件 `useContext` 消费 |
| P1-4 | `AnnouncementForm` 硬编码路由 | 新增 `onSuccess?` / `onCancel?` 回调 prop回调优先于 `router.push`;默认 `successHref` prop 兜底 |
| P1-5 | `resolveTargetUserIds` 放 actions.ts | 下沉到 `data-access.ts` 的 `resolveAnnouncementTargetUserIds(announcement)` 纯函数actions.ts 仅做编排 |
| P1-6 | 非原子 toggle / markRead | `toggleAnnouncementPin` 改为 `UPDATE ... SET is_pinned = NOT is_pinned``markAnnouncementAsRead` 改为 `INSERT ... ON DUPLICATE KEY UPDATE id=id`Drizzle 的 `onDuplicateKeyUpdate` |
| P1-7 | `handleActionError` 吞错误 | 新增 `console.error` + `trackEvent("announcement.action_error")`message 走 i18n不向客户端返回原始 `e.message` |
| P1-8 | 表单与 Dialog 行为冲突 | 表单通过 `onSuccess` 回调关闭 Dialog移除表单内的 `router.push` |
### P2中优先级 · 体验与工程化)
| # | 问题 | 改进方向 |
|---|------|---------|
| P2-1 | a11y按钮嵌套在 Link 内 | 重构 `AnnouncementCard`:卡片本身为 `<Link>`,置顶按钮用绝对定位 + `z-index` 独立于链接,或改用 `<article>` + 独立链接 + 独立按钮的语义结构 |
| P2-2 | `mapRow` 类型脱钩 | 改用 `typeof announcements.$inferSelect` 推导;移除手写内联类型 |
| P2-3 | i18n 死键 / 缺键 | 删除未使用的 9 个 `messages.*` 死键(或随 P1-1 启用);补 `description.detail` / `description.create` |
| P2-4 | `detailHrefBuilder` 死 prop | 删除该 prop仅保留 `detailHrefPrefix` |
| P2-5 | 重复骨架屏 | 抽取 `AnnouncementListSkeleton` 共享组件到 `components/` |
| P2-6 | ✅ 已实施 | 无分页 UI → 新增 `AnnouncementPagination` 组件;`getUserAnnouncementsPageData` 返回 `{ items, total, page, pageSize }` |
| P2-7 | ✅ 已实施 | 无测试 → 新增 `schema.test.ts`18 测试,`refineAudience` 矩阵)、`is-announcement-visible.test.ts`15 测试,纯函数含多孩子场景)、`announcement-card.test.tsx`16 测试,交互 + a11y |
### 中长期P3 · 功能演进,对应行业差距)
| # | 方向 | 说明 |
|---|------|------|
| P3-1 | 富文本 + 附件 | 接入 `files` 模块(已支持 `targetType="announcement"`);引入轻量富文本编辑器(如 Tiptap |
| P3-2 | 分类/标签 | 新增 `announcement_tags` 表 + 列表筛选;预置 K12 分类(学科/活动/安全/家长信) |
| P3-3 | 定时发布 | 表单增加 `publishedAt` 日期选择器;新增 cron 校验到点自动 `status="published"` |
| P3-4 | 已读统计仪表盘 | 管理端列表展示已读率柱状图;详情页展示未读名单 + 一键催读(触发 `sendBatchNotifications` |
| P3-5 | 批量操作 | 列表多选 + 批量归档/删除 Action |
| P3-6 | 全文搜索 | 接入 `app/api/search` 已有的全局搜索(当前已支持 announcement 类型) |
| P3-7 | Dashboard 集成 | 新增 `AnnouncementsWidget`(最新 3 条 + 未读红点),挂载到各角色 Dashboard |
| P3-8 | 草稿预览 | 表单"预览"按钮展开只读视图 |
---
## 五、架构图同步说明
本次审计发现 `004_architecture_impact_map.md` §2.16 与 `005_architecture_data.json#announcements` 存在以下遗漏,需在实施后同步更新:
1. **新增节点**
- `data-access.resolveAnnouncementTargetUserIds`P1-5 下沉的纯函数)
- `data-access.getAnnouncementByIdForUser`P0-1 新增的受众过滤读取)
- `AnnouncementsServiceContext`P1-3 新增的依赖注入 Provider
- `AnnouncementListSkeleton`P2-5 抽取的共享骨架屏)
- `AnnouncementPagination`P2-6 新增的分页组件)
2. **删除节点**
- `actions.getAnnouncementReadStatusAction`(若 P1-2 选择删除而非启用)
- `AnnouncementList.detailHrefBuilder` propP2-4 删除)
3. **修改节点**
- `GetAnnouncementsParams.audience` 类型从 `{ gradeId?; classId? }` 改为 `{ gradeIds: string[]; classIds: string[] }`
- `AnnouncementDetail` 的 `canManage` 分支启用记录(新增管理端详情页后)
- `actions.ts` 行数变化(下沉 `resolveTargetUserIds` 后减少)
- `data-access.ts` 行数变化(新增函数后增加)
4. **新增依赖关系**
- `announcements → files`P3-1 附件集成后)
- `announcements → dashboard`P3-7 Widget 集成后)
5. **已知问题清单更新**
- 新增"P0-1 详情页越权"(修复后标记 ✅)
- 新增"P0-2 多孩子截断"(修复后标记 ✅)
- 新增"P0-3 资源级校验缺失"(修复后标记 ✅)
- 新增"P1-1 Actions i18n"(修复后标记 ✅)
---
## 附:实施清单(与上述优先级一一对应)
实施将按 P0 → P1 → P2 → P3 顺序推进P3 为中长期演进,本次实施聚焦 P0/P1/P2P3 中富文本/附件/Dashboard 集成将择期推进。每完成一项同步更新架构图与运行 `npm run lint` + `npx tsc --noEmit` 验证。