# 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 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. 架构分层与关系 ```text ┌─ 元数据 ──────────────────────────────────────────────┐ │ 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 │ ``` ```text 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` 与迁移列表同步提交。 ```sql 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。 ```sql 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。 ```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 与上游账号解耦,客户端不感知账号池。 ```sql 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 状态。 ```sql 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)。 ```sql 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`)。 ```sql 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 合并:更新频率差多个数量级,合表会让高频计数写污染低频实体行。 ```sql 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。 ```sql 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 层。 ```sql 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 各自朝自己的查询模型演进。 ```sql 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)。 ```sql 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 依据。 ```sql 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`)。 ```sql 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,指纹行会被后续更新覆写。级联删除可接受:指纹本体删除后其历史无独立价值。 ```sql 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/审计时新建历史表。 ```sql 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 链只在原账号内有效)。纯运行态:全表清空只影响路由优化,不损失事实。**禁止**承载会话历史/审计。 ```sql 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 卡片不得自创口径: ```text 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 写入路径与事务 ```text 请求成功 ─┬─ 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`: ```rust struct Migration { version: i64, name: &'static str, sql: &'static str, /// SQL 之后、同一事务内执行的 Rust 步骤(哈希回填等 SQL 做不到的事)。 post: Option) -> BoxFuture<'_, Result<(), sqlx::Error>>>, } ``` 应用顺序不变:`raw_sql(migration.sql)` → `post`(若有)→ `record_migration`,全程同一事务。当前 `CURRENT_SCHEMA_VERSION = 3`;本节 0004 为目标迁移草案,落地时再提升版本。 ### 6.2 0004_final_schema.sql(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 = `;`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 之后这条自动满足。 **明确不做**:无多实例需求前不引入外部数据库/缓存;不为历史数据加任何兼容过滤;不预建"可能有用"的列——每一列都必须有当前的真实读者;不照搬外部项目的字段全集。