Files
codex-proxy-rs/docs/database.md
T
2026-07-09 13:06:54 +08:00

52 KiB
Raw Permalink Blame History

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_tokens NULL = 上游未报告,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 索引

  1. 每个索引对应一条真实查询;unique 约束已覆盖的等值查找不再建索引。
  2. 列表分页统一 keyset 模式,配 (排序时间 desc, id desc) 复合索引。
  3. 可空列上的筛选索引一律部分索引 where <col> is not null。
  4. FK 子表必须有以 FK 列开头的索引(SQLite 级联删除在子表无索引时对父表每行全扫)。免除条件必须注明(如 admin_sessions:行数个位数)。
  5. 高频写入表的索引总数进 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_token not null 是账号存在的最低要求;refresh_token NULL = 不可续期,到期即 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 钩子,同一事务)

  1. client key 哈希搬迁:读 client_api_keys_v3 全部行 → 每行计算 SHA-256(key) → insert 进新表(key_hash = 哈希,其余列原样)→ drop table client_api_keys_v3。明文自此不存在于库中。
  2. 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 交付,按依赖顺序:

  1. 迁移框架(infra/database.rs):Migration.post 钩子;连接参数补 synchronous(Normal)、auto_vacuum(Incremental);目标迁移落地时再提升 CURRENT_SCHEMA_VERSION。
  2. 鉴权归因(proxy/auth.rs):authorize_client_api_key_result 改为返回 key 的 id(现在验完即弃);SHA-256 后查 key_hash。调用链把 client_api_key_id 装进请求上下文。
  3. 事件结构(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 不再写已提升字段。
  4. 写入路径(admin/monitoring/usage_record_store.rs、ops error store):§5.2 的两条事务;错误路径对桶只 error_count + 1;__unknown__ sentinel;min/max 延迟直接 min()/max()(无 0-sentinel 分支)。
  5. 查询路径(usage_record_service.rs、dashboard.rs、account_usage_service.rs):summary/分布/趋势全部走列,删除所有 json_extract 聚合与 like '%…%' 搜索;Dashboard 按 §5.3 口径改卡片来源与文案。
  6. 维护命令:管理端 rebuild-derived——删除事实保留期内的桶并从两张事实表重算;保留期外只读不动。范围明确不含 account_usage / account_model_usage(§2.9,不可重建)。
  7. 清理任务:三个 trim 任务改读 runtime_settings 三列;新增 bucket trim;每轮 trim 后 pragma incremental_vacuum。
  8. 管理端 API(admin/auth/service.rs、config/settings.rs、keys 路由):session 创建/校验走 token_hash;verify_admin_api_key 对来键哈希后 ct_eq;key 列表响应删除完整 key 字段(只余 prefix)——前端同步移除完整 key 展示。
  9. 测试(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 之后这条自动满足。

明确不做:无多实例需求前不引入外部数据库/缓存;不为历史数据加任何兼容过滤;不预建"可能有用"的列——每一列都必须有当前的真实读者;不照搬外部项目的字段全集。