# 会议出勤（Meeting Attendance）- API 文档

> **版本**: v1.2
> **最后更新**: 2026-04-21
> **维护者**: 待定

---

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

### 统一约定

- Base URL: `/api/v1/meeting-attendance`
- 认证: Bearer Token (JWT)，访客签到与公开会议信息为匿名访问
- 错误码: [08-error-codes.md](./08-error-codes.md)
- 响应格式: **兼容旧系统**，多数接口返回原始 JSON（非统一封装）
- 角色: Administrator/MeetingManager/Leader/Employee（旧角色 ADMIN/MANAGER/LEADER/EMPLOYEE 映射）

### 通用状态码

| 状态码 | 说明 |
|-------|------|
| 200 | 成功 |
| 201 | 创建成功 |
| 400 | 参数错误/业务校验失败 |
| 401 | 未认证 |
| 403 | 无权限 |
| 404 | 资源不存在 |
| 409 | 业务冲突 |
| 500 | 服务器错误 |

### 接口数量汇总

- 总计: 92 个接口（v1.2 签到方式校验域共 8 个：撤销 v1.1 的导入 3 个，新增 5 个城市派生 + 系列级覆盖 + 导入预览相关；v1.0 议程与资料域共 **22 个对外路径**：议程查看 1 + 段 CRUD + reorder 4 + 项 CRUD + reorder 4 + 上传任务 4 + 我的待办 1 + 议程项资料 CRUD 3 + 会议级资料 CRUD 3 + 下载 2）
- 统计口径: 下方“接口清单（按域）”全部路径

---

## 接口清单（按域）

### 1) 会议（12）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 001 | GET | /meetings | 登录 |
| 002 | POST | /meetings | Administrator/MeetingManager |
| 003 | GET | /meetings/:id | 登录 |
| 004 | PUT | /meetings/:id | Administrator/MeetingManager |
| 005 | DELETE | /meetings/:id | Administrator/MeetingManager |
| 006 | GET | /meetings/:id/public | 匿名 |
| 007 | GET | /meetings/:id/required-attendees | 登录 |
| 008 | POST | /meetings/:id/required-attendees | Administrator/MeetingManager |
| 009 | DELETE | /meetings/:id/required-attendees/:userId | Administrator/MeetingManager |
| 010 | POST | /meetings/:id/import-attendees | Administrator/MeetingManager |
| 011 | POST | /meetings/:id/mark-absent | Administrator/MeetingManager |
| 012 | GET | /meetings/:id/attendees/search | 匿名 |
| 073 | PATCH | /meetings/:id/enforce-checkin-mode | Administrator/MeetingManager |
| 074 | PATCH | /meetings/:id/required-attendees/:userId/checkin-mode | Administrator/MeetingManager |
| 084 | POST | /meetings/:meetingId/apply-pto | Administrator/MeetingManager |

### 2) 签到（3）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 013 | POST | /meetings/:id/checkin | 登录 |
| 014 | POST | /meetings/:id/guest-checkin | 匿名 |
| 015 | PATCH | /meetings/:id/attendance/:userId | Administrator/MeetingManager |

### 3) 系列会议（13）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 016 | GET | /series | 登录 |
| 017 | POST | /series | Administrator/MeetingManager |
| 018 | GET | /series/:id | 登录 |
| 019 | PUT | /series/:id | Administrator/MeetingManager |
| 020 | DELETE | /series/:id | Administrator/MeetingManager |
| 021 | GET | /series/:id/history | 登录 |
| 022 | POST | /series/:id/update-schedule | Administrator/MeetingManager |
| 023 | GET | /series/:id/attendees | 登录；v1.3 起聚合视图自动过滤排除清单中的用户 |
| 024 | POST | /series/:id/attendees | Administrator/MeetingManager/Leader 或 系列创建者；权限点 `meeting_attendance:manage` |
| 025 | PATCH | /series/:id/attendees | Administrator/MeetingManager/Leader 或 系列创建者；权限点 `meeting_attendance:manage` |
| 026 | DELETE | /series/:id/attendees | Administrator/MeetingManager/Leader 或 系列创建者；权限点 `meeting_attendance:manage`；v1.3 起持久化排除清单（见 06-data-model.md） |
| 027 | PUT | /series/:id/meetings/:meetingId | Administrator/MeetingManager |
| 028 | DELETE | /series/:id/meetings/:meetingId | Administrator/MeetingManager |
| 076 | PATCH | /series/:id/enforce-checkin-mode | Administrator/MeetingManager |
| 080 | GET | /series/:id/attendee-preferences | Administrator/MeetingManager |
| 081 | PUT | /series/:id/attendee-preferences | Administrator/MeetingManager |
| 082 | DELETE | /series/:id/attendee-preferences/:userId | Administrator/MeetingManager |

### 4) 模板（5）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 029 | GET | /templates | 登录 |
| 030 | POST | /templates | 登录 |
| 031 | PUT | /templates/:id | 登录 |
| 032 | DELETE | /templates/:id | 登录 |
| 033 | POST | /templates/:id/create-meeting | 登录 |

### 5) 用户（6）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 034 | GET | /users | Administrator/MeetingManager |
| 035 | POST | /users | Administrator/MeetingManager |
| 036 | GET | /users/:id | 本人或 Administrator/MeetingManager |
| 037 | PUT | /users/:id | 本人或 Administrator |
| 038 | DELETE | /users/:id | Administrator |
| 039 | GET | /users/search | 登录 |
| 040 | POST | /users/import | Administrator/MeetingManager |
| 041 | GET | /users/import | Administrator/MeetingManager |
| 042 | POST | /users/:id/reset-password | 本人或 Administrator |
| 043 | DELETE | /users/:id/permanent | Administrator/MeetingManager |
| 083 | GET | /cities/suggestions | 登录 |

### 6) 报表（3）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 044 | GET | /reports/attendance | Administrator/MeetingManager/Leader |
| 045 | GET | /reports/series | Administrator/MeetingManager/Leader |
| 046 | GET | /reports/single-meeting | Administrator/MeetingManager/Leader |
| 085 | GET | /reports/series-options | Administrator/MeetingManager/Leader |

### 7) 审计（2）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 047 | GET | /audit-logs | Administrator/MeetingManager |
| 048 | GET | /audit-logs/stats | Administrator/MeetingManager |

### 8) Outlook 同步集成（18）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 053 | GET | /integrations/outlook/candidates | Administrator/MeetingManager |
| 071 | GET | /integrations/outlook/candidates/:seriesMasterId/children | Administrator/MeetingManager |
| 054 | POST | /integrations/outlook/bindings | Administrator/MeetingManager |
| 055 | GET | /integrations/outlook/bindings | Administrator/MeetingManager |
| 072 | GET | /integrations/outlook/bindings/series/:seriesMasterId/children | Administrator/MeetingManager |
| 070 | GET | /integrations/outlook/bindings/all | Administrator |
| 068 | POST | /integrations/outlook/bindings/:id/takeover | Administrator/MeetingManager |
| 057 | GET | /integrations/outlook/bindings/:id | Administrator/MeetingManager |
| 063 | GET | /integrations/outlook/bindings/:id/history | Administrator/MeetingManager |
| 064 | GET | /integrations/outlook/bindings/:id/history/export.csv | Administrator/MeetingManager |
| 065 | GET | /integrations/outlook/bindings/:id/exclusions | Administrator/MeetingManager |
| 066 | POST | /integrations/outlook/bindings/:id/exclusions | Administrator/MeetingManager |
| 067 | POST | /integrations/outlook/exclusions/:id/remove | Administrator/MeetingManager |
| 058 | POST | /integrations/outlook/sync/reconcile | Administrator/MeetingManager |
| 059 | GET | /integrations/outlook/settings | Administrator/MeetingManager |
| 060 | PATCH | /integrations/outlook/settings | Administrator/MeetingManager |
| 061 | POST | /integrations/outlook/webhooks/notifications | 匿名（Graph 回调） |
| 062 | POST | /integrations/outlook/webhooks/lifecycle | 匿名（Graph 回调） |
| 086 | POST | /integrations/outlook/bindings/:id/unmanage | Administrator/MeetingManager |
| 087 | POST | /integrations/outlook/bindings/:id/resume-sync | Administrator/MeetingManager |

