# IT 运营 / AI Coding 用量 - 数据模型文档

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

---

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

### Schema 摘要

| 字段 | 内容 |
|------|------|
| Prisma 文件 | `backend/prisma/schema/platform_ai_usage.prisma`（**新建**，遵循项目 `platform_*` 命名约定；与 `platform_ai.prisma`（AI Assistant 聊天模块）独立） |
| 业务域 | AI Coding 工具用量采集与聚合 |
| 核心实体 | `ai_usage_tokens` / `ai_usage_devices` / `ai_usage_events` / `ai_usage_event_dlq` / `ai_usage_daily_rollups` |
| 标准字段 | 所有表必含 `id` / `createdAt` / `updatedAt` / `createdById` / `organizationId`（CLAUDE.md 铁律） |
| 命名 | Prisma model 用 PascalCase，`@@map` 到 snake_case 物理表 |

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

| 实体 | 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| ai_usage_tokens | id | UUID | ✅ | 主键 |
| ai_usage_tokens | userId | UUID | ✅ | 归属员工 |
| ai_usage_tokens | name | String(64) | ✅ | 员工自命名 |
| ai_usage_tokens | prefix | String(16) | ✅ | "ffai_xxxxxxxx"，UI 显示用 |
| ai_usage_tokens | tokenHash | String | ✅ | bcrypt(完整 token)，永不存明文 |
| ai_usage_tokens | lastUsedAt | DateTime | ❌ | 最后一次成功 ingestion 时间 |
| ai_usage_tokens | lastUsedIp | String(64) | ❌ | 最后使用 IP |
| ai_usage_tokens | revokedAt | DateTime | ❌ | 撤销时间，null = 有效 |
| ai_usage_tokens | revokedById | UUID | ❌ | 撤销人（自撤 = userId，admin 代撤 = admin id） |
| ai_usage_tokens | organizationId | UUID | ✅ | 标准字段 |
| ai_usage_tokens | createdById | UUID | ✅ | 标准字段 |
| ai_usage_tokens | createdAt | DateTime | ✅ | 标准字段 |
| ai_usage_tokens | updatedAt | DateTime | ✅ | 标准字段 |
| ai_usage_devices | id | UUID | ✅ | 主键 |
| ai_usage_devices | deviceId | String(64) | ✅ | 客户端生成 UUIDv4，UNIQUE |
| ai_usage_devices | userId | UUID | ✅ | **device 所有者锁定为首次注册的员工**（按 deviceId UNIQUE upsert，update 路径不刷 userId）。若同一物理机被多人轮流上报，event.userId 仍按当次 token 准确归属，但"我的设备"列表只对首次注册者可见。当前假设 1 员工 ≥ 1 私有机；共享开发机支持留 v2 复合主键 `(deviceId, userId)` |
| ai_usage_devices | hostname | String(255) | ✅ | 机器 hostname |
| ai_usage_devices | osUser | String(64) | ❌ | OS 登录用户名 |
| ai_usage_devices | osPlatform | Enum | ✅ | `linux` / `darwin` / `windows` |
| ai_usage_devices | agentVersion | String(32) | ❌ | ffctk 版本号 |
| ai_usage_devices | firstSeenAt | DateTime | ✅ | 首次注册 |
| ai_usage_devices | firstSeenIp | String(64) | ❌ | 首次 IP |
| ai_usage_devices | lastSeenAt | DateTime | ✅ | 最后一次上报 |
| ai_usage_devices | blockedAt | DateTime | ❌ | 拉黑时间，null = ACTIVE |
| ai_usage_devices | blockedById | UUID | ❌ | 拉黑人 |
| ai_usage_devices | blockedReason | String(500) | ❌ | 拉黑理由 |
| ai_usage_devices | organizationId | UUID | ✅ | 标准字段 |
| ai_usage_devices | createdById | UUID | ✅ | 系统注册时 = userId |
| ai_usage_devices | createdAt | DateTime | ✅ | 标准字段 |
| ai_usage_devices | updatedAt | DateTime | ✅ | 标准字段 |
| ai_usage_events | id | UUID | ✅ | 主键 |
| ai_usage_events | rawMessageId | String(128) | ✅ | 客户端去重键，UNIQUE |
| ai_usage_events | deviceId | UUID | ✅ | 关联 device |
| ai_usage_events | userId | UUID | ✅ | 冗余存储便于查询 |
| ai_usage_events | tool | Enum | ✅ | `claude-code` / `codex-cli` |
| ai_usage_events | sessionId | String(128) | ✅ | 客户端 session |
| ai_usage_events | projectPath | Text | ✅ | 完整路径，无脱敏 |
| ai_usage_events | projectBasename | String(255) | ✅ | basename，索引用 |
| ai_usage_events | model | String(128) | ✅ | 模型 ID |
| ai_usage_events | ts | DateTime | ✅ | 事件发生时间（客户端） |
| ai_usage_events | receivedAt | DateTime | ✅ | 服务端接收时间 |
| ai_usage_events | inputTokens | Int | ✅ | default 0 |
| ai_usage_events | outputTokens | Int | ✅ | default 0 |
| ai_usage_events | cacheCreationTokens | Int | ✅ | default 0 |
| ai_usage_events | cacheReadTokens | Int | ✅ | default 0 |
| ai_usage_events | totalTokens | Int | ✅ | generated column = sum |
| ai_usage_events | estimatedCostUsd | Decimal(12,6) | ✅ | 客户端估算 |
| ai_usage_events | gitBranch | String(255) | ❌ | v1.1 富 metadata：本 turn 时 cwd 所在 git 分支（不含 PII） |
| ai_usage_events | agentVersionEvent | String(32) | ❌ | v1.1：本 turn 时 CLI 版本号（不同于设备级 `agentVersion`） |
| ai_usage_events | worktreeLabel | String(64) | ❌ | v1.1：从 cwd 推断的 worktree 名（`slot-N` / 命名 worktree） |
| ai_usage_events | cwdBasename | String(255) | ❌ | v1.1：cwd 的 basename（路径终端段，非完整路径） |
| ai_usage_events | turnIndex | Int | ❌ | v1.1：本 session 内 assistant turn 序号（1-based） |
| ai_usage_events | toolUseCount | Int | ❌ | v1.1：本 turn 内 tool_use 块计数 |
| ai_usage_events | toolNames | JSONB | ❌ | v1.1：本 turn 调用的 tool 名数组（仅 name，**永不含参数 / 输出**） |
| ai_usage_events | stopReason | String(32) | ❌ | v1.1：`end_turn` / `tool_use` / `stop_sequence` / `max_tokens` |
| ai_usage_events | serviceTier | String(32) | ❌ | v1.1：`standard` / `priority` / `batch` |
| ai_usage_events | organizationId | UUID | ✅ | 标准字段 |
| ai_usage_events | createdById | UUID | ✅ | = userId |
| ai_usage_events | createdAt | DateTime | ✅ | 标准字段 |
| ai_usage_events | updatedAt | DateTime | ✅ | 标准字段 |
| ai_usage_event_dlq | id | UUID | ✅ | 主键 |
| ai_usage_event_dlq | deviceId | UUID | ❌ | 失败 event 关联 device（如能解析） |
| ai_usage_event_dlq | reason | Enum | ✅ | `BAD_FORMAT` / `TS_OUT_OF_WINDOW` / `BLOCKED_DEVICE` / `OVER_LIMIT` |
| ai_usage_event_dlq | rawPayload | Json | ✅ | 原始 event payload |
| ai_usage_event_dlq | createdAt | DateTime | ✅ | 标准字段 |
| ai_usage_event_dlq | organizationId | UUID | ✅ | 标准字段 |
| ai_usage_daily_rollups | id | UUID | ✅ | 主键 |
| ai_usage_daily_rollups | date | Date | ✅ | UTC 日期 |
| ai_usage_daily_rollups | userId | UUID | ✅ | 维度 |
| ai_usage_daily_rollups | projectBasename | String(255) | ✅ | 维度（归档时不保留完整路径） |
| ai_usage_daily_rollups | tool | Enum | ✅ | 维度 |
| ai_usage_daily_rollups | model | String(128) | ✅ | 维度 |
| ai_usage_daily_rollups | totalTokens | BigInt | ✅ | 聚合 |
| ai_usage_daily_rollups | inputTokens | BigInt | ✅ | 聚合 |
| ai_usage_daily_rollups | outputTokens | BigInt | ✅ | 聚合 |
| ai_usage_daily_rollups | cacheCreationTokens | BigInt | ✅ | 聚合 |
| ai_usage_daily_rollups | cacheReadTokens | BigInt | ✅ | 聚合 |
| ai_usage_daily_rollups | totalCostUsd | Decimal(14,6) | ✅ | 聚合 |
| ai_usage_daily_rollups | eventCount | Int | ✅ | 行数计 |
| ai_usage_daily_rollups | organizationId | UUID | ✅ | 标准字段 |
| ai_usage_daily_rollups | createdById | UUID | ✅ | = 系统 user |
| ai_usage_daily_rollups | createdAt | DateTime | ✅ | 标准字段 |
| ai_usage_daily_rollups | updatedAt | DateTime | ✅ | 标准字段 |

