# usage_logs 表字段设计 > 数据模型第二张表:调用日志明细——增量同步的落点,本项目「任意时间范围用量」的唯一数据来源。字段清单依据 `/api/log/token` 实测响应(2026-09-11,1000 条样本)与上游源码(`model/log.go`)双重校对;上游只有 1000 条滚动窗口,**错过即永久丢失**。 | 项目 | 内容 | |------|------| | 状态 | 定稿(2026-09-11,逐字段讨论收敛) | | 关联 | `docs/upstream/NewAPI-sk-key接口文档.md`(接口②字段依据)· `docs/design/api_keys表字段设计.md`(`key_id` 外键指向) | | 定稿范围 | 列结构 + 去重与写入策略;同步引擎细节(调度、401 处理)不在此文 | | 字段数 | 23 = 20 上游字段 + 3 系统字段(上游响应共 21 个字段,其中展示序号 `id` 不落表,见 3.2) | --- ## 1. 表定位 - **一行 = 一次上游调用记录**(消费 / 错误等类型全覆盖) - 它是本项目最核心的数据资产:上游 `/api/log/token` 只保留最近 1000 条(实测当前速率下 ≈ 3 天),**错过即永久丢失**——本地全量累积是"任意时间范围用量"的唯一实现路径 - 与 `api_keys` 多对一:每条日志经 `key_id` 指向其所属 key - 数据规模:实测约 300+ 条/天,量级为数十万行/年——单表 + 时间索引完全够用,无需分区或预聚合 ## 2. 字段清单 ### 2.1 系统字段(3 个,本系统产生) | 列 | 类型 | 值来源 | 说明 | |----|------|--------|------| | `id` | bigserial PK | 自增 | 本地主键(与上游的展示序号 `id` 无关) | | `key_id` | bigint FK | 同步引擎 | → `api_keys.id`;日志归属与索引锚点 | | `dedupe_key` | text UNIQUE | 写入前生成 | **去重唯一键**:`${key_id}:${request_id}`;`request_id` 为空时退化为内容指纹(见 3.1) | ### 2.2 上游字段(20 个,`/api/log/token` 响应照收) | 列 | 类型 | 上游字段 | 说明 | |----|------|---------|------| | `user_id` | bigint | `user_id` | 账号 ID(同一实例内恒定) | | `created_at` | timestamptz | `created_at` | 上游 UTC Unix 秒,入库转换;范围查询主维度 | | `type` | smallint | `type` | 日志类型 0-7 枚举(完整表见接口文档);照存不过滤 | | `content` | text | `content` | 消费记录为空串;错误记录为错误详情 | | `username` | text | `username` | 账号用户名 | | `token_name` | text | `token_name` | 快照存储(保真;展示取当前名口径由 API 契约另定) | | `model_name` | text | `model_name` | | | `quota` | bigint | `quota` | 原始 quota | | `prompt_tokens` | int | `prompt_tokens` | | | `completion_tokens` | int | `completion_tokens` | | | `use_time` | int | `use_time` | 秒 | | `is_stream` | bool | `is_stream` | | | `channel` | int | `channel` | 渠道 ID | | `channel_name` | text | `channel_name` | 普通用户视角为空串 | | `token_id` | bigint | `token_id` | 上游 token ID(对账用) | | `group_name` | text | `group` | PG 保留字改名 | | `ip` | text | `ip` | 普通用户视角为空串 | | `request_id` | text | `request_id` | 原值保留;JSON tag 为 `omitempty`,源码写入时有兜底 | | `upstream_request_id` | text NULL | `upstream_request_id` | **可选字段**:`omitempty`——空值时不出现、有值才出现(实测样本中缺席) | | `other` | jsonb | `other` | JSON 字符串解析入库(可查询;含缓存/倍率/延迟等扩展信息) | > 上游响应中的展示序号 `id`(每次从 1 重编)不落表——见 3.2。 ### 2.3 索引 | 索引 | 列 | 用途 | |------|-----|------| | PRIMARY KEY | `id` | | | UNIQUE | `dedupe_key` | 去重(`ON CONFLICT` 的落点) | | 普通 | `(key_id, created_at DESC)` | 明细页 + 按 key 聚合 | | 普通 | `(created_at DESC)` | 跨 key 合计与统计 | ## 3. 决策记录(含理由) ### 3.1 去重:单列 `dedupe_key`(即 `request_id` 的落库形态) - 去重依据就是 `request_id`(上游为每次请求生成的唯一编号);`dedupe_key` 是它在库中的形态,99.9% 的情况下等于 `${key_id}:${request_id}` - **为什么不把 UNIQUE 直接挂在 `request_id` 列上**:PostgreSQL 的空值语义两难——`NULL` 在唯一索引中互不冲突(空值行会重复入库);而把空值转成空串则过度冲突(第二条空值记录被静默丢弃)。单列 `dedupe_key` + 空值指纹兜底从根上规避这两种坏结局 - `key_id:` 前缀:跨 key 隔离的零成本保险 - 内容指纹(仅 `request_id` 为空时启用):`sha256(key_id, created_at, model_name, quota, prompt_tokens, completion_tokens)`——同一条日志的重复拉取必然同指纹(正确去重),真实不同的请求指纹不同(都保留) - `request_id` 原值仍单独成列(2.2 之一)——两者并行,互不替代 - **为什么它不做主键**:主键是「行身份」(短、稳定、永不变化),`dedupe_key` 是「防重签名」(长、由业务规则拼接、可能随上游演化)——让可变规则决定行身份是耦合隐患,且长字符串在引用场景(外键、调试、游标)更笨重;代理键(自增 `id`)+ 独立唯一约束是标准做法 ### 3.2 上游展示序号 `id` 不落表 它是每次响应从 1 重编的展示序号(源码 `assignDisplayLogIds`),跨请求无意义——绝不能用于去重,也没有存储价值。 ### 3.3 `other` 用 jsonb - 原样保真(解析后完整存储)+ 查询能力(`other->>'cache_tokens'`);jsonb 是 PostgreSQL 内置类型 - 普通用户视角的 `other` 已由服务端做过一层「可见性过滤」——我们存的就是用户实际可见的部分 ### 3.4 `raw` 兜底列:不加 21 个候选字段(响应理论全集)已由实测 + 源码双重校对齐全,且 `other` 已单独 jsonb 保真(信息富集区);字段集变化的风险由「同步持续运行 + 代码随上游更新」覆盖,接受历史缺值的可能性。 ### 3.5 命名与类型 - 上游字段采用原名;例外:`group` → `group_name`(PG 保留字) - 时间列统一 `timestamptz`(应用层做 UTC ↔ 北京时间转换);`quota` 用 `bigint` - `type` 用 `smallint` 存原值(0-7 为稳定枚举),展示层翻译(未知值原样显示数字) ## 4. 写入时机与策略 | 时机 | 动作 | |------|------| | 登记首轮同步 | 拉最近 1000 条 → 逐条入库 | | 每轮轮询 | 同上;`INSERT ... ON CONFLICT (dedupe_key) DO NOTHING`——幂等,只落新行 | - 写入者唯一:同步引擎(不存在其他写入路径) - 日志不可变:已入库的行不做 UPDATE - 与本表相关的读取链:从 `api_keys` 取密文 → 解密 → 调上游 → 新日志写本表 ## 5. 待定事项(关联后续环节) | 事项 | 归属 | |------|------| | 明细/统计的查询口径(`type` 过滤策略) | API 契约 | | `other` 内高频键是否提为独立列(如 `cache_tokens`) | 出现真实分析需求时再评估 | | 数据保留策略(是否清理旧数据) | 后议 |