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

393 lines
31 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.
# 公告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` 验证。