### 索引清单

| 表 | 索引 | 用途 |
|----|------|------|
| ai_usage_tokens | `(userId, revokedAt)` | 列出员工有效 token |
| ai_usage_tokens | `prefix` UNIQUE | UI 反查 + 校验前置 |
| ai_usage_devices | `deviceId` UNIQUE | client → device upsert |
| ai_usage_devices | `(userId, lastSeenAt DESC)` | 员工自查页 device 列表 |
| ai_usage_devices | `(hostname, osUser)` | 软指纹去重建议（应用层用） |
| ai_usage_events | `rawMessageId` UNIQUE | 客户端去重 |
| ai_usage_events | `(userId, ts DESC)` | 个人趋势 |
| ai_usage_events | `(organizationId, ts DESC)` | 全公司趋势 |
| ai_usage_events | `(projectBasename, ts DESC)` | 按项目下钻 |
| ai_usage_events | `(tool, model, ts DESC)` | 按工具/模型 |
| ai_usage_events | `(deviceId, ts DESC)` | device 详情页 |
| ai_usage_events | `(sessionId, ts)` | v1.1：session 内 turn 排序 / LAG 计算 turn 间隔 |
| ai_usage_events | `(gitBranch, ts DESC)` | v1.1：按分支热力图聚合 |
| ai_usage_events | `(organizationId, serviceTier, ts DESC)` | v1.1：服务等级配比聚合 |
| ai_usage_event_dlq | `(reason, createdAt DESC)` | 监控 DLQ |
| ai_usage_daily_rollups | `(date, userId)` UNIQUE 组合 | 防止重复归档 |
| ai_usage_daily_rollups | `(date, projectBasename)` | 历史下钻 |

