# 会议出勤（Meeting Attendance）- 架构设计文档

> **版本**: v1.0（议程能力首发）
> **状态**: Active
> **创建日期**: 2026-01-22
> **最后更新**: 2026-05-20
> **架构师**: 待定

---

## ✅ 机器读取区（必填）

### 架构摘要

| 字段 | 内容 |
|------|------|
| 模块定位 | 会议出勤管理子系统，提供会议/系列/签到/报表能力 |
| 关键组件 | 前端页面、会议/系列服务、签到服务、报表服务、审计记录 |
| 关键接口 | 会议 API、系列 API、签到 API、报表 API、用户/参会人 API |
| 数据流 | QR 码生成 → 扫码 → 签到 API → 出勤记录更新 → 报表统计 |
| 关键依赖 | FFOA 登录/权限、Prisma、二维码生成、时区转换 |
| 外部集成 | Microsoft Graph（Events/Subscriptions/Delta） |

---

## 🧭 人类阅读区（可选）

### 分层架构（目标形态）

```
frontend (Next.js 16)
  └── meeting-attendance pages
backend (NestJS)
  └── meeting-attendance module
      ├── controllers (HTTP)
      ├── services (business)
      └── repositories (Prisma)
DB (PostgreSQL)
  ├── platform_meeting_attendance schema
  └── platform_iam schema（用户与角色）
External
  └── Microsoft Graph Webhook + Delta
```

### 关键决策

1) **保留访客免登录签到**：对应公开接口与页面，不使用 JWT。
2) **路径兼容**：保留 `/meetingattendance` 作为前端入口路径，避免二维码历史地址失效。
3) **二维码重生成**：迁移后统一重新生成二维码并回写 `qrCodeOnline/qrCodeOffline`。
4) **响应格式兼容**：为减少前端改动，优先保留旧系统响应结构（不强制统一封装）。

### 关键组件说明

- **会议服务**: 会议 CRUD、二维码生成、状态推导（SCHEDULED/IN_PROGRESS/COMPLETED/CANCELLED）。
- **系列服务**: 批量生成会议、批量改期、系列软删除。
- **签到服务**: 系统用户签到 + 访客签到 + 设备限制 + 迟到判定。
- **报表服务**: 单场报表、系列报表、状态分布、低出勤排名。
- **审计服务**: 记录会议/签到/用户管理等关键操作。
- **Outlook 同步服务**: 负责源邮箱订阅、续订、通知消费、Delta 拉取与对账。
- **同步编排任务**: 处理通知入队、失败重试、批量处理与对账调度。

### Outlook 同步架构

1) 数据来源层  
- 支持多个源邮箱（共享邮箱 + 个人邮箱）。  
- 每个源邮箱独立维护 Graph 订阅与 Delta 游标。  

2) 纳管层  
- 管理员在候选池手动选择纳管会议。  
- 仅纳管会议会写入或更新出勤业务实体。  
- 对系列会议，统一以 `seriesMaster` 为纳管根，实例由系统自动扩展维护。  

3) 系列排除层
- 在系列纳管根上维护 occurrence exclusion 清单。  
- 增量/对账处理时先匹配 exclusion，命中后跳过该实例更新。  

4) 绑定归属层  
- 绑定记录保存：`graphEventId`（全局唯一）+ 绑定管理员 + 同步来源邮箱 + `syncFrom`。  
- 同一 Outlook 日历事件只允许一个有效绑定，避免多管理员并发写入抖动。  
- 接管为强制模式：有权限用户可接管，接管后绑定管理员与来源邮箱同时切换并写入审计。  

5) 同步执行层  
- Webhook 到达后仅做轻量校验与入队。  
- Worker 拉取 Delta 并按规则 upsert。  
- 定时对账任务按窗口修复漏同步。  
- 同步边界遵循 `syncFrom`，只同步绑定/接管时点之后的会议实例。  

6) 配置层  
- `.env` 仅保留 Azure 凭据与 `GRAPH_NOTIFICATION_CLIENT_STATE`。  
- 业务策略（cron、batch、窗口、重试）走后台配置并提供默认值。  
- 邮箱访问范围建议由 Exchange App RBAC 控制，应用侧按授权结果回传可观测错误。  
- 租户默认时区固定为 `America/Los_Angeles`（仅兜底，不覆盖事件源时区）。  

