# 公告(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` 类型"(隐含 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`,本应用于列表页批量标记已读/未读,但列表页从未调用。 - **后果**: - 管理员无法在 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 { 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】置顶按钮嵌套在 `` 内的键盘交互问题 - **位置**:[announcement-card.tsx:80-92,116-121](file:///e:/Desktop/CICD/src/modules/announcements/components/announcement-card.tsx) - **问题**:`AnnouncementCard` 在有 `href` 时用 `` 包裹整个卡片,同时卡片内的"置顶"按钮是一个 `