### 9) 议程查看与段 CRUD（v1.0，5）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 100 | GET | /meetings/:id/agenda | `meeting:agenda:read`（参会人即可） |
| 101 | POST | /meetings/:id/agenda/sections | `meeting:agenda:update`（MeetingManager / 会议创建人） |
| 102 | PATCH | /meetings/:id/agenda/sections/:sectionId | `meeting:agenda:update` |
| 103 | DELETE | /meetings/:id/agenda/sections/:sectionId | `meeting:agenda:update` |
| 104 | PATCH | /meetings/:id/agenda/sections/reorder | `meeting:agenda:update` |

### 10) 议程项 CRUD（v1.0，4）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 105 | POST | /agenda-sections/:sectionId/items | `meeting:agenda:update` |
| 106 | PATCH | /agenda-sections/:sectionId/items/:itemId | `meeting:agenda:update` |
| 107 | DELETE | /agenda-sections/:sectionId/items/:itemId | `meeting:agenda:update` |
| 108 | PATCH | /agenda-sections/:sectionId/items/reorder | `meeting:agenda:update` |

### 11) 议程项上传任务（v1.0，4）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 109 | POST | /agenda-items/:itemId/upload-tasks | `meeting:upload-task:assign` |
| 110 | GET | /agenda-items/:itemId/upload-tasks | `meeting:agenda:read` |
| 111 | PATCH | /agenda-items/:itemId/upload-tasks/:taskId | `meeting:upload-task:assign` |
| 112 | DELETE | /agenda-items/:itemId/upload-tasks/:taskId | `meeting:upload-task:assign` |

### 12) 我的待办（v1.0，1）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 113 | GET | /my-upload-tasks | 登录（仅返回当前用户被 assign 的任务） |

### 13) 议程项级资料（v1.0，3）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 114 | POST | /agenda-items/:itemId/attachments | `meeting:attachment:upload:any` 或 (`meeting:attachment:upload:assigned` AND 是该 item 的 assignee) |
| 115 | GET | /agenda-items/:itemId/attachments | `meeting:agenda:read` |
| 116 | DELETE | /agenda-items/:itemId/attachments/:attachmentId | uploader 本人或 MeetingManager |

### 14) 会议级资料（v1.0，3）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 117 | POST | /meetings/:id/attachments | `meeting:attachment:upload:any` |
| 118 | GET | /meetings/:id/attachments | `meeting:agenda:read`（参会人即可） |
| 119 | DELETE | /meetings/:id/attachments/:attachmentId | uploader 本人或 MeetingManager |

### 15) 资料下载（v1.0，2）

| 序号 | 方法 | 路径 | 权限 |
|------|------|------|------|
| 120 | GET | /attachments/agenda-item/:id/download | `meeting:attachment:download`（参会人即可） |
| 121 | GET | /attachments/meeting/:id/download | `meeting:attachment:download`（参会人即可） |

---

## 数据结构定义（字段级）

### Meeting

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | ✅ | 会议 ID |
| title | string | ✅ | 标题 |
| description | string | ❌ | 描述 |
| startTime | datetime | ✅ | 开始时间（UTC instant） |
| endTime | datetime | ✅ | 结束时间（UTC instant） |
| timezone | string | ✅ | 业务时区（IANA，如 America/Los_Angeles） |
| location | string | ❌ | 地点 |
| type | enum | ✅ | OFFLINE/ONLINE/HYBRID |
| status | enum | ✅ | SCHEDULED/IN_PROGRESS/COMPLETED/CANCELLED |
| qrCodeOnline | string | ❌ | 线上二维码 |
| qrCodeOffline | string | ❌ | 线下二维码 |
| creatorId | uuid | ✅ | 创建人（platform_iam.users.id） |
| seriesId | string | ❌ | 系列 ID |
| city | string | ❌ | 会议地点城市名（v1.2）；字符串，区分大小写匹配 |
| enforceCheckinMode | boolean | ✅ | 签到方式校验开关（v1.2）；开启后按城市派生拦截扫码 |
| outlookSync | object | ❌ | Outlook 纳管与同步状态摘要，仅单次/实例会议返回 |

#### Meeting.outlookSync

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| bindingId | string | ✅ | Outlook 纳管绑定 ID |
| manageStatus | enum | ✅ | PENDING_SELECTION/MANAGED/SYNC_ERROR/DISABLED |
| syncMode | enum | ✅ | AUTO/LOCKED_BY_LOCAL_EDIT |
| localOverrideAt | datetime | ❌ | 最近一次转为本地维护的时间 |
| localOverrideByUserId | string | ❌ | 最近一次触发本地维护的用户 ID |
| localOverrideByEmail | string | ❌ | 最近一次触发本地维护的用户邮箱 |
| localOverrideReason | string | ❌ | 最近一次本地维护原因 |
| localOverrideFields | json | ❌ | 触发本地维护的字段列表 |
| primaryMailbox | object | ❌ | 当前生效来源邮箱摘要 |

### Attendance

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | ✅ | 出勤 ID |
| userId | uuid | ✅ | 用户 ID（platform_iam.users.id） |
| meetingId | string | ✅ | 会议 ID |
| status | enum | ✅ | ON_SITE/ONLINE/LATE/ABSENT/PTO/BUSINESS_CONFLICT/NOT_CHECKED_IN |
| checkinTime | datetime | ❌ | 签到时间 |
| checkinType | enum | ❌ | QR_CODE/MANUAL |
| deviceId | string | ❌ | 设备 ID |

### MeetingRequiredAttendee

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | ✅ | 参会名单 ID |
| meetingId | string | ✅ | 会议 ID |
| userId | uuid | ✅ | 系统用户 ID（platform_iam.users.id） |
| role | enum | ✅ | 参会角色 |
| checkinMode | enum | ❌ | 本场会议签到方式覆盖（v1.2），`ON_SITE/ONLINE/null`；null 沿用系列级 → 城市派生 |

### MeetingSeriesAttendeePreference（v1.2）

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| seriesId | string | ✅ | 系列 ID（复合主键一） |
| userId | uuid | ✅ | 用户 ID（复合主键二，FK platform_iam.users.id） |
| defaultCheckinMode | enum | ✅ | `ON_SITE/ONLINE`；null 值不建记录 |
| updatedByUserId | uuid | ❌ | 最近一次修改人 |
| updatedAt | datetime | ✅ | 更新时间 |

### User（v1.2 补充）

现有 DTO 加字段：

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| workCity | string | ❌ | 用户工作城市（v1.2）；字符串，trim 后原样；与 meeting.city 精确比较 |

### User

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 用户 ID |
| email | string | ✅ | 邮箱 |
| displayName | string | ✅ | 姓名 |
| role | enum | ✅ | Administrator/MeetingManager/Leader/Employee（映射到 ADMIN/MANAGER/LEADER/EMPLOYEE） |
| department | object | ❌ | 主部门（基于平台用户主部门） |
| position | object | ❌ | 主岗位（基于平台用户主岗位） |