7) 时间模型层
- 入库统一采用双轨模型：`startTime/endTime` 保存 UTC 时间点（instant），`timezone` 保存事件业务时区（IANA）。
- Graph 时间解析优先级：`start.timeZone/end.timeZone`（含 recurrence 时区）> `originalStartTimeZone/originalEndTimeZone` > 租户默认时区。
- 同步与排序统一按 UTC 时间点执行；页面展示优先按会议业务时区显示，必要时补充用户时区对照。

8) 上线手工清理层  
- 系统提供“历史系列检查清单 + 操作清单”页面。  
- 上线后管理员手工截断本系统历史系列结束时间（截断到当天前）。  
- 不对 Outlook 原系列数据做写操作。  

### 登录与权限

- 统一使用 FFOA 登录与 JWT。
- 访客签到与公开会议信息不需要登录。
- 会议出勤模块访问控制对齐系统角色（详见 05/07 文档）：
  - Administrator
  - MeetingManager
  - Leader
  - Employee
- 旧系统角色映射：
  - ADMIN -> Administrator
  - MANAGER -> MeetingManager
  - LEADER -> Leader
  - EMPLOYEE -> Employee
  
#### 角色与权限初始化（开发/上线）

- 角色种子: `backend/prisma/seeds/roles.seed.ts`（包含 Administrator/MeetingManager/Leader/Employee）
- 开发环境初始化:
  - `npm run db:seed` 或 `bash scripts/dev/dev.sh init:permissions`
- 线上初始化:
  - `bash scripts/deploy/deploy.sh <env> init:permissions` 或 `db:seed`
- 初始化后需在权限页为用户分配对应角色与权限

### 数据迁移与同步

- 源数据为 SQLite 文件（`dev.db`）。
- 开发期允许反复同步；最终切换时以停机后的最新 `dev.db` 为准。
- 迁移策略：会议出勤数据使用平台用户 ID（UUID）作为关联主键。
- 用户映射：以 `email` 对齐或先创建 platform_iam.User，再将 meeting_attendance 记录关联到平台用户 ID。
- 数据迁移顺序：platform users → meeting_series → meetings → meeting_required_attendees → attendances → templates → template_attendees → audit_logs。
- 参会名单仅允许系统用户（meeting_required_attendees.user_id 必填）。
- 二维码重生成：迁移后统一重生成并回写 `qrCodeOnline/qrCodeOffline`（参考脚本 `scripts/backend/data/regenerate-meeting-attendance-qrcodes.ts`）。

---

## 议程能力架构（v1.0 新增）

### 设计目标

议程能力是会议的"内容载体"——参会前能看到议题与主讲人，会前管理员可以分配资料上传任务，会中按议程驱动会议节奏，会后回顾时议程项级的资料还在原位。架构上的核心约束是：

- 议程数据**强耦合 meeting-attendance 模块**，不下沉到 platform_master（独立 schema 内 5 张新表）
- **议程项是资料挂载锚点**：删除议程项 → 资料和任务在同事务里 cascade soft-delete，不留孤儿
- **议程层与签到层完全解耦**：议程改动不影响签到流程，签到改动不影响议程
- **外部访客视图在后端剥离**：不依赖前端隐藏，控制器层根据 `requireMeetingUser` 派生的角色返回不同 DTO
- **所有 5 张新表使用 CLAUDE.md §标准字段**（`createdById` / `organizationId` / `deletedAt`），DataScope 零配置；删除一律软删（30 天后 cron 物理清理）

### 5 个新 entity 关系（ER 图）

