# 运营中心 - API 文档（v1：M365 休眠账号）

> **版本**: v0.2
> **最后更新**: 2026-04-30
> **维护者**: FFOA 开发团队

---

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

### 统一约定

- Base URL: `/api/v1/ops-center`
- 认证: Bearer Token (JWT)
- 权限: `m365-dormant:read` / `:sync` / `:export`（仅 IT 管理员角色）
- 响应封装：项目统一 `TransformInterceptor` / `AllExceptionsFilter`，下方示例仅展示 `data`
- 时间字段统一 ISO 8601 UTC 字符串，前端按 locale 格式化
- **同步元数据**底层来自 `platform_automation.AutomationExecution`，本模块对外把它叫"sync execution"或简称 `executionId`，公开 API shape 不暴露 AutomationExecution 内部字段名（如 `triggerType` / `result.*`），由 service 层映射成 M365 业务语义返回

### 接口数量汇总

总计：5 个

| Method | Path | 权限 | 说明 |
|--------|------|------|------|
| GET  | `/m365-dormant/users` | `:read` / `:export`(csv) | 列表（基于 `M365User` 当前态） |
| GET  | `/m365-dormant/users/:userId/timeline` | `:read` | 单用户活动时间线（来自 change 事件流） |
| POST | `/m365-dormant/sync` | `:sync` | 手动触发一次全量同步 |
| GET  | `/m365-dormant/sync/latest` | `:read` | 最近一次同步状态摘要 |
| GET  | `/m365-dormant/sync/history` | `:read` | 历史同步批次（含直方图，趋势用） |

---

## 接口详情

### 1. GET `/m365-dormant/users`

查询当前态用户列表（来源 `M365User`，`daysInactive` 为"上次同步时点"的值）。

#### Query 参数

| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|------|------|------|
| inactiveDays | Int | ❌ | 180 | 阈值；返回 `daysInactive >= inactiveDays` 或 `lastAnyActivity IS NULL`（视下一参数）的用户 |
| includeNeverSignedIn | Boolean | ❌ | true | 是否包含从未登录用户 |
| accountEnabled | Boolean | ❌ | - | 过滤启用/禁用 |
| hasLicense | Boolean | ❌ | - | 过滤是否有 license |
| keyword | String | ❌ | - | 三字段 ILIKE：`userPrincipalName` / `displayName` / `mail` |
| missingFromLatestSync | Boolean | ❌ | false | 仅返回上次同步未在 Graph 中见到的用户（`lastSeenInBatchId != latest`） |
| sortBy | Enum | ❌ | `daysInactive` | `daysInactive` / `lastAnyActivity` / `userPrincipalName` / `updatedAt` |
| sortOrder | Enum | ❌ | `desc` | `asc` / `desc` |
| page | Int | ❌ | 1 | |
| pageSize | Int | ❌ | 50 | 上限 200 |
| format | Enum | ❌ | `json` | `json` / `csv`；`csv` 走 `:export` 权限，忽略分页 |

#### 响应（json）

```json
{
  "snapshotAt": "2026-04-30T03:02:14Z",
  "snapshotExecutionId": "uuid",
  "items": [
    {
      "id": "uuid",
      "graphUserId": "...",
      "userPrincipalName": "alice@contoso.com",
      "displayName": "Alice",
      "mail": "alice@contoso.com",
      "department": "Finance",
      "jobTitle": "Analyst",
      "accountEnabled": true,
      "hasLicense": true,
      "licenses": [
        { "skuId": "uuid", "skuPartNumber": "ENTERPRISEPACK", "displayName": "Microsoft 365 E3" },
        { "skuId": "uuid", "skuPartNumber": "EMS", "displayName": "Enterprise Mobility + Security E3" }
      ],
      "lastSignInDateTime": "2025-09-12T08:11:00Z",
      "lastNonInteractiveSignInDateTime": null,
      "lastEmailActivity": "2025-10-01T00:00:00Z",
      "lastOneDriveActivity": null,
      "lastTeamsActivity": null,
      "lastSharePointActivity": null,
      "lastAnyActivity": "2025-10-01T00:00:00Z",
      "daysInactive": 211,
      "missingFromLatestSync": false
    }
  ],
  "pagination": { "page": 1, "pageSize": 50, "total": 312, "totalPages": 7 }
}
```

