# 运营中心 - 产品需求文档（v1：M365 休眠账号）

> **版本**: v0.1
> **状态**: Draft
> **创建日期**: 2026-04-30
> **最后更新**: 2026-04-30
> **产品经理**: AI Assistant

---

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

### 通用字段

| 字段 | 内容 |
|------|------|
| 模块 | 运营中心 / M365 休眠账号 |
| 文档类型 | PRD |
| 目标 | 把现有 PowerShell 脚本（`Export-M365Inactive.ps1`）的逻辑产品化进 FFOA：通过 Microsoft Graph 拉取 M365 / Entra ID 用户的登录与服务活动，识别休眠账号，提供可筛选、可导出的运营视图 |
| 范围 | In Scope: app-only Graph 同步、活动数据落库（保留历史快照）、休眠列表查询、阈值筛选、CSV 导出、手动触发同步、IT 角色权限管控；Out of Scope: 自动禁用账号、license 回收、删除操作、定时任务、Graph delta 增量、多租户切换 |
| 核心规则 | v1 只读；同步只支持手动触发；同步元数据复用 `platform_automation.AutomationExecution`（不自建 SyncBatch）；用户当前态 + 活动变更事件落本模块独立两表；阈值（默认 180 天）只是查询参数，不影响同步逻辑；凭据复用项目现有 `.env` 的 `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET`（与 Entra 同步 / SharePoint / Outlook 共用同一份 App Registration），不新增 M365 专用前缀；菜单与接口仅 IT 角色可见 |
| 验收标准 | 配置好 `.env` 凭据后可手动同步成功并落库；列表展示用户邮箱、显示名、6 个活动时间、`lastAnyActivity`、`daysInactive`；可按 `inactiveDays` 阈值筛选；可 CSV 导出当前筛选结果；可查看历史同步批次列表；同步失败给出可定位的错误信息；非 IT 角色看不到菜单和接口 |
| 关联文档 | `06-data-model.md` / `07-api.md` / `05-ui-interaction-spec.md` |

### 功能清单（最小）

| 功能 | 优先级 | 说明 |
|------|--------|------|
| Microsoft Graph app-only 客户端 | P0 | client-credentials 流，token 缓存 + 自动续期 |
| 用户活动同步（全量） | P0 | `users` + `signInActivity` + 4 份服务活动报告，合并 6 个时间字段 |
| 同步元数据持久化 | P0 | **复用** `platform_automation.AutomationTask` + `AutomationExecution`，加 `M365_DORMANT_SYNC` enum 值，不自建 SyncBatch 表（`[LRN-20260425-001]`）。直方图存 `AutomationExecution.result` JSON 字段 |
| 用户当前态持久化（M365User） | P0 | 一个用户一行，每次同步 upsert 6 时间字段 |
| 用户活动变更事件流（M365UserActivityChange） | P0 | 仅当某字段相比上次同步有变化时追加，支撑用户级历史时间线 |
| 休眠列表查询 | P0 | 基于 M365User 当前态，支持分页、排序、`inactiveDays` 阈值筛选、关键字搜索 |
| 单用户活动时间线 | P0 | 从变更事件流查询某个用户的 6 个字段历史变化 |
| 手动触发同步 | P0 | 同一时刻只允许一个 running 批次（互斥） |
| 最近同步状态 | P0 | 用于前端展示"上次同步时间 / 状态 / 直方图" |
| 历史同步批次 | P0 | 趋势图数据源（每个 batch 携带直方图） |
| 阈值可调趋势 | P0 | 前端用直方图按用户选定阈值实时累加，无需后端预聚合多档 |
| CSV 导出 | P0 | 导出当前筛选条件下的用户列表 |
| 角色权限 | P0 | 仅 IT 管理员角色可访问菜单与接口 |

---

## 🧭 人类阅读区

### 背景

IT 团队此前用 PowerShell 脚本 `MyBrain/Work/IT-Operations/scripts/Export-M365Inactive.ps1` 定期统计 Microsoft 365 / Entra ID 的休眠账号。脚本输出 CSV，需要人工跑、人工分发、人工筛选——执行成本高，结果也不沉淀。

把这套逻辑搬进 FFOA：

- **数据源不是 FFOA 自身的登录记录**，而是 **Microsoft Graph**：
  - `users.signInActivity.lastSignInDateTime`（交互式登录）
  - `users.signInActivity.lastNonInteractiveSignInDateTime`（非交互登录）
  - Email 活动报告（180 天窗口）
  - OneDrive 活动报告
  - Teams User 活动报告
  - SharePoint 活动报告