```mermaid
erDiagram
  Meeting ||--o{ MeetingAgendaSection : "1 meeting has N sections"
  Meeting ||--o{ MeetingAttachment : "1 meeting has N meeting-level attachments"
  MeetingAgendaSection ||--o{ MeetingAgendaItem : "1 section has N items"
  MeetingAgendaItem ||--o{ MeetingAgendaItemUploadTask : "1 item has N tasks"
  MeetingAgendaItem ||--o{ MeetingAgendaItemAttachment : "1 item has N attachments"
  User ||--o{ MeetingAgendaItemUploadTask : "assignee + assignedBy"
  User ||--o{ MeetingAgendaItem : "presenter (optional)"
  User ||--o{ MeetingAgendaItemAttachment : "uploadedBy"
  User ||--o{ MeetingAttachment : "uploadedBy"

  Meeting {
    string id PK "VarChar(32)"
    string status
    string city
  }
  MeetingAgendaSection {
    uuid id PK
    string meetingId FK "VarChar(32)"
    int order
    string title "VarChar(200)"
    uuid createdById FK
    uuid organizationId FK "nullable"
    timestamptz deletedAt "nullable, soft-delete"
    timestamptz createdAt
    timestamptz updatedAt
  }
  MeetingAgendaItem {
    uuid id PK
    uuid sectionId FK
    int order
    string title "VarChar(200)"
    text description "nullable"
    string code "VarChar(64) nullable, user-filled"
    int timeMinutes "nullable"
    uuid presenterUserId FK "nullable, no cascade"
    enum categoryTag "AgendaCategoryTag nullable"
    uuid createdById FK
    uuid organizationId FK "nullable"
    timestamptz deletedAt "nullable, soft-delete"
  }
  MeetingAgendaItemUploadTask {
    uuid id PK
    uuid agendaItemId FK
    uuid assigneeUserId FK
    uuid assignedById FK
    enum status "UploadTaskStatus"
    timestamptz dueAt "nullable"
    timestamptz assignedAt
    timestamptz completedAt "nullable"
    uuid createdById FK "= assignedById"
    uuid organizationId FK "nullable"
    timestamptz deletedAt "nullable, soft-delete"
  }
  MeetingAgendaItemAttachment {
    uuid id PK
    uuid agendaItemId FK
    uuid uploadedById FK
    string filename "VarChar(255) unicode"
    string mimeType "VarChar(128)"
    bigint size "serialized as string"
    string storagePath "VarChar(512) relative path"
    timestamptz uploadedAt
    uuid createdById FK "= uploadedById"
    uuid organizationId FK "nullable"
    timestamptz deletedAt "nullable, 30-day cron purge"
  }
  MeetingAttachment {
    uuid id PK
    string meetingId FK "VarChar(32)"
    uuid uploadedById FK
    enum category "MeetingAttachmentCategory nullable"
    string filename
    string mimeType
    bigint size "serialized as string"
    string storagePath
    timestamptz uploadedAt
    uuid createdById FK "= uploadedById"
    uuid organizationId FK "nullable"
    timestamptz deletedAt "nullable, 30-day cron purge"
  }
  User {
    uuid id PK
  }
```

### 与现有 Meeting 实体的关系

- `MeetingAgendaSection.meetingId` 指向 `Meeting.id`（`VarChar(32)`），**不依赖 FK onDelete**，service 层显式 cascade soft-delete
- `MeetingAttachment.meetingId` 同上
- 现有 Meeting 表**不加字段**，议程数据完全在新表里承载（避免改动现有 schema 的回归风险）
- 现有 MeetingRequiredAttendee 表**不改**，议程权限通过 `requireMeetingUser` middleware 判定参会身份（与签到流程同一套）
- 现有 MeetingAttendanceAuditLog **复用**：扩展 `backend/src/modules/meeting-attendance/constants/audit.ts` 中 `MEETING_ATTENDANCE_AUDIT_ACTIONS` 常量对象，新增 10 个键；`audit_logs.action` 字段类型不变（`String VarChar(100)`），**不需要 prisma migration**（AuditAction 不是 prisma enum 而是 TS 常量）

### 议程能力分层（落在现有 meeting-attendance module 内）

```
backend/src/modules/meeting-attendance/
  ├── controllers/
  │   ├── agenda.controller.ts             ← 议程段 + 项 CRUD + 排序 + 查看
  │   ├── upload-task.controller.ts        ← 任务 CRUD + 我的待办
  │   └── attachment.controller.ts         ← 议程项级 + 会议级资料 CRUD + 下载
  ├── services/
  │   ├── agenda.service.ts                ← 段 / 项业务逻辑 + 级联软删（cascade soft-delete）事务
  │   ├── upload-task.service.ts           ← 任务状态机 + 我的待办查询
  │   ├── attachment.service.ts            ← 上传 / 下载 / 删除 + MIME 校验 + 大小校验
  │   └── storage/
  │       └── local-disk.storage.ts        ← MVP 本地 disk 实现，二期可加 s3.storage.ts
  └── repositories/
      ├── agenda.repository.ts
      ├── upload-task.repository.ts
      └── attachment.repository.ts
```

### 资料上传 multipart 流程（sequenceDiagram）

