52 KiB
Database —— 唯一权威文档
本文档是 Codex Proxy RS 数据库的唯一权威文档:终态 Schema 全量定义、全局纪律、统计口径、一次到位的迁移方案(v3 → v4)与代码改造清单。
- 取代并合并旧的三份文档(database-design.md / database-audit.md / database-schema.md),旧文档一律作废删除。
- 基线:2026-07-08,生产库 schema v3(
backend/src/infra/database.rs)。 - 策略:一次破坏性迁移直达终态,不做兼容层。没有双写期、没有回填过渡期、没有查询层兼容过滤;不满足终态契约的历史数据按 §6.3 的规则迁移或丢弃,丢弃项全部显式列出。
1. 运行形态
单进程 SQLite。这是刻意选择:自托管代理、单实例部署,SQLite 免去外部依赖且性能远超需求。多实例是明确的未来触发条件而非当前需求(§8)。
连接参数(database.rs 连接期设置,全部显式):
| Pragma | 值 | 理由 |
|---|---|---|
journal_mode |
WAL | 读写不互斥,单写多读的正确形态 |
synchronous |
NORMAL | WAL 下的标准取舍:掉电最多丢最后一次 checkpoint 后的事务,不损坏库。FULL 的额外 fsync 对本系统无意义 |
foreign_keys |
ON | 级联删除是业务语义的一部分 |
busy_timeout |
5s | 单写者下的排队上限 |
auto_vacuum |
INCREMENTAL | 三张增长表按保留期批量删除,必须有空间回收路径;trim 任务后执行 pragma incremental_vacuum(§7.7)。对已存在的库,该设置在 0004 迁移后的一次 vacuum 时生效 |
2. 全局纪律
2.1 命名
| 对象 | 规则 | 示例 |
|---|---|---|
| 表名 | snake_case;实体集合复数;子表 <父实体单数>_<内容> |
accounts、account_cookies |
| 表名后缀 | 事实 _records/_logs;聚合 _buckets;缓存 _snapshots;运行态 _leases/_affinities;审计 _history |
usage_records |
| 列名 | snake_case,全库同义同名 | account_id 处处一致 |
| 时间戳 | *_at,TEXT,RFC3339 UTC |
created_at |
| 时长 | *_seconds / *_ms,INTEGER |
latency_ms |
| 计数 | *_count,INTEGER,check >= 0 |
request_count |
| JSON | *_json,TEXT |
metadata_json |
| 布尔 | 裸形容词/过去式,INTEGER,check in (0,1) |
enabled |
| 外键/逻辑引用列 | <被引用实体单数>_id |
client_api_key_id |
| 凭据哈希 | *_hash,明文永不落库(§2.6) |
key_hash、token_hash |
| 主键 | id,除非自然复合键或哈希键 |
— |
| 普通/唯一索引 | idx_<表>_<列摘要> / ux_<表>_<列摘要> |
ux_accounts_chatgpt_identity |
| 双端状态码 | 视角前缀 | status_code / client_status_code / upstream_status_code |
| 模型归因 | 来源前缀 | model / requested_model / upstream_model |
2.2 类型
只用两种存储类型表达业务数据:
| 用途 | 类型 | 约定 |
|---|---|---|
| ID、名称、枚举、时间戳、JSON、哈希 | TEXT | 枚举加 check in (...) |
| 计数、时长、布尔、状态码 | INTEGER | 计数 >= 0;布尔 in (0,1) |
不使用 REAL,不存储金额。成本一律查询期由 token × 单价计算(admin/monitoring/billing.rs):单价会变、需追溯重算,落库的浮点成本既漂移又过期。
2.3 时间戳
- 库内一律 UTC,统一经
chrono::DateTime::to_rfc3339()写入(+00:00后缀)。RFC3339 UTC 文本的字典序 = 时间序,TEXT 时间戳因此可直接做范围查询与排序索引。 - 时区换算(中国时区展示、15 分钟槽对齐)只在代码层(
infra/time.rs的china_*系列),数据库不感知时区。 - 禁止在同一张做范围比较的表里混用
strftime('…Z')与to_rfc3339()。唯一例外:schema_migrations.applied_at用strftime(不做范围比较)。
2.4 ID
主键一律 TEXT 随机 ID(infra/identity.rs 生成):无中心分配、写入前可生成、跨表无碰撞。例外:schema_migrations.version(天然有序整数)、runtime_settings.id(单例锚点)、凭据表以哈希为键(§2.6)。
2.5 NULL 与 sentinel
- "无值 / 未观测"一律 NULL;禁止 0、空字符串做哨兵。NULL 与 0 语义不同(
input_tokensNULL = 上游未报告,0 = 确认为零)。 - 聚合维度列不可空时,未知值用
__unknown__;预聚合汇总行(预留)用__all__。 - 唯一例外:
ux_accounts_chatgpt_identity的索引表达式coalesce(chatgpt_user_id, '')——空串只存在于索引表达式,不是存储值,不构成先例。
2.6 Secret 纪律(无例外)
凡是"出示即获得权限"的值都是凭据,明文一律不落库:
| 凭据 | 存储 | 哈希类型 | 理由 |
|---|---|---|---|
| 管理员密码 | admin_users.password_hash |
慢哈希(argon2/bcrypt) | 低熵人类输入,必须抗字典攻击 |
| 客户端 API key | client_api_keys.key_hash |
SHA-256 | 高熵随机串无字典攻击面,快哈希保住 unique 索引 O(log n) 查找;明文只在创建响应出现一次,prefix 供事后定位 |
| 管理端 session token | admin_sessions.token_hash |
SHA-256 | 同上。库文件/备份泄露不再等于会话劫持 |
| 管理 API key | runtime_settings.admin_api_key_hash |
SHA-256 | 同上;校验 = 哈希后 ct_eq |
| 上游 access/refresh token、Cookie value | 明文列,标记 secret | —(功能上必须可逆) | 不进日志、不进列表 API;静态加密的触发条件见 §8 |
2.7 JSON 边界
*_json 列只允许三类内容:① 上游原样快照(quota_json、models_json、manifest_json);② 结构化模板(default_headers_json、header_order_json);③ 调试细节(metadata_json)。凡进入 WHERE / GROUP BY / 告警的字段必须是一等列;列一旦提升即为唯一可查询真相,JSON 内同名字段仅供人工排查,写入时不再往 metadata 双写已提升字段。
2.8 索引
- 每个索引对应一条真实查询;unique 约束已覆盖的等值查找不再建索引。
- 列表分页统一 keyset 模式,配
(排序时间 desc, id desc)复合索引。 - 可空列上的筛选索引一律部分索引
where <col> is not null。 - FK 子表必须有以 FK 列开头的索引(SQLite 级联删除在子表无索引时对父表每行全扫)。免除条件必须注明(如
admin_sessions:行数个位数)。 - 高频写入表的索引总数进 code review,新增需说明查询来源。
2.9 保留与可重建性(诚实边界)
| 表 | 保留 | 配置 |
|---|---|---|
usage_records |
30 天(默认) | runtime_settings.usage_retention_days |
ops_error_logs |
30 天(默认) | runtime_settings.ops_error_retention_days |
request_time_buckets |
90 天(默认) | runtime_settings.bucket_retention_days |
admin_sessions / account_cookies / session_affinities |
按 expires_at 清理 |
定时任务 |
| 其余 | 常量级增长 | — |
可重建性以事实保留期为界,不夸大:
request_time_buckets在事实保留期内可由两张事实表重算(rebuild-derived命令,§7.6);超出事实保留期的桶是因事实过期而升格的一级数据,写入纪律 + 同事务写入(§5.2)是它们的唯一保障。account_usage/account_model_usage的累计列不可重建(事实早已过期),同样只受事务保护。文档不再声称"一切派生皆可重算"。window_*列是运行态(窗口边界来自上游),不可重建,可丢弃。
3. 架构分层与关系
┌─ 元数据 ──────────────────────────────────────────────┐
│ schema_migrations │
├─ 配置 ────────────────────────────────────────────────┤
│ runtime_settings(单例热配置) │
├─ 接入 ────────────────────────────────────────────────┤
│ admin_users · admin_sessions · client_api_keys │
├─ 账号 ────────────────────────────────────────────────┤
│ accounts · account_cookies · fingerprints │
│ fingerprint_update_history · model_plan_snapshots │
├─ 事实(不可替代的业务事件)────────────────────────────┤
│ usage_records(成功) · ops_error_logs(失败/运维) │
├─ 派生(口径见 §2.9)──────────────────────────────────┤
│ request_time_buckets · account_usage │
│ account_model_usage │
└─ 运行态(临时协调,可丢弃)───────────────────────────┤
session_affinities · account_refresh_leases │
admin_users 1──< admin_sessions FK cascade(索引免除:行数个位数)
accounts 1──1 account_usage FK cascade(PK=FK)
accounts 1──1 account_refresh_leases FK cascade(PK=FK)
accounts 1──< account_model_usage FK cascade(复合 PK 前缀)
accounts 1──< account_cookies FK cascade(account_domain 前缀)
accounts 1──< session_affinities FK cascade(idx_…_account)
fingerprints 1──< fingerprint_update_history FK cascade(idx_…_fingerprint)
usage_records.{account_id, client_api_key_id} → 逻辑引用,无 FK
ops_error_logs.{account_id, client_api_key_id} → 逻辑引用,无 FK
request_time_buckets.account_id → 逻辑维度,无 FK
事实表/聚合表不建 FK 是有意为之:审计事实的生命周期必须独立于账号与 key——删除账号或 key 不得抹掉历史用量和错误记录。运行态表相反:账号没了亲和/租约就失去意义,级联删除是正确语义。
4. 表设计(终态 DDL)
4.1 schema_migrations
迁移版本的唯一事实来源。由 database.rs 建库时创建,不走迁移文件。只增不改;已发布迁移永不修改;CURRENT_SCHEMA_VERSION 与迁移列表同步提交。
create table schema_migrations (
version integer not null check (version > 0),
name text not null,
applied_at text not null,
primary key (version)
);
4.2 admin_users
单管理员模型的登录凭据。不预建 username/email/role 死列;多管理员的扩展路径见 §8。
create table admin_users (
id text primary key,
password_hash text not null,
created_at text not null,
updated_at text not null
);
4.3 admin_sessions
管理端会话的服务端状态。session 存库(而非 JWT)使"登出即失效、改密码踢会话、重启不掉线"都是一条 SQL。
create table admin_sessions (
token_hash text primary key,
user_id text not null references admin_users(id) on delete cascade,
expires_at text not null,
created_at text not null
);
create index idx_admin_sessions_expires on admin_sessions(expires_at);
| 字段 | 说明 |
|---|---|
token_hash |
SHA-256(session token)。客户端持有明文 token,鉴权时哈希后按 PK 等值查找。明文 token 不落库(§2.6) |
user_id |
FK cascade,删用户即踢会话 |
expires_at |
鉴权比较 + 清理任务扫描(idx_admin_sessions_expires) |
user_id 不建索引是注明的免除:单管理员下行数个位数。IP/UA 审计不进本表;需要时新建 admin_login_events。
4.4 client_api_keys
客户端调用 /v1/* 的凭据。key 与上游账号解耦,客户端不感知账号池。
create table client_api_keys (
id text primary key,
name text not null,
prefix text not null,
key_hash text not null unique,
label text,
enabled integer not null default 1 check (enabled in (0, 1)),
created_at text not null,
last_used_at text
);
create index idx_client_api_keys_created_id on client_api_keys(created_at desc, id desc);
| 字段 | 说明 |
|---|---|
id |
稳定标识;管理操作与事实归因(usage_records.client_api_key_id)都引用 id,key 轮换不影响引用 |
name / label |
name 必填主展示,label 可选注记 |
prefix |
明文前几位(sk-abc1…),明文销毁后定位 key 的唯一手段 |
key_hash |
SHA-256(key),unique 索引即鉴权查找路径。明文只在创建响应出现一次,管理端列表 API 永不回显完整 key |
enabled |
软开关;刻意不建索引(低基数布尔) |
last_used_at |
鉴权成功后异步更新,不在请求关键路径强一致 |
每 key 配额/限流不进本表——未来 client_api_key_policies 附属表(§8),归因数据已由事实表的 client_api_key_id 备好。
4.5 runtime_settings
热更新全局配置,单行表,check (id = 1) 约束层面保证单例。选宽表不选 KV:列级类型与 check 是 KV 给不了的;加配置要加列,恰好强迫走迁移评审。只放小型标量配置,禁塞大 JSON 状态。
create table runtime_settings (
id integer primary key check (id = 1),
model_aliases_json text not null default '{}',
refresh_margin_seconds integer not null check (refresh_margin_seconds > 0),
refresh_concurrency integer not null check (refresh_concurrency > 0),
max_concurrent_per_account integer not null check (max_concurrent_per_account > 0),
request_interval_ms integer not null check (request_interval_ms >= 0),
rotation_strategy text not null check (rotation_strategy in ('smart', 'quota_reset_priority', 'round_robin', 'sticky')),
admin_api_key_hash text,
usage_retention_days integer not null default 30 check (usage_retention_days > 0),
ops_error_retention_days integer not null default 30 check (ops_error_retention_days > 0),
bucket_retention_days integer not null default 90 check (bucket_retention_days > 0),
updated_at text not null
);
| 字段 | 说明 |
|---|---|
model_aliases_json |
客户端模型名 → 上游模型名映射。JSON 合规:整体读写、不按别名查询 |
refresh_* / max_concurrent_per_account / request_interval_ms |
刷新与调度参数 |
rotation_strategy |
枚举 check;枚举值变更必须走迁移(0002 先例),库内无死值 |
admin_api_key_hash |
管理 API 机器凭据的 SHA-256。NULL = 未启用。校验 = 对来键哈希后 ct_eq 比较 |
*_retention_days 三列 |
三张增长表各自独立的保留天数。bucket 默认更长:趋势比明细有更长查询价值 |
4.6 accounts
上游账号的身份、凭据、调度状态与 quota 快照。保持单表是当前正确决策:调度器每次决策同时读身份+状态+冷却,拆表只增加 join 与事务面;拆分触发条件明确(§8)。
create table accounts (
id text primary key,
email text,
chatgpt_account_id text,
chatgpt_user_id text,
label text,
plan_type text,
access_token text not null,
refresh_token text,
access_token_expires_at text,
next_refresh_at text,
status text not null check (status in ('active', 'expired', 'quota_exhausted', 'disabled', 'banned')),
quota_json text,
quota_fetched_at text,
quota_limit_reached integer not null default 0 check (quota_limit_reached in (0, 1)),
quota_verify_required integer not null default 0 check (quota_verify_required in (0, 1)),
quota_cooldown_until text,
cloudflare_cooldown_until text,
added_at text not null,
updated_at text not null
);
create index idx_accounts_status on accounts(status);
create index idx_accounts_added_id on accounts(added_at desc, id desc);
create unique index ux_accounts_chatgpt_identity
on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, ''))
where chatgpt_account_id is not null;
要点:
- 身份:
id与上游 ID 解耦(导入半成品账号仍可运转);chatgpt_account_id+chatgpt_user_id复合唯一防重复导入,身份未知的账号不参与去重(部分索引)。plan_type不加枚举 check:取值由上游定义。 - 凭据(secret):
access_tokennot null 是账号存在的最低要求;refresh_tokenNULL = 不可续期,到期即expired。不进日志、不进列表 API。 - 状态:单列业务状态机 + 布尔/冷却补充列,避免状态爆炸。
refreshing不落accounts.status,由account_refresh_leases/ 进程内 in-flight 派生为运行时展示态。冷却用时间而非布尔:自然过期无需回写。quota 与 CF 冷却分列:成因、时长、解除策略都不同。 - 快照:
quota_json上游原样快照(JSON 合规);added_at语义化命名——账号是"被添加进池子"的。
4.7 account_refresh_leases
token 刷新互斥租约。租约(owner + 到期)是最小充分的互斥原语:持有者崩溃后自然失效,无需显式释放;天然支持未来多实例竞争。运行态表,不承载历史(需要时另建 account_refresh_events)。
create table account_refresh_leases (
account_id text primary key references accounts(id) on delete cascade,
owner text not null,
expires_at text not null,
updated_at text not null
);
create index idx_account_refresh_leases_expires on account_refresh_leases(expires_at);
PK=FK 的 1:1 建模把"每账号至多一个持有者"变成主键约束。owner 在续约/释放时校验,防误释放他人租约。
4.8 account_usage
账号维度两组计数:① 生命周期累计(列表展示,不可重建,§2.9);② 当前 quota 窗口计数(调度限流输入,运行态)。1:1 派生表,不与 accounts 合并:更新频率差多个数量级,合表会让高频计数写污染低频实体行。
create table account_usage (
account_id text primary key references accounts(id) on delete cascade,
request_count integer not null default 0 check (request_count >= 0),
empty_response_count integer not null default 0 check (empty_response_count >= 0),
input_tokens integer not null default 0 check (input_tokens >= 0),
output_tokens integer not null default 0 check (output_tokens >= 0),
cached_tokens integer not null default 0 check (cached_tokens >= 0),
reasoning_tokens integer not null default 0 check (reasoning_tokens >= 0),
total_tokens integer not null default 0 check (total_tokens >= 0),
image_input_tokens integer not null default 0 check (image_input_tokens >= 0),
image_output_tokens integer not null default 0 check (image_output_tokens >= 0),
image_request_count integer not null default 0 check (image_request_count >= 0),
image_request_failed_count integer not null default 0 check (image_request_failed_count >= 0),
window_request_count integer not null default 0 check (window_request_count >= 0),
window_input_tokens integer not null default 0 check (window_input_tokens >= 0),
window_output_tokens integer not null default 0 check (window_output_tokens >= 0),
window_cached_tokens integer not null default 0 check (window_cached_tokens >= 0),
window_image_input_tokens integer not null default 0 check (window_image_input_tokens >= 0),
window_image_output_tokens integer not null default 0 check (window_image_output_tokens >= 0),
window_image_request_count integer not null default 0 check (window_image_request_count >= 0),
window_image_request_failed_count integer not null default 0 check (window_image_request_failed_count >= 0),
window_started_at text,
window_reset_at text,
limit_window_seconds integer check (limit_window_seconds is null or limit_window_seconds > 0),
last_used_at text
);
create index idx_account_usage_last_used_account
on account_usage(last_used_at desc, account_id desc);
| 组 | 说明 |
|---|---|
| 累计 | 口径 = usage_records 成功事实;失败率不得由本表推断。total_tokens 是已知的"有偿冗余"(列表排序高频),与分量列同一 upsert 内更新,事务保证一致 |
| 窗口 | 当前 quota 窗口内计数,窗口重置清零。窗口组刻意不含 reasoning_tokens/total_tokens:窗口列只镜像上游 quota 核算维度,reasoning 不参与上游窗口计数;累计组是展示口径,两组不对称是设计而非遗漏 |
| 边界 | window_started_at / window_reset_at / limit_window_seconds 来自上游 rate-limit 响应,NULL = 未知 |
宽表(20 计数列)而非 EAV (account_id, metric, value):计数种类是代码常量,宽表一次 upsert 全更新、check 逐列可写;EAV 只换来动态 SQL 和类型丢失。
4.9 account_model_usage
账号 × 模型分布,服务账号详情页与调度辅助。自然复合键,不引入代理 id。
create table account_model_usage (
account_id text not null references accounts(id) on delete cascade,
model text not null,
request_count integer not null default 0 check (request_count >= 0),
error_count integer not null default 0 check (error_count >= 0),
input_tokens integer not null default 0 check (input_tokens >= 0),
output_tokens integer not null default 0 check (output_tokens >= 0),
cached_tokens integer not null default 0 check (cached_tokens >= 0),
last_used_at text,
primary key (account_id, model)
);
create index idx_account_model_usage_last_used
on account_model_usage(last_used_at desc, account_id, model);
error_count 是收窄口径:仅统计已归属到账号+模型之后的失败,用作调度回避信号(频繁失败 → 降权)。这是全库唯一允许的口径重叠(与 ops_error_logs),因为用途不同(调度局部信号 vs 全局错误事实);Dashboard 错误率禁止取自本列。
4.10 usage_records ★
每个成功的客户端请求恰好一行。用量、token、成本、账号与调用方归因的唯一事实来源。这是系统的账本;"只收成功"的边界由 DB 约束兜底,不再只靠 service 层。
create table usage_records (
id text primary key,
request_id text,
client_api_key_id text,
kind text not null,
route text,
provider text not null,
account_id text not null,
model text not null,
requested_model text,
upstream_model text,
service_tier text,
status_code integer not null check (status_code between 200 and 399),
transport text,
attempt_index integer check (attempt_index is null or attempt_index >= 0),
response_id text,
upstream_request_id text,
latency_ms integer check (latency_ms is null or latency_ms >= 0),
first_token_ms integer check (first_token_ms is null or first_token_ms >= 0),
input_tokens integer check (input_tokens is null or input_tokens >= 0),
output_tokens integer check (output_tokens is null or output_tokens >= 0),
cached_tokens integer check (cached_tokens is null or cached_tokens >= 0),
reasoning_tokens integer check (reasoning_tokens is null or reasoning_tokens >= 0),
message text not null,
metadata_json text not null,
created_at text not null
);
create index idx_usage_records_created_id on usage_records(created_at desc, id desc);
create index idx_usage_records_request_id on usage_records(request_id) where request_id is not null;
create index idx_usage_records_kind_created on usage_records(kind, created_at desc);
create index idx_usage_records_account_created on usage_records(account_id, created_at desc);
create index idx_usage_records_model_created on usage_records(model, created_at desc);
create index idx_usage_records_key_created on usage_records(client_api_key_id, created_at desc) where client_api_key_id is not null;
create index idx_usage_records_response_id on usage_records(response_id) where response_id is not null;
create index idx_usage_records_upstream_request_id on usage_records(upstream_request_id) where upstream_request_id is not null;
| 字段 | 说明 |
|---|---|
request_id |
请求链路 ID,与 ops 表同域——跨两表串起一次请求的完整故事(成功前的失败尝试在 ops 表) |
client_api_key_id |
调用方归因(逻辑引用 client_api_keys.id,无 FK)。可空:管理端代发/内部探测请求无 key。归因列是事实字段,错过不可回补——按 key 用量、未来按 key 配额核算都依赖它 |
kind / route |
kind 是稳定事件族(v1.response),route 是原始 HTTP 事实;route 变体收敛到同一 kind,历史统计不断裂。映射只允许存在于 event_kind() 一处 |
provider |
上游 provider 归属('openai',规划中 'cloudflare')。not null:成功请求必知上游。事实必须自包含——账号可删除、模型别名可重映射,provider 不能靠 join 推导。开放取值不加 check(SQLite 改 check 要重建大表,新 provider 只应是新增取值)。低基数列刻意不建索引 |
account_id |
not null:成功请求必有归属。无 FK(§3) |
model / requested_model / upstream_model |
展示归因(计费默认)/ 客户端原始请求(别名映射前)/ 上游实际执行。三列语义互不混用 |
service_tier |
计费单价第二维度 |
status_code |
between 200 and 399:终态响应码 + 只收成功,数据库层兜底。1xx 非终态,4xx/5xx 属 ops 表 |
transport / attempt_index |
传输方式与成功发生在第几次尝试(>0 意味着此前有失败,记录在 ops 表) |
latency_ms / first_token_ms |
端到端完成延迟 / 首 token 延迟。NULL = 未测得,区别于 0 |
input/output/cached/reasoning_tokens |
一等列(§2.7)。NULL = 上游未报告,与 0 语义不同——因此不用 not null default 0 |
message |
一行人类可读摘要,必须脱敏 |
metadata_json |
仅调试细节;已提升为列的字段不再写入 metadata |
8 个索引全部对应真实查询:keyset 分页 / 链路排查 / 事件族过滤 / 账号维度 / 模型维度 / 调用方维度 / response 续写排查 / 上游报障对账。每行 8 索引是全库最高写放大,是明细可查性的直接代价;量级失控的出路是缩短保留期(配置已就位),不是删索引。
禁止:写入失败事件;token 写回 metadata 当真相;加任何兼容过滤查询历史数据。
4.11 ops_error_logs ★
每个失败请求/运维错误恰好一行。与 usage_records 互斥:一次请求终态只进一张表。失败与成功的查询维度完全不同(阶段/分类/归责 vs token/成本),拆表让两边 schema 各自朝自己的查询模型演进。
create table ops_error_logs (
id text primary key,
request_id text,
client_api_key_id text,
kind text not null,
provider text,
account_id text,
route text,
model text,
status_code integer check (status_code is null or (status_code between 100 and 599)),
client_status_code integer check (client_status_code is null or (client_status_code between 100 and 599)),
upstream_status_code integer check (upstream_status_code is null or (upstream_status_code between 100 and 599)),
transport text,
attempt_index integer check (attempt_index is null or attempt_index >= 0),
failure_class text,
response_id text,
upstream_request_id text,
latency_ms integer check (latency_ms is null or latency_ms >= 0),
message text not null,
metadata_json text not null,
created_at text not null
);
create index idx_ops_error_logs_created_id on ops_error_logs(created_at desc, id desc);
create index idx_ops_error_logs_request_id on ops_error_logs(request_id) where request_id is not null;
create index idx_ops_error_logs_key_created on ops_error_logs(client_api_key_id, created_at desc) where client_api_key_id is not null;
create index idx_ops_error_logs_account on ops_error_logs(account_id, created_at desc) where account_id is not null;
create index idx_ops_error_logs_route_created on ops_error_logs(route, created_at desc) where route is not null;
create index idx_ops_error_logs_model_created on ops_error_logs(model, created_at desc) where model is not null;
create index idx_ops_error_logs_status_created on ops_error_logs(status_code, created_at desc) where status_code is not null;
create index idx_ops_error_logs_transport_created on ops_error_logs(transport, created_at desc) where transport is not null;
create index idx_ops_error_logs_failure_class on ops_error_logs(failure_class, created_at desc) where failure_class is not null;
create index idx_ops_error_logs_response_id on ops_error_logs(response_id) where response_id is not null;
create index idx_ops_error_logs_upstream_request_id on ops_error_logs(upstream_request_id) where upstream_request_id is not null;
| 字段 | 说明 |
|---|---|
client_api_key_id |
调用方归因,可空(鉴权前失败、后台任务无 key) |
provider |
上游 provider 归属,可空:模型解析前失败(鉴权失败、路由失败)时上游未定。与 account_id 同为"失败可以不知道,成功必须知道"的口径差异 |
account_id |
可空是本表的关键差异:调度前失败(无可用账号、模型不可用)天然无归属——这正是这类事件进不了 usage_records 的原因 |
| 三视角状态码 | status_code(事件主视角)/ client_status_code(用户看到什么)/ upstream_status_code(上游返回什么)。错误排查的第一个问题永远是"哪一层出的错" |
failure_class |
开放取值不加 check:分类法仍在演进 |
metadata_json |
禁止存长请求/响应体(capture body 开启也必须截断脱敏);禁止存计费 token——失败不是计费事实,将来统计失败中观测到的 token 必须用 observed_ 前缀新列 |
11 个索引全部对应错误排查的真实切片;可空维度全部部分索引;错误表写入频率远低于成功表,写放大可承受。
预留演进列(有 UI/告警需求时增列,刻意不预建,本清单为唯一权威版本):surface(client_api/admin/system)、error_phase(auth/dispatch/upstream/stream/parse/internal)、error_type(稳定错误类型)、severity(P1–P3)、requested_model / upstream_model / service_tier、client_ip / user_agent、error_source / error_owner、is_retryable / retry_count。原则:字段跟着真实查询需求走,不照搬外部项目全集。
4.12 request_time_buckets ★
15 分钟 × provider × 账号 × 模型 × service_tier 预聚合,Dashboard 趋势与流量卡片的唯一数据源。单表同时服务两种口径:traffic(success + error)与 usage(token,仅成功)。曾有双表方案,已否决:每请求两次 upsert 的写放大不值得;分列 + 写入纪律已同时提供两种口径,rebuild 命令兜底(保留期内,§2.9)。
create table request_time_buckets (
bucket_start text not null,
provider text not null default '__unknown__',
account_id text not null default '__unknown__',
model text not null default '__unknown__',
service_tier text not null default '__unknown__',
success_count integer not null default 0 check (success_count >= 0),
error_count integer not null default 0 check (error_count >= 0),
input_tokens integer not null default 0 check (input_tokens >= 0),
output_tokens integer not null default 0 check (output_tokens >= 0),
cached_tokens integer not null default 0 check (cached_tokens >= 0),
first_token_latency_sum integer not null default 0 check (first_token_latency_sum >= 0),
first_token_latency_count integer not null default 0 check (first_token_latency_count >= 0),
latency_sum integer not null default 0 check (latency_sum >= 0),
latency_count integer not null default 0 check (latency_count >= 0),
max_latency_ms integer not null default 0 check (max_latency_ms >= 0),
min_latency_ms integer check (min_latency_ms is null or min_latency_ms >= 0),
updated_at text not null,
primary key (bucket_start, provider, account_id, model, service_tier)
);
create index idx_request_time_buckets_model on request_time_buckets(model, bucket_start);
| 字段 | 说明 |
|---|---|
bucket_start |
UTC 时刻,按中国时区 15 分钟槽对齐(china_quarter_hour_start)。对齐规则只活在代码层 |
| 维度四列(provider / account_id / model / service_tier) | 不可空 + __unknown__ sentinel(§2.5)。provider 由两条写入路径显式给值(成功/失败路径都知道自己在打哪家),__unknown__ 仅兜底 |
success_count / error_count |
分别由 usage / ops 写入路径 +1。request_count 不落库,恒等于两者之和,查询期推导——存储恒等式必然漂移 |
| token 三列 | 仅成功路径写入;错误路径只 +1 error_count,不得触碰 token 与延迟列 |
| latency sum/count 对 | 平均值不可再聚合,sum/count 才能跨桶合并、跨维度上卷。口径 = 仅成功(失败延迟混入会让"变慢"和"在报错"互相污染) |
max_latency_ms / min_latency_ms |
可单调合并的极值。min 可空:NULL = 无样本,min() 天然忽略 NULL。check 是 >= 0——0ms 是合法样本(缓存命中/本地短路),与 latency_ms >= 0 的合法域一致;> 0 会让 0ms 样本在同事务写入中炸掉整笔成功事实 |
写入事务纪律:事实 insert 与桶 upsert 必须同事务(§5.2)。
禁止:错误路径写 token/延迟列;落库 request_count;空字符串维度。
4.13 account_cookies
账号维度上游 Cookie(部分上游流程需要浏览器态凭据)。语义直接采用 RFC 6265 模型;unique 约束 = RFC 替换语义,upsert 依据。
create table account_cookies (
id text primary key,
account_id text not null references accounts(id) on delete cascade,
domain text not null,
name text not null,
value text not null,
path text not null default '/',
expires_at text,
updated_at text not null,
unique(account_id, domain, name, path)
);
create index idx_account_cookies_account_domain on account_cookies(account_id, domain);
create index idx_account_cookies_expires on account_cookies(expires_at) where expires_at is not null;
value 是 secret(§2.6)。expires_at NULL = 会话 Cookie,不参与清理。v3 单独的 idx_account_cookies_account 被 account_domain 前缀覆盖,终态不保留。
4.14 fingerprints
设备指纹模板(UA、header 集合与顺序——顺序本身是指纹的一部分)。行数常量级、按 PK 取用,无二级索引。版本三元组用 TEXT:版本号不是数字(1.2.3-beta)。
create table fingerprints (
id text primary key,
originator text not null,
app_version text not null,
build_number text not null,
platform text not null,
arch text not null,
chromium_version text not null,
user_agent_template text not null,
default_headers_json text not null,
header_order_json text not null,
source text not null,
created_at text not null,
updated_at text not null
);
4.15 fingerprint_update_history
指纹更新审计。版本快照列固化"当时更新到了什么"——不能只存 FK,指纹行会被后续更新覆写。级联删除可接受:指纹本体删除后其历史无独立价值。
create table fingerprint_update_history (
id text primary key,
current_fingerprint_id text not null references fingerprints(id) on delete cascade,
app_version text not null,
build_number text not null,
chromium_version text,
source text not null,
manifest_json text,
created_at text not null
);
create index idx_fingerprint_update_history_created_id
on fingerprint_update_history(created_at desc, id desc);
create index idx_fingerprint_update_history_fingerprint
on fingerprint_update_history(current_fingerprint_id);
4.16 model_plan_snapshots
套餐 → 可用模型清单缓存。刻意"最新态覆盖"而非追加历史——缓存表的正确形态,可随时清空重建。禁止当模型目录事实表用;需要 diff/审计时新建历史表。
create table model_plan_snapshots (
plan_type text primary key,
models_json text not null,
fetched_at text not null
);
4.17 session_affinities
response_id → 账号/会话上下文映射,保证续写请求路由回同一账号(上游 response 链只在原账号内有效)。纯运行态:全表清空只影响路由优化,不损失事实。禁止承载会话历史/审计。
create table session_affinities (
response_id text primary key,
account_id text not null references accounts(id) on delete cascade,
conversation_id text not null,
turn_state text,
instructions_hash text,
input_tokens integer check (input_tokens is null or input_tokens >= 0),
function_call_ids_json text not null default '[]',
variant_hash text,
expires_at text not null,
created_at text not null
);
create index idx_session_affinities_conversation on session_affinities(conversation_id, created_at desc);
create index idx_session_affinities_active_order on session_affinities(expires_at, created_at, response_id);
create index idx_session_affinities_account on session_affinities(account_id);
instructions_hash / variant_hash 共同约束隐式续写安全(指令或参数变了就不能隐式续写);存哈希不存原文。active_order 同时服务过期清理(前缀)与活跃排序;v3 单独的 expires 索引是其前缀,终态删除。account 索引是 FK 子表索引(级联删除路径)。
5. 统计口径(全系统唯一定义)
5.1 口径恒等式
任何 UI 卡片不得自创口径:
traffic: request_count = success_count + error_count ← request_time_buckets(查询期推导)
usage: token / 成本 ← usage_records 列(明细)、bucket token 列(趋势,仅成功)
errors: 错误分布 / 排查 ← ops_error_logs
按 key: 调用方用量 / 失败 ← usage_records / ops_error_logs 的 client_api_key_id
调度负载: account_usage / account_model_usage ← 派生累计,事务保护,不可重建(§2.9)
5.2 写入路径与事务
请求成功 ─┬─ usage_records insert
├─ request_time_buckets upsert(success_count+1,token、延迟列)
├─ account_usage upsert(累计 + 窗口)
└─ account_model_usage upsert
全部在同一个事务(pool.begin() … commit)
请求失败 ─┬─ ops_error_logs insert
└─ request_time_buckets upsert(error_count+1,仅此一列)
同一个事务
5.3 Dashboard 口径决策(钉死)
- 所有 traffic 卡片(今日请求、区间请求、错误率、QPS 趋势)一律来自 request_time_buckets,语义为"bucket 保留期内"。卡片文案如实标注区间,不提供也不暗示"历史总量"——bucket 有 90 天保留期,任何"total"字样都是口径谎言。
- 生命周期累计仅有一处合法来源:
account_usage累计列(成功口径),使用时必须标注"成功累计"。 - 同一张卡片禁止混排两种口径的数字(v3 的
todayRequests来自 bucket、totalRequests来自 usage summary 就是这个病)。 - 真正的长期趋势需求 → 日粒度归档表
request_day_buckets(§8),不是延长 15 分钟桶保留期。
6. 迁移 0004:一次到位(v3 → v4)
6.1 前置:迁移框架支持 Rust 步骤
SQL 无法计算 SHA-256,0004 需要一个事务内的 Rust 钩子。改造 database.rs:
struct Migration {
version: i64,
name: &'static str,
sql: &'static str,
/// SQL 之后、同一事务内执行的 Rust 步骤(哈希回填等 SQL 做不到的事)。
post: Option<fn(&mut Transaction<'_, Sqlite>) -> BoxFuture<'_, Result<(), sqlx::Error>>>,
}
应用顺序不变:raw_sql(migration.sql) → post(若有)→ record_migration,全程同一事务。当前 CURRENT_SCHEMA_VERSION = 3;本节 0004 为目标迁移草案,落地时再提升版本。
6.2 0004_final_schema.sql(SQL 部分)
-- ===== admin_sessions:清空重建(哈希键) =====
-- 数据决策:现存会话全部作废,管理员重新登录。会话是运行态,无保留价值。
drop table admin_sessions;
create table admin_sessions ( … §4.3 DDL … );
-- ===== client_api_keys:改名暂存,Rust 步骤哈希回填 =====
alter table client_api_keys rename to client_api_keys_v3;
drop index if exists idx_client_api_keys_key_enabled;
drop index if exists idx_client_api_keys_created_id;
create table client_api_keys ( … §4.4 DDL … );
create index idx_client_api_keys_created_id on client_api_keys(created_at desc, id desc);
-- ===== runtime_settings:重建(admin_api_key → 暂存,Rust 哈希) =====
create table _migration_admin_key as select admin_api_key as value from runtime_settings;
alter table runtime_settings rename to runtime_settings_v3;
create table runtime_settings ( … §4.5 DDL … );
insert into runtime_settings (id, model_aliases_json, refresh_margin_seconds, refresh_concurrency,
max_concurrent_per_account, request_interval_ms, rotation_strategy, admin_api_key_hash, updated_at)
select id, model_aliases_json, refresh_margin_seconds, refresh_concurrency,
max_concurrent_per_account, request_interval_ms, rotation_strategy, null, updated_at
from runtime_settings_v3;
drop table runtime_settings_v3;
-- ===== ops_error_logs:先增列(下方 usage_records 迁移要写入这些列) =====
alter table ops_error_logs add column client_api_key_id text;
alter table ops_error_logs add column provider text;
update ops_error_logs set provider = 'openai';
create index idx_ops_error_logs_key_created on ops_error_logs(client_api_key_id, created_at desc) where client_api_key_id is not null;
-- ===== usage_records:重建为成功事实表 =====
alter table usage_records rename to usage_records_v3;
create table usage_records ( … §4.10 DDL 与全部索引 … );
-- 数据决策 1:满足成功契约的行迁入,token 等列从 metadata 提升(此后不再回读 metadata)。
insert into usage_records (
id, request_id, client_api_key_id, kind, route, provider, account_id, model,
requested_model, upstream_model, service_tier, status_code, transport, attempt_index,
response_id, upstream_request_id, latency_ms, first_token_ms,
input_tokens, output_tokens, cached_tokens, reasoning_tokens,
message, metadata_json, created_at)
select
id, request_id, null, kind, route, 'openai', account_id, model,
nullif(trim(json_extract(metadata_json, '$.requestedModel')), ''),
nullif(trim(json_extract(metadata_json, '$.upstreamModel')), ''),
nullif(trim(json_extract(metadata_json, '$.serviceTier')), ''),
status_code, transport, attempt_index,
response_id, upstream_request_id, latency_ms,
coalesce(cast(json_extract(metadata_json, '$.firstTokenMs') as integer),
cast(json_extract(metadata_json, '$.usage.firstTokenMs') as integer)),
coalesce(cast(json_extract(metadata_json, '$.usage.inputTokens') as integer),
cast(json_extract(metadata_json, '$.inputTokens') as integer)),
coalesce(cast(json_extract(metadata_json, '$.usage.outputTokens') as integer),
cast(json_extract(metadata_json, '$.outputTokens') as integer)),
coalesce(cast(json_extract(metadata_json, '$.usage.cachedTokens') as integer),
cast(json_extract(metadata_json, '$.cachedTokens') as integer)),
coalesce(cast(json_extract(metadata_json, '$.usage.reasoningTokens') as integer),
cast(json_extract(metadata_json, '$.reasoningTokens') as integer)),
message, metadata_json, created_at
from usage_records_v3
where level <> 'error'
and account_id is not null and trim(account_id) <> ''
and model is not null and trim(model) <> ''
and status_code between 200 and 399;
-- 数据决策 2:历史错误行迁入 ops_error_logs(列同构)。
insert into ops_error_logs (
id, request_id, client_api_key_id, kind, provider, account_id, route, model,
status_code, client_status_code, upstream_status_code, transport, attempt_index,
failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at)
select
id, request_id, null, kind, 'openai', account_id, route, model,
status_code, null, upstream_status_code, transport, attempt_index,
failure_class, response_id, upstream_request_id, latency_ms, message, metadata_json, created_at
from usage_records_v3
where level = 'error';
-- 数据决策 3:既非成功契约也非 error 的行(无账号/无模型/无终态状态码的历史噪音)不迁移,随 v3 表一起删除。
drop table usage_records_v3;
-- ===== usage_time_buckets → request_time_buckets =====
create table request_time_buckets ( … §4.12 DDL … );
create index idx_request_time_buckets_model on request_time_buckets(model, bucket_start);
-- 数据决策 4:历史桶迁入(sentinel 归一、success 推导、0-sentinel 转 NULL)。
-- 历史桶的 token 列含 v3 错误路径污染,按旧口径保留展示;事实保留期内的窗口
-- 随后由 rebuild-derived 用新口径重算覆盖(§6.4 验收第 4 步)。
insert into request_time_buckets (
bucket_start, provider, account_id, model, service_tier,
success_count, error_count, input_tokens, output_tokens, cached_tokens,
first_token_latency_sum, first_token_latency_count,
latency_sum, latency_count, max_latency_ms, min_latency_ms, updated_at)
select
bucket_start,
'openai',
case when account_id = '' then '__unknown__' else account_id end,
case when model = '' then '__unknown__' else model end,
case when service_tier = '' then '__unknown__' else service_tier end,
max(request_count - error_count, 0), error_count,
input_tokens, output_tokens, cached_tokens,
first_token_latency_sum, first_token_latency_count,
latency_sum, latency_count, max_latency_ms,
nullif(min_latency_ms, 0), updated_at
from usage_time_buckets;
drop table usage_time_buckets;
-- ===== 索引修正(其余表) =====
drop index if exists idx_session_affinities_expires;
create index idx_session_affinities_account on session_affinities(account_id);
create index idx_fingerprint_update_history_fingerprint on fingerprint_update_history(current_fingerprint_id);
drop index if exists idx_account_cookies_account;
6.3 0004 的 Rust 步骤(post 钩子,同一事务)
- client key 哈希搬迁:读
client_api_keys_v3全部行 → 每行计算SHA-256(key)→ insert 进新表(key_hash= 哈希,其余列原样)→drop table client_api_keys_v3。明文自此不存在于库中。 - admin API key 哈希:读
_migration_admin_key.value,非空则update runtime_settings set admin_api_key_hash = <sha256>;drop table _migration_admin_key。
6.4 迁移后动作与验收
| # | 动作 | 验收 |
|---|---|---|
| 1 | 事务提交后执行一次 vacuum(使 auto_vacuum = incremental 对旧库生效;vacuum 不能在事务内) |
pragma auto_vacuum 返回 2 |
| 2 | 管理员重新登录(会话已清空) | 登录成功,新会话 token_hash 长度 64 |
| 3 | 抽查 select count(*) from client_api_keys where length(key_hash) <> 64 = 0 |
明文 key 消失,鉴权仍通过 |
| 4 | 执行 rebuild-derived(§7.6),用新口径重算事实保留期内的桶 |
保留期内桶 token 列 = usage_records 聚合值 |
| 5 | 核对 Dashboard 卡片全部走 bucket 口径 | 同卡片无混合口径数字 |
显式数据损失清单(接受即执行,不做挽留方案):全部管理端会话;v3 usage_records 中既非成功契约也非 error 的噪音行;历史桶中错误路径污染的 token 按旧口径保留、不追溯清洗(保留期内被 rebuild 覆盖为新口径)。
7. 代码改造清单
与 0004 同一 PR 交付,按依赖顺序:
- 迁移框架(
infra/database.rs):Migration.post钩子;连接参数补synchronous(Normal)、auto_vacuum(Incremental);目标迁移落地时再提升CURRENT_SCHEMA_VERSION。 - 鉴权归因(
proxy/auth.rs):authorize_client_api_key_result改为返回 key 的id(现在验完即弃);SHA-256 后查key_hash。调用链把client_api_key_id装进请求上下文。 - 事件结构(
proxy/dispatch/usage_events.rs、proxy/dispatch/responses/event_recording.rs):成功/失败事件携带client_api_key_id;token、requested/upstream_model、service_tier、first_token_ms 从 metadata 移到一等字段;metadata 不再写已提升字段。 - 写入路径(
admin/monitoring/usage_record_store.rs、ops error store):§5.2 的两条事务;错误路径对桶只error_count + 1;__unknown__sentinel;min/max 延迟直接min()/max()(无 0-sentinel 分支)。 - 查询路径(
usage_record_service.rs、dashboard.rs、account_usage_service.rs):summary/分布/趋势全部走列,删除所有json_extract聚合与like '%…%'搜索;Dashboard 按 §5.3 口径改卡片来源与文案。 - 维护命令:管理端
rebuild-derived——删除事实保留期内的桶并从两张事实表重算;保留期外只读不动。范围明确不含account_usage/account_model_usage(§2.9,不可重建)。 - 清理任务:三个 trim 任务改读
runtime_settings三列;新增 bucket trim;每轮 trim 后pragma incremental_vacuum。 - 管理端 API(
admin/auth/service.rs、config/settings.rs、keys 路由):session 创建/校验走token_hash;verify_admin_api_key对来键哈希后ct_eq;key 列表响应删除完整 key 字段(只余prefix)——前端同步移除完整 key 展示。 - 测试(
backend/tests/infra/storage_schema/):断言终态 DDL、迁移数据规则(成功行迁入 / error 行转 ops / 噪音行丢弃)、两条写入事务的原子性、0ms 延迟样本可入桶。
8. 扩展路径与明确不做
每个可预见的增长方向都有"只加不改"的路径:
| 未来需求 | 扩展方式 |
|---|---|
| 多管理员 | admin_users 加 username/role + 新表 admin_login_events |
| 按 key 限流/配额 | 新表 client_api_key_policies (key_id FK, …);归因数据已在事实表就位 |
| 错误运维大盘 | ops_error_logs 按 §4.11 预留清单增列 |
| 长期趋势/年度账务 | 新表 request_day_buckets(由 15 分钟桶降采样归档),Dashboard 才允许出现"历史总量" |
| 刷新历史分析 | 新表 account_refresh_events |
| 模型别名审计 | model_aliases_json 拆 model_aliases 表 |
| 凭据静态加密 / 合规 | accounts 拆 account_credentials(1:1,PK=FK),集中一个破坏窗口 |
| 接入第二上游 provider(已规划:Cloudflare Workers AI) | 事实表与桶的 provider 维度已在 v4 就位(§4.10–4.12),CF 首条请求落库前无需再动事实表。届时一个迁移完成:accounts 加 provider + chatgpt_account_id/user_id 更名 upstream_account_id/upstream_user_id + 唯一索引加 provider 维度;model_plan_snapshots PK 改 (provider, plan_type);billing 单价查找加 provider 参数。回填全部确定为 'openai' |
| 多实例部署 | SQLite → Postgres:TEXT 时间戳 → timestamptz、check in → enum、upsert 语法兼容;租约表天然支持竞争 |
Cloudflare 账号对现有 schema 的适配(无需新列):静态 API key = access_token,refresh_token/access_token_expires_at NULL(永不过期,语义已覆盖);无套餐配额窗口 → account_usage.window_* 恒为初始值、窗口边界 NULL;account_cookies 对 CF 账号空置。硬规则:任何新 provider 的第一条请求落库之前,其事实行必须已携带 provider 归因——v4 之后这条自动满足。
明确不做:无多实例需求前不引入外部数据库/缓存;不为历史数据加任何兼容过滤;不预建"可能有用"的列——每一列都必须有当前的真实读者;不照搬外部项目的字段全集。