`snapshotAt` = 最近一次 success 批次的 `startedAt`，用于前端展示"截至 X 时刻的数据"。

#### 响应（csv）

响应头：

```
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="m365-dormant-{YYYYMMDD}.csv"
```

文件名日期使用同步批次的 `startedAt`（UTC）格式化，不用服务端当前时间——保证导出文件名与数据时点一致。BOM：UTF-8 BOM 在文件开头，列顺序与 json 字段一致。

#### 错误

| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 404 | `NO_SUCCESSFUL_SYNC` | 还没有任何成功的同步批次 |
| 403 | `FORBIDDEN` | 缺少 IT 角色 |

---

### 2. GET `/m365-dormant/users/:userId/timeline`

查询单个用户的活动变更时间线（来自 `M365UserActivityChange`）。

#### Path 参数

| 参数 | 类型 | 说明 |
|------|------|------|
| userId | UUID | `M365User.id` |

#### Query 参数

| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| limit | Int | 100 | 上限 500 |
| field | Enum | - | 仅返回某字段的变更（`lastSignInDateTime` 等） |
| since | DateTime | - | 仅返回 `createdAt >= since` 的事件 |

#### 响应

```json
{
  "user": {
    "id": "uuid",
    "userPrincipalName": "alice@contoso.com",
    "displayName": "Alice",
    "currentDaysInactive": 211,
    "firstSeenAt": "2025-06-15T00:00:00Z"
  },
  "events": [
    {
      "id": "uuid",
      "executionId": "uuid",
      "observedAt": "2026-04-30T03:00:00Z",
      "field": "lastEmailActivity",
      "previousValue": "2025-09-15T00:00:00Z",
      "currentValue": "2025-10-01T00:00:00Z"
    },
    {
      "id": "uuid",
      "executionId": "uuid",
      "observedAt": "2026-03-23T03:00:00Z",
      "field": "accountEnabled",
      "previousValue": "true",
      "currentValue": "false"
    }
  ]
}
```

按 `observedAt desc` 排序。`previousValue` / `currentValue` 是字符串化的值（DateTime ISO / Boolean `"true"`/`"false"`）。

#### 错误

| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 404 | `USER_NOT_FOUND` | userId 不存在或不属于当前 organization |

---

### 3. POST `/m365-dormant/sync`

手动触发一次全量同步。立刻返回 `executionId`（即 `AutomationExecution.id`），后台异步跑 Graph 拉取。

#### Request body

无（v1）。

#### 响应（202 Accepted）

```json
{
  "executionId": "uuid",
  "status": "running",
  "startedAt": "2026-04-30T03:00:00Z"
}
```

#### 错误

| 状态码 | 错误码 | 说明 |
|--------|--------|------|
| 409 | `SYNC_IN_PROGRESS` | 已有 running 批次 |
| 400 | `AZURE_CREDENTIAL_MISSING` | `.env` 缺 `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` |

---

### 4. GET `/m365-dormant/sync/latest`

#### 响应

```json
{
  "id": "uuid",
  "status": "success",
  "triggerSource": "manual",
  "createdById": "uuid",
  "createdByName": "张三",
  "startedAt": "2026-04-30T03:00:00Z",
  "finishedAt": "2026-04-30T03:02:14Z",
  "totalUsers": 4321,
  "changedUsers": 87,
  "inactiveDistribution": {
    "never": 21,
    "buckets": [
      { "from": 0,   "to": 30,   "count": 3200 },
      { "from": 30,  "to": 60,   "count": 200 },
      { "from": 60,  "to": 90,   "count": 100 },
      { "from": 90,  "to": 180,  "count": 150 },
      { "from": 180, "to": 365,  "count": 200 },
      { "from": 365, "to": null, "count": 450 }
    ]
  },
  "errorCode": null,
  "errorMessage": null
}
```