```mermaid
sequenceDiagram
  participant FE as Frontend
  participant Ctrl as AttachmentController
  participant Auth as requireMeetingUser
  participant Svc as AttachmentService
  participant Stor as LocalDiskStorage
  participant DB as Postgres
  participant Audit as AuditService

  FE->>Ctrl: POST /agenda-items/:itemId/attachments<br/>(multipart/form-data, file 唯一字段)
  Ctrl->>Auth: 校验登录 + 拉 actor.permissions
  Auth-->>Ctrl: { user, permissions, meetingId }

  Ctrl->>Svc: uploadToAgendaItem(itemId, file, actor)
  Svc->>DB: SELECT agenda_item + section + meetingId (WHERE deletedAt IS NULL)

  alt actor 有 attachment:upload:any
    Note over Svc: 直接放行，无需 task 命中
  else
    Note over Svc,DB: assigned 路径 auto-flip 决策（request body 不带 taskId）
    Svc->>DB: SELECT task WHERE agenda_item_id=:itemId AND assignee_user_id=actor.id AND status='PENDING' ORDER BY assigned_at ASC LIMIT 1 FOR UPDATE
    alt 无匹配 PENDING task
      alt actor 也无 attachment:upload:assigned 权限
        Svc-->>Ctrl: 403 UPLOAD_TASK_NOT_OWNED
        Ctrl-->>FE: error
      end
    end
  end

  Svc->>Svc: 校验 Content-Type ∈ 白名单
  alt MIME header 非法
    Svc-->>Ctrl: 415 ATTACHMENT_MIME_NOT_ALLOWED
  end
  Svc->>Svc: file-type 读首 4KB magic bytes 二次校验
  alt magic bytes 与 Content-Type 不一致
    Svc-->>Ctrl: 415 ATTACHMENT_MIME_MISMATCH
  end
  Svc->>Svc: 校验 size ≤ MEETING_ATTACHMENT_MAX_BYTES
  alt size 超限
    Svc-->>Ctrl: 413 ATTACHMENT_TOO_LARGE
  end

  Svc->>Stor: rename(tmpPath, finalPath) → relativePath
  Stor-->>Svc: storagePath

  Svc->>DB: BEGIN
  Svc->>DB: INSERT MeetingAgendaItemAttachment (createdById=actor.id)
  alt assigned 路径命中
    Svc->>DB: UPDATE MeetingAgendaItemUploadTask SET status='UPLOADED', completedAt=now() WHERE id=task.id AND status='PENDING'
    Note over Svc,DB: 检查 affectedRows，若 0 则查另一条 PENDING task
  end
  Svc->>Audit: INSERT MeetingAttendanceAuditLog (ATTACHMENT_UPLOADED, taskCompleted?)
  Svc->>DB: COMMIT

  Svc-->>Ctrl: attachment DTO
  Ctrl-->>FE: 201 + attachment 元数据
```

下载流程的本质差异：

- 提供两条独立下载 endpoint（不用 `:type` 形参，路径语义更直白）：
  - `GET /api/v1/meeting-attendance/attachments/agenda-item/:id/download`
  - `GET /api/v1/meeting-attendance/attachments/meeting/:id/download`
- 鉴权：`meeting:attachment:download` + 参会身份校验
- 响应：`Content-Disposition: attachment; filename="<ascii-fallback>"; filename*=UTF-8''<percent-encoded>`（RFC 5987），`storagePath` 由 `LocalDiskStorage.createReadStream()` 流式回写，不一次性载入内存

### 权限矩阵

> 角色基于现有 Administrator / MeetingManager / Leader / Employee + 会议级派生身份（creator / required attendee / external attendee）。
> ✅ 允许 / ❌ 拒绝 / ⚪ 受限（仅自己上传的） / ➖ 不适用

