# 会议出勤（Meeting Attendance）- 数据模型文档

> **版本**: v1.2
> **最后更新**: 2026-04-21
> **维护者**: 后端团队

---

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

### Schema 摘要

| 字段 | 内容 |
|------|------|
| Schema 名称 | `platform_meeting_attendance` |
| 业务域 | 会议、签到、参会名单、报表、审计、签到方式校验（v1.2）、议程与资料上传（v1.0） |
| 核心实体 | Meeting, MeetingSeries, MeetingAttendance, MeetingRequiredAttendee, MeetingTemplate, MeetingAttendanceAuditLog, MeetingSeriesAttendeePreference（v1.2）, MeetingSeriesAttendeeException（v1.3）, MeetingAgendaSection（v1.0）, MeetingAgendaItem（v1.0）, MeetingAgendaItemUploadTask（v1.0）, MeetingAgendaItemAttachment（v1.0）, MeetingAttachment（v1.0） |

### 实体字段清单（最小）

| 实体 | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| platform_iam.users | id | uuid | ✅ | 平台用户主键 |
| platform_iam.users | email | string | ✅ | 登录邮箱 |
| platform_iam.users | displayName | string | ✅ | 用户姓名 |
| meetings | id | string | ✅ | 会议主键（cuid） |
| meetings | title | string | ✅ | 会议标题 |
| meetings | startTime | datetime | ✅ | 开始时间 |
| meetings | endTime | datetime | ✅ | 结束时间 |
| meetings | status | enum | ✅ | 会议状态 |
| meetings | qrCodeOnline | string | ❌ | 线上二维码 |
| meetings | qrCodeOffline | string | ❌ | 线下二维码 |
| meetings | city | string | ❌ | 会议地点城市名（v1.2）。字符串自动补全；开关开启前后端 guard 要求非空 |
| meetings | enforceCheckinMode | bool | ✅ | 签到方式校验开关（v1.2）。schema `@default(false)`；系列修改时级联刷新；Outlook 新同步实例从 series 继承 |
| meeting_series | id | string | ✅ | 系列主键（cuid） |
| meeting_series | pattern | enum | ✅ | 复发规则 |
| meeting_series | frequency | int | ✅ | 频率 |
| meeting_series | city | string | ❌ | 系列地点城市名（v1.2）。修改时级联刷新下属 meeting.city |
| meeting_series | enforceCheckinMode | bool | ✅ | 签到方式校验开关（v1.2）。schema `@default(false)`；修改时级联刷新下属 meeting |
| attendances | id | string | ✅ | 出勤主键（cuid） |
| attendances | userId | uuid | ✅ | 用户 ID（platform_iam.users.id） |
| attendances | meetingId | string | ✅ | 会议 ID（cuid） |
| attendances | status | enum | ✅ | 出勤状态 |
| meeting_required_attendees | id | string | ✅ | 参会名单主键（cuid） |
| meeting_required_attendees | meetingId | string | ✅ | 会议 ID（cuid） |
| meeting_required_attendees | userId | uuid | ✅ | 关联用户（platform_iam.users.id） |
| meeting_required_attendees | checkinMode | enum | ❌ | 本场会议的签到方式覆盖（v1.2），`ON_SITE/ONLINE/null`；null 表示沿用系列/城市派生 |
| platform_iam.users | workCity | string | ❌ | 用户工作城市（v1.2）。字符串存储（trim 后原样写入）；组织架构模块维护；与 meeting.city 精确字符串比较 |
| meeting_series_attendee_preferences | seriesId | string | ✅ | 复合主键一（v1.2，cuid） |
| meeting_series_attendee_preferences | userId | uuid | ✅ | 复合主键二（v1.2，FK 到 platform_iam.users.id） |
| meeting_series_attendee_preferences | defaultCheckinMode | enum | ✅ | 该用户在该系列下的默认签到方式覆盖（v1.2），`ON_SITE/ONLINE`；null 不建记录 |
| meeting_series_attendee_preferences | updatedByUserId | uuid | ❌ | 最近一次修改人（v1.2） |
| meeting_series_attendee_preferences | createdAt | datetime | ✅ | 创建时间 |
| meeting_series_attendee_preferences | updatedAt | datetime | ✅ | 更新时间 |
| meeting_series_attendee_exceptions | seriesId | string | ✅ | 复合主键一（v1.3，cuid，FK 到 meeting_series.id，onDelete: Cascade） |
| meeting_series_attendee_exceptions | userId | uuid | ✅ | 复合主键二（v1.3，被排除的用户，FK 到 platform_iam.users.id，onDelete: Cascade） |
| meeting_series_attendee_exceptions | excludedBy | uuid | ✅ | 触发排除的操作者 userId（v1.3） |
| meeting_series_attendee_exceptions | excludedAt | datetime | ✅ | 排除时间（v1.3，默认 NOW） |
| meeting_series_attendee_exceptions | reason | string | ❌ | 排除理由（v1.3，最大 500 字符，留空表示无说明） |
| meeting_templates | id | string | ✅ | 模板主键（cuid） |
| audit_logs | id | string | ✅ | 审计日志主键（cuid） |
| audit_logs | action | string | ✅ | 操作类型 |
| audit_logs | resource | string | ✅ | 资源类型 |
| outlook_sync_mailboxes | id | string | ✅ | 源邮箱配置主键（cuid） |
| outlook_sync_mailboxes | mailboxEmail | string | ✅ | 源邮箱地址（唯一） |
| outlook_sync_mailboxes | mailboxType | enum | ✅ | SHARED/PERSONAL |
| outlook_sync_mailboxes | isPrimaryDefault | bool | ✅ | 系统内部兜底标记（自动维护，不对业务管理员暴露） |
| outlook_sync_mailboxes | isEnabled | bool | ✅ | 是否启用同步 |
| outlook_subscriptions | id | string | ✅ | 订阅主键（cuid） |
| outlook_subscriptions | mailboxId | string | ✅ | 源邮箱 ID |
| outlook_subscriptions | graphSubscriptionId | string | ✅ | Graph 订阅 ID |
| outlook_subscriptions | expirationAt | datetime | ✅ | 订阅到期时间 |
| outlook_sync_bindings | id | string | ✅ | 纳管绑定主键（cuid） |
| outlook_sync_bindings | meetingId | string | ❌ | 本地会议 ID（single/occurrence/exception 有值；seriesMaster 允许为空，仅作为系列锚点） |
| outlook_sync_bindings | graphEventId | string | ✅ | Graph eventId |
| outlook_sync_bindings | ownerUserId | uuid | ❌ | 当前绑定管理员（接管后更新） |
| outlook_sync_bindings | ownerEmail | string | ❌ | 当前绑定管理员邮箱（审计展示） |
| outlook_sync_bindings | syncFrom | datetime | ✅ | 同步起点，仅同步该时刻之后实例 |
| outlook_sync_bindings | iCalUId | string | ✅ | Graph iCalUId |
| outlook_sync_bindings | manageStatus | enum | ✅ | PENDING_SELECTION/MANAGED/DISABLED |
| outlook_sync_bindings | primaryMailboxId | string | ✅ | 当前主来源邮箱 |
| outlook_sync_bindings | cancellationSource | enum | ❌ | CANCELLED_BY_ORGANIZER/DELETED |
| outlook_sync_bindings | syncMode | enum | ✅ | AUTO/LOCKED_BY_LOCAL_EDIT |
| outlook_sync_bindings | localOverrideAt | datetime | ❌ | 最近一次转为本地维护的时间 |
| outlook_sync_bindings | localOverrideByUserId | uuid | ❌ | 最近一次触发本地维护的用户 ID |
| outlook_sync_bindings | localOverrideByEmail | string | ❌ | 最近一次触发本地维护的用户邮箱 |
| outlook_sync_bindings | localOverrideReason | string | ❌ | 本地维护原因（如 MANUAL_ATTENDEE_DELETE / MANUAL_MEETING_EDIT） |
| outlook_sync_bindings | localOverrideFields | json | ❌ | 触发本地维护的字段集合 |
| outlook_sync_bindings | bootstrapStatus | enum | ❌ | 系列纳管补齐任务状态：QUEUED/RUNNING/SUCCEEDED/FAILED |
| outlook_sync_bindings | bootstrapError | string | ❌ | 系列补齐失败错误信息（失败时记录） |
| outlook_sync_bindings | bootstrapUpdatedAt | datetime | ❌ | 系列补齐状态最近更新时间 |
| meeting_external_attendees | id | string | ✅ | 外部参会人快照主键（cuid） |
| meeting_external_attendees | meetingId | string | ✅ | 会议 ID（cuid） |
| meeting_external_attendees | email | string | ❌ | 外部参会人邮箱 |
| meeting_external_attendees | displayName | string | ❌ | 外部参会人显示名 |
| meeting_external_attendees | attendeeType | enum | ✅ | REQUIRED/OPTIONAL/ORGANIZER |
| meeting_external_attendees | responseStatus | string | ❌ | none/accepted/tentativelyAccepted/declined |
| outlook_sync_settings | id | string | ✅ | 同步设置主键（cuid） |
| outlook_sync_settings | includeOrganizerAsAttendee | bool | ✅ | 是否将 organizer 并入参会人名单（默认 false） |
| outlook_series_occurrence_exclusions | id | string | ✅ | 系列单次排除主键（cuid） |
| outlook_series_occurrence_exclusions | bindingId | string | ✅ | 关联系列主绑定 ID |
| outlook_series_occurrence_exclusions | occurrenceGraphEventId | string | ✅ | 被排除的 Outlook occurrence 事件 ID |
| outlook_series_occurrence_exclusions | iCalUId | string | ❌ | 被排除 occurrence 的 iCalUId |
| outlook_series_occurrence_exclusions | reason | string | ❌ | 排除原因 |
| outlook_series_occurrence_exclusions | createdByEmail | string | ❌ | 操作人邮箱 |
| outlook_series_occurrence_exclusions | createdAt | datetime | ✅ | 创建时间 |
| outlook_sync_event_logs | id | string | ✅ | 同步事件日志主键（cuid） |
| outlook_sync_event_logs | bindingId | string | ✅ | 绑定 ID |
| outlook_sync_event_logs | mailboxId | string | ✅ | 来源邮箱 ID |
| outlook_sync_event_logs | eventType | string | ✅ | 事件类型（MANAGED/TAKEN_OVER/SYNC_UPDATED/SYNC_REMOVED） |
| outlook_sync_event_logs | resultStatus | string | ✅ | SUCCESS/ERROR/INFO（前端可按 ERROR 过滤） |
| outlook_sync_event_logs | message | text | ❌ | 事件描述 |
| outlook_sync_event_logs | payload | json | ❌ | 事件上下文（如 stage/errorCode/statusCode/errorMessage/triggerSource/changedFields/summary） |
| outlook_sync_event_logs | createdAt | datetime | ✅ | 记录时间 |
| outlook_event_source_versions | id | string | ✅ | 官方源版本主键（cuid） |
| outlook_event_source_versions | bindingId | string | ✅ | 对应纳管绑定 ID |
| outlook_event_source_versions | mailboxId | string | ✅ | 来源邮箱 ID |
| outlook_event_source_versions | graphEventId | string | ✅ | Graph eventId |
| outlook_event_source_versions | graphSeriesMasterId | string | ❌ | Graph seriesMasterId |
| outlook_event_source_versions | graphEventType | enum/string | ✅ | single/seriesMaster/occurrence/exception |
| outlook_event_source_versions | versionSource | string | ✅ | 版本来源：WEBHOOK_DELTA/RECONCILE/MANAGE_BOOTSTRAP/MANUAL_REFRESH |
| outlook_event_source_versions | graphLastModifiedAt | datetime | ❌ | Outlook 官方 `lastModifiedDateTime` |
| outlook_event_source_versions | fetchedAt | datetime | ✅ | 本系统获取该官方源版本的时间 |
| outlook_event_source_versions | etag | string | ❌ | Graph 返回的 etag（如有） |
| outlook_event_source_versions | payloadHash | string | ✅ | 标准化 payload 哈希，用于判重 |
| outlook_event_source_versions | attendeesCount | int | ✅ | 官方源 attendees 总数 |
| outlook_event_source_versions | attendeesRequiredCount | int | ✅ | 官方源 required attendees 数 |
| outlook_event_source_versions | attendeesOptionalCount | int | ✅ | 官方源 optional attendees 数 |
| outlook_event_source_versions | attendeesResourceCount | int | ✅ | 官方源 resource attendees 数 |
| outlook_event_source_versions | organizerEmail | string | ❌ | organizer 邮箱 |
| outlook_event_source_versions | startTime | datetime | ❌ | 官方源开始时间 |
| outlook_event_source_versions | endTime | datetime | ❌ | 官方源结束时间 |
| outlook_event_source_versions | isCancelled | bool | ✅ | 官方源取消状态 |
| outlook_event_source_versions | rawPayload | json | ✅ | 原始官方源 payload |
| outlook_event_source_versions | normalizedPayload | json | ✅ | 标准化后的稳定比对 payload |
| outlook_event_source_versions | createdAt | datetime | ✅ | 版本落库时间 |
| outlook_event_sync_diffs | id | string | ✅ | 同步差异主键（cuid） |
| outlook_event_sync_diffs | bindingId | string | ✅ | 对应纳管绑定 ID |
| outlook_event_sync_diffs | sourceVersionId | string | ✅ | 当前官方源版本 ID |
| outlook_event_sync_diffs | previousSourceVersionId | string | ❌ | 上一个官方源版本 ID |
| outlook_event_sync_diffs | detectedAt | datetime | ✅ | 检测并生成差异的时间 |
| outlook_event_sync_diffs | diffType | string | ✅ | CREATED/UPDATED/NO_CONTENT_CHANGE/REMOVED |
| outlook_event_sync_diffs | changedFields | json | ✅ | 变化字段列表 |
| outlook_event_sync_diffs | summaryJson | json | ✅ | 结构化摘要（before/after/counts/deltas） |
| outlook_event_sync_diffs | attendeesAdded | json | ❌ | 新增参会人邮箱/类型列表 |
| outlook_event_sync_diffs | attendeesRemoved | json | ❌ | 移除参会人邮箱/类型列表 |
| outlook_event_sync_diffs | attendeesResponseChanged | json | ❌ | 响应状态变化列表 |
| outlook_event_sync_diffs | graphAttendeesCountBefore | int | ❌ | 官方源变更前 attendees 总数 |
| outlook_event_sync_diffs | graphAttendeesCountAfter | int | ❌ | 官方源变更后 attendees 总数 |
| outlook_event_sync_diffs | internalMatchedCountBefore | int | ❌ | 变更前内部匹配人数 |
| outlook_event_sync_diffs | internalMatchedCountAfter | int | ❌ | 变更后内部匹配人数 |
| outlook_event_sync_diffs | meetingRequiredCountBefore | int | ❌ | 变更前本地 requiredAttendees 数 |
| outlook_event_sync_diffs | meetingRequiredCountAfter | int | ❌ | 变更后本地 requiredAttendees 数 |
| outlook_event_sync_diffs | createdAt | datetime | ✅ | 差异记录创建时间 |
| outlook_sync_cursors | mailboxId | string | ✅ | 源邮箱 ID（唯一） |
| outlook_sync_cursors | deltaToken | text | ✅ | Delta 游标 |
| outlook_sync_cursors | lastReconciledAt | datetime | ❌ | 最近对账时间 |
| meeting_agenda_sections | id | uuid | ✅ | 议程段主键（v1.0） |
| meeting_agenda_sections | meetingId | string | ✅ | 会议 ID（FK 到 meetings.id；不依赖 FK onDelete，service 层显式 cascade soft-delete，v1.0） |
| meeting_agenda_sections | order | int | ✅ | 段内显示顺序（默认 0，v1.0） |
| meeting_agenda_sections | title | string | ✅ | 段标题（最大 200 字符，v1.0） |
| meeting_agenda_sections | createdById | uuid | ✅ | 创建者 ID（FK 到 platform_iam.users.id，CLAUDE.md §标准字段，v1.0） |
| meeting_agenda_sections | organizationId | uuid | ❌ | 所属组织 ID（FK 到 organizations.id，与现有 meeting 模块一致；DataScope 零配置，v1.0） |
| meeting_agenda_sections | deletedAt | datetime | ❌ | 软删时间戳；非空表示已软删，所有查询默认 `WHERE deletedAt IS NULL` 过滤（v1.0） |
| meeting_agenda_sections | createdAt | datetime | ✅ | 创建时间（v1.0） |
| meeting_agenda_sections | updatedAt | datetime | ✅ | 更新时间（v1.0） |
| meeting_agenda_items | id | uuid | ✅ | 议程项主键（v1.0） |
| meeting_agenda_items | sectionId | uuid | ✅ | 所属议程段 ID（FK 到 meeting_agenda_sections.id；service 层显式 cascade soft-delete，v1.0） |
| meeting_agenda_items | order | int | ✅ | 项内显示顺序（默认 0，v1.0） |
| meeting_agenda_items | title | string | ✅ | 项标题（最大 200 字符，v1.0） |
| meeting_agenda_items | description | text | ❌ | 议题描述（建议 ≤ 2000 字符，后端 DTO `@MaxLength(2000)`，v1.0） |
| meeting_agenda_items | code | string | ❌ | 可选自填编号（如 `S1-ECAI051826-1`，最大 64 字符，v1.0） |
| meeting_agenda_items | timeMinutes | int | ❌ | 议题预计耗时（分钟，可选；如填则必须 > 0，v1.0） |
| meeting_agenda_items | presenterUserId | uuid | ❌ | 议题主讲人（FK 到 platform_iam.users.id，可选；用户禁用/删除时**不联动**议程项，查询 LEFT JOIN 并前端展示"(已离职)"标签，v1.0） |
| meeting_agenda_items | categoryTag | enum | ❌ | 业务分类标签（`AgendaCategoryTag`，可选，v1.0） |
| meeting_agenda_items | createdById | uuid | ✅ | 创建者 ID（FK 到 platform_iam.users.id，CLAUDE.md §标准字段，v1.0） |
| meeting_agenda_items | organizationId | uuid | ❌ | 所属组织 ID（v1.0） |
| meeting_agenda_items | deletedAt | datetime | ❌ | 软删时间戳；非空表示已软删（v1.0） |
| meeting_agenda_items | createdAt | datetime | ✅ | 创建时间（v1.0） |
| meeting_agenda_items | updatedAt | datetime | ✅ | 更新时间（v1.0） |
| meeting_agenda_item_upload_tasks | id | uuid | ✅ | 上传任务主键（v1.0） |
| meeting_agenda_item_upload_tasks | agendaItemId | uuid | ✅ | 所属议程项 ID（FK 到 meeting_agenda_items.id；service 层显式 cascade soft-delete，v1.0） |
| meeting_agenda_item_upload_tasks | assigneeUserId | uuid | ✅ | 被分配用户 ID（FK 到 platform_iam.users.id，v1.0） |
| meeting_agenda_item_upload_tasks | assignedById | uuid | ✅ | 分配操作人 ID（FK 到 platform_iam.users.id，v1.0） |
| meeting_agenda_item_upload_tasks | status | enum | ✅ | 任务状态（`UploadTaskStatus`，默认 `PENDING`，v1.0） |
| meeting_agenda_item_upload_tasks | dueAt | datetime | ❌ | 截止时间（v1.0） |
| meeting_agenda_item_upload_tasks | assignedAt | datetime | ✅ | 分配时间（v1.0） |
| meeting_agenda_item_upload_tasks | completedAt | datetime | ❌ | 完成时间（assignee 上传成功后自动写入，v1.0） |
| meeting_agenda_item_upload_tasks | createdById | uuid | ✅ | 创建者 ID（= `assignedById`，标准字段冗余以保 DataScope 一致，v1.0） |
| meeting_agenda_item_upload_tasks | organizationId | uuid | ❌ | 所属组织 ID（v1.0） |
| meeting_agenda_item_upload_tasks | deletedAt | datetime | ❌ | 软删时间戳（v1.0） |
| meeting_agenda_item_attachments | id | uuid | ✅ | 议程项资料主键（v1.0） |
| meeting_agenda_item_attachments | agendaItemId | uuid | ✅ | 所属议程项 ID（FK 到 meeting_agenda_items.id；service 层显式 cascade soft-delete，v1.0） |
| meeting_agenda_item_attachments | uploadedById | uuid | ✅ | 上传者 ID（FK 到 platform_iam.users.id，v1.0） |
| meeting_agenda_item_attachments | filename | string | ✅ | 原始文件名（最大 255 unicode 字符，v1.0） |
| meeting_agenda_item_attachments | mimeType | string | ✅ | MIME 类型（最大 128 字符，v1.0） |
| meeting_agenda_item_attachments | size | bigint | ✅ | 文件大小字节数；前端响应序列化为字符串（BigInt JSON.stringify 兼容，v1.0） |
| meeting_agenda_item_attachments | storagePath | string | ✅ | 存储相对路径（最大 512 字符，见末尾"文件存储约定"，v1.0） |
| meeting_agenda_item_attachments | uploadedAt | datetime | ✅ | 上传时间（v1.0） |
| meeting_agenda_item_attachments | createdById | uuid | ✅ | 创建者 ID（= `uploadedById`，标准字段冗余以保 DataScope 一致，v1.0） |
| meeting_agenda_item_attachments | organizationId | uuid | ❌ | 所属组织 ID（v1.0） |
| meeting_agenda_item_attachments | deletedAt | datetime | ❌ | 软删时间戳；非空表示已软删，30 天后 cron 物理清理（v1.0） |
| meeting_attachments | id | uuid | ✅ | 会议级资料主键（v1.0） |
| meeting_attachments | meetingId | string | ✅ | 会议 ID（FK 到 meetings.id；service 层显式 cascade soft-delete，v1.0） |
| meeting_attachments | uploadedById | uuid | ✅ | 上传者 ID（FK 到 platform_iam.users.id，v1.0） |
| meeting_attachments | category | enum | ❌ | 资料分类（`MeetingAttachmentCategory`，可选，v1.0） |
| meeting_attachments | filename | string | ✅ | 原始文件名（最大 255 unicode 字符，v1.0） |
| meeting_attachments | mimeType | string | ✅ | MIME 类型（最大 128 字符，v1.0） |
| meeting_attachments | size | bigint | ✅ | 文件大小字节数；前端响应序列化为字符串（v1.0） |
| meeting_attachments | storagePath | string | ✅ | 存储相对路径（最大 512 字符，v1.0） |
| meeting_attachments | uploadedAt | datetime | ✅ | 上传时间（v1.0） |
| meeting_attachments | createdById | uuid | ✅ | 创建者 ID（= `uploadedById`，标准字段冗余以保 DataScope 一致，v1.0） |
| meeting_attachments | organizationId | uuid | ❌ | 所属组织 ID（v1.0） |
| meeting_attachments | deletedAt | datetime | ❌ | 软删时间戳；非空表示已软删，30 天后 cron 物理清理（v1.0） |

