Files
NextEdu/src/modules/announcements/README.md

8.5 KiB

announcements 公告模块

管理全校/年级/班级公告的发布、状态流转、受众可见性过滤、已读回执与 fan-out 推送 代码规模: 15 文件 · 3,056 行(arch.db) 模块路径: src/modules/announcements/


架构图

graph TB
    subgraph Client["客户端组件"]
        direction TB
        AdminView["admin-announcements-view<br/>(83 行, 管理端)"]
        Form["announcement-form<br/>(268 行, Zod 校验)"]
        List["announcement-list<br/>(166 行, 用户端)"]
        Card["announcement-card<br/>(157 行)"]
        Detail["announcement-detail<br/>(263 行, 详情)"]
        Page["announcement-pagination<br/>(131 行)"]
        Ctx["announcements-service-context<br/>(43 行)"]
    end

    subgraph Server["服务端"]
        Actions["actions.ts<br/>(421 行, 9 个 Server Actions)"]
        DA["data-access.ts<br/>(603 行, server-only)"]
    end

    subgraph Lib["纯函数 / Schema"]
        Schema["schema.ts<br/>(96 行, refineAudience)"]
        DefaultSvc["default-announcements-service<br/>(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<br/>sendBatchNotifications"]
    DA --> Announcements
    DA --> Reads
    DA --> Users["users.getAllUserIds"]
    DA --> School["school"]
    DA --> Classes["classes"]

核心流程图

流程 1: 公告创建 + 受众校验 + 状态流转

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 维度分页遍历)

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: 通知失败不阻塞发布<br/>仅 log.error
    Act->>Act: invalidateFor
    Act-->>U: ActionState

流程 3: 用户端可见性过滤 + 已读回执(P0-2 多受众修复)

flowchart TB
    A[用户进入公告列表] --> B[getAnnouncementsAction<br/>权限: ANNOUNCEMENT_READ]
    B --> C[resolveUserAudience<br/>解析 gradeIds[] / classIds[]]
    C --> D{受众数组校验}
    D --> E[isAnnouncementVisibleToAudience<br/>纯函数过滤]
    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 原生支持,错误信息精准定位字段