| 操作 | Administrator | MeetingManager | Leader / Employee | External Attendee | 备注 |
|------|---------------|----------------|-------------------|-------------------|------|
| 看议程（完整视图） | ✅ | ✅ | ✅（仅参会人） | ❌ | 外部访客走受限视图 |
| 看议程（受限视图：仅 section/item title） | ➖ | ➖ | ➖ | ✅ | 后端 DTO 剥离 description / presenter / 资料 |
| 创建 / 改 / 删段 | ✅ | ✅ | ❌ | ❌ | manager + creator 才能改 |
| 创建 / 改 / 删项 | ✅ | ✅ | ❌ | ❌ | 同上 |
| 拖拽排序段 / 项 | ✅ | ✅ | ❌ | ❌ |  |
| 分配上传任务 | ✅ | ✅ | ❌ | ❌ | `meeting:upload-task:assign` |
| 取消 / 删任务 | ✅ | ✅ | ❌ | ❌ |  |
| 看「我的待办」 | ✅ | ✅ | ✅ | ❌ | 按 assigneeUserId = actor 过滤 |
| 上传议程项资料（无 task） | ✅ | ✅ | ❌ | ❌ | `meeting:attachment:upload:any` |
| 上传议程项资料（命中 task） | ➖ | ➖ | ✅ | ❌ | `meeting:attachment:upload:assigned` |
| 上传会议级资料 | ✅ | ✅ | ❌ | ❌ | 仅 manager / creator |
| 下载议程项 / 会议级资料 | ✅ | ✅ | ✅ | ❌ | `meeting:attachment:download` |
| 删自己上传的资料 | ✅ | ✅ | ⚪ | ❌ | uploader 本人 |
| 删他人上传的资料（强删） | ✅ | ✅ | ❌ | ❌ | manager / creator |

判定顺序：
1. `requireMeetingUser` middleware 先确认登录 + 参会身份（含 external attendee 标记）
2. 再判权限点（`meeting:agenda:update` / `meeting:upload-task:assign` / `meeting:attachment:upload:*`）
3. 资料删除额外判 uploader 身份（仅自己 vs manager 强删）
4. 外部访客在 controller 入口直接走受限视图分支，不会进入 mutation 路径

### 议程项级 vs 会议级资料的差异

|  | 议程项级（MeetingAgendaItemAttachment） | 会议级（MeetingAttachment） |
|--|---------------------------------------|--------------------------|
| 挂载锚点 | `agendaItemId`（议程项） | `meetingId`（会议） |
| 业务语义 | 议程驱动的物料（PPT / 文档） | 议程外的资料（会议纪要 / 签到表 / 通知） |
| `category` 字段 | 无（议程项本身有 categoryTag） | 有（MINUTES / MATERIAL / 其他） |
| task 关联 | 可关联 upload-task | 不关联（不在 task 流程内） |
| 议程项软删时 | 同事务 cascade 软删（deletedAt 标记） | 不受影响（挂在 meeting 上） |
| 上传权限 | `:upload:any`（manager） / `:upload:assigned`（task 命中） | 仅 `:upload:any` |

### 文件存储抽象（v1.0 → 二期切换）

v1.0 MVP 用本地 disk（env / 命名等具体约定见 `06-data-model.md §文件存储约定` 为单源）：
- 根目录由 `MEETING_ATTACHMENT_STORAGE_ROOT` 配置（默认 `./var/meeting-attachments`）
- 相对路径写入 `storagePath`（不含根目录），便于二期迁移时只改根目录
- **cascade 删除策略**：service 层显式 soft-delete cascade（事务内 `UPDATE deletedAt = now() WHERE parent_id = ?`），不依赖 FK `onDelete: Cascade`
- 上传半路失败清扫策略见 `06-data-model.md` 同段

二期切 S3 / OSS：
- 新增 `s3.storage.ts` 实现，配置开关切换
- `storagePath` 字段语义变成 S3 key，不动 schema
- 历史数据迁移由独立脚本完成（不在 v1.0 范围）

### 软删 + cron 物理清理

- attachment 删除一律软删（`UPDATE deletedAt = now()`），不立即物理删 DB 行也不 unlink 文件
- 后台 cron job 扫 `deletedAt < now() - MEETING_ATTACHMENT_PHYSICAL_DELETE_DAYS days` 的行 → 物理删 DB 行 + `unlink storagePath` 对应文件
- env `MEETING_ATTACHMENT_PHYSICAL_DELETE_DAYS`，默认 30 天
- 用户在 30 天窗口内联系 admin 可恢复（admin 把 `deletedAt` 置 null 即可）
- 议程段/项软删时 service 层显式 cascade soft-delete 下属 attachment（同事务），cron 30 天后统一物理清理

### 上传中间件配置

- 使用 NestJS `FileInterceptor('file', { storage: diskStorage({...}), limits: { fileSize: 200*1024*1024, files: 1, fieldSize: 10*1024*1024 } })`
- **必须用 `diskStorage`，不用 `memoryStorage`**（否则 200MB 文件 OOM）
- tmp dest = `${MEETING_ATTACHMENT_STORAGE_ROOT}/tmp/uploads/`（与正式存储同盘，避免 EXDEV cross-device `rename()` 失败）
- 上传成功后 `rename(tmpPath, finalPath)`；失败时 `req.on('close'|'aborted')` unlink tmp，cron 兜底
- multer 抛 `LIMIT_FILE_SIZE` → controller exception filter 转 413 `ATTACHMENT_TOO_LARGE`
- 全局 `main.ts` 的 `express.json({ limit: '50mb' })` **不影响** multipart 路径（multer 单独解析）