### OutlookMailbox

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | ✅ | 源邮箱配置 ID |
| mailboxEmail | string | ✅ | 源邮箱地址 |
| mailboxType | enum | ✅ | SHARED/PERSONAL |
| isEnabled | boolean | ✅ | 启停状态（系统自动维护） |
| subscriptionStatus | enum | ✅ | ACTIVE/EXPIRED/ERROR |
| expirationAt | datetime | ❌ | 当前订阅到期时间 |

### OutlookBinding

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | string | ✅ | 纳管绑定 ID |
| meetingId | string | ❌ | 本地会议 ID（single/occurrence/exception 有值；seriesMaster 允许为空，仅作为系列锚点） |
| graphEventId | string | ✅ | Graph 事件 ID |
| iCalUId | string | ✅ | Graph iCalUId |
| ownerUserId | string | ❌ | 当前绑定管理员 ID（接管后更新） |
| ownerEmail | string | ❌ | 当前绑定管理员邮箱 |
| syncFrom | datetime | ✅ | 同步边界起点，仅同步该时间之后实例 |
| manageStatus | enum | ✅ | PENDING_SELECTION/MANAGED/DISABLED |
| primaryMailboxId | string | ✅ | 当前主来源邮箱 |
| cancellationSource | enum | ❌ | CANCELLED_BY_ORGANIZER/DELETED |
| syncMode | enum | ✅ | AUTO/LOCKED_BY_LOCAL_EDIT |
| localOverrideAt | datetime | ❌ | 最近一次转为本地维护的时间 |
| localOverrideByUserId | string | ❌ | 最近一次触发本地维护的用户 ID |
| localOverrideByEmail | string | ❌ | 最近一次触发本地维护的用户邮箱 |
| localOverrideReason | string | ❌ | 最近一次本地维护原因 |
| localOverrideFields | json | ❌ | 触发本地维护的字段列表 |

---

## 统一格式 + 简短示例

**[001] GET /meetings**
- 说明: 会议列表（支持分页与状态过滤）
- 请求 Query: { page?, limit?, status?, anchor? }
- 响应 Data: { meetings: Meeting[], pagination }
- 示例请求: `GET /api/v1/meeting-attendance/meetings?page=1&limit=10&status=SCHEDULED`
- 示例响应:
```json
{
  "meetings": [],
  "pagination": { "page": 1, "limit": 10, "total": 0, "totalPages": 0 }
}
```

**[013] POST /meetings/:id/checkin**
- 说明: 登录用户签到
- 请求 Body: { qrData?, checkinType?, attendanceStatus?, deviceId? }
- 响应 Data: { message, attendance, isLate, debug? }
- 状态码: 200, 400, 401, 404

**[014] POST /meetings/:id/guest-checkin**
- 说明: 访客签到（免登录，必须在参会名单内）
- 请求 Body: { name, email?, attendanceStatus, deviceId? }
- 响应 Data: { message, attendance, isLate }
- 状态码: 200, 400, 403, 404

**[009] DELETE /meetings/:id/required-attendees/:userId**
- 说明: 删除单次会议参会人
- 请求 Path: `{ id, userId }`
- 业务规则:
  - 若该参会人不存在于名单中，返回 404
  - 若该参会人已有真实出勤记录（已签到、非 `NOT_CHECKED_IN` 或人工调整），返回 409
  - 删除成功后同步删除其占位 attendance 记录
  - 若会议已纳管 Outlook 绑定，删除成功后该绑定切换为 `LOCKED_BY_LOCAL_EDIT`
- 响应 Data: `{ message, remainingCount, syncMode?, lockInfo? }`
- 状态码: 200, 404, 409

**[017] POST /series**
- 说明: 创建系列会议并生成实例
- 请求 Body: { title, pattern, startDate, duration, frequency?, endDate?, maxOccurrences?, attendees? }
- 响应 Data: { series, meetings, message }
- 状态码: 201, 400, 401, 403

**[031] PUT /templates/:id**
- 说明: 更新会议模板
- 请求 Body: { name?, title?, description?, duration?, location?, type?, isPublic?, attendeeIds? }
- 响应 Data: { template }
- 状态码: 200, 400, 401, 403, 404

**[032] DELETE /templates/:id**
- 说明: 删除会议模板
- 请求 Body: { }
- 响应 Data: { message }
- 状态码: 200, 401, 403, 404

**[044] GET /reports/attendance**
- 说明: 出勤报表总览（用于 Teams 报表）
- 请求 Query: { }
- 响应 Data: { summary, departmentStats }
- 状态码: 200, 401, 403
- 示例响应:
```json
{
  "summary": {
    "totalAttendances": 120,
    "presentCount": 85,
    "lateCount": 10,
    "absentCount": 25,
    "attendanceRate": 71
  },
  "departmentStats": [
    { "department": "IT", "total": 40, "present": 30, "attendanceRate": 75 }
  ]
}
```

**[045] GET /reports/series**
- 说明: 生成系列会议报表
- 请求 Query: { seriesId, startDate?, endDate? }
- 响应 Data: { series, meetingRange, overallStats, statusDistribution, roleStats, personalStats, departmentStats, lowAttendanceRanking }
- 状态码: 200, 400, 401, 403
- `overallStats` 字段（v1.3 起 optional 拆出）:
  - `totalMeetings`：本次报表覆盖的会议数（仅 series 报表，single-meeting 无此字段）
  - `totalRequired` / `totalAttended` / `attendanceRate`：**仅统计 REGULAR_ATTENDEE**
  - `optionalTotal` / `optionalAttended`：可选参会人单独累计，不进 `attendanceRate` 分母
  - `statusDistribution` 和 `departmentStats` 也仅统计 REGULAR_ATTENDEE，跟 `totalRequired` 口径一致
  - `roleStats` 仍按 role 完整分桶（含 OPTIONAL_ATTENDEE 行）
  - `personalStats` 完整返回（前端可据 `role` 字段做过滤展示）

**[046] GET /reports/single-meeting**
- 说明: 生成单场会议报表
- 请求 Query: { meetingId }
- 响应 Data: { meeting, overallStats, statusDistribution, roleStats, personalStats, departmentStats }
- 状态码: 200, 400, 401, 403
- `overallStats` 字段口径同 [045]，含 `optionalTotal` / `optionalAttended`

**[047] GET /audit-logs**
- 说明: 查询审计日志（管理员）
- 请求 Query: { userId?, userEmail?, action?, resource?, startDate?, endDate?, page?, pageSize? }
- 响应 Data: { logs, pagination }
- 状态码: 200, 401, 403

**[053] GET /integrations/outlook/candidates**
- 说明: 查询候选会议池（仅未纳管或可重纳管记录）
- 请求 Query: { mailboxId?, keyword?, eventType?, includeCancelled?, includePast?, onlyUnmanaged?, startDate?, endDate?, page?, pageSize? }
- 说明补充: 当未传 `mailboxId` 且当前管理员邮箱尚未登记为源邮箱时，系统自动创建并启用该个人邮箱后再返回候选会议。
- 说明补充: 当未传 `eventType` 时，候选顶层默认仅返回 `single + seriesMaster`；`occurrence/exception` 通过 `[071]` 展开接口按需加载（若显式传 `eventType=occurrence/exception` 则按筛选返回对应类型）。
- 说明补充: 当该邮箱尚未建立同步游标（首次进入场景）时，接口会异步触发候选快照初始化（按 `lookbackDays/lookaheadDays` 拉取，包含系列实例），当前请求不阻塞等待初始化完成。
- 说明补充: 当“包含历史会议”未勾选时，已结束 `seriesMaster` 不返回候选列表；已纳管系列仍通过已绑定列表/系列页查看。
- 响应 Data: { mailbox, items, pagination, sourceLagSeconds, snapshotInitializing }，其中：
  - `snapshotInitializing`: 是否正在执行首次候选快照初始化（`true` 时前端应提示“数据初始化中”）
  - `items[]` 关键字段包含：
  - `seriesChildCount`: 系列主会议在当前筛选口径下的下属实例数量（无需先展开子项）
  - `bootstrapStatus`: 系列纳管补齐状态（QUEUED/RUNNING/SUCCEEDED/FAILED）
  - `bootstrapError`: 系列纳管补齐失败时的错误描述
  - `managedBySeries`: 是否由系列纳管自动覆盖
    - 判定边界：仅当实例开始时间 `>=` 系列绑定 `syncFrom` 时为 `true`；`syncFrom` 之前的历史实例不标记为已纳管。
  - `effectiveSyncFrom`: 候选显示过滤边界（有绑定时取绑定 `syncFrom`，无绑定为空）
  - `isExcludedBySeries`: 是否被系列排除规则命中
  - `seriesBindingId`: 对应 seriesMaster 绑定 ID（用于排除/取消排除实例）
  - 过滤口径补充：当“包含历史会议”未勾选时，候选会议严格仅展示未来会议（`startTime >= now`）；`seriesMaster` 仅在未结束且存在未来实例时展示。
