# 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 原生支持,错误信息精准定位字段 |