# announcements 公告模块 > 管理全校/年级/班级公告的发布、状态流转、受众可见性过滤、已读回执与 fan-out 推送 > 代码规模: 15 文件 · 3,056 行(arch.db) > 模块路径: `src/modules/announcements/` --- ## 架构图 ```mermaid graph TB subgraph Client["客户端组件"] direction TB AdminView["admin-announcements-view
(83 行, 管理端)"] Form["announcement-form
(268 行, Zod 校验)"] List["announcement-list
(166 行, 用户端)"] Card["announcement-card
(157 行)"] Detail["announcement-detail
(263 行, 详情)"] Page["announcement-pagination
(131 行)"] Ctx["announcements-service-context
(43 行)"] end subgraph Server["服务端"] Actions["actions.ts
(421 行, 9 个 Server Actions)"] DA["data-access.ts
(603 行, server-only)"] end subgraph Lib["纯函数 / Schema"] Schema["schema.ts
(96 行, refineAudience)"] DefaultSvc["default-announcements-service
(31 行)"] end subgraph DB["数据库表"] Announcements[(announcements)] Reads[(announcement_reads)] end AdminView --> Form AdminView --> List List --> Card Card --> Detail List --> Page Form --> Schema Form --> Actions List --> Actions Detail --> Actions Ctx --> DefaultSvc Actions --> DA Actions --> Notifications["notifications
sendBatchNotifications"] DA --> Announcements DA --> Reads DA --> Users["users.getAllUserIds"] DA --> School["school"] DA --> Classes["classes"] ``` --- ## 核心流程图 ### 流程 1: 公告创建 + 受众校验 + 状态流转 ```mermaid sequenceDiagram participant U as 管理员 participant Form as announcement-form participant Schema as schema.ts participant Act as actions.ts participant DA as data-access.ts participant DB as MySQL U->>Form: 填写标题/内容/类型/受众 Form->>Schema: CreateAnnouncementSchema.superRefine Schema->>Schema: refineAudience 校验 alt type=school Schema->>Schema: targetGradeId/ClassId 必须空 else type=grade Schema->>Schema: targetGradeId 必填 else type=class Schema->>Schema: targetClassId 必填 end Form->>Act: createAnnouncementAction Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE) Act->>DA: insertAnnouncement DA->>DB: INSERT announcements(status=draft) Act->>Act: invalidateFor Act-->>Form: ActionState<{id}> ``` ### 流程 2: 发布 + fan-out 通知(school 维度分页遍历) ```mermaid sequenceDiagram participant U as 管理员 participant Act as actions.ts participant DA as data-access.ts participant Notify as notifications participant DB as MySQL U->>Act: publishAnnouncementAction(id) Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE) Act->>DA: publishAnnouncementById(id, now) DA->>DB: UPDATE announcements SET status=published Act->>DA: resolveAnnouncementTargetUserIds alt type=school DA->>DB: 分页遍历 getAllUserIds else type=grade DA->>DB: 查询年级下所有用户 else type=class DA->>DB: 查询班级成员 end DB-->>DA: targetUserIds[] DA-->>Act: string[] Act->>Notify: sendBatchNotifications(payloads) Note over Act,Notify: 通知失败不阻塞发布
仅 log.error Act->>Act: invalidateFor Act-->>U: ActionState ``` ### 流程 3: 用户端可见性过滤 + 已读回执(P0-2 多受众修复) ```mermaid flowchart TB A[用户进入公告列表] --> B[getAnnouncementsAction
权限: ANNOUNCEMENT_READ] B --> C[resolveUserAudience
解析 gradeIds[] / classIds[]] C --> D{受众数组校验} D --> E[isAnnouncementVisibleToAudience
纯函数过滤] E --> F{公告类型} F -->|school| G[全员可见] F -->|grade| H{targetGradeId ∈ audience.gradeIds} F -->|class| I{targetClassId ∈ audience.classIds} G --> J[返回可见公告列表] H --> J I --> J J --> K[用户点击公告] K --> L[markAnnouncementAsReadAction] L --> M[INSERT announcement_reads] M --> N[返回 isReadByCurrentUser] ``` --- ## 技术栈 | 类别 | 技术 | 用途 | |------|------|------| | 数据库 | Drizzle ORM | announcements / announcement_reads 表 CRUD | | 缓存 | cacheFn | 列表/详情查询请求级去重 | | 缓存失效 | invalidateFor | 写操作后精确刷新列表与详情两条路径 | | 表单校验 | Zod + superRefine | schema.ts 的 refineAudience 联合校验类型与受众 | | Server Action | requirePermission | ANNOUNCEMENT_MANAGE(写) / ANNOUNCEMENT_READ(读) 两权限点 | | 通知推送 | notifications.sendBatchNotifications | 发布后 fan-out 批量推送 | | i18n | next-intl getTranslations | 错误消息与通知文案本地化 | | 事件追踪 | trackEvent | 操作错误上报监控告警 | | 日志 | createModuleLogger | 模块级结构化日志 | | 服务抽象 | service-context | 客户端服务注入,便于 Mock 测试 | --- ## 文件结构 ``` src/modules/announcements/ ├─ actions.ts # Server Actions (421 行): 9 个 Action 编排 ├─ data-access.ts # 数据访问 (603 行, server-only): CRUD + fan-out + 可见性 ├─ schema.ts # Zod 校验 (96 行): refineAudience 联合校验 ├─ types.ts # 类型定义 (125 行): Announcement / UserAudience ├─ is-announcement-visible.test.ts # 可见性纯函数单测 (154 行) ├─ schema.test.ts # Schema 校验单测 (211 行) ├─ components/ # 7 个组件 │ ├─ admin-announcements-view.tsx # 管理端视图 (83 行) │ ├─ announcement-form.tsx # 公告表单 (268 行) │ ├─ announcement-list.tsx # 用户端列表 (166 行) │ ├─ announcement-card.tsx # 公告卡片 (157 行) │ ├─ announcement-detail.tsx # 公告详情 (263 行) │ ├─ announcement-pagination.tsx # 分页组件 (131 行) │ ├─ announcements-service-context.tsx # 服务上下文 (43 行) │ ├─ announcement-card.test.tsx # 卡片组件单测 (304 行) │ └─ default-announcements-service.ts # 默认服务实现 (31 行) ``` --- ## 依赖关系 ### 依赖(本模块 → 他模块) | 模块 | 依赖类型 | 用途 | |------|---------|------| | shared | import | DB / cache / auth-guard / logger / trackEvent | | notifications | import | 发布时 sendBatchNotifications 批量推送 | | users | data-access-call | resolveAnnouncementTargetUserIds 调用 getAllUserIds | | school | data-access-call | 年级公告解析目标年级用户 | | classes | data-access-call | 班级公告解析目标班级成员 | ### 被依赖(他模块 → 本模块) | 模块 | 依赖类型 | 用途 | |------|---------|------| | search | data-access-call | 全文检索聚合 announcements 表 | | dashboard | data-access-call | 首页公告统计与最近公告展示 | --- ## 关键约束 - **schema refineAudience 联合校验**: school 不指定受众、grade 必填 targetGradeId、class 必填 targetClassId,避免创建无受众公告 - **状态机严格流转**: draft → published → archived,由独立 Action 处理(publishAnnouncementById / archiveAnnouncementById / toggleAnnouncementPin) - **P0-2 多受众修复**: audience 为 `gradeIds[]` / `classIds[]` 数组,解决家长多孩子/教师多班级/年级主任多年级只看到首个受众的截断 Bug - **通知失败不阻塞发布**: notifyAnnouncementPublished 内部 try/catch,失败仅 log.error,不回滚公告状态 - **缓存失效双路径**: 写操作后 invalidateFor 必须覆盖列表与详情两条缓存路径 - **data-access.ts 首行 server-only**: 禁止前端组件直接调用 --- ## 架构决策 | 决策 | 原因 | |------|------| | school fan-out 分页遍历 getAllUserIds | 避免一次性加载全体用户 ID 导致内存峰值 | | announcementReads 独立表 | 与公告本体解耦,支持按用户聚合已读状态,避免公告表膨胀 | | audience 升级为数组(P0-2) | 单值无法表达家长多孩子/教师多班级场景,导致可见性截断 | | 可见性判断抽纯函数 isAnnouncementVisibleToAudience | 便于单测覆盖三类公告的可见性矩阵 | | 通知发送与发布解耦 | 通知基础设施故障不应阻塞业务公告发布 | | service-context 注入服务 | 客户端组件可注入 Mock 服务,便于组件单测 | | refineAudience 用 superRefine | 跨字段联合校验,Zod 原生支持,错误信息精准定位字段 |