- 状态码: 200, 401, 403

**[071] GET /integrations/outlook/candidates/:seriesMasterId/children**
- 说明: 查询候选系列主会议下的实例列表（occurrence/exception），用于前端按需展开，避免候选首屏一次加载全部实例
- 请求 Query: { mailboxId?, includeCancelled?, includePast? }
- 说明补充: 优先读取本地 `outlook_event_snapshots` 子项；仅在本地无子项时回源 Outlook 拉取并回写快照。若同步游标陈旧，会后台异步入队邮箱同步，不阻塞本次展开响应。
- 说明补充: 当“包含历史会议”未勾选且该 `seriesMaster` 已结束时，返回空列表；即使本地快照存在残留未来实例也不得返回。
- 响应 Data: { mailbox, seriesMasterId, items[] }
- 状态码: 200, 400, 401, 403, 404

**[054] POST /integrations/outlook/bindings**
- 说明: 纳管候选会议并生成/绑定本地会议签到项；当目标属于系列会议时，自动切换为 `seriesMaster` 级纳管（仅建立系列锚点，不直接创建 seriesMaster 对应单场会议）
- 请求 Body: { mailboxId, graphEventId, iCalUId, action: "MANAGE" }
- 响应 Data: { binding, meeting, bootstrapQueued }（`graphEventType=seriesMaster` 时 `meeting` 可为空；当 `bootstrapQueued=true` 时，系列实例补齐在后台异步执行并创建 occurrence/exception 会议）
- 说明补充: 已结束 `seriesMaster` 禁止纳管，返回 400 与明确提示。
- 状态码: 201, 400, 401, 403, 404, 409

**[055] GET /integrations/outlook/bindings**
- 说明: 查询已纳管会议绑定列表（支持分页与关键词）
- 请求 Query: { mailboxId, keyword?, eventType?, status?, page?, pageSize? }
- 响应 Data: { mailbox, items, pagination }，其中 `items[]` 对系列会议返回：
  - `seriesChildCount`: 已纳管系列下实例数量
  - `bootstrapStatus/bootstrapError`: 系列补齐状态与失败原因
- 状态码: 200, 400, 401, 403, 404

**[072] GET /integrations/outlook/bindings/series/:seriesMasterId/children**
- 说明: 查询已纳管系列会议下的实例绑定列表（分页列表中的系列行按需展开）
- 请求 Query: { mailboxId? }，`mailboxId` 为空时按权限返回跨邮箱子项（Administrator 场景）
- 响应 Data: { seriesMasterId, items[], total }
- 状态码: 200, 401, 403, 404

**[070] GET /integrations/outlook/bindings/all**
- 说明: 查询全局已纳管会议绑定列表（仅 Administrator，可跨邮箱）
- 请求 Query: { keyword?, eventType?, status?, page?, pageSize? }
- 响应 Data: { items, pagination }
- 状态码: 200, 401, 403

**[063] GET /integrations/outlook/bindings/:id/history**
- 说明: 查询单个纳管绑定的同步事件时间线（按时间倒序）
- 请求 Query: { page?, pageSize?, startDate?, endDate?, eventType?, stage?, onlyError? }
- 说明补充: 时间线项需关联最近一次官方源版本/差异摘要，至少返回该次同步对应的 `graphLastModifiedAt`、`fetchedAt`、`hasContentChange`、`changedFields` 与 `changeSummary`。
- 说明补充: `changeSummary` 需同时给出 `graph attendees count`、`internal matched attendees count`、`meeting required attendees count` 的前后值；当存在参会人变化时，支持返回 `attendeesAdded/attendeesRemoved/attendeesResponseChanged` 摘要。
- 说明补充: 当会议已转为本地维护且本次同步被跳过时，时间线项使用 `eventType=SYNC_SKIPPED_LOCAL_OVERRIDE`，并在 `payload` 中返回 `reason/changedFields/triggerSource`。
- 响应 Data: { bindingId, items[], pagination }
- 状态码: 200, 400, 401, 403, 404

**[064] GET /integrations/outlook/bindings/:id/history/export.csv**
- 说明: 导出单个纳管绑定的同步事件时间线 CSV（支持与列表一致的筛选条件）
- 请求 Query: { startDate?, endDate?, eventType?, stage?, onlyError? }
- 说明补充: CSV 导出需包含可读的官方源差异摘要列，至少覆盖 `graphLastModifiedAt`、`fetchedAt`、`changedFields` 与关键人数变化摘要。
- 响应: `text/csv`
- 状态码: 200, 400, 401, 403, 404

**[065] GET /integrations/outlook/bindings/:id/exclusions**
- 说明: 查询系列主绑定下的“单次 occurrence 排除”列表
- 请求 Query: { page?, pageSize? }
- 响应 Data: { bindingId, items[], pagination }
- 状态码: 200, 400, 401, 403, 404

**[066] POST /integrations/outlook/bindings/:id/exclusions**
- 说明: 在系列主绑定下新增 occurrence 排除（仅允许 occurrence/exception）
- 请求 Body: { occurrenceGraphEventId, iCalUId?, reason? }
- 响应 Data: { exclusion }
- 状态码: 201, 400, 401, 403, 404

**[067] POST /integrations/outlook/exclusions/:id/remove**
- 说明: 移除单次 occurrence 排除记录
- 请求 Body: {}
- 响应 Data: { removed, exclusionId }
- 状态码: 200, 401, 403, 404

**[058] POST /integrations/outlook/sync/reconcile**
- 说明: 触发 Outlook 对账；当传入 `mailboxId` 时仅对当前邮箱执行，对账在 Delta 同步后会按批次补齐当前候选窗口内缺少 `occurrence/exception` 快照的 `seriesMaster`
- 请求 Body: { mailboxId? }
- 说明补充: 系列子快照补齐为后台限量批处理，目标是改善候选列表完整性，不保证单次请求覆盖全部系列
- 响应 Data: { accepted, mailboxId }
- 状态码: 200, 400, 401, 403, 404

**[059] GET /integrations/outlook/settings**
- 说明: 获取 Outlook 同步后台策略配置
- 请求 Query: {}
- 响应 Data: { settings }，关键字段：
  - `reconcileCron`, `deltaBatchSize`, `lookaheadDays`, `lookbackDays`, `renewBeforeMinutes`
  - `includeOrganizerAsAttendee`（是否将 organizer 并入参会人名单）
- 状态码: 200, 401, 403

