# 公告（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` 权限的登录用户，只要知道/猜到公告 ID（cuid2），即可读取：
  - 草稿（`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)）。
- **后果**：中文用户在创建/发布/删除公告后看到英文 Toast，i18n 字典中已定义的 `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` prop（P2-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/P2，P3 中富文本/附件/Dashboard 集成将择期推进。每完成一项同步更新架构图与运行 `npm run lint` + `npx tsc --noEmit` 验证。