- 6 个时间字段取最大值 → `lastAnyActivity`，再算 `daysInactive = today - lastAnyActivity`。
- License 假设：Entra ID P1/P2，`signInActivity` 字段可用；若两个 signIn 字段都为 null，UI 给出兜底文案"无登录记录（可能 license 等级不足或账号从未登录）"。

### 目标用户

| 角色 | 描述 | 使用场景 |
|------|------|---------|
| IT 管理员 | 负责 M365 / Entra ID 治理 | 周期性巡检休眠账号、配合安全审计、为 license 优化提供数据 |

v1 暂不开放给其他角色。

### 业务流程

```mermaid
graph TD
    A[IT 管理员点击同步] --> B[创建 AutomationExecution status=RUNNING]
    B --> C[Graph: 拉 users + signInActivity]
    C --> D[Graph: 拉 4 份活动报告]
    D --> E[合并 6 字段 -> lastAnyActivity / daysInactive]
    E --> F[Upsert M365User + 写 M365UserActivityChange diff]
    F --> G[execution status=SUCCESS + result 含直方图与统计]
    G --> H[前端列表展示最新批次]
    H --> I[阈值筛选 / 排序 / 搜索 / CSV 导出]
```

### 详细需求

#### 1. 同步触发

- v1 **只支持手动触发**（前端按钮 + POST API）。
- 同一时刻只允许一个 `running` 批次；并发触发返回 `409 SYNC_IN_PROGRESS`。
- 同步是**全量**：每次拉所有用户的所有 6 个时间字段，整体落一个新批次。
- 失败时记录 `error` 字段（错误码 + 摘要），便于排查。

#### 2. 历史保留策略

采用"当前态 + 变更事件 + 复用同步基础设施"三层存储（详见 `06-data-model.md`）：

- **`M365User`（L2 当前态）**：一个用户一行，每次同步 upsert 最新值。列表查询走这张表，常驻只有 N 行（≈ 5000）。
- **`M365UserActivityChange`（L3 变更事件流）**：6 个时间字段或 `accountEnabled` / `hasLicense` / `licenses` 中任一相比上次有变化时追加一行。不变就不写。
- **`AutomationExecution`（L1 同步元数据，复用 platform_automation）**：每次同步 1 行，业务数据（totalUsers / changedUsers / inactiveDistribution）存在 `result: Json`；不自建 SyncBatch 表。

**趋势能力**：

- 组织级休眠数趋势 → 从所有 `SUCCESS` execution 的 `result.inactiveDistribution` 取数，前端用用户选定的阈值在直方图上实时累加。阈值前端可调。
- 单用户活动时间线 → 从 `M365UserActivityChange` 直接查，看清"Alice 的 lastEmailActivity 何时从 X 变成 Y"。
- 当前列表 → 从 `M365User` 查，毫秒级响应。

**附带收益**：复用 platform_automation 自动获得管理后台 / 失败告警 / 手动触发 / 统计聚合等能力（与 LDAP_SYNC / DINGTALK_SYNC / ADP_SYNC 共享同一套），不需要本模块单独实现。

#### 3. 阈值

- 阈值不参与同步逻辑——所有用户的活动数据都落库。
- 阈值是**查询参数**：`?inactiveDays=180`，默认 180 天，前端可改。
- 列表始终展示用户的真实 `daysInactive`，前端按阈值过滤。

#### 4. 凭据

**复用现有 Azure App Registration**，不新增前缀。项目里 `.env.example` 已有：

```
AZURE_TENANT_ID=
AZURE_CLIENT_ID=
AZURE_CLIENT_SECRET=
```

已被 `organization/entra` / `knowledge-base/sharepoint-sync` / `meeting-attendance/outlook-sync` 三个模块共用。M365 休眠账号同步直接复用同一份。

- 后端通过 `MsalClient`（client-credentials flow）在运行时换取 access token + 缓存（≈ 1 小时），不在 env 存 token。
- 缺失任一变量时同步直接失败，错误码 `AZURE_CREDENTIAL_MISSING`（与现有模块对齐错误形态）。
- `.env.example` 不需改动。

#### 4.1 Graph API 权限要求

App-only（client-credentials）调用所需的 Application Permissions（需 admin consent）：