**[060] PATCH /integrations/outlook/settings**
- 说明: 更新 Outlook 同步后台策略配置
- 请求 Body: { reconcileCron?, deltaBatchSize?, lookaheadDays?, lookbackDays?, renewBeforeMinutes?, includeOrganizerAsAttendee? }
- 响应 Data: { settings }
- 状态码: 200, 400, 401, 403

**[061] POST /integrations/outlook/webhooks/notifications**
- 说明: Graph 变更通知回调与 validationToken 验证
- 请求 Query: { validationToken? }
- 响应 Data: { accepted, received } 或 text/plain token
- 状态码: 200, 202

---

## 签到方式校验（v1.2）

所有签到方式相关修改走独立 endpoint，**不触发** Outlook `LOCKED_BY_LOCAL_EDIT`。

**[073] PATCH /meetings/:id/enforce-checkin-mode**
- 说明: 修改单场会议的签到方式校验开关；不影响 Outlook 同步状态
- 请求 Body: `{ enforceCheckinMode: boolean }`
- 业务规则:
  - 当 `enforceCheckinMode=true` 且 `meeting.city == null` → 后端 guard 拒绝，返回 `MEETING_ATTENDANCE_036`
  - 当 `enforceCheckinMode` 从 `true → false` 时，**清空本场所有 `MeetingRequiredAttendee.checkinMode`**（meeting-level 临时覆盖），**保留** `MeetingSeriesAttendeePreference`（系列级默认覆盖）（v1.4）
- 响应 Data: `{ meetingId, enforceCheckinMode, clearedAttendeeOverrides? }`（`clearedAttendeeOverrides` 仅在 ON→OFF 切换时返回，表示清空的会议级覆盖条目数）
- 状态码: 200, 400, 401, 403, 404

**[074] PATCH /meetings/:id/required-attendees/:userId/checkin-mode**
- 说明: 对本场会议某参会人做签到方式临时调整；传 null 清除覆盖
- 请求 Body: `{ checkinMode: "ON_SITE" | "ONLINE" | null }`
- 响应 Data: `{ meetingId, userId, checkinMode, previousCheckinMode }`
- 业务规则:
  - 仅允许调整本场已存在的 `MeetingRequiredAttendee`
  - 写入 audit log（`action=ATTENDEE_CHECKIN_MODE_OVERRIDE`）
  - **不**调用 `lockOutlookBindingForMeeting`
- 状态码: 200, 400, 401, 403, 404

**[076] PATCH /series/:id/enforce-checkin-mode**
- 说明: 修改系列的签到方式校验开关；级联刷新所有下属 meeting 的同名字段
- 请求 Body: `{ enforceCheckinMode: boolean }`
- 业务规则:
  - 当 `enforceCheckinMode=true` 且 `series.city == null` → 后端 guard 拒绝，返回 `MEETING_ATTENDANCE_036`
  - 当 `enforceCheckinMode` 从 `true → false` 时，**级联清空所有下属会议的 `MeetingRequiredAttendee.checkinMode`**（meeting-level 临时覆盖），**保留** `MeetingSeriesAttendeePreference`（系列级默认覆盖）（v1.4）
- 响应 Data: `{ seriesId, enforceCheckinMode, updatedMeetingCount, clearedAttendeeOverrides? }`（`clearedAttendeeOverrides` 仅在 ON→OFF 切换时返回，表示清空的会议级覆盖条目数）
- 状态码: 200, 400, 401, 403, 404

**[080] GET /series/:id/attendee-preferences**
- 说明: 查询系列的参会人签到方式默认覆盖列表
- 请求 Query: `{ page?, pageSize?, keyword? }`（keyword 支持姓名/邮箱模糊，供弹窗搜索）
- 响应 Data: `{ items: { userId, displayName, email, defaultCheckinMode, updatedByUserId, updatedAt }[], pagination }`
- 状态码: 200, 401, 403, 404
- ⚠️ 后端默认 `pageSize=50`（Math.min cap = 200）。**全量映射场景**（弹窗建 prefMap / 导出 / 批量 diff）必须显式传 `pageSize=200`，否则 > 50 人的系列会切掉余下条目导致 UI 显示"跟随工作地"。教训详见 99-changelog.md 2026-04-22 hotfix 段。

**[081] PUT /series/:id/attendee-preferences**
- 说明: 批量设置多个参会人在本系列下的默认签到方式（upsert）
- 请求 Body: `{ preferences: { userId: string, defaultCheckinMode: "ON_SITE" | "ONLINE" }[] }`
- 业务规则:
  - 对每个 `{userId, defaultCheckinMode}`：若已有记录 → update；否则 insert
  - 仅允许设置本系列已有参会人（从下属 meeting 的 `MeetingRequiredAttendee` 聚合判断）
  - 每条变更写审计日志（`action=SERIES_ATTENDEE_PREFERENCE_UPSERT`）
- 响应 Data: `{ seriesId, created, updated, skippedUnknownUserIds[] }`
- 状态码: 200, 400, 401, 403, 404

**[082] DELETE /series/:id/attendee-preferences/:userId**
- 说明: 移除某参会人在本系列下的默认签到方式（恢复为城市派生）
- 响应 Data: `{ seriesId, userId, removed: boolean }`
- 状态码: 200, 401, 403, 404

**[083] GET /cities/suggestions**
- 说明: 查询系统已有城市列表，供前端自动补全下拉使用
- 请求 Query: `{ keyword?, limit? }`（keyword 前缀/子串匹配；默认 limit=20）
- 响应 Data: `{ items: string[] }`（所有 `users.work_city` + `meetings.city` + `meeting_series.city` 去重后的非空字符串列表，按拼音/字母排序）
- 状态码: 200, 401

### 用户工作地（v1.2）

`User.workCity` 的单用户编辑复用组织架构模块现有用户 update 接口（非本模块接口）。**Excel 批量导入**属于组织架构模块职责，格式与接口：

- 入口: `POST /organization/users/import-work-city`（由组织架构模块提供）
- Body: multipart/form-data `file` + `preview: boolean`（`preview=true` 返回预览分类，不落库；`preview=false` 按 `approvals[]` 批准结果落库）
- 预览响应:
  ```
  {
    total, uniqueCitiesInFile,
    categorized: {
      exact: [{ city, rowCount }],             // 🟢 完全匹配已有
      similar: [{ city, similarTo, distance, rowCount }],  // 🟡 编辑距离 ≤ 2
      new: [{ city, rowCount }]                // 🔵 全新
    },
    unmatchedEmails: [...],
    totalAffectedRows
  }
  ```
- 提交响应: `{ created, updated, skipped, unmatchedEmails[] }`
- 相似度算法: Levenshtein 距离；仅当 `|len(a) - len(b)| <= 2 && distance(a, b) <= 2` 才归入"相似"；缩写类（如 `LA` vs `Los Angeles`）因长度差太大自动归入"全新"

### 签到校验新行为（v1.2）

`[013] POST /meetings/:id/checkin` 与 `[014] POST /meetings/:id/guest-checkin` 均新增以下校验流程（先于现有迟到/设备/重复签到判断）：

1. 取 `meeting.enforceCheckinMode`，`false` → 跳过校验进入现有流程
2. 真 → 解析 `qrType`（来自 URL `type=online|on_site`，二维码原生参数）
3. `allowed` 按三层 fallback（从高到低）：
   - `MeetingRequiredAttendee.checkinMode`（会议级临时调整）
   - `MeetingSeriesAttendeePreference.defaultCheckinMode`（系列级参会人默认，仅当 `meeting.seriesId` 非空时查）
   - 按城市派生：`meeting.city == null` → `MEETING_ATTENDANCE_036`；`user.workCity == null` → `MEETING_ATTENDANCE_034`；两者 trim 后精确比较相等 → `ON_SITE`；不等 → `ONLINE`