### 关系与约束

| 关系/约束 | 说明 |
|-----------|------|
| meetings.creatorId -> platform_iam.users.id | 会议创建者 |
| meetings.seriesId -> meeting_series.id | 会议隶属系列 |
| attendances.userId + meetingId UNIQUE | 单用户单会议唯一出勤记录 |
| meeting_required_attendees.meetingId + userId UNIQUE | 单用户单会议唯一参会名单 |
| meeting_templates.creatorId -> platform_iam.users.id | 模板创建者 |
| outlook_subscriptions.mailboxId -> outlook_sync_mailboxes.id | 每个源邮箱可有 0..n 次订阅记录 |
| outlook_sync_bindings.primaryMailboxId -> outlook_sync_mailboxes.id | 纳管会议主来源 |
| outlook_sync_bindings.localOverrideByUserId -> platform_iam.users.id | 最近一次本地维护操作者 |
| outlook_sync_event_logs.bindingId -> outlook_sync_bindings.id | 绑定维度同步事件日志 |
| outlook_sync_event_logs.mailboxId -> outlook_sync_mailboxes.id | 事件所属来源邮箱 |
| outlook_event_source_versions.bindingId -> outlook_sync_bindings.id | 官方源历史版本依附于单个纳管绑定 |
| outlook_event_source_versions.mailboxId -> outlook_sync_mailboxes.id | 官方源版本所属来源邮箱 |
| outlook_event_sync_diffs.bindingId -> outlook_sync_bindings.id | 差异记录归属单个纳管绑定 |
| outlook_event_sync_diffs.sourceVersionId -> outlook_event_source_versions.id | 差异记录指向当前版本 |
| outlook_event_sync_diffs.previousSourceVersionId -> outlook_event_source_versions.id | 差异记录可回溯上一版本 |
| outlook_series_occurrence_exclusions.bindingId -> outlook_sync_bindings.id | 系列排除记录依附于系列主绑定 |
| outlook_sync_cursors.mailboxId UNIQUE | 每个源邮箱一个当前游标 |
| outlook_sync_mailboxes.mailboxEmail UNIQUE | 源邮箱唯一 |
| outlook_sync_bindings.graphEventId UNIQUE | 同一 Outlook 事件全局唯一绑定 |
| outlook_sync_bindings.(manageStatus,syncMode) INDEX | 支持按“同步中/已本地维护”筛选纳管会议 |
| outlook_event_source_versions.(bindingId,payloadHash) UNIQUE | 同一绑定下相同标准化版本仅保留一份 |
| outlook_event_source_versions.(mailboxId,graphEventId,createdAt) INDEX | 支持按事件时间线追溯版本 |
| outlook_event_sync_diffs.(bindingId,detectedAt) INDEX | 支持同步历史页按时间读取差异摘要 |
| outlook_series_occurrence_exclusions.(bindingId, occurrenceGraphEventId) UNIQUE | 防止同系列重复排除同一实例 |
| meeting_external_attendees.(meetingId,email,attendeeType) UNIQUE | 防止同会议重复写入同类型外部参会人 |
| meeting_series_attendee_preferences.(seriesId, userId) PK | 复合主键（v1.2） |
| meeting_series_attendee_preferences.seriesId -> meeting_series.id | 系列级参会人覆盖依附于系列（v1.2），onDelete: Cascade |
| meeting_series_attendee_preferences.userId -> platform_iam.users.id | 同上依附于用户（v1.2），onDelete: Cascade |
| meeting_series_attendee_preferences.userId INDEX | 支持按用户查询所有系列级覆盖 |
| meeting_series_attendee_exceptions.(seriesId, userId) PK | 复合主键（v1.3） |
| meeting_series_attendee_exceptions.seriesId -> meeting_series.id | 系列层排除清单依附于系列（v1.3），onDelete: Cascade |
| meeting_series_attendee_exceptions.userId -> platform_iam.users.id | 同上依附于被排除用户（v1.3），onDelete: Cascade |
| meeting_series_attendee_exceptions.userId INDEX | 支持按用户查询其所有被排除的系列（v1.3） |
| meeting_agenda_sections.meetingId -> meetings.id | 议程段依附于会议（v1.0）；service 层显式 cascade soft-delete（事务内 `UPDATE deletedAt = now() WHERE meeting_id = ?`），不依赖 FK onDelete |
| meeting_agenda_sections.createdById -> platform_iam.users.id | 创建者（v1.0）；relation name `AgendaSectionCreatedBy` |
| meeting_agenda_sections.organizationId -> organizations.id | 所属组织（v1.0，可选）；relation name `AgendaSectionOrg` |
| meeting_agenda_sections.(meetingId, order) INDEX | 支持按会议拉取议程并按 order 排序（v1.0） |
| meeting_agenda_sections.(organizationId, deletedAt) INDEX | DataScope 过滤 + 软删过滤复合索引（v1.0） |
| meeting_agenda_items.sectionId -> meeting_agenda_sections.id | 议程项依附于段（v1.0）；service 层显式 cascade soft-delete |
| meeting_agenda_items.presenterUserId -> platform_iam.users.id | 议题主讲人指向平台用户（v1.0），可选；用户禁用/删除时**不联动**议程项，查询走 LEFT JOIN，前端展示"(已离职)"标签；relation name `AgendaItemPresenter` |
| meeting_agenda_items.createdById -> platform_iam.users.id | 创建者（v1.0）；relation name `AgendaItemCreatedBy` |
| meeting_agenda_items.organizationId -> organizations.id | 所属组织（v1.0，可选）；relation name `AgendaItemOrg` |
| meeting_agenda_items.(sectionId, order) INDEX | 支持段内按 order 渲染议题（v1.0） |
| meeting_agenda_items.(organizationId, deletedAt) INDEX | DataScope 过滤 + 软删过滤复合索引（v1.0） |
| meeting_agenda_item_upload_tasks.agendaItemId -> meeting_agenda_items.id | 上传任务依附于议程项（v1.0）；service 层显式 cascade soft-delete |
| meeting_agenda_item_upload_tasks.assigneeUserId -> platform_iam.users.id | 任务被分配人（v1.0）；relation name `UploadTaskAssignee` |
| meeting_agenda_item_upload_tasks.assignedById -> platform_iam.users.id | 任务派发人（v1.0）；relation name `UploadTaskAssignedBy` |
| meeting_agenda_item_upload_tasks.createdById -> platform_iam.users.id | 创建者（= `assignedById`，v1.0）；relation name `UploadTaskCreatedBy` |
| meeting_agenda_item_upload_tasks.organizationId -> organizations.id | 所属组织（v1.0，可选）；relation name `UploadTaskOrg` |
| meeting_agenda_item_upload_tasks.(assigneeUserId, status) INDEX | 支持"我的待办"按状态聚合（v1.0） |
| meeting_agenda_item_upload_tasks.agendaItemId INDEX | 支持议程项详情拉取本项任务列表（v1.0） |
| meeting_agenda_item_upload_tasks.(organizationId, deletedAt) INDEX | DataScope 过滤 + 软删过滤复合索引（v1.0） |
| meeting_agenda_item_attachments.agendaItemId -> meeting_agenda_items.id | 议程项资料依附于议程项（v1.0）；service 层显式 cascade soft-delete |
| meeting_agenda_item_attachments.uploadedById -> platform_iam.users.id | 上传者（v1.0）；relation name `AgendaItemAttachmentUploader` |
| meeting_agenda_item_attachments.createdById -> platform_iam.users.id | 创建者（= `uploadedById`，v1.0）；relation name `AgendaItemAttachmentCreatedBy` |
| meeting_agenda_item_attachments.organizationId -> organizations.id | 所属组织（v1.0，可选）；relation name `AgendaItemAttachmentOrg` |
| meeting_agenda_item_attachments.agendaItemId INDEX | 支持按议程项拉资料（v1.0） |
| meeting_agenda_item_attachments.(organizationId, deletedAt) INDEX | DataScope 过滤 + 软删过滤复合索引（v1.0） |
| meeting_attachments.meetingId -> meetings.id | 会议级资料依附于会议（v1.0）；service 层显式 cascade soft-delete |
| meeting_attachments.uploadedById -> platform_iam.users.id | 上传者（v1.0）；relation name `MeetingAttachmentUploader` |
| meeting_attachments.createdById -> platform_iam.users.id | 创建者（= `uploadedById`，v1.0）；relation name `MeetingAttachmentCreatedBy` |
| meeting_attachments.organizationId -> organizations.id | 所属组织（v1.0，可选）；relation name `MeetingAttachmentOrg` |
| meeting_attachments.(meetingId, uploadedAt) INDEX | 支持会议详情按时间倒序拉资料（v1.0） |
| meeting_attachments.(organizationId, deletedAt) INDEX | DataScope 过滤 + 软删过滤复合索引（v1.0） |