| Scope | 用途 | 现状 |
|---|---|---|
| `User.Read.All` | 列用户 + Graph metadata（部门、岗位、enabled、license） | ✅ 已具备（Entra 同步在用） |
| `AuditLog.Read.All` | 读 `users.signInActivity`（`lastSignInDateTime` / `lastNonInteractiveSignInDateTime`） | ✅ 已添加并 admin consent（2026-04-30） |
| `Reports.Read.All` | 读 4 份活动报告：Email / OneDrive / Teams / SharePoint（180 天窗口） | ✅ 已添加并 admin consent（2026-04-30） |

License 假设：Tenant 持有 **Entra ID P1 或 P2**，否则 `signInActivity` 字段返回 null（用户态视为"无登录记录"，UI 给兜底文案）。

**操作路径**：Azure Portal → App Registrations → 当前 App → API permissions → Add permission → Microsoft Graph → Application permissions → 勾选上述 scope → Grant admin consent。

#### 4.2 M365 管理中心配置要求（关键）

⚠️ **必须由 IT 在 M365 Admin Center 关闭"去识别名称"开关**，否则 4 份活动报告（Email/OneDrive/Teams/SharePoint）返回的 UPN 会被替换为 hash，**无法 JOIN 回 `M365User`，整个休眠识别失效**。

**操作路径**：M365 Admin Center → Settings → Org settings → Services → **Reports** → 取消勾选 **"Display concealed user, group, and site names in all reports"** → Save。

确认后后端才能正确合并 6 个时间字段。

#### 4.3 License 详情展示

不仅要展示"是否有 license"（boolean），还要展示**具体哪些 license**（如 E3 / E5 / EMS / Power BI Pro 等）。

- Graph User 对象的 `assignedLicenses` 是 `[{ skuId: UUID, disabledPlans: [...] }]`，`skuId` 是 GUID 不可读。
- 后端在每次同步开始时调一次 `GET /subscribedSkus`（本租户已订阅的 SKU 列表），建立 `skuId → { skuPartNumber, displayName }` 映射，落给每个用户。
- `M365User.licenses` 存数组：`[{ skuId, skuPartNumber: "ENTERPRISEPACK", displayName: "Microsoft 365 E3" }]`。
- License 变化（新增 / 移除）也作为 change 事件追加到 `M365UserActivityChange`。
- UI：列表用紧凑展示（如"E3, EMS"或徽章组），详情抽屉展示完整列表。

#### 5. 权限

- 角色：**IT 管理员**（沿用 IAM 既有角色，若不存在则新建）。
- 菜单："运营中心"一级菜单 + "M365 休眠账号"二级菜单，非授权用户看不到。
- 后端所有接口均经过 `PermissionsGuard` 校验。

**实施清单（必须随代码 PR 一起做）**：

1. `backend/prisma/seeds/permissions.seed.ts` 增加三条权限种子（与项目现有惯例一致：`{ resource, action, description, module }` 四元组，**resource 单段 kebab-case**，不允许多级冒号）：
   ```ts
   { resource: 'm365-dormant', action: 'read',   description: '查看 M365 休眠账号', module: '运营中心' },
   { resource: 'm365-dormant', action: 'sync',   description: '触发 M365 休眠账号同步', module: '运营中心' },
   { resource: 'm365-dormant', action: 'export', description: '导出 M365 休眠账号 CSV', module: '运营中心' },
   ```
   Controller 用 `@RequirePermissions('m365-dormant:read')` 等。
2. `backend/prisma/seeds/roles.seed.ts` 把上述三个权限点挂到"IT 管理员"角色（如不存在则新建该角色）。
3. 开发者本地启动需重新跑 `cd backend && npm run db:seed`（force-reset 模式）后权限才生效，否则后端 controller 即便写了 `@RequirePermissions(...)` 也会一律 403。
4. UAT/生产部署清单需包含 seed 步骤。

#### 6. 数据展示与导出

- **列表列（紧凑视图）**：邮箱 / 显示名 / 部门 / 启用 / License（紧凑展示，如 "E3, EMS"） / 最后登录（`lastSignInDateTime`） / 最后活动（`lastAnyActivity`） / 休眠天数。完整 6 个时间字段在详情抽屉里展开，避免表格太宽。
- **详情抽屉**：6 个时间字段全部 + license 完整列表 + Graph metadata（部门、岗位、`firstSeenAt`、`missingFromLatestSync` 标签） + 活动时间线 Tab。
- 默认排序：`daysInactive desc`。
- 筛选：`inactiveDays`（≥）、关键字（UPN / 显示名 / mail 三字段 ILIKE）、是否启用、是否有 license。
- 导出：CSV，导出当前筛选条件下的全量记录（不分页），上限 50000 行（超过返 `EXPORT_TOO_LARGE`）。
- **审计**：在 `POST /sync` 与 `GET /users?format=csv` 的 Controller 方法上加 `@Auditable()` + `@Sensitive()` 装饰器（来自 `backend/src/core/observability/audit/decorators/`），由 `AuditLogInterceptor` 自动记录 actor / 时间 / 参数 / IP，**不在 service 里手写 audit 调用**——这是项目惯例。