4. `allowed != qrType` → `MEETING_ATTENDANCE_033`（400）
5. 通过则进入现有流程，`attendanceStatus` 按 `qrType` 派生，忽略 Body 传入值（防止前端绕过）

### 报表字段新增（v1.2）

- `[045] GET /reports/series` 响应 `personalStats[]` 每项追加：
  - `allowedMode`: `ON_SITE | ONLINE | null`（基于城市 + 系列覆盖派生，null 仅当用户 workCity 缺失）
  - `allowedModeSource`: `MEETING_OVERRIDE | SERIES_PREFERENCE | CITY_DERIVED`（来源标签，前端 tag 用；系列报表按"单场被覆盖"的场次计 `adjustmentCount`，系列级覆盖不计）
  - `adjustmentCount`: int（该人在该系列下 `MeetingRequiredAttendee.checkinMode` 非空的场次数）
  - `adjustmentMeetingIds`: string[]（被调整的会议 ID 列表，前端 tooltip 用）
- `[045]/[046]` 响应增加：
  - `enforceCheckinMode`: boolean（当前开关状态，前端顶部卡片展示）
  - `city`: string | null（当前地点，前端顶部卡片展示）
- `[046] GET /reports/single-meeting` 响应 `personalStats[]` 每项追加：
  - `allowedMode`: `ON_SITE | ONLINE | null`
  - `allowedModeSource`: `MEETING_OVERRIDE | SERIES_PREFERENCE | CITY_DERIVED`
  - `isOverridden`: boolean（true 当 source=MEETING_OVERRIDE）
  - `defaultMode`: `ON_SITE | ONLINE | null`（source=MEETING_OVERRIDE 时 tooltip 显示若不覆盖该是什么）
  - `overriddenByUserId`, `overriddenAt`: ID + 时间，用于 tooltip 展示"谁什么时候改的"

### 参会人列表签到方式 enrichment（v1.2）

`[007] GET /meetings/:id/required-attendees` 响应 `requiredAttendees[]` 每项在原有字段基础上追加（用于会议详情页前端直接渲染 tag 列，不用前端再派生）：

- `checkinMode`: `ON_SITE | ONLINE | null` —— 会议级临时调整（MeetingRequiredAttendee.checkinMode）
- `workCity`: `string | null` —— 该用户工作地（用于"未设置"红色 tag 提示）
- `allowedMode`: `ON_SITE | ONLINE | null` —— 三层 fallback 求值结果（同签到校验逻辑）
- `allowedModeSource`: `MEETING_OVERRIDE | SERIES_PREFERENCE | CITY_DERIVED`
- `defaultMode`: `ON_SITE | ONLINE | null` —— 清除会议级覆盖后该是什么（tooltip 显示 `新←旧`）
- `isOverridden`: `boolean` —— `true` 当 `allowedModeSource = MEETING_OVERRIDE`

派生字段与报表/签到使用同一 resolver；repository 仅多 select `user.workCity` + 一次 series attendee preferences 批量查询。

---

## 议程与资料上传（v1.0）

### 数据结构定义（v1.0）

#### MeetingAgendaSection

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 议程段 ID |
| meetingId | string | ✅ | 会议 ID |
| order | int | ✅ | 段顺序（默认 0） |
| title | string | ✅ | 段标题（≤200） |
| items | MeetingAgendaItem[] | ✅ | 段下议题（聚合返回） |
| createdAt | datetime | ✅ | 创建时间 |
| updatedAt | datetime | ✅ | 更新时间 |

#### MeetingAgendaItem

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 议程项 ID |
| sectionId | uuid | ✅ | 所属段 ID |
| order | int | ✅ | 项顺序（默认 0） |
| title | string | ✅ | 项标题（≤200） |
| description | string | ❌ | 议题描述 |
| code | string | ❌ | 可选自填编号（≤64） |
| timeMinutes | int | ❌ | 预计耗时分钟 |
| presenterUserId | uuid | ❌ | 主讲人用户 ID |
| presenter | UserBrief | ❌ | 主讲人摘要（`{ id, displayName, email }`，由后端展开） |
| categoryTag | enum | ❌ | `FFAI_EAI_EV` / `EAI_ROBOTICS` / `MIXED` / `OTHER` |
| uploadTasks | MeetingAgendaItemUploadTask[] | ✅ | 议程项任务列表（聚合返回） |
| attachments | MeetingAgendaItemAttachment[] | ✅ | 议程项资料列表（聚合返回） |
| createdAt | datetime | ✅ | 创建时间 |
| updatedAt | datetime | ✅ | 更新时间 |

#### MeetingAgendaItemUploadTask

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 任务 ID |
| agendaItemId | uuid | ✅ | 议程项 ID |
| assigneeUserId | uuid | ✅ | 被分配人用户 ID |
| assignee | UserBrief | ✅ | 被分配人摘要 |
| assignedById | uuid | ✅ | 派发人用户 ID |
| assignedBy | UserBrief | ✅ | 派发人摘要 |
| status | enum | ✅ | `PENDING` / `UPLOADED` / `CANCELLED` |
| dueAt | datetime | ❌ | 截止时间 |
| assignedAt | datetime | ✅ | 分配时间 |
| completedAt | datetime | ❌ | 完成时间（`UPLOADED` 时存在） |

#### MeetingAgendaItemAttachment

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 资料 ID |
| agendaItemId | uuid | ✅ | 议程项 ID |
| uploadedById | uuid | ✅ | 上传者用户 ID |
| uploadedBy | UserBrief | ✅ | 上传者摘要 |
| filename | string | ✅ | 原始文件名（≤ 255 unicode 字符） |
| mimeType | string | ✅ | MIME 类型 |
| size | string | ✅ | 字节数；后端 BigInt 序列化为字符串（`JSON.stringify(BigInt)` 默认抛 TypeError，全局打 `BigInt.prototype.toJSON`），前端用 `BigInt(resp.size)` 反序列化做计算 |
| storagePath | string | ✅ | 存储相对路径（不直接对外暴露，仅 download endpoint 使用） |
| uploadedAt | datetime | ✅ | 上传时间 |

#### MeetingAttachment（会议级）

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | uuid | ✅ | 会议级资料 ID |
| meetingId | string | ✅ | 会议 ID |
| uploadedById | uuid | ✅ | 上传者用户 ID |
| uploadedBy | UserBrief | ✅ | 上传者摘要 |
| category | enum | ❌ | `MeetingAttachmentCategory`：`MINUTES` / `MATERIAL` / `PRESENTATION` / `OTHER` |
| filename | string | ✅ | 原始文件名（≤ 255 unicode 字符） |
| mimeType | string | ✅ | MIME 类型 |
| size | string | ✅ | 字节数；BigInt 序列化为字符串（同上） |
| storagePath | string | ✅ | 存储相对路径（同上） |
| uploadedAt | datetime | ✅ | 上传时间 |

### 接口详细定义（v1.0）

**[100] GET /meetings/:id/agenda**
- 说明: 拉取一场会议的完整议程（段 → 项 → 任务 / 资料聚合返回，一次请求够前端渲染）；所有列表默认 `WHERE deletedAt IS NULL` 过滤
- 鉴权: 登录 + `meeting:agenda:read`（参会人即可；非参会人 403）
- 请求 Path: `{ id }`
- 响应 Data:
  ```
  {
    sections: [
      {
        id, order, title, createdAt, updatedAt,
        items: [
          {
            ...所有字段, presenter: UserBrief|null,
            uploadTasks: [...], attachments: [...]
          }
        ]
      }
    ]
  }
  ```
- 错误码: `MEETING_ATTENDANCE_003`(404 会议不存在)
- 状态码: 200, 401, 403, 404