### Enum 清单（v1.0）

| Enum | 取值 | Schema | 说明 |
|------|------|--------|------|
| `UploadTaskStatus` | `PENDING` / `UPLOADED` / `CANCELLED` | `platform_meeting_attendance` | 议程项资料上传任务状态。`PENDING` 默认；assignee 成功上传 → `UPLOADED`（同事务刷 `completedAt`）；manager 撤销 → `CANCELLED` |
| `AgendaCategoryTag` | `FFAI_EAI_EV` / `EAI_ROBOTICS` / `MIXED` / `OTHER` | `platform_meeting_attendance` | 议题业务分类标签（可空），仅用于报表/筛选展示，不参与状态机 |
| `MeetingAttachmentCategory` | `MINUTES` / `MATERIAL` / `PRESENTATION` / `OTHER` | `platform_meeting_attendance` | 会议级资料分类（可空），仅用于筛选展示；二期可加值不删值 |

---

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

### 备注

- 迁移时保留原有会议相关 cuid 主键，保证引用完整性。
- 审计相关表（audit_logs/data_access_logs/alert_rules/alerts/retention_policies）保留以兼容旧系统。
- 时间字段语义统一：
  - `meetings.startTime/endTime`、`meeting_series.startDate/endDate` 存 UTC instant；
  - `meetings.timezone`、`meeting_series.timezone` 存业务时区（IANA）；
  - 展示层按业务时区渲染，排序与计算按 UTC 执行。