#### 7. i18n

- 所有可见文案双语（zh-CN / en-US），切换语言无 missing key。
- 日期按 locale 格式化。

### 非功能需求

- 同步性能：v1 假设组织规模 ≤ 5000 用户，单次同步 ≤ 5 分钟可接受。
- Graph 限流：429 响应需按 `Retry-After` 退避重试（最多 3 次）。Microsoft 公开限制：`/users` 每 app+tenant **每 10s 130 次**，`/reports/*` **每 10s 14000 次**——5000 用户单次同步基本不会触发，3 次重试是兜底。
- 凭据泄露防护：`clientSecret` 不进日志、不进 API 响应、不进前端。
- **数据时效性**：4 份服务活动报告（Email/OneDrive/Teams/SharePoint）来自 Microsoft 后台离线统计，**官方明示存在 24-48 小时延迟**。`signInActivity`（交互/非交互登录）接近实时（≤ 1 小时）。UI 抽屉与同步状态条都需提示该口径，避免用户疑问"我刚发邮件了为什么没更新"。

### 测试策略

- **后端 service 层抽象 `GraphClient` interface**：sync service 依赖接口，而非具体实现。生产用 MsalClient + fetch 实现；测试用 fake 实现 return fixture。
- **Fixtures**：`testing/fixtures/m365/` 下放 5 套预录响应：
  - `users-page1.json` / `users-page2.json`（覆盖分页）
  - `subscribed-skus.json`（含常见和稀有 SKU，测试 displayName 兜底）
  - `report-email.csv` / `report-onedrive.csv` / `report-teams.csv` / `report-sharepoint.csv`（正常 UTF-8 with BOM）
  - `report-email-obfuscated.csv`（脱敏，断言抛 `REPORTS_OBFUSCATED`）
  - `users-with-mixed-case-upn.json` + `report-email-lower-upn.csv`（断言大小写 JOIN 正确）
  - `error-401.json`（断言 `GRAPH_AUTH_FAILED`）
- **L1 集成测试覆盖**：
  - 首次同步（无 existing user，全部走 create 分支，不写 change 事件）
  - 二次同步无变化（验证 0 条 change 事件）
  - 二次同步部分用户字段变化（验证 change 事件数 = 实际变化数）
  - 二次同步用户消失（验证 `lastSeenInBatchId` 不更新，`missingFromLatestSync` 查询命中）
  - 并发互斥（service 层 findFirst RUNNING execution，触发返 409）
  - 进程重启恢复（startup hook 标记 stuck running 为 SYNC_INTERRUPTED）
  - 直方图聚合（验证 6 桶 + never 加总 = totalUsers）
- **L2 E2E (Playwright MCP)**：用 fixture mode 跑列表展示 / 阈值过滤 / 详情抽屉 / 趋势 slider / CSV 导出 / 双语切换。不真打 Graph。

#### Stub Mode（本地开发 / E2E 测试）

后端无 `AZURE_*` 凭据时同步直接报 `AZURE_CREDENTIAL_MISSING`，本地开发与第三方贡献者无法跑此模块。

- 引入环境变量 `OPS_CENTER_M365_STUB=true`
- `OpsCenterM365Module` 在 stub mode 下注入 fake `GraphClient` 实现（同 L1 测试用的接口）
- fake 实现从 `testing/fixtures/m365/*.{json,csv}` 读固定数据，模拟分页 / SKU / 报告 CSV
- 前端无感知，正常跑列表 / 同步 / 趋势 / 详情抽屉 / CSV 导出
- `.env.example` 需加注释说明该开关

### 不做的

- 自动禁用 / 删除 / 回收 license（涉及生产账户的写操作，需独立评审）。
- 定时任务（v1.1 评估）。
- Graph delta 增量（v1.1 评估）。
- 多租户切换（当前固定单租户）。
- **多组织共享同步结果**：v1 假定 FFOA 单组织部署。如未来多 organization 同时使用，每个组织都会独立调一次 Graph（M365 数据本身是租户级，会重复拉），v1.x 评估是否做共享缓存。