**[101] POST /meetings/:id/agenda/sections**
- 说明: 在某会议下新建议程段
- 鉴权: `meeting:agenda:update`（MeetingManager / 会议创建人）
- 请求 Body: `{ title: string, order?: number }`（`order` 缺省 → 追加到末尾，取当前最大 + 1）
- 响应 Data: `{ section: MeetingAgendaSection }`
- 错误码: `MEETING_ATTENDANCE_003`(404), `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`(403)
- 状态码: 201, 400, 401, 403, 404

**[102] PATCH /meetings/:id/agenda/sections/:sectionId**
- 说明: 修改议程段标题或单独调位（reorder 推荐走 [104]）
- 鉴权: `meeting:agenda:update`
- 请求 Body: `{ title?: string, order?: number }`
- 响应 Data: `{ section: MeetingAgendaSection }`
- 错误码: `AGENDA_SECTION_NOT_FOUND`(404), `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`(403)
- 状态码: 200, 400, 401, 403, 404

**[103] DELETE /meetings/:id/agenda/sections/:sectionId**
- 说明: 软删议程段（`UPDATE deletedAt = now()`）；service 层显式 cascade soft-delete 段下所有 item / task / attachment（同事务）；attachment 30 天后由 cron 物理删 + unlink 文件
- 鉴权: `meeting:agenda:update`
- 响应 Data: 空 body
- 错误码: `AGENDA_SECTION_NOT_FOUND`(404), `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`(403)
- 状态码: 204, 401, 403, 404

**[104] PATCH /meetings/:id/agenda/sections/reorder**
- 说明: 整批重排段顺序；后端按数组下标整批重写 `order = idx`
- 鉴权: `meeting:agenda:update`
- 请求 Body: `{ ids: string[] }`（必须等于当前会议下全部 section id 集合，缺漏 → 400）
- 响应 Data: `{ meetingId, sections: MeetingAgendaSection[] }`
- 错误码: `MEETING_ATTENDANCE_006`(400 ids 不完整), `MEETING_ATTENDANCE_003`(404)
- 状态码: 200, 400, 401, 403, 404

**[105] POST /agenda-sections/:sectionId/items**
- 说明: 在议程段下新建议题
- 鉴权: `meeting:agenda:update`
- 请求 Body: `{ title: string, description?: string, code?: string, timeMinutes?: number, presenterUserId?: string, categoryTag?: AgendaCategoryTag, order?: number }`
- 响应 Data: `{ item: MeetingAgendaItem }`
- 错误码: `AGENDA_SECTION_NOT_FOUND`(404), `MEETING_ATTENDANCE_005`(404 presenterUserId 无效)
- 状态码: 201, 400, 401, 403, 404

**[106] PATCH /agenda-sections/:sectionId/items/:itemId**
- 说明: 修改议题属性
- 鉴权: `meeting:agenda:update`
- 请求 Body: `{ title?, description?, code?, timeMinutes?, presenterUserId?, categoryTag?, order? }`
- 响应 Data: `{ item: MeetingAgendaItem }`
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404)
- 状态码: 200, 400, 401, 403, 404

**[107] DELETE /agenda-sections/:sectionId/items/:itemId**
- 说明: 软删议题（`UPDATE deletedAt = now()`）；service 层显式 cascade soft-delete 该项的 task / attachment（同事务）
- 鉴权: `meeting:agenda:update`
- 响应 Data: 空 body
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404)
- 状态码: 204, 401, 403, 404

**[108] PATCH /agenda-sections/:sectionId/items/reorder**
- 说明: 整批重排段内项顺序
- 鉴权: `meeting:agenda:update`
- 请求 Body: `{ ids: string[] }`（必须等于该段下全部 item id 集合）
- 响应 Data: `{ sectionId, items: MeetingAgendaItem[] }`
- 错误码: `MEETING_ATTENDANCE_006`(400), `AGENDA_SECTION_NOT_FOUND`(404)
- 状态码: 200, 400, 401, 403, 404

**[109] POST /agenda-items/:itemId/upload-tasks**
- 说明: 向一个或多个参会人批量派发议程项资料上传任务（**重复分配静默跳过**，不抛 409）
- 鉴权: `meeting:upload-task:assign`
- 请求 Body: `{ assigneeUserIds: string[], dueAt?: datetime }`
- 业务规则:
  - 同 (agendaItemId, assigneeUserId) 已有非 `CANCELLED` 且未软删的任务 → **后端静默跳过**该人不重复创建（不抛错），响应 `skippedExistingUserIds[]` 中标注该 userId
  - `assignedById` 取当前请求人；写入 `createdById = assignedById`
  - 仅允许派发给该会议的 `MeetingRequiredAttendee` 用户；非参会人列表 → 整体 403 `MEETING_ATTENDANCE_011`
  - 前端拿到 `skippedExistingUserIds` 后 toast「已有 X 人已分配，跳过」（i18n key `meeting.agenda.uploadTask.skippedExisting`）
- 响应 Data: `{ created: MeetingAgendaItemUploadTask[], skippedExistingUserIds: string[] }`
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404), `MEETING_ATTENDANCE_011`(403 不在参会名单)
- 状态码: 201, 400, 401, 403, 404

**[110] GET /agenda-items/:itemId/upload-tasks**
- 说明: 拉取议程项下全部任务列表；默认 `WHERE deletedAt IS NULL` 过滤
- 鉴权: `meeting:agenda:read`
- 请求 Query: `{ status?: UploadTaskStatus }`
- 响应 Data: `{ items: MeetingAgendaItemUploadTask[] }`
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404)
- 状态码: 200, 401, 403, 404

**[111] PATCH /agenda-items/:itemId/upload-tasks/:taskId**
- 说明: 修改任务（manager 调整 dueAt 或显式置 CANCELLED；不允许 manager 手动置 UPLOADED）
- 鉴权: `meeting:upload-task:assign`
- 请求 Body: `{ status?: 'CANCELLED', dueAt?: datetime }`
- 业务规则:
  - `status` 仅允许传 `CANCELLED`；传 `UPLOADED` → 400（必须通过上传附件路径自动驱动）
  - 任务已是 `UPLOADED` → 409
- 响应 Data: `{ task: MeetingAgendaItemUploadTask }`
- 错误码: `UPLOAD_TASK_NOT_FOUND`(404), `UPLOAD_TASK_ALREADY_UPLOADED`(409), `MEETING_ATTENDANCE_006`(400)
- 状态码: 200, 400, 401, 403, 404, 409

**[112] DELETE /agenda-items/:itemId/upload-tasks/:taskId**
- 说明: 软删任务（`UPDATE deletedAt = now()`，语义 = 撤销 + 不再展示；幂等）
- 鉴权: `meeting:upload-task:assign`
- 响应 Data: 空 body
- 错误码: `UPLOAD_TASK_NOT_FOUND`(404), `UPLOAD_TASK_ALREADY_UPLOADED`(409)
- 状态码: 204, 401, 403, 404, 409

**[113] GET /my-upload-tasks**
- 说明: 当前用户的待办任务列表（默认 `PENDING`，按 `dueAt` 升序，`dueAt = null` 排末尾）；默认 `WHERE deletedAt IS NULL` 过滤
- 鉴权: 登录即可（后端自动注入 `assigneeUserId = currentUser.id`，无法越权查别人的）
- 请求 Query: `{ status?: UploadTaskStatus = PENDING, sort?: 'dueAt_asc' | 'assignedAt_desc' = 'dueAt_asc', limit?: number = 20, cursor?: string }`
  - `cursor` 编码 = `base64({ assignedAt, id })`（稳定排序游标，避免边界重复 / 漏数据）