- Outlook 历史追溯采用“双层存储”：
  - `outlook_event_snapshots` 仅保存当前态缓存，服务候选查询与同步当前态读取；
  - `outlook_event_source_versions` 保存历史版本链；
  - `outlook_event_sync_diffs` 保存版本间差异摘要，避免排查时人工比对完整 raw payload。
- 历史保留策略（第一阶段建议值）：
  - `outlook_event_snapshots` 长期保留当前态；
  - `outlook_event_source_versions.rawPayload` 保留 180 天；
  - `outlook_event_source_versions.normalizedPayload` 与 `outlook_event_sync_diffs.summaryJson` 可长期保留；
  - 后续如需清理，由后台定时任务按 `createdAt` 清理过期 `rawPayload`，不删除对应 diff 摘要。
- `normalizedPayload` 仅保留稳定比对字段：`subject/start/end/location/isCancelled/organizer/attendees`。
- `location` 标准化字段边界：
  - 保留 `displayName`、`locationUri`、`uniqueId`；
  - 忽略空地址对象、坐标、`@odata.*` 等非业务字段。
- `attendees` 标准化规则：
  - 邮箱统一转小写；
  - 空邮箱记录直接丢弃；
  - 保留 `email/displayName/type/response` 四类字段；
  - 按 `email + type` 升序排序后参与 hash；
  - 同邮箱同类型重复时仅保留一条，优先保留响应状态更完整、显示名非空的记录。