### 关系

- `User 1 - n AiUsageToken`
- `User 1 - n AiUsageDevice`
- `User 1 - n AiUsageEvent`
- `AiUsageDevice 1 - n AiUsageEvent` (`ON DELETE RESTRICT`，device 不能轻易删除)
- `Organization 1 - n` 所有上述实体（DataScope 零配置）

### Prisma 草稿

```prisma
enum AiUsageTool {
  CLAUDE_CODE @map("claude-code")
  CODEX_CLI   @map("codex-cli")
}

enum AiUsageOsPlatform {
  LINUX
  DARWIN
  WINDOWS
}

enum AiUsageDlqReason {
  BAD_FORMAT
  TS_OUT_OF_WINDOW
  BLOCKED_DEVICE
  OVER_LIMIT
}

model AiUsageToken {
  id              String    @id @default(uuid()) @db.Uuid
  userId          String    @db.Uuid
  name            String    @db.VarChar(64)
  prefix          String    @unique @db.VarChar(16)
  tokenHash       String
  lastUsedAt      DateTime?
  lastUsedIp      String?   @db.VarChar(64)
  revokedAt       DateTime?
  revokedById     String?   @db.Uuid

  organizationId  String    @db.Uuid
  createdById     String    @db.Uuid
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt

  user            User      @relation("AiUsageTokenOwner", fields: [userId], references: [id])
  organization    Organization @relation(fields: [organizationId], references: [id])

  @@index([userId, revokedAt])
  @@map("ai_usage_tokens")
}

model AiUsageDevice {
  id              String    @id @default(uuid()) @db.Uuid
  deviceId        String    @unique @db.VarChar(64)
  userId          String    @db.Uuid
  hostname        String    @db.VarChar(255)
  osUser          String?   @db.VarChar(64)
  osPlatform      AiUsageOsPlatform
  agentVersion    String?   @db.VarChar(32)
  firstSeenAt     DateTime
  firstSeenIp     String?   @db.VarChar(64)
  lastSeenAt      DateTime
  blockedAt       DateTime?
  blockedById     String?   @db.Uuid
  blockedReason   String?   @db.VarChar(500)

  organizationId  String    @db.Uuid
  createdById     String    @db.Uuid
  createdAt       DateTime  @default(now())
  updatedAt       DateTime  @updatedAt

  user            User      @relation("AiUsageDeviceOwner", fields: [userId], references: [id])
  organization    Organization @relation(fields: [organizationId], references: [id])
  events          AiUsageEvent[]

  @@index([userId, lastSeenAt(sort: Desc)])
  @@index([hostname, osUser])
  @@map("ai_usage_devices")
}

model AiUsageEvent {
  id                   String    @id @default(uuid()) @db.Uuid
  rawMessageId         String    @unique @db.VarChar(128)
  deviceId             String    @db.Uuid
  userId               String    @db.Uuid
  tool                 AiUsageTool
  sessionId            String    @db.VarChar(128)
  projectPath          String    @db.Text
  projectBasename      String    @db.VarChar(255)
  model                String    @db.VarChar(128)
  ts                   DateTime
  receivedAt           DateTime
  inputTokens          Int       @default(0)
  outputTokens         Int       @default(0)
  cacheCreationTokens  Int       @default(0)
  cacheReadTokens      Int       @default(0)
  totalTokens          Int       /// generated column, sum of 4 above
  estimatedCostUsd     Decimal   @db.Decimal(12, 6)

  organizationId       String    @db.Uuid
  createdById          String    @db.Uuid
  createdAt            DateTime  @default(now())
  updatedAt            DateTime  @updatedAt

  device               AiUsageDevice @relation(fields: [deviceId], references: [id])
  user                 User      @relation("AiUsageEventOwner", fields: [userId], references: [id])

  @@index([userId, ts(sort: Desc)])
  @@index([organizationId, ts(sort: Desc)])
  @@index([projectBasename, ts(sort: Desc)])
  @@index([tool, model, ts(sort: Desc)])
  @@index([deviceId, ts(sort: Desc)])
  @@map("ai_usage_events")
}

model AiUsageEventDlq {
  id              String    @id @default(uuid()) @db.Uuid
  deviceId        String?   @db.Uuid
  reason          AiUsageDlqReason
  rawPayload      Json
  organizationId  String    @db.Uuid
  createdAt       DateTime  @default(now())

  @@index([reason, createdAt(sort: Desc)])
  @@map("ai_usage_event_dlq")
}

model AiUsageDailyRollup {
  id                   String    @id @default(uuid()) @db.Uuid
  date                 DateTime  @db.Date
  userId               String    @db.Uuid
  projectBasename      String    @db.VarChar(255)
  tool                 AiUsageTool
  model                String    @db.VarChar(128)
  totalTokens          BigInt
  inputTokens          BigInt
  outputTokens         BigInt
  cacheCreationTokens  BigInt
  cacheReadTokens      BigInt
  totalCostUsd         Decimal   @db.Decimal(14, 6)
  eventCount           Int

  organizationId       String    @db.Uuid
  createdById          String    @db.Uuid
  createdAt            DateTime  @default(now())
  updatedAt            DateTime  @updatedAt

  @@unique([date, userId, projectBasename, tool, model], name: "uniq_rollup_dim")
  @@index([date, projectBasename])
  @@map("ai_usage_daily_rollups")
}
```

