usage_logs 表字段设计
数据模型第二张表:调用日志明细——增量同步的落点,本项目「任意时间范围用量」的唯一数据来源。字段清单依据 /api/log/token 实测响应(2026-09-11,1000 条样本)与上游源码(model/log.go)双重校对;上游只有 1000 条滚动窗口,错过即永久丢失。
| 项目 |
内容 |
| 状态 |
已实现(2026-09-15) |
| 关联 |
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) |
出现真实分析需求时再评估 |
| 数据保留策略(是否清理旧数据) |
后议 |