- 响应 Data:
  ```
  {
    items: [
      {
        ...MeetingAgendaItemUploadTask,
        assignedBy: UserBrief,  // { id, displayName, email }
        agendaItem: { id, title, sectionTitle, meeting: { id, title, startTime, endTime } }
      }
    ],
    nextCursor?: string
  }
  ```
- 状态码: 200, 401

**[114] POST /agenda-items/:itemId/attachments**
- 说明: 上传议程项级资料文件
- 鉴权: 满足以下二者之一即可
  - `meeting:attachment:upload:any`（manager / 会议创建人）
  - `meeting:attachment:upload:assigned` AND 当前用户在该 `agendaItem` 下存在 `PENDING` upload task
- 请求: `multipart/form-data` 字段 `file`（必填，唯一字段；**不带** taskId —— service 内部按 `(agenda_item_id, assignee_user_id, status='PENDING')` `ORDER BY assigned_at ASC LIMIT 1 FOR UPDATE` 自动选最早的 PENDING task auto-flip）
- 业务规则:
  - 文件大小 > 200MB → `ATTACHMENT_TOO_LARGE` (413)
  - Content-Type 不在白名单 → `ATTACHMENT_MIME_NOT_ALLOWED` (415)
  - magic bytes（首 4KB）与 Content-Type 不一致 → `ATTACHMENT_MIME_MISMATCH` (415)
  - 如走 "assigned" 鉴权路径，service 在同事务里 `UPDATE ... WHERE id = task.id AND status = 'PENDING'`，affectedRows = 1 时刷 `completedAt`；affectedRows = 0 时退一格选下一条 PENDING task 重试；都查不到则 attachment 直接挂 item 不 flip
  - storagePath 由后端按"文件存储约定"生成（`<yyyy>/<mm>/<uuid>.<ext>`，不含原始 filename），前端不可指定
- 响应 Data: `{ attachment: MeetingAgendaItemAttachment, taskUpdated?: MeetingAgendaItemUploadTask }`
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404), `UPLOAD_TASK_NOT_OWNED`(403 actor 无 any 且无 assigned 路径匹配), `ATTACHMENT_TOO_LARGE`(413), `ATTACHMENT_MIME_NOT_ALLOWED`(415), `ATTACHMENT_MIME_MISMATCH`(415)
- 状态码: 201, 400, 401, 403, 404, 413, 415

**[115] GET /agenda-items/:itemId/attachments**
- 说明: 拉取议程项下全部资料元数据；默认 `WHERE deletedAt IS NULL` 过滤
- 鉴权: `meeting:agenda:read`
- 响应 Data: `{ items: MeetingAgendaItemAttachment[] }`
- 错误码: `AGENDA_ITEM_NOT_FOUND`(404)
- 状态码: 200, 401, 403, 404

**[116] DELETE /agenda-items/:itemId/attachments/:attachmentId**
- 说明: 软删议程项资料（uploader 本人或 MeetingManager；删除不会回滚 `UploadTaskStatus`）
- 鉴权: uploader 本人或 MeetingManager
- 业务规则: `UPDATE deletedAt = now()`；30 天后 cron 物理删 + unlink 底层文件
- 响应 Data: 空 body
- 错误码: `ATTACHMENT_NOT_FOUND`(404), `ATTACHMENT_DELETE_FORBIDDEN`(403)
- 状态码: 204, 401, 403, 404

**[117] POST /meetings/:id/attachments**
- 说明: 上传会议级资料（不挂任何议程项，例如会议纪要 / 整场 PPT）
- 鉴权: `meeting:attachment:upload:any`
- 请求: `multipart/form-data` 字段 `file` + 可选字段 `category`（enum `MeetingAttachmentCategory`）
- 业务规则: 大小 / MIME 校验同 [114]（含 magic bytes 二次校验）
- 响应 Data: `{ attachment: MeetingAttachment }`
- 错误码: `MEETING_ATTENDANCE_003`(404), `ATTACHMENT_TOO_LARGE`(413), `ATTACHMENT_MIME_NOT_ALLOWED`(415), `ATTACHMENT_MIME_MISMATCH`(415)
- 状态码: 201, 400, 401, 403, 404, 413, 415

**[118] GET /meetings/:id/attachments**
- 说明: 拉取会议级资料列表（按 `uploadedAt` 倒序）；默认 `WHERE deletedAt IS NULL` 过滤
- 鉴权: `meeting:agenda:read`
- 请求 Query: `{ category?: MeetingAttachmentCategory }`
- 响应 Data: `{ items: MeetingAttachment[] }`
- 错误码: `MEETING_ATTENDANCE_003`(404)
- 状态码: 200, 401, 403, 404

**[119] DELETE /meetings/:id/attachments/:attachmentId**
- 说明: 软删会议级资料（`UPDATE deletedAt = now()`；30 天后 cron 物理删 + unlink 底层文件）
- 鉴权: uploader 本人或 MeetingManager
- 响应 Data: 空 body
- 错误码: `ATTACHMENT_NOT_FOUND`(404), `ATTACHMENT_DELETE_FORBIDDEN`(403)
- 状态码: 204, 401, 403, 404

**[120] GET /attachments/agenda-item/:id/download**
- 说明: 下载议程项资料文件（stream + RFC 5987 Content-Disposition）
- 鉴权: `meeting:attachment:download` + 当前用户必须是该会议参会人
- 请求: 无 body
- 响应: 文件流（`Content-Type` 取 `attachment.mimeType`，`Content-Length` 取 `attachment.size`）；Header `Content-Disposition: attachment; filename="<ascii-fallback>"; filename*=UTF-8''<percent-encoded>`（兼容老浏览器 + 正确显示中文/特殊字符文件名）
- 错误码: `ATTACHMENT_NOT_FOUND`(404 含已软删), `MEETING_ATTENDANCE_011`(403 非参会人)
- 状态码: 200, 401, 403, 404

**[121] GET /attachments/meeting/:id/download**
- 说明: 下载会议级资料文件（语义同 [120]）
- 鉴权: 同 [120]
- 响应: 文件流（同 [120] RFC 5987 Content-Disposition）
- 错误码: `ATTACHMENT_NOT_FOUND`(404 含已软删), `MEETING_ATTENDANCE_011`(403)
- 状态码: 200, 401, 403, 404

### 权限矩阵（v1.0）

| 权限点 | 默认绑定角色 | 覆盖端点 |
|--------|--------------|----------|
| `meeting:agenda:read` | 全部参会人（Administrator/MeetingManager/Leader/Employee） | [100], [110], [115], [118] |
| `meeting:agenda:update` | Administrator / MeetingManager / 会议创建人 | [101]-[108] |
| `meeting:upload-task:assign` | Administrator / MeetingManager / 会议创建人 | [109], [111], [112] |
| `meeting:attachment:upload:any` | Administrator / MeetingManager / 会议创建人 | [114] (any 路径), [117] |
| `meeting:attachment:upload:assigned` | 全部参会人（仅对自己被 assign 的议程项生效） | [114] (assigned 路径) |
| `meeting:attachment:download` | 全部参会人 | [120], [121] |
| 无（仅登录） | 全部用户 | [113]（只看自己的任务） |

**鉴权流程通用规则**：
- 非参会人访问议程 / 资料相关接口一律 403，错误码 `MEETING_ATTENDANCE_011`
- 普通用户尝试改议程（PATCH/POST/DELETE）一律 403，错误码 `AGENDA_FORBIDDEN_NOT_MANAGER_OR_CREATOR`
- `meeting:attachment:upload:assigned` 路径走"任务归属校验"，非该 item 的 assignee → `UPLOAD_TASK_NOT_OWNED` (403)