### 数据迁移

- 无历史数据，新建表
- 新建 schema 文件：`backend/prisma/schema/platform_ai_usage.prisma`
- 单一迁移文件：`prisma/migrations/<timestamp>_add_ai_usage/migration.sql`
- 注意 `totalTokens` generated column 需手写 SQL：
  ```sql
  ALTER TABLE ai_usage_events
    ADD COLUMN total_tokens INTEGER
    GENERATED ALWAYS AS (input_tokens + output_tokens + cache_creation_tokens + cache_read_tokens) STORED;
  ```

### 容量规划

| 表 | 估算行数（1 年） | 备注 |
|---|---|---|
| ai_usage_tokens | < 500 | 100 员工 × 2-3 token 平均 |
| ai_usage_devices | < 300 | 100 员工 × 1-3 机器 |
| ai_usage_events | ~ 7M / 年 | 归档后旧数据物理删 |
| ai_usage_event_dlq | < 10k / 年 | < 0.15% 失败率假设 |
| ai_usage_daily_rollups | 100 员工 × 5 项目 × 2 工具 × 5 模型 × 365 ~ 2M / 年 | 永久保留 |

### 隔离与权限

**关键约束**：DataScope 拦截器仅对**列表 / 单查**查询自动注入 `WHERE organizationId IN (...)`，**不覆盖 `groupBy` / `aggregate` / raw SQL**（参考 `backend/src/modules/feedback/feedback.service.ts` 现有 dashboard 实现）。

