# IT 运营 / AI Coding 用量 - 状态机文档

> **版本**: v0.1
> **最后更新**: 2026-05-15
> **维护者**: 后端团队

---

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

本模块涉及两条独立的状态机：**Token** 与 **Device**。

### 1. Token 状态机

```
        生成
   ─────────────▶  ACTIVE
                    │
                    │ 撤销（员工自撤 / admin 代撤）
                    ▼
                  REVOKED  ←─ 终态
```

**状态枚举**（通过字段计算，非显式 enum）：

| 状态 | 计算条件 |
|------|----------|
| ACTIVE | `revokedAt IS NULL` |
| REVOKED | `revokedAt IS NOT NULL` |

**转换表**：

| from | event | to | actor | 副作用 |
|------|-------|----|----|--------|
| - | `POST /me/tokens` | ACTIVE | 员工 | 写 audit `ai-usage.token.created` |
| ACTIVE | `DELETE /me/tokens/:id` | REVOKED | 员工本人 | 写 audit `ai-usage.token.revoked.self` |
| ACTIVE | admin `POST /tokens/:id/revoke` | REVOKED | admin | 写 audit `ai-usage.token.revoked.by-admin` + reason |

**不变量**：
- REVOKED 是终态，不可恢复（再用要新生成）
- ingestion 路径检查 `revokedAt IS NULL` 才允许通过
- bcrypt 校验前必须先 prefix 命中，避免 timing leak

### 2. Device 状态机

```
                  首次上报（token 校验通过）
    ─────────────────────────────────────▶  ACTIVE
                                              │  ▲
                       admin 拉黑              │  │ admin 解黑
                                              ▼  │
                                            BLOCKED
                                              │
                              （不会自动恢复）
```

**状态枚举**（通过字段计算）：

| 状态 | 计算条件 |
|------|----------|
| ACTIVE | `blockedAt IS NULL` |
| BLOCKED | `blockedAt IS NOT NULL` |

**转换表**：

| from | event | to | actor | 副作用 |
|------|-------|----|----|--------|
| - | 首次 ingestion 携带新 `X-Device-Id` | ACTIVE | system（token.userId 自动归属） | 创建 row + lastSeenAt 写入；user 收到首次注册通知（站内信） |
| ACTIVE | 后续 ingestion | ACTIVE | system | 仅 update `lastSeenAt` / `lastSeenIp` / `agentVersion` |
| ACTIVE | admin `POST /devices/:id/block` | BLOCKED | admin | 写 audit + reason；后续 ingestion 全部 403 |
| BLOCKED | 上报尝试 | BLOCKED | system | 直接拒绝 403，DLQ 记录 `BLOCKED_DEVICE` |
| BLOCKED | admin `POST /devices/:id/unblock` | ACTIVE | admin | 写 audit + note |

**不变量**：
- Device 创建时 `userId` 必填且通过 token 自动确定，不允许后续修改（如果发现归属错误，正确流程：撤销旧 token + 拉黑旧 device + 员工重新生成 token 走新 device）
- BLOCKED 期间的 ingestion 全部进 DLQ（不静默丢弃），便于审计/取证
- 软指纹：当新 device_id 上报但 `(hostname, osUser, userId)` 已存在 ACTIVE device 时，admin 列表用"建议合并"标签提示，但**不自动合并**（合并是手工操作，避免误判）

---

### 3. Event 生命周期（非状态机，列在此便于一处看全）

```
客户端解析 → 上报 → 服务端校验
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
    ai_usage_events  去重         ai_usage_event_dlq
        │             │                │
        │（1 年后）   │                │
        ▼             ▼                │
   AiUsageDailyRollup  丢弃            │
        │                              │
        └─────── 永久保留 ──────────────┘
                                       │
                                  保留 30 天，cron 清理
```

**转换规则**：

| 进入 | 条件 |
|------|------|
| `ai_usage_events` | token ✅ + device ACTIVE ✅ + ts 在窗内 ✅ + payload 合法 ✅ + `rawMessageId` 未见过 |
| 去重丢弃（不计 error）| `rawMessageId` 已存在 |
| `ai_usage_event_dlq` | 任一校验失败（按 `reason` 枚举分类）|
| `ai_usage_daily_rollups` | cron 触发 `ts < now - 365d` 的 event 聚合 |
| 物理删除 | 归档完成 + 验证成功后 |

**DLQ 清理**：cron 每周清理 `createdAt < now - 30d` 的 DLQ 行（保留 30 天足够人工排查）。

---

## 边界条件

| 场景 | 行为 |
|------|------|
| Token 撤销瞬间客户端正在上报 | 当前请求 401，客户端缓冲后台重试 → 仍 401 → 客户端 backoff 后台日志告警；不丢数据（缓冲在本地） |
| Device 拉黑瞬间客户端正在上报 | 当前请求 403，客户端**停止重试**该 device（避免无限循环）；本地缓冲保留，员工换 device 或 admin 解黑后手工 `ffctk resync` |
| 客户端 device_id 文件被删 | 客户端重新生成 UUID → 服务端注册成新 device（admin 列表用软指纹提示"疑似同一机器"） |
| 同一 `rawMessageId` 通过两个不同 token 上报 | 第一次成功落库，第二次去重丢弃；不视为冲突 |
| Event ts 比 now 超前 5 分钟 | 进 DLQ `TS_OUT_OF_WINDOW`，admin DLQ 看板可见 |
| Event ts 比 now 滞后 30 天 | 进 DLQ `TS_OUT_OF_WINDOW`（防止离线缓冲堆太久污染历史） |
| 同一 device 在多个 user 的 token 间切换 | 服务端记录但**不自动改 userId**；写 audit log；admin device 列表显著标红"疑似 token 共用"，需人工处理 |
| 归档 cron 在归档过程中失败 | 已聚合的 rollup 不回滚；下次 cron 跳过已 rollup 的 (date, userId, ...) 组合（UNIQUE 约束兜底） |

---

## 不变量（系统级）

1. **身份链不断**：每一行 event 都能追溯到 device → user → token，断链 = bug
2. **隐私白名单**：event 表只能存 schema 定义字段，CI 检查 schema diff 是否引入新文本字段
3. **token 单向**：明文只在生成时返回一次，bcrypt 不可逆
4. **去重幂等**：客户端任意次重试同一 `rawMessageId` 不会产生重复数据
5. **DLQ 不阻塞**：单条 event 校验失败不影响整批其他 event 入库