**无任何批次时**：HTTP 200，`data` 字段直接为 `null`（不是空对象 `{}`）。前端用 `if (data == null)` 判空。

---

### 5. GET `/m365-dormant/sync/history`

#### Query 参数

| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| limit | Int | 30 | 上限 90 |
| status | Enum | - | 过滤 `success` / `failed` 等；**默认不过滤，全部返回**（含 failed），由前端决定是否过滤画图 |

#### 响应

```json
{
  "items": [
    {
      "id": "uuid",
      "status": "success",
      "startedAt": "2026-04-30T03:00:00Z",
      "finishedAt": "2026-04-30T03:02:14Z",
      "totalUsers": 4321,
      "changedUsers": 87,
      "inactiveDistribution": { "never": 21, "buckets": [...] },
      "triggerSource": "manual",
      "createdByName": "张三",
      "errorCode": null,
      "errorMessage": null
    }
  ]
}
```

按 `startedAt desc` 返回。前端用 `inactiveDistribution` + 用户选定阈值，**前端侧累加**画趋势线：

```ts
function dormantCount(distribution, threshold) {
  let sum = distribution.never  // 视 includeNeverSignedIn 决定是否纳入
  for (const b of distribution.buckets) {
    if (b.from >= threshold) sum += b.count
    else if (b.to !== null && b.to > threshold) {
      // 跨桶阈值：取该桶内按比例近似（v1 简化为整桶纳入或忽略，看 UX 选哪种）
      sum += b.count
    }
  }
  return sum
}
```

阈值粒度需求更精细时，后端改桶定义重新跑同步（旧批次仍按旧桶渲染）。

---

## 错误码（汇总）

| 错误码 | HTTP | 含义 | 处置建议 |
|--------|------|------|---------|
| `NO_SUCCESSFUL_SYNC` | 404 | 还未跑过成功同步 | 前端引导用户点击"立即同步" |
| `SYNC_IN_PROGRESS` | 409 | 已有 running 批次 | 前端禁用同步按钮 + 显示当前进行中批次 |
| `AZURE_CREDENTIAL_MISSING` | 400 | `.env` 缺 `AZURE_TENANT_ID` / `AZURE_CLIENT_ID` / `AZURE_CLIENT_SECRET` | 提示运维补 `.env`（与 Entra/SharePoint/Outlook 共用） |
| `GRAPH_INSUFFICIENT_SCOPE` | 502 | App Registration 缺少 `AuditLog.Read.All` 或 `Reports.Read.All` | 提示 IT 去 Azure Portal 加 scope + admin consent |
| `USER_NOT_FOUND` | 404 | userId 不存在 | 前端处理失效链接 |
| `GRAPH_AUTH_FAILED` | 502 | Graph token 申请失败 | 检查 tenantId / clientId / clientSecret |
| `GRAPH_RATE_LIMIT` | 502 | Graph 限流（重试 3 次后仍失败） | 稍后重试 |
| `GRAPH_UPSTREAM_ERROR` | 502 | Graph 其他上游错误 | 看 execution.error |
| `EXPORT_TOO_LARGE` | 400 | CSV 导出超过 50000 行上限 | 缩小筛选条件 |
| `SYNC_INTERRUPTED` | - | 进程重启恢复时把卡住的 RUNNING execution 标 TIMEOUT 用的错误前缀（不会通过 API 主动返回，仅出现在 execution.error） | 用户重新点同步即可 |
| `REPORTS_OBFUSCATED` | 502 | 检测到活动报告 UPN 是 hash（M365 Admin Center "Display concealed names" 未关闭） | 提示 IT 关闭该开关 |

错误响应统一形态：`{ "success": false, "error": { "code": "...", "message": "<i18n key>" }, ... }`。