### 反向代理 body size + timeout（部署前置）

议程 v1.0 上传 200MB 文件需要反向代理放行大 body + 长 timeout：

**Caddy**：
```
request_body {
  max_size 200MB
}
reverse_proxy backend:3000 {
  transport http {
    dial_timeout 30s
    read_timeout 10m
    write_timeout 10m
  }
}
```

**Nginx**：
```
client_max_body_size 210M;
client_body_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
```

下载链路同样需要长 timeout（200MB 文件流式下载在慢网络下可能 5-10 分钟）。

### BigInt 响应序列化

- `size` 字段在 Prisma 是 `BigInt`，`JSON.stringify(BigInt)` 默认抛 `TypeError`
- 解决：全局打 `BigInt.prototype.toJSON = function() { return this.toString(); }`（或写 NestJS interceptor 统一序列化）
- 后果：`06-data-model.md` + `07-api.md` 响应 schema 标注 `size: string`（前端用 `BigInt(resp.size)` 反序列化做计算）

### 并发与事务

- **reorder**：单事务 `UPDATE meeting_agenda_sections SET order = CASE id WHEN $1 THEN 0 WHEN $2 THEN 1 ... END WHERE id IN (...)` 一条 SQL 完成；或先 `SELECT FOR UPDATE` 锁定后批量 update
- **DELETE item**：事务内 `SELECT FOR UPDATE` 锁议程项行，避免与并发 `INSERT attachment` race condition
- **multi-assignee 上传 auto-flip**：`UPDATE ... WHERE id = task.id AND status = 'PENDING'`，检查 affectedRows；若 0 说明被另一并发请求抢先 flip，则查另一条 PENDING task；都查不到则视作"已无待办"放行（任 attachment 直接挂 item，不 flip task）
- **attachment INSERT + task UPDATE 同事务**：保证"附件落库" 与 "任务转 UPLOADED" 原子性

### 审计扩展（复用 MeetingAttendanceAuditLog）

扩展 `backend/src/modules/meeting-attendance/constants/audit.ts` 中 `MEETING_ATTENDANCE_AUDIT_ACTIONS` 常量对象，新增 10 个键；`audit_logs.action` 字段类型不变（`String VarChar(100)`），**不需要 prisma migration**（AuditAction 不是 prisma enum 而是 TS 常量）。

新增 10 个 AuditAction（key 即 value，字符串常量）：
- `AGENDA_SECTION_CREATED` / `AGENDA_SECTION_UPDATED` / `AGENDA_SECTION_DELETED`
- `AGENDA_ITEM_CREATED` / `AGENDA_ITEM_UPDATED` / `AGENDA_ITEM_DELETED`
- `UPLOAD_TASK_ASSIGNED` / `UPLOAD_TASK_CANCELLED`
- `ATTACHMENT_UPLOADED` / `ATTACHMENT_DELETED`

同时新增 4 个 AuditResource（同样是 TS 常量）：`AGENDA_SECTION` / `AGENDA_ITEM` / `UPLOAD_TASK` / `ATTACHMENT`。

| AuditAction | AuditResource |
|-------------|---------------|
| AGENDA_SECTION_CREATED | AGENDA_SECTION |
| AGENDA_SECTION_UPDATED | AGENDA_SECTION |
| AGENDA_SECTION_DELETED | AGENDA_SECTION |
| AGENDA_ITEM_CREATED | AGENDA_ITEM |
| AGENDA_ITEM_UPDATED | AGENDA_ITEM |
| AGENDA_ITEM_DELETED | AGENDA_ITEM |
| UPLOAD_TASK_ASSIGNED | UPLOAD_TASK |
| UPLOAD_TASK_CANCELLED | UPLOAD_TASK |
| ATTACHMENT_UPLOADED | ATTACHMENT |
| ATTACHMENT_DELETED | ATTACHMENT |

`changes` 字段（JSONB）至少包含 `entityId` / `entityType` / `before` / `after`（或对应字段）；会议结束后改议程的场景额外加 `agendaModifiedAfterMeetingEnd: true` flag。