- 第一阶段仅要求对“已纳管会议”的官方源版本与差异落库；候选会议仍以当前态快照为主，不强制保留完整历史版本链。

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

- **新 enum** `AttendanceMode { ON_SITE, ONLINE }`：独立于现有 `AttendanceStatus`（后者是出勤结果 7 分类），专用于"允许签到方式"语义。
- **`User.workCity` 放 `platform_iam.users`**：组织架构模块维护的用户属性，避免单独建"城市表"。字符串存储（trim 后原样），不做大小写归一化；比较时 `trim` 后区分大小写精确比较。未来从 AD `officeLocation` 派生的功能会把派生结果回填同字段，签到逻辑不变。
- **`Meeting.city` / `MeetingSeries.city`**：同为字符串，前端自动补全数据源为 `SELECT DISTINCT` 聚合三张表（`users.work_city`、`meetings.city`、`meeting_series.city`）去重合并。
- **`Meeting.enforceCheckinMode` / `MeetingSeries.enforceCheckinMode`**：Prisma `@default(false)` 保护存量数据。系列级修改时业务层必须级联刷新下属 meeting 的同名字段 + `city`；Outlook 同步路径（[outlook-sync.service.ts:2200-2214](../../../backend/src/modules/meeting-attendance/services/outlook-sync.service.ts#L2200)）创建/更新 meeting 时从 series 读 `city` + `enforceCheckinMode` 继承。
- **新表 `meeting_series_attendee_preferences`**：
  - 放在 `platform_meeting_attendance` schema 下
  - 复合主键 `(seriesId, userId)`，`defaultCheckinMode` 必填（null 则不建记录）
  - 不受 Outlook 同步影响（Outlook 仅动 `MeetingRequiredAttendee`）
  - 系列被删除时级联删除
- **MeetingRequiredAttendee 新增 `checkinMode`**：字段为 null 时沿用系列级 / 城市派生。Outlook 同步 upsert 时 `update` 子句仅更新 `role`，不会冲掉 `checkinMode`（verified at [outlook-sync.service.ts:2770-2783](../../../backend/src/modules/meeting-attendance/services/outlook-sync.service.ts#L2770)）。
- **不做数据迁移脚本**：
  - 所有 `enforceCheckinMode` 默认 `false`，存量会议完全不受影响
  - 运营在目标系列上手动配置 `city` 和打开开关
  - 撤销 v1.1 的"2 个系列 + 65 场会议回填"方案

### 系列层参会人排除清单（v1.3）

**问题**：v1.2 之前"从系列层删除参会人"实现为 fan-out 的瞬时 `DELETE MeetingRequiredAttendee`，没有任何持久化标记。Outlook 同步把单次会议的 attendees 列表当作真理来源全量重写（[outlook-sync.service.ts syncAttendees()](../../../backend/src/modules/meeting-attendance/services/outlook-sync.service.ts)），删除决策在下一轮同步被回填。同样地，`!meeting.hasCustomAttendees` 过滤会跳过被人单独自定义过参会人的单次会议，造成"系列删了、单次还在"。

**事实源调整**：
- **`MeetingRequiredAttendee` 仍然是参会人的真理表**（per-meeting），结构不变
- 新增 **`MeetingSeriesAttendeeException`** 表，记录 "(seriesId, userId) 已在系列层被排除" 的**声明式决策**
- Outlook 同步从 "(meetingId, attendees from Outlook) → 全量重写 `MeetingRequiredAttendee`" 改成 "(meetingId, attendees from Outlook) ∖ exception(seriesId) → 重写"

**应用规则**（钉死语义）：
- 任一 (seriesId, userId) 命中 exception 表 = Outlook 同步对该 series 下所有 meeting 跳过 upsert 该 user；事务末的 `deleteMany(notIn reservedUserIds)` 会顺带清掉现存行
- `getSeriesAttendees` 聚合 "默认参会人" 时同样过滤命中行，避免短暂时序差让前端看到"幽灵参会人"
- 用户想把被排除的人加回来：直接在单次会议页面手动添加（触发该 meeting 的 `hasCustomAttendees=true`）；该单次脱离系列默认，不再受 exception 影响
- 单次层删除（`MeetingAttendeeException`）是后续 v1.x 范围，本次不做；当前用户在单次页删人 = 直接 `DELETE MeetingRequiredAttendee` 一行 + 标 `hasCustomAttendees=true`

**与 `hasCustomAttendees` 的语义边界**：
- `hasCustomAttendees` 是单次会议级别的"标记此场已被人工干预"hint
- v1.3 之前 cascade 操作用 `!hasCustomAttendees` 过滤 → 错误地保护了"曾经被改过的单次"，违背用户系列层的明示决策
- v1.3 起 cascade 不再看此字段：系列层删除穿透所有未来 / 进行中的单次会议
- `hasCustomAttendees` 本身保留（仍用于聚合视图挑"默认场次"），但**不再控制 cascade 边界**

**回填迁移**：
- 不做数据迁移脚本。历史上靠前端"系列删除"过的用户记录可能已经被 Outlook 同步回填，需要重新走一次"系列删除"流程以触发 exception 插入
- 已有 `MeetingRequiredAttendee` 行不动；exception 与现有数据并存

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

**问题**：现有 `Meeting` 只挂"会议头"信息（title / startTime / location 等），无法承载"会议有多个段、每段有多个议题、议题可附主讲人 / 时长 / 编号、并可派发上传任务给参会人提交资料"这一组协同需求；同时会议本身也需要承载非议题级的整体资料（会议纪要、PPT、视频）。

**事实源调整（v1.0）**：
- 新增 **`MeetingAgendaSection`** —— 议程"段"（如"第一段：技术专题"），归属单个会议
- 新增 **`MeetingAgendaItem`** —— 议程"项"（具体议题），归属单个段；附 `presenterUserId` / `timeMinutes` / `code` / `categoryTag` 可选元信息
- 新增 **`MeetingAgendaItemUploadTask`** —— 议程项级资料上传任务，manager 向多个参会人派发，assignee 完成上传后自动置 `UPLOADED` + `completedAt`
- 新增 **`MeetingAgendaItemAttachment`** —— 议程项级资料文件元数据（实际文件存 disk / S3，记录 `storagePath`）
- 新增 **`MeetingAttachment`** —— 会议级资料文件元数据（不挂任何议程项），含 `category` 区分 `MINUTES` / `MATERIAL` / `PRESENTATION` 等

**核心 prisma 定义**：

```prisma
model MeetingAgendaSection {
  id              String    @id @default(uuid()) @db.Uuid
  meetingId       String    @map("meeting_id") @db.VarChar(32)
  order           Int       @default(0)
  title           String    @db.VarChar(200)
  createdById     String    @map("created_by_id") @db.Uuid
  organizationId  String?   @map("organization_id") @db.Uuid
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  createdAt       DateTime  @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime  @updatedAt @map("updated_at") @db.Timestamptz(3)
  meeting         Meeting   @relation(fields: [meetingId], references: [id])
  createdBy       User      @relation("AgendaSectionCreatedBy", fields: [createdById], references: [id])
  organization    Organization? @relation("AgendaSectionOrg", fields: [organizationId], references: [id])
  items           MeetingAgendaItem[]
  @@index([meetingId, order])
  @@index([organizationId, deletedAt])
  @@map("meeting_agenda_sections")
  @@schema("platform_meeting_attendance")
}

model MeetingAgendaItem {
  id              String  @id @default(uuid()) @db.Uuid
  sectionId       String  @map("section_id") @db.Uuid
  order           Int     @default(0)
  title           String  @db.VarChar(200)
  description     String? @db.Text
  code            String? @db.VarChar(64)
  timeMinutes     Int?    @map("time_minutes")
  presenterUserId String? @map("presenter_user_id") @db.Uuid
  categoryTag     AgendaCategoryTag? @map("category_tag")
  createdById     String  @map("created_by_id") @db.Uuid
  organizationId  String? @map("organization_id") @db.Uuid
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  createdAt       DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
  updatedAt       DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
  section         MeetingAgendaSection @relation(fields: [sectionId], references: [id])
  presenter       User? @relation("AgendaItemPresenter", fields: [presenterUserId], references: [id])
  createdBy       User  @relation("AgendaItemCreatedBy", fields: [createdById], references: [id])
  organization    Organization? @relation("AgendaItemOrg", fields: [organizationId], references: [id])
  uploadTasks     MeetingAgendaItemUploadTask[]
  attachments     MeetingAgendaItemAttachment[]
  @@index([sectionId, order])
  @@index([organizationId, deletedAt])
  @@map("meeting_agenda_items")
  @@schema("platform_meeting_attendance")
}

model MeetingAgendaItemUploadTask {
  id             String   @id @default(uuid()) @db.Uuid
  agendaItemId   String   @map("agenda_item_id") @db.Uuid
  assigneeUserId String   @map("assignee_user_id") @db.Uuid
  assignedById   String   @map("assigned_by_id") @db.Uuid
  status         UploadTaskStatus @default(PENDING)
  dueAt          DateTime? @map("due_at") @db.Timestamptz(3)
  assignedAt     DateTime @default(now()) @map("assigned_at") @db.Timestamptz(3)
  completedAt    DateTime? @map("completed_at") @db.Timestamptz(3)
  createdById    String   @map("created_by_id") @db.Uuid
  organizationId String?  @map("organization_id") @db.Uuid
  deletedAt      DateTime? @map("deleted_at") @db.Timestamptz(3)
  agendaItem     MeetingAgendaItem @relation(fields: [agendaItemId], references: [id])
  assignee       User @relation("UploadTaskAssignee", fields: [assigneeUserId], references: [id])
  assignedBy     User @relation("UploadTaskAssignedBy", fields: [assignedById], references: [id])
  createdBy      User @relation("UploadTaskCreatedBy", fields: [createdById], references: [id])
  organization   Organization? @relation("UploadTaskOrg", fields: [organizationId], references: [id])
  @@index([assigneeUserId, status])
  @@index([agendaItemId])
  @@index([organizationId, deletedAt])
  @@map("meeting_agenda_item_upload_tasks")
  @@schema("platform_meeting_attendance")
}

model MeetingAgendaItemAttachment {
  id              String   @id @default(uuid()) @db.Uuid
  agendaItemId    String   @map("agenda_item_id") @db.Uuid
  uploadedById    String   @map("uploaded_by_id") @db.Uuid
  filename        String   @db.VarChar(255)
  mimeType        String   @map("mime_type") @db.VarChar(128)
  size            BigInt
  storagePath     String   @map("storage_path") @db.VarChar(512)
  uploadedAt      DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(3)
  createdById     String   @map("created_by_id") @db.Uuid
  organizationId  String?  @map("organization_id") @db.Uuid
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  agendaItem      MeetingAgendaItem @relation(fields: [agendaItemId], references: [id])
  uploadedBy      User @relation("AgendaItemAttachmentUploader", fields: [uploadedById], references: [id])
  createdBy       User @relation("AgendaItemAttachmentCreatedBy", fields: [createdById], references: [id])
  organization    Organization? @relation("AgendaItemAttachmentOrg", fields: [organizationId], references: [id])
  @@index([agendaItemId])
  @@index([organizationId, deletedAt])
  @@map("meeting_agenda_item_attachments")
  @@schema("platform_meeting_attendance")
}

model MeetingAttachment {
  id              String   @id @default(uuid()) @db.Uuid
  meetingId       String   @map("meeting_id") @db.VarChar(32)
  uploadedById    String   @map("uploaded_by_id") @db.Uuid
  category        MeetingAttachmentCategory? @map("category")
  filename        String   @db.VarChar(255)
  mimeType        String   @map("mime_type") @db.VarChar(128)
  size            BigInt
  storagePath     String   @map("storage_path") @db.VarChar(512)
  uploadedAt      DateTime @default(now()) @map("uploaded_at") @db.Timestamptz(3)
  createdById     String   @map("created_by_id") @db.Uuid
  organizationId  String?  @map("organization_id") @db.Uuid
  deletedAt       DateTime? @map("deleted_at") @db.Timestamptz(3)
  meeting         Meeting  @relation(fields: [meetingId], references: [id])
  uploadedBy      User     @relation("MeetingAttachmentUploader", fields: [uploadedById], references: [id])
  createdBy       User     @relation("MeetingAttachmentCreatedBy", fields: [createdById], references: [id])
  organization    Organization? @relation("MeetingAttachmentOrg", fields: [organizationId], references: [id])
  @@index([meetingId, uploadedAt])
  @@index([organizationId, deletedAt])
  @@map("meeting_attachments")
  @@schema("platform_meeting_attendance")
}

enum UploadTaskStatus {
  PENDING
  UPLOADED
  CANCELLED
  @@schema("platform_meeting_attendance")
}

enum AgendaCategoryTag {
  FFAI_EAI_EV
  EAI_ROBOTICS
  MIXED
  OTHER
  @@schema("platform_meeting_attendance")
}

enum MeetingAttachmentCategory {
  MINUTES
  MATERIAL
  PRESENTATION
  OTHER
  @@schema("platform_meeting_attendance")
}
```

> **删除策略**：所有 5 张新表均**软删（deletedAt 标记）**，不依赖 FK `onDelete: Cascade`。Service 层显式在同事务里 cascade soft-delete 所有从属行（`UPDATE deletedAt = now() WHERE parent_id = ?`）。所有 list/find 查询默认带 `WHERE deletedAt IS NULL` 过滤（Prisma middleware 或显式 where）。attachment 软删后 30 天由 cron 物理清理 + unlink 文件，详见 `03-architecture.md §软删 + cron 物理清理`。

**应用规则（钉死语义）**：
- 议程段与议程项的 `order` 是稀疏整数；reorder API 整批重写（不做相对位移）
- `MeetingAgendaItem.code` 由用户自填，**不** unique（业务上可重）；为空表示该议题无外部编号
- `MeetingAgendaItem.categoryTag` 仅作为筛选 / 报表 tag，不参与状态机或权限判定
- `MeetingAgendaItemUploadTask` 状态机：`PENDING → UPLOADED`（assignee 成功上传时自动 + 写 `completedAt`） / `PENDING → CANCELLED`（manager 显式撤销）；`UPLOADED` 与 `CANCELLED` 互斥且为终态
- assignee 成功 `POST /agenda-items/:itemId/attachments` 且自身存在某条 `PENDING` task 时，**在同事务里**把该 task 置 `UPLOADED` 并写 `completedAt`；同 user 多条 `PENDING` 时只刷"最早 assignedAt"的一条
- `MeetingAgendaItemAttachment` 与 `MeetingAttachment` 在 schema 层不上传任 task；删除附件**不会**回滚 task 状态（保留"我已经交过"的事实）
- **删除一律为软删**（`UPDATE deletedAt = now()`）；service 层显式在同事务里 cascade soft-delete 所有从属行（段 → 项 → 任务 / 附件）；attachment 软删后 30 天由 cron 物理清理 + unlink 文件
- 议程相关写操作（段 / 项 / 任务）要求 `meeting:agenda:update`；阅览要求 `meeting:agenda:read`
- 资料权限拆细：
  - `meeting:attachment:upload:any`（manager / 创建人，可上传到任意议程项 / 会议）
  - `meeting:attachment:upload:assigned`（参会人，仅能上传到自己被 assign 的议程项）
  - `meeting:attachment:download`（参会人即可，下载任何议程项 / 会议级资料）
  - `meeting:upload-task:assign`（manager / 创建人，派发 / 撤销上传任务）

### 文件存储约定（v1.0）

- **MVP 后端存储为本地 disk**：`storagePath` 字段保存相对路径（不含 root）；实际落盘路径 = `MEETING_ATTACHMENT_STORAGE_ROOT` env（默认 `./var/meeting-attachments`）+ `storagePath`
- **路径命名**：`<yyyy>/<mm>/<uuid>.<ext>`（按上传时间分目录，避免单目录文件数爆炸；**不含原始 filename**，路径穿越 + ENAMETOOLONG 双防御）
- **二期可改 S3 不动 schema**：增 env `STORAGE_BACKEND=local|s3` + `S3_BUCKET` / `S3_REGION` / `S3_ENDPOINT`；schema 字段语义不变（`storagePath` 直接当 S3 key 用）
- **单文件大小上限 200MB**（含图片 / 视频），超出 → `ATTACHMENT_TOO_LARGE` (413)

**MIME 白名单（v1.0 锁定，写入 backend constants）**，不在白名单 → `ATTACHMENT_MIME_NOT_ALLOWED` (415)：

| MIME | 扩展名 |
|------|--------|
| `application/pdf` | pdf |
| `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | docx |
| `application/vnd.openxmlformats-officedocument.presentationml.presentation` | pptx |
| `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | xlsx |
| `image/jpeg` | jpeg / jpg |
| `image/png` | png |
| `video/mp4` | mp4 |
| `video/quicktime` | mov |

> 不含老 Office (`application/msword` / `application/vnd.ms-*`) / `text/plain` / `text/csv`。

**MIME 校验两阶段**：
1. Content-Type header 快速过滤（白名单匹配）
2. **必须**用 `file-type` lib 读首 4KB magic bytes 二次校验。magic bytes 推断的 MIME 与 Content-Type 不一致 → 415 `ATTACHMENT_MIME_MISMATCH`（防 `.exe` 改扩展名伪装 `.pdf`）

**filename UTF-8 + RFC 5987 Content-Disposition**：
- `filename` 字段最大 255 unicode 字符
- 落盘永远用 uuid（绝不用原始 filename，路径穿越 + ENAMETOOLONG 双防御）
- 下载 Header：`Content-Disposition: attachment; filename="<ascii-fallback>"; filename*=UTF-8''<percent-encoded>`，兼容老浏览器 + 正确显示中文/特殊字符文件名

**删除策略（软删 + cron 物理清理）**：
- DELETE attachment → `UPDATE deletedAt = now()`（不立即删 DB 行，不立即删文件）
- 所有 list/find 查询默认带 `WHERE deletedAt IS NULL` 过滤
- 后台 cron（env `MEETING_ATTACHMENT_PHYSICAL_DELETE_DAYS`，默认 30）扫描 `deletedAt < now() - 30d` 的行 → 物理删 DB 行 + `unlink storagePath` 文件
- 议程段/项软删时 service 层显式 cascade soft-delete 下属 attachment（同事务）
- 用户在 30 天窗口内联系 admin 可恢复（admin UPDATE deletedAt = null）

**上传半路失败清扫策略**：
- multer `diskStorage` tmp 写入 `${MEETING_ATTACHMENT_STORAGE_ROOT}/tmp/uploads/`（与正式同盘避免 EXDEV cross-device rename 失败）
- `req.on('close')` / `req.on('aborted')` 兜底 unlink tmp 文件
- 后台 cron GC（env `MEETING_ATTACHMENT_GC_INTERVAL_HOURS`，默认 1）：
  - **正向扫描**：扫 `${MEETING_ATTACHMENT_STORAGE_ROOT}/**` 不在 `MeetingAgendaItemAttachment.storagePath` ∪ `MeetingAttachment.storagePath` 集合中、mtime < now()-1h 的文件 → 物理删
  - **反向扫描**：DB 行没对应文件 → log warn + **不删 DB**（运维介入排查，避免误删）

- **下载鉴权**：`GET /attachments/agenda-item/:id/download` 与 `GET /attachments/meeting/:id/download` 校验 `meeting:attachment:download` + 校验当前用户是会议参会人（非参会人 403）；下载走 stream + RFC 5987 Content-Disposition
- **去重 / 校验和不做**：MVP 不计算文件 hash；同一文件重复上传产生多条独立记录