因此本模块所有聚合查询（admin dashboard / 员工自查 / 趋势 / breakdown）**必须由 service 层手工**：

```typescript
const baseWhere = {
  organizationId: this.orgContext.getCurrentOrgId(),
  ...(needScopeToUser ? { userId: currentUser.id } : {}),
};
const rows = await this.prisma.aiUsageEvent.groupBy({
  by: ['model'],
  where: baseWhere,            // 强制注入
  _sum: { totalTokens: true, estimatedCostUsd: true },
});
```

- L1 测试 **必须** 包含跨 org 验证用例（创建两个 org 数据，admin user 查询不应看到另一 org）
- 应用层权限点：
  - `ai-usage:view-own` → service 强制 `where.userId = currentUser.id`
  - `ai-usage:view-all` → service 仅强制 `where.organizationId`
- 列表/单查走 `DataScopeService`（标准字段已命中，零配置）

### v1.1 富 metadata 增列（2026-05-16）

**动机**：MVP 只上报 token / cost。运营 dashboard 要展开"工具使用频次 / 思考密度 / session 形态 / 服务等级配比 / 分支热力图"分析，但仍**不能突破隐私铁律**。

**允许字段**（全部可空，类型见上表）：

| 字段 | 数据源 | 用于回答的问题 |
|------|--------|----------------|
| `gitBranch` | Claude Code transcript 顶层 `gitBranch` | "哪些分支烧 token 最多 / 是否集中在 hotfix" |
| `agentVersionEvent` | transcript 顶层 `version` | "用户 CLI 版本分布；老版本占比" |
| `worktreeLabel` | 客户端从 `cwd` 推断 | "agent pool 各 slot 利用率" |
| `cwdBasename` | `path.basename(obj.cwd)` | "项目识别（跟 projectBasename 互校验，跨平台健壮）" |
| `turnIndex` | watcher 进程内 sessionId 计数 | "session 平均 turn 数 / 超长 session 检测" |
| `toolUseCount` | `count(content[].type==='tool_use')` | "本 turn 工具密度" |
| `toolNames` | `content[].name` 去重数组 | "Bash 占比 vs Edit 占比 vs Write 占比" |
| `stopReason` | `message.stop_reason` | "max_tokens 触顶频率 = 需要扩 max_tokens 的信号" |
| `serviceTier` | `usage.service_tier` | "priority / batch 成本占比" |

**严格禁止上传**（隐私铁律，DTO 层 + parser 层双重把关）：

- `message.content[]` 中 type 为 `text` / `thinking` 的块（含 user 提问 / assistant 回答 / 模型思考）
- `tool_use.input`、`tool_result.content`（工具参数与执行结果）
- session 标题、用户开场白、文件 diff、命令输出、URL、文档片段、错误堆栈中含路径的部分
- 任何会泄露**意图 / 内容 / 数据**的字段

**后端校验**：DTO 显式 allowlist，未知字段 strip；超长字段拒绝（DTO `@MaxLength`）。
**审计验证**：L1 测试覆盖每个新字段从 ingestion → DB 落盘的正确性；隐私字段 grep 在 ai-review 阶段卡 P0。
