# Database —— 唯一权威文档 本文档是 Codex Proxy RS 存储层的**唯一**权威文档:终态形态为 **PostgreSQL(持久层)+ Redis(运行态/缓存)**。全量定义 PG Schema、Redis 键契约、全局纪律、统计口径、SQLite → PG 的一次性搬迁方案与代码改造清单。 - 取代 2026-07-08 版(SQLite 单进程形态 + 0004 迁移草案)。旧版描述的 v4 终态 Schema 语义**全部保留**,宿主引擎由 SQLite 换为 PG + Redis,0004 迁移方案改由导入命令吸收。 - 基线:生产 SQLite 库 schema v3(读侧,只读打开);尚未上线的 PG 谱系由 0001 直接建立当前 v1 终态(写侧)。 - 策略:**一次破坏性切换直达终态,不做兼容层**。没有双写期、没有 SQLite 回退路径、没有查询层兼容过滤;不满足终态契约的历史数据按 §6.3 规则导入或丢弃,丢弃项全部显式列出。 --- ## 1. 运行形态 **单实例部署;PostgreSQL 承载一切持久数据,Redis 承载一切运行态与缓存。** 选择外置存储是运维形态决策,不以多实例为目标(**不考虑多实例部署**): - PG:真正的数据库服务器——在线备份、成熟工具链、管理端重查询与热路径写入不再争抢单写者;容器内不再有本地数据文件。 - Redis:运行态的原生 TTL 语义——会话/租约/亲和的过期由存储自身表达,三个清理任务与"重启恢复"步骤整体消失。 | 存储 | 承载 | 丢失语义 | | --- | --- | --- | | PostgreSQL | 元数据、配置、接入凭据、账号、事实、派生聚合 | 不可丢失,常规备份 | | Redis | 管理会话、刷新租约、会话亲和、模型清单缓存 | 可丢失:会话重登、租约自愈、亲和退化为重新调度、缓存重拉 | 连接参数(`infra/database.rs` / `infra/redis.rs`,全部显式): | 参数 | 值 | 理由 | | --- | --- | --- | | PG 池 `max_connections` | 10 | 自托管代理的写并发远低于此 | | PG 池 `acquire_timeout` | 5s | 排队上限,接替 SQLite `busy_timeout` 的角色 | | Redis 连接 | 单条多路复用连接(ConnectionManager,自动重连) | 命令全部为 O(1)/O(logN) 小操作,无需连接池 | | Redis 键前缀 | `cpr:` | 与共用实例的其他应用隔离;测试用随机前缀隔离 | **明确不做**:PG 侧不用 LISTEN/NOTIFY、不用 advisory lock(互斥一律走 Redis 租约);Redis 侧不开键空间通知、不承载任何"不可丢失"数据。 --- ## 2. 全局纪律 ### 2.1 命名 | 对象 | 规则 | 示例 | | --- | --- | --- | | 表名 | snake_case;实体集合复数;子表 `<父实体单数>_<内容>` | `accounts`、`account_cookies` | | 表名后缀 | 事实 `_records`/`_logs`;聚合 `_buckets`;审计 `_history` | `usage_records` | | 列名 | snake_case,全库同义同名 | `account_id` 处处一致 | | 时间戳 | `*_at`,**timestamptz** | `created_at` | | 时长 | `*_seconds` / `*_ms`,bigint | `latency_ms` | | 计数 | `*_count`,bigint,`check >= 0` | `request_count` | | JSON | `*_json`,**jsonb** | `metadata_json` | | 布尔 | 裸形容词/过去式,**boolean** | `enabled` | | 外键/逻辑引用列 | `<被引用实体单数>_id` | `client_api_key_id` | | 凭据哈希 | `*_hash`,用于不可逆凭据(§2.6) | `password_hash`、`token_hash` | | 主键 | `id`,除非自然复合键或哈希键 | — | | 普通/唯一索引 | `idx_<表>_<列摘要>` / `ux_<表>_<列摘要>` | `ux_accounts_chatgpt_identity` | | 双端状态码 | 视角前缀 | `status_code` / `client_status_code` / `upstream_status_code` | | 模型归因 | 来源前缀 | `model` / `requested_model` / `upstream_model` | | Redis 键 | `cpr:<域>:<实体>:<键值>`,冒号分层 | `cpr:lease:refresh:` | ### 2.2 类型 | 用途 | PG 类型 | 约定 | | --- | --- | --- | | ID、名称、枚举、哈希 | text | 枚举加 `check in (...)` | | 时间戳 | timestamptz | 库内一律 UTC 时刻 | | 计数、时长 | bigint | 计数 `>= 0` | | 状态码 | integer | `between` check | | 布尔 | boolean | — | | 快照/模板/调试 JSON | jsonb | 见 §2.7 | **枚举用 text + check,不用 PG 原生 enum**(有意修订旧文档 §8 的一句话设想):原生 enum 增删值要 `alter type` 且值永远无法删除;check 约束换一条 `drop/add constraint` 即完成枚举演进(SQLite 0002 改名先例证明枚举确实会变)。**不使用 real/double 存业务数据,不存储金额**。成本一律查询期由 token × 单价计算(`telemetry/billing.rs`):单价会变、需追溯重算,落库的浮点成本既漂移又过期。 ### 2.3 时间戳 - 库内一律 timestamptz;代码层统一 `chrono::DateTime` 直接绑定,**不再经 RFC3339 文本中转**。 - RFC3339 文本只存在于两处边界:API 响应序列化、keyset 分页游标(游标文本解码回 `DateTime` 后再进 SQL,比较发生在 timestamptz 域)。 - 时区换算(中国时区展示、15 分钟槽对齐)只在代码层(`infra/time.rs` 的 `china_*` 系列),数据库不感知时区。+08:00 是整小时偏移,15 分钟槽在 UTC 与中国时区下对齐结果相同,SQL 侧重算桶可直接 `to_timestamp(floor(extract(epoch from t) / 900) * 900)`。 ### 2.4 ID 主键一律 text 随机 ID(`infra/identity.rs` 生成):无中心分配、写入前可生成、跨表无碰撞。例外:`schema_migrations.version`(天然有序整数)、`runtime_settings.id`(单例锚点)。**不用 serial/identity/uuid 列类型**——ID 是业务字符串,不是数据库生成物。 ### 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 纪律与可取回边界 凡是"出示即获得权限"的值都是凭据。默认只保存不可逆摘要;但客户端 API key 与上游凭据有明确的可取回功能需求,必须可逆保存。所有可逆凭据均不得进入日志、遥测或非管理端响应,数据库备份与访问权限必须按 secret 级别保护。 | 凭据 | 存储 | 哈希类型 | 理由 | | --- | --- | --- | --- | | 管理员密码 | PG `admin_users.password_hash` | 慢哈希(argon2) | 低熵人类输入,必须抗字典攻击 | | 客户端 API key | PG `client_api_keys.key` 明文列,标记 secret | —(功能上必须可逆) | 管理端需要长期复制并导入 CCSwitch;鉴权走 `key` unique 索引点查,列表只对已认证管理员返回完整值 | | 管理端 session token | Redis `cpr:admin:session:` | SHA-256 | 键即哈希。Redis RDB/AOF 或内存转储泄露不再等于会话劫持 | | 管理 API key | PG `runtime_settings.admin_api_key_hash` | SHA-256 | 校验 = 哈希后 `ct_eq`;哈希不可脱敏展示,状态 API 只回答"是否已启用" | | 上游 access/refresh token、Cookie value | PG 明文列,标记 secret | —(功能上必须可逆) | 不进日志、不进列表 API;静态加密的触发条件见 §8 | ### 2.7 JSON 边界 `*_json` 列(jsonb)只允许三类内容:① 上游原样快照(`quota_json`、`manifest_json`);② 结构化模板(`default_headers_json`、`header_order_json`);③ 调试细节(`metadata_json`)。**凡进入 WHERE / GROUP BY / 告警的字段必须是一等列**;列一旦提升即为唯一可查询真相,写入时不再往 metadata 双写已提升字段,查询路径禁止出现 `->>` 聚合。 ### 2.8 索引 1. 每个索引对应一条真实查询;unique 约束已覆盖的等值查找不再建索引。 2. 列表分页统一 keyset 模式,配 `(排序时间 desc, id desc)` 复合索引。 3. 可空列上的筛选索引一律部分索引 `where is not null`。 4. FK 子表必须有以 FK 列开头的索引(PG 级联删除同样会在子表无索引时全扫)。免除条件必须注明。 5. 高频写入表的索引总数进 code review,新增需说明查询来源。 ### 2.9 Redis 纪律 1. **每个键有确定的过期路径**:TTL(会话、租约、亲和)或"最新态覆盖"(缓存 HASH)。禁止只增不减的键空间。 2. **可丢弃性是准入条件**:FLUSHALL 之后系统必须无数据损失地继续运行(会话重登、租约重竞争、亲和退化为普通调度、缓存重拉)。不满足即不属于 Redis。 3. **互斥必须原子**:获取用 `SET NX PX`,续约/释放用 Lua 比较 owner 后操作,禁止 GET-判断-SET 三段式。 4. 二级索引(亲和的 conversation / account ZSET)由写入方维护,读取方容忍成员悬垂(主键已过期),遇悬垂惰性清理。 5. 单个业务对象统一编码为完整 JSON 文本,不把对象属性拆成 Redis Hash 字段。集合缓存可使用 HASH(如 `cpr:models:plan_snapshots`),但每个 field 的 value 仍是一个完整 JSON 对象,整读整写。 ### 2.10 保留与可重建性(诚实边界) | 数据 | 保留 | 配置/机制 | | --- | --- | --- | | `usage_records` | 30 天(默认) | `runtime_settings.usage_retention_days`,周期 trim 任务 | | `ops_error_logs` | 30 天(默认) | `runtime_settings.ops_error_retention_days`,同上 | | `request_time_buckets` | 90 天(默认) | `runtime_settings.bucket_retention_days`,同上 | | `account_cookies` | 按 `expires_at` 清理 | 周期任务(PG) | | `account_usage` | 每账号 1 行 | 账号删除时 FK 级联删除 | | `fingerprints` | 当前指纹固定 1 行 | 固定 ID 覆盖更新 | | `fingerprint_update_history` | 全局最近 100 行 | 每小时按 `created_at desc, id desc` 裁剪 | | Redis 会话/租约/亲和 | TTL 自然过期 | 无清理任务 | | 其余 PG 实体表 | 随管理实体数量增长 | 不随请求数增长 | - trim 是**每小时执行的后台任务**:三张时间表每轮读 `runtime_settings` 保留列,指纹历史使用上表的固定行数上限。不再在每次写入后内联执行——v3 的"每写一次 delete 一次"是已知的写放大缺陷,终态修复。 - 30/30/90 天是数据时间窗,不是字节硬上限;高流量下时间窗内的事实仍可很大。PostgreSQL `autovacuum` 会回收 DELETE 后的页供后续复用,但不保证数据文件立即缩小;不周期执行会锁表的 `VACUUM FULL`。 - `request_time_buckets` 在事实保留期内可由两张事实表重算(`rebuild-buckets` 命令,§7);超出事实保留期的桶是**因事实过期而升格的一级数据**,写入纪律 + 同事务写入(§5.2)是它们的唯一保障。 - `account_usage` 的累计列**不可重建**(事实早已过期),只受写入纪律保护。模型窗口分布直接从 `request_time_buckets` 查询,不再维护无生产读者的第二份 `account_model_usage` 累计。本文档不声称"一切派生皆可重算"。 - `window_*` 列是运行态(窗口边界来自上游),不可重建,可丢弃。 --- ## 3. 架构分层与关系 ```text ┌─ PostgreSQL ──────────────────────────────────────────┐ │ ┌─ 元数据 ────────────────────────────────────────┐ │ │ │ schema_migrations │ │ │ ├─ 配置 ──────────────────────────────────────────┤ │ │ │ runtime_settings(单例热配置) │ │ │ ├─ 接入 ──────────────────────────────────────────┤ │ │ │ admin_users · client_api_keys │ │ │ ├─ 账号 ──────────────────────────────────────────┤ │ │ │ accounts · account_cookies · fingerprints │ │ │ │ fingerprint_update_history │ │ │ ├─ 事实(不可替代的业务事件)──────────────────────┤ │ │ │ usage_records(成功) · ops_error_logs(失败) │ │ │ └─ 派生(口径见 §2.10)──────────────────────────┘ │ │ request_time_buckets · account_usage │ └─────────────────────────────────────────────────────┘ ┌─ Redis(运行态/缓存,可丢弃)─────────────────────────┐ │ cpr:admin:session:* 管理端会话(TTL) │ │ cpr:lease:refresh:* token 刷新互斥租约(TTL) │ │ cpr:affinity:* 会话亲和 + 二级索引(TTL) │ │ cpr:models:plan_snapshots 套餐→模型清单缓存(HASH) │ └─────────────────────────────────────────────────────┘ ``` ```text accounts 1──1 account_usage FK cascade(PK=FK) accounts 1──< account_cookies FK cascade(account_domain 前缀) 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 Redis 亲和.account_id → 账号删除时经 cpr:affinity:v2:account: 索引显式清理(§4B.3) Redis 租约.account_id → 不清理,TTL(分钟级)自然过期 ``` **事实表/聚合表不建 FK 是有意为之**:审计事实的生命周期必须独立于账号与 key——删除账号或 key 不得抹掉历史用量和错误记录。运行态相反:账号没了亲和/租约就失去意义,v3 的 FK 级联在 Redis 中由"显式清理(亲和)+ TTL 自愈(租约)"等价实现。 `admin_users` 与 Redis 会话之间不存在级联:单管理员模型下删除用户无入口;未来多管理员需要"删用户踢会话"时,增加 `cpr:admin:user_sessions:` SET 索引(§8)。 --- ## 4. PostgreSQL 表设计(终态 DDL) 以下 DDL 即 `infra/migrations/0001_initial.sql` 描述的 PG v1 终态。PG 库由迁移框架从空库建立;历史数据经导入命令进入(§6),不存在 SQLite → PG 的原地迁移链。 ### 4.1 schema_migrations 迁移版本的唯一事实来源。由 `database.rs` 建库时创建,不走迁移文件。已发布迁移只增不改;当前版本从 `MIGRATIONS.last()` 派生,不存在手工版本常量。启动时先校验内置版本严格递增且不重复,再校验数据库中的已应用记录是内置迁移的有序前缀,并逐条核对名称与 SHA-256 SQL checksum。PG 谱系从 1 重新编号,与 SQLite 谱系(止于 3)无延续关系。 ```sql create table schema_migrations ( version bigint not null check (version > 0), name text not null, checksum text not null, applied_at timestamptz not null default now(), primary key (version) ); ``` ### 4.2 admin_users 单管理员模型的登录凭据。不预建 `username`/`email`/`role` 死列;多管理员的扩展路径见 §8。会话不在 PG(§4B.1)。 ```sql create table admin_users ( id text primary key, password_hash text not null, created_at timestamptz not null, updated_at timestamptz not null ); ``` ### 4.3 client_api_keys 客户端调用 `/v1/*` 的凭据。key 与上游账号解耦,客户端不感知账号池。 ```sql create table client_api_keys ( id text primary key, name text not null, prefix text not null, key text not null unique, label text, enabled boolean not null default true, created_at timestamptz not null, last_used_at timestamptz ); 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` | 完整客户端凭据,unique 索引即鉴权查找路径。只在已认证管理端列表/创建响应返回,供长期复制与导入 CCSwitch;严禁进入日志和遥测 | | `enabled` | 软开关;刻意不建索引(低基数布尔) | | `last_used_at` | 鉴权成功后延迟批量更新(1s 去抖),不在请求关键路径强一致 | **鉴权路径**:`where key = $1 and enabled` unique 索引单点查询,**删除进程内明文鉴权表**——单一代码路径、禁用即刻生效、无缓存同步逻辑。管理端取回与客户端鉴权读取同一事实列,不存在 hash/明文双写。每 key 配额/限流不进本表(§8)。 ### 4.4 runtime_settings 热更新全局配置,单行表,`check (id = 1)` 约束层面保证单例。选宽表不选 KV:列级类型与 check 是 KV 给不了的;加配置要加列,恰好强迫走迁移评审。只放小型标量配置,禁塞大 JSON 状态。 ```sql create table runtime_settings ( id bigint primary key check (id = 1), model_aliases_json jsonb not null default '{}', refresh_margin_seconds bigint not null check (refresh_margin_seconds > 0), refresh_concurrency bigint not null check (refresh_concurrency > 0), max_concurrent_per_account bigint not null check (max_concurrent_per_account > 0), request_interval_ms bigint 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 bigint not null default 30 check (usage_retention_days > 0), ops_error_retention_days bigint not null default 30 check (ops_error_retention_days > 0), bucket_retention_days bigint not null default 90 check (bucket_retention_days > 0), updated_at timestamptz not null ); ``` | 字段 | 说明 | | --- | --- | | `model_aliases_json` | 客户端模型名 → 上游模型名映射。JSON 合规:整体读写、不按别名查询 | | `refresh_*` / `max_concurrent_per_account` / `request_interval_ms` | 刷新与调度参数 | | `rotation_strategy` | 枚举 check;枚举值变更必须走迁移(SQLite 0002 先例),库内无死值 | | `admin_api_key_hash` | 管理 API 机器凭据的 SHA-256。NULL = 未启用。校验 = 对来键哈希后 `ct_eq`。哈希不可脱敏,状态 API 只回答存在性 | | `*_retention_days` 三列 | 三张增长表各自独立的保留天数,trim 任务每轮读取。bucket 默认更长:趋势比明细有更长查询价值 | ### 4.5 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 timestamptz, next_refresh_at timestamptz, status text not null check (status in ('active', 'expired', 'quota_exhausted', 'disabled', 'banned')), quota_json jsonb, quota_fetched_at timestamptz, quota_limit_reached boolean not null default false, quota_verify_required boolean not null default false, quota_cooldown_until timestamptz, cloudflare_cooldown_until timestamptz, added_at timestamptz not null, updated_at timestamptz not null ); 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`,由 Redis 刷新租约 / 进程内 in-flight 派生为运行时展示态。冷却用时间而非布尔:自然过期无需回写。quota 与 CF 冷却分列:成因、时长、解除策略都不同。 - **快照**:`quota_json` 上游原样快照(JSON 合规);`added_at` 语义化命名——账号是"被添加进池子"的。 - 池加载顺序 = `added_at asc, id asc`(替代 SQLite 隐式 rowid 序,语义相同且跨引擎稳定)。 ### 4.6 account_usage 账号维度两组计数:① 生命周期累计(列表展示,**不可重建**,§2.10);② 当前 quota 窗口计数(调度限流输入,运行态)。1:1 派生表,不与 accounts 合并:更新频率差多个数量级,合表会让高频计数写污染低频实体行。 ```sql create table account_usage ( account_id text primary key references accounts(id) on delete cascade, request_count bigint not null default 0 check (request_count >= 0), empty_response_count bigint not null default 0 check (empty_response_count >= 0), input_tokens bigint not null default 0 check (input_tokens >= 0), output_tokens bigint not null default 0 check (output_tokens >= 0), cached_tokens bigint not null default 0 check (cached_tokens >= 0), reasoning_tokens bigint not null default 0 check (reasoning_tokens >= 0), total_tokens bigint not null default 0 check (total_tokens >= 0), image_input_tokens bigint not null default 0 check (image_input_tokens >= 0), image_output_tokens bigint not null default 0 check (image_output_tokens >= 0), image_request_count bigint not null default 0 check (image_request_count >= 0), image_request_failed_count bigint not null default 0 check (image_request_failed_count >= 0), window_request_count bigint not null default 0 check (window_request_count >= 0), window_input_tokens bigint not null default 0 check (window_input_tokens >= 0), window_output_tokens bigint not null default 0 check (window_output_tokens >= 0), window_cached_tokens bigint not null default 0 check (window_cached_tokens >= 0), window_image_input_tokens bigint not null default 0 check (window_image_input_tokens >= 0), window_image_output_tokens bigint not null default 0 check (window_image_output_tokens >= 0), window_image_request_count bigint not null default 0 check (window_image_request_count >= 0), window_image_request_failed_count bigint not null default 0 check (window_image_request_failed_count >= 0), window_started_at timestamptz, window_reset_at timestamptz, limit_window_seconds bigint check (limit_window_seconds is null or limit_window_seconds > 0), last_used_at timestamptz ); ``` | 组 | 说明 | | --- | --- | | 累计 | 口径 = usage_records 成功事实;失败率不得由本表推断。`total_tokens` 是已知的"有偿冗余"(列表排序高频),与分量列同一 upsert 内更新 | | 窗口 | 当前 quota 窗口内计数,窗口重置清零。**窗口组刻意不含 `reasoning_tokens`/`total_tokens`**:窗口列只镜像上游 quota 核算维度,累计组是展示口径,两组不对称是设计而非遗漏 | | 边界 | `window_started_at` / `window_reset_at` / `limit_window_seconds` 来自上游 rate-limit 响应,NULL = 未知 | 宽表(20 计数列)而非 EAV:计数种类是代码常量,宽表一次 upsert 全更新、check 逐列可写;EAV 只换来动态 SQL 和类型丢失。 ### 4.7 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 bigint check (attempt_index is null or attempt_index >= 0), response_id text, upstream_request_id text, latency_ms bigint check (latency_ms is null or latency_ms >= 0), first_token_ms bigint check (first_token_ms is null or first_token_ms >= 0), input_tokens bigint check (input_tokens is null or input_tokens >= 0), output_tokens bigint check (output_tokens is null or output_tokens >= 0), cached_tokens bigint check (cached_tokens is null or cached_tokens >= 0), reasoning_tokens bigint check (reasoning_tokens is null or reasoning_tokens >= 0), message text not null, metadata_json jsonb not null, created_at timestamptz 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(新 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 索引是全库最高写放大,是明细可查性的直接代价;量级失控的出路是缩短保留期(配置已就位),不是删索引。 **搜索边界**:列表 API 的 `search` 参数只匹配 `message ilike` 与 request_id/response_id 精确等值;**禁止**对 `metadata_json` 做任何 contains 扫描——metadata 是调试细节,不是查询面。 **禁止**:写入失败事件;token 写回 metadata 当真相;加任何兼容过滤查询历史数据。 ### 4.8 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 bigint check (attempt_index is null or attempt_index >= 0), failure_class text, response_id text, upstream_request_id text, latency_ms bigint check (latency_ms is null or latency_ms >= 0), message text not null, metadata_json jsonb not null, created_at timestamptz 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_` 前缀新列 | 错误明细的管理端查询面(`/api/admin/ops/errors`)直接读本表;成功事件列表不再混排错误行。11 个索引全部对应错误排查的真实切片;可空维度全部部分索引;错误表写入频率远低于成功表,写放大可承受。 **预留演进列(有 UI/告警需求时增列,刻意不预建,本清单为唯一权威版本)**:`surface`、`error_phase`、`error_type`、`severity`、`requested_model` / `upstream_model` / `service_tier`、`client_ip` / `user_agent`、`error_source` / `error_owner`、`is_retryable` / `retry_count`。原则:字段跟着真实查询需求走,不照搬外部项目全集。 ### 4.9 request_time_buckets ★ 15 分钟 × provider × 账号 × 模型 × service_tier 预聚合,Dashboard 趋势与流量卡片的唯一数据源。**单表同时服务两种口径**:traffic(success + error)与 usage(token,仅成功)。曾有双表方案,已否决:每请求两次 upsert 的写放大不值得;分列 + 写入纪律已同时提供两种口径,rebuild 命令兜底(保留期内,§2.10)。 ```sql create table request_time_buckets ( bucket_start timestamptz 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 bigint not null default 0 check (success_count >= 0), error_count bigint not null default 0 check (error_count >= 0), input_tokens bigint not null default 0 check (input_tokens >= 0), output_tokens bigint not null default 0 check (output_tokens >= 0), cached_tokens bigint not null default 0 check (cached_tokens >= 0), first_token_latency_sum bigint not null default 0 check (first_token_latency_sum >= 0), first_token_latency_count bigint not null default 0 check (first_token_latency_count >= 0), latency_sum bigint not null default 0 check (latency_sum >= 0), latency_count bigint not null default 0 check (latency_count >= 0), max_latency_ms bigint not null default 0 check (max_latency_ms >= 0), min_latency_ms bigint check (min_latency_ms is null or min_latency_ms >= 0), updated_at timestamptz not null, primary key (bucket_start, provider, account_id, model, service_tier) ); ``` | 字段 | 说明 | | --- | --- | | `bucket_start` | 15 分钟槽起点(UTC 槽与中国时区槽对齐结果相同,§2.3) | | 维度四列 | 不可空 + `__unknown__` sentinel(§2.5)。provider 由两条写入路径显式给值(成功/失败路径都知道自己在打哪家),`__unknown__` 仅兜底 | | `success_count` / `error_count` | 分别由成功/失败事务 +1。**`request_count` 不落库**,恒等于两者之和,查询期推导——存储恒等式必然漂移 | | token 三列 | **仅成功路径写入**;错误路径只 +1 `error_count`,不得触碰 token 与延迟列 | | latency sum/count 对 | 平均值不可再聚合,sum/count 才能跨桶合并、跨维度上卷。**口径 = 仅成功**(失败延迟混入会让"变慢"和"在报错"互相污染) | | `max_latency_ms` / `min_latency_ms` | 可单调合并的极值(`greatest()`/`least()`)。min 可空:NULL = 无样本,`min()` 天然忽略 NULL。**check 是 `>= 0`**——0ms 是合法样本(缓存命中/本地短路),`> 0` 会让 0ms 样本在同事务写入中炸掉整笔成功事实 | **写入事务纪律**:事实 insert 与桶 upsert 必须同事务(§5.2)。 **索引决策**:终态只保留以 `bucket_start` 为首列的主键索引。2026-07-12 在本地真实迁移数据(672 个桶)上对生产时间窗聚合执行 `EXPLAIN ANALYZE`:保留 `idx_request_time_buckets_model` 时为顺序扫描、`0.955 ms`;事务内删除后仍为相同顺序扫描、`0.764 ms`。现有三条查询均不以 `model` 等值作为前导条件,因此尚未上线的 0001 基线不创建该无读者索引。 **禁止**:错误路径写 token/延迟列;落库 `request_count`;空字符串维度。 ### 4.10 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 timestamptz, updated_at timestamptz 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,不参与清理。**过期时间在捕获时解析为 timestamptz**(RFC2822/RFC3339 均接受,解析失败按会话 Cookie 落 NULL)——v3 存原始字符串再做字典序比较是已知缺陷,终态修复。 ### 4.11 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 jsonb not null, header_order_json jsonb not null, source text not null, created_at timestamptz not null, updated_at timestamptz not null ); ``` ### 4.12 fingerprint_update_history 指纹更新审计。版本快照列固化"当时更新到了什么"——不能只存 FK,指纹行会被后续更新覆写。级联删除可接受:指纹本体删除后其历史无独立价值。 每小时按 `created_at desc, id desc` 全局保留最新 100 行,让审计深度确定且不随运行年限增长。 ```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 jsonb, created_at timestamptz 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); ``` --- ## 4B. Redis 键契约(终态) 运行态与缓存的唯一权威定义。所有键带 `cpr:` 前缀(测试环境随机前缀隔离)。值为 JSON 文本(§2.9)。 ### 4B.1 管理端会话 ```text 键 cpr:admin:session: token_hash = SHA-256(session token) hex 值 {"userId":"...","createdAt":"..."} TTL session_ttl_minutes(创建时一次性设定) ``` - 客户端持有明文 token(`sess_` + 256bit 随机),鉴权时哈希后 GET,存在即有效——过期由 TTL 表达,不再有 `expires_at` 比较,**清理任务消失**。 - 登出 = DEL;"重启不掉线"由 Redis 持久化(RDB/AOF)提供;Redis 冷启丢失 = 管理员重登,可接受(§2.9)。 - IP/UA 审计不进会话值;需要时新建 PG `admin_login_events`(§8)。 ### 4B.2 token 刷新互斥租约 ```text 键 cpr:lease:refresh: 值 owner(实例/任务标识,不透明字符串) TTL 租约时长(acquire 时以 PX 设定) ``` - 获取:`SET key owner NX PX ttl`;失败后允许**同 owner 重入续约**(Lua:GET == owner 则 `SET PX` 刷新)。 - 释放:Lua 比较 owner 后 DEL,防误释放他人租约。 - 活跃租约批查(运行时展示 refreshing):`MGET` 一批键,非空即持有。 - 持有者崩溃 → TTL 自然失效,无需清理任务。历史不承载(需要时另建 PG `account_refresh_events`)。 ### 4B.3 会话亲和 ```text 主键 cpr:affinity:v2:resp: 值 {"accountId","conversationId","turnState","instructionsHash", "inputTokens","functionCallIds":[…],"variantHash","continuationScope", "replay":{"parentResponseId","turnInput","turnOutput","depth","totalBytes"}, "createdAt"} TTL 亲和 TTL(默认 4h),EXPIREAT createdAt+ttl 索引 cpr:affinity:v2:conv: ZSET member=response_id score=createdAt(epoch ms) 索引 cpr:affinity:v2:account: ZSET member=response_id score=createdAt(epoch ms) ``` - `v2` 明确隔离旧的累计 `fullInput` 编码,不读取旧键。 - 写入(记录一次成功响应):MULTI 内 `SET resp EXAT` + 两个 `ZADD` + 两个 `EXPIRE`,并对 conversation/account 索引执行 `ZREMRANGEBYSCORE -inf now-ttl` 惰性剪枝。 - 按 response_id 查:GET 主键。按 conversation 查最新:`ZREVRANGE conv` 取候选 → GET 主键过滤 variant_hash / max_age → 第一个存活者;悬垂成员(主键已过期)当场 ZREM。 - replay 每轮只保存本轮 `turnInput` + `turnOutput` 增量和父 response ID,读取时按父链重建;禁止在每个节点重复保存累计 `fullInput`,从而避免多轮会话 O(n²) 空间增长。重放数据递归删除 `encrypted_content`,并删除每个顶层 replay item 的上游 `id`;`call_id` 等协议关联字段保留。 - 容量边界:客户端 `/v1` 请求体最大 16 MiB;单 replay 节点 JSON 最大 2 MiB;单会话累计最大 16 MiB;父链最大 128 层。任一边界超限即不再为该响应写 replay 基线,正常上游响应不受影响,不会为绕过边界而截断或篡改请求。 - forget(response_id):GET 后在一次事务中 DEL 主键 + ZREM 两个索引。 - **账号删除的级联**:一次 ZRANGE 取得账号 ZSET 成员,一次 MGET 读取归属,随后用单个 Redis 原子 pipeline 批量删除主键、conversation 成员和 account 索引;禁止逐成员网络往返。等价于 v3 的 FK cascade + `idx_session_affinities_account`。 - **进程内不再维护亲和映射副本**:Redis 本身就是快存,进程内再留一份只换来双写同步与重启恢复逻辑;亲和查找相对上游请求(百 ms 级)是 <1ms 的往返,可忽略。v3 的"重启恢复"步骤随之消失。 - replay 是仅用于节点切换恢复的有界临时上下文,不是历史或审计事实;全部键被清空只会失去透明恢复能力,不会损失 PG 事实。 - `identity_hmac_secret` 必须跨重启和镜像更换持久化。Docker 部署由 `/app/data` 挂载承载该文件;禁止把它放在容器可写层或每次启动重新生成。该 256-bit 密钥与账号 ID 分域派生稳定的 prompt/session/thread/window/installation 伪名,密钥变化会让全部账号身份同时漂移。 ### 4B.4 模型清单缓存 ```text 键 cpr:models:plan_snapshots HASH field= 值 {"models":[…],"fetchedAt":"..."} TTL 无(最新态覆盖,HSET 即替换) ``` 缓存的正确形态,可随时清空重建(丢失 = 下次按需重拉)。刷新使用原子 `DEL + HSET` 全量替换,已从上游消失的 `plan_type` field 必须同步删除,不能只覆盖仍存在的字段。**禁止**当模型目录事实表用;需要 diff/审计时新建 PG 历史表。 --- ## 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 ← 派生累计,不可重建(§2.10) ``` ### 5.2 写入路径与事务 ```text 请求成功 ─┬─ usage_records insert └─ request_time_buckets upsert(success_count+1,token、延迟列) 同一个 PG 事务(pool.begin() … commit) 请求失败 ─┬─ ops_error_logs insert └─ request_time_buckets upsert(error_count+1,仅此一列) 同一个 PG 事务 ``` `account_usage` 的 upsert **不在**上述事务内:它由调度器在槽位释放 / 用量观测时点独立写入(与事实写入不在同一代码时刻),每笔 upsert 自身原子。模型窗口分布统一从 `request_time_buckets` 查询,不再双写独立累计表。错误事件没有可信 `service_tier` 一等列,因此实时错误桶与 `rebuild-buckets` 都固定写 `__unknown__`;两条路径不得使用不同来源猜测 tier。 ### 5.3 Dashboard 口径决策(钉死) - 周期 traffic 卡片(今日请求、区间请求、错误率、QPS 趋势)**一律来自 request_time_buckets**,语义为"bucket 保留期内";生命周期累计卡片不从 bucket 推导。 - 生命周期累计仅有一处合法来源:`account_usage` 累计列(成功口径)。这是后端数据语义,不要求改动既有前端文案。 - 同一张卡片禁止混排两种口径的数字(v3 的 `todayRequests` 来自 bucket、`totalRequests` 来自 usage_records 全表扫描就是这个病)。 - 错误明细与错误分布来自 ops_error_logs;成功事件列表不再混排错误行。 - 真正的长期趋势需求 → 日粒度归档表 `request_day_buckets`(§8),不是延长 15 分钟桶保留期。 --- ## 6. SQLite v3 → PG 一次性搬迁 引擎切换,不存在就地迁移:PG 库由 0001 全新建立到 v1 终态,历史数据经**导入命令**搬入。旧文档的 0004 迁移草案(数据决策 1–4 与管理 API key 哈希回填)全部由导入命令吸收,SQLite 侧不再演进(v3 即其终版)。 ### 6.1 导入命令 ```text codex-proxy-rs import-sqlite <旧库路径.sqlite> ``` Docker Compose 部署应先停止旧 SQLite 实例并备份源库,确认目标 PG volume 为新建空卷, 再只启动 PG/Redis 并显式执行容器内二进制;不能先启动 `codex-proxy-rs` 服务,否则服务 初始化的管理员与运行时设置会使目标库不再为空: ```bash docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d postgres redis docker compose --env-file deploy/.env -f deploy/docker-compose.yml run --rm --no-deps \ -v "$(realpath /path/to/legacy.sqlite):/tmp/legacy.sqlite:ro" \ codex-proxy-rs codex-proxy-rs import-sqlite /tmp/legacy.sqlite docker compose --env-file deploy/.env -f deploy/docker-compose.yml run --rm --no-deps \ codex-proxy-rs codex-proxy-rs rebuild-buckets docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d codex-proxy-rs ``` 镜像入口为 `tini`,服务二进制位于镜像 `CMD`;因此 Compose `run` 覆盖命令时必须写成 `codex-proxy-rs codex-proxy-rs import-sqlite ...`,第一个是服务名,第二个才是容器内二进制。 - 前置:目标 PG 已迁移到当前版本且无业务行,否则拒绝执行——导入是一次性动作,不做增量合并。 - 源库校验:`schema_migrations` 最高版本必须为 3,否则拒绝(未知谱系不猜)。 - 全程单个 PG 事务:要么全部导入,要么全部回滚。源库只读打开。 - Redis 不参与导入:v3 的运行态数据按 §6.3 显式丢弃。 - 结束打印逐表行数、规范化计数与丢弃计数报告。 ### 6.2 逐表规则 | v3 源表 | 终态去向 | 变换 | | --- | --- | --- | | `admin_users` | PG 原样 | 时间戳 text→timestamptz | | `client_api_keys` | PG | `key`、`prefix` 原值搬迁;完整 key 继续作为管理端长期复制与 CCSwitch 导入的唯一事实 | | `runtime_settings` | PG | `admin_api_key` 明文 → 哈希落 `admin_api_key_hash`;三个 retention 列取默认值 | | `accounts` | PG | 布尔 0/1→boolean,时间戳→timestamptz;旧版曾落库的瞬态 `refreshing` → `expired` 并清空 `next_refresh_at`,防止迁移后自动消费 RT;其余业务状态原样 | | `account_usage` | PG 原样 | 同上 | | `account_model_usage` | **不导入** | 终态无生产读者,只计入丢弃报告 | | `account_cookies` | PG | `expires_at` 字符串解析为 timestamptz(RFC2822/RFC3339);解析失败 → NULL(降级为会话 Cookie)并计数报告 | | `fingerprints` / `fingerprint_update_history` | PG 原样 | JSON text→jsonb | | `usage_records`(v3 混合表) | **拆分**,见 §6.3 | — | | `usage_time_buckets` | `request_time_buckets` | `provider='openai'`;空串维度→`__unknown__`;`success_count=max(request_count-error_count,0)`;`min_latency_ms` 0→NULL | | `ops_error_logs`(v3) | PG | 增列 `client_api_key_id=NULL`、`provider='openai'` | | `admin_sessions` / `session_affinities` / `account_refresh_leases` / `model_plan_snapshots` | **不导入** | 运行态/缓存,§6.3 损失清单 | ### 6.3 v3 usage_records 的拆分决策(原 0004 数据决策) 1. **成功契约行迁入 usage_records**:`level <> 'error'` 且 `account_id`/`model` 非空 且 `status_code` 200–399。token、`requested_model`/`upstream_model`/`service_tier`/`first_token_ms` 从 metadata 提升为列(`usage.*` 优先、顶层回退),此后不再回读 metadata;`provider='openai'`;`client_api_key_id=NULL`(v3 未归因,错过不可回补——这正是归因列必须尽早存在的证明)。 2. **`level = 'error'` 行迁入 ops_error_logs**(列同构映射,`client_status_code=NULL`)。 3. **既非成功契约也非 error 的行**(无账号/无模型/无终态状态码的历史噪音)**不迁移**,计数后随源库废弃。 **显式数据损失清单**(接受即执行,不做挽留方案): - 全部管理端会话(重新登录)、会话亲和(退化为重新调度)、刷新租约(自愈)、模型清单快照(按需重拉)——运行态按 §2.9 本就可丢弃; - v3 usage_records 中的噪音行(决策 3); - v3 `account_model_usage` 行(终态模型窗口直接查询 `request_time_buckets`); - 旧版误落库的 `accounts.status='refreshing'` 不作为业务状态保留,按 §6.2 规范化为 `expired`; - 历史桶中错误路径污染的 token 按旧口径保留展示、不追溯清洗;事实保留期内的窗口随后由 `rebuild-buckets` 用新口径重算覆盖。 ### 6.4 迁移后动作与验收 | # | 动作 | 验收 | | --- | --- | --- | | 1 | 服务指向 PG+Redis 启动 | `/healthz` 204(PG `select 1` + Redis `PING` 双活) | | 2 | 管理员重新登录 | 登录成功;Redis 出现 `cpr:admin:session:*`,TTL ≈ session_ttl | | 3 | 抽查 `select count(*) from client_api_keys where key is null or key = ''` = 0 | 旧 key 值不变;鉴权、管理端复制与 CCSwitch 导入均可用 | | 3a | 抽查 `select count(*) from accounts where status = 'refreshing'` = 0 | 旧瞬态状态已规范化为 `expired`,且 `next_refresh_at is null` | | 4 | 执行 `rebuild-buckets`(§7),用新口径重算事实保留期内的桶 | 保留期内桶 token 列 = usage_records 聚合值 | | 5 | 核对 Dashboard 周期卡片走 bucket、生命周期累计走 account_usage,前端既有文案不变 | 同卡片无混合口径数字 | --- ## 7. 代码改造清单(本次交付) 按依赖顺序交付。**2026-07-12 终态以本节与自动化测试共同约束**,验收由后端集成测试、前端构建、架构检查和 Compose 配置检查共同覆盖: 1. **连接层**(`infra/database.rs` + `infra/redis.rs`):PgPool(参数见 §1)+ 迁移框架(PG 谱系 0001 起,版本从末条迁移派生,严格顺序与 checksum 校验);Redis ConnectionManager + 键前缀 + `PING` 健康检查。`Cargo.toml`:sqlx 加 `postgres`(保留 `sqlite` 仅供导入命令读源库),新增 `redis` crate,删除未使用的 SQLx `uuid` feature。 2. **配置**(`bootstrap/config.rs`、`deploy/config.example.yaml`):`database.url` 改 `postgres://…`,新增 `redis.url`。 3. **鉴权归因**(`keys/{store,service,manage}.rs`、`api/client/auth.rs`):key 创建落唯一 `key` 列;鉴权按完整 key 做 PG unique 点查(删除进程内鉴权缓存);鉴权返回 key `id`,调用链把 `client_api_key_id` 装进请求上下文直至事件写入。管理端列表持续返回完整 key,前端长期保留复制与 CCSwitch 导入入口。 4. **事件结构**(`telemetry/{usage,ops}`、`telemetry/recorder.rs`、`dispatch/recording.rs`):成功/失败事件携带 `client_api_key_id` 与 `provider`;token、requested/upstream_model、service_tier、first_token_ms 从 metadata 移到一等字段;metadata 不再写已提升字段;UsageRecord 删除 `level`(成功事实无等级)。 5. **写入路径**(`telemetry/usage/store.rs`、`telemetry/ops/store.rs`、`telemetry/buckets/store.rs`):§5.2 的两条 PG 事务;错误路径对桶只 `error_count + 1`;`__unknown__` sentinel;min/max 延迟使用 nullable-safe 的 `least()`/`greatest()`;内联 trim 移除。 6. **查询路径**(`telemetry/{usage,ops,account_usage}`、`api/admin/{usage,ops,dashboard,accounts}_routes*`):summary/分布/趋势全部走列,删除所有 `->>` 聚合与 metadata LIKE;新增 `/api/admin/ops/errors` 错误明细查询面;Dashboard 按 §5.3 只改取数来源,不改既有文案。 7. **运行态改 Redis**(`auth/store.rs` 会话、`accounts/refresh/lease.rs`、`dispatch/affinity/store.rs`、`models/store.rs`):按 §4B 契约实现;删除 admin 会话/亲和清理任务与亲和重启恢复;账号删除路径挂 Redis 亲和批量级联;模型 HASH 全量替换;持久化 `identity_hmac_secret`。 8. **账号导入**(`fleet/manage/import.rs` + `fleet/store/write.rs`):新账号身份/凭据、`next_refresh_at` 与 quota 状态由单条原子 SQL 一次提交;已有账号更新与刷新时间同样原子,任何字段校验或数据库错误都不得留下半初始化行。 9. **维护命令**(`main.rs` CLI):`import-sqlite `(§6);`rebuild-buckets`——删除事实保留期内的桶并从两张事实表重算(15 分钟槽对齐在 SQL 侧 `floor(epoch/900)*900`,§2.3);保留期外只读不动。范围明确**不含** `account_usage`(§2.10,不可重建)。 10. **清理任务**(`bootstrap/tasks/`):三张时间增长表的周期 trim(读 `runtime_settings` 三列),`fingerprint_update_history` 全局 100 行硬上限;cookie 清理保留;admin 会话与亲和清理任务删除(TTL 接管)。 11. **健康检查**(`api/router.rs`):PG `select 1` + Redis `PING`,任一失败即 503。 12. **测试**(`backend/tests/`):support 使用每测试独立 PG 数据库与随机 Redis 前缀,并在测试进程前缀级清理残留;覆盖 v1 终态 schema 与 checksum 防篡改、导入原子性、回放容量/TTL/账号删除/敏感字段、账号池与 WebSocket pool 并发不变量、存储失败降级以及保留期任务。第一条真实增量迁移上线时再增加对应的 v1→v2 演进测试,不得为尚未发布的基线制造伪历史。 13. **部署**(`deploy/docker-compose.yml`):新增 postgres、redis 服务与健康检查依赖,PG/Redis 数据卷及 `/app/data` 身份密钥目录跨镜像持久化。 --- ## 8. 扩展路径与明确不做 每个可预见的增长方向都有"只加不改"的路径: | 未来需求 | 扩展方式 | | --- | --- | | 多管理员 | `admin_users` 加 `username`/`role` + PG `admin_login_events` + Redis `cpr:admin:user_sessions:` SET(踢会话索引) | | 按 key 限流/配额 | 新表 `client_api_key_policies (key_id FK, …)`;归因数据已在事实表就位;运行态计数走 Redis | | 错误运维大盘 | `ops_error_logs` 按 §4.8 预留清单增列 | | 长期趋势/年度账务 | 新表 `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` 维度已就位(§4.7–4.9),CF 首条请求落库前无需再动事实表。届时一个迁移完成:`accounts` 加 `provider` + `chatgpt_account_id/user_id` 更名 `upstream_account_id/upstream_user_id` + 唯一索引加 provider 维度;模型缓存 HASH field 改 `:`;billing 单价查找加 provider 参数。回填全部确定为 `'openai'` | Cloudflare 账号对现有 schema 的适配(无需新列):静态 API key = `access_token`,`refresh_token`/`access_token_expires_at` NULL(永不过期,语义已覆盖);无套餐配额窗口 → `account_usage.window_*` 恒为初始值、窗口边界 NULL;`account_cookies` 对 CF 账号空置。**硬规则**:任何新 provider 的第一条请求落库之前,其事实行必须已携带 provider 归因——终态之后这条自动满足。 **明确不做**:**不考虑多实例部署**——调度器、登录限速、`last_used_at` 去抖、CF 风控跟踪均为单进程语义,PG/Redis 的选择不以横向扩容为目标;不保留 SQLite 运行路径(源库只被导入命令只读打开);不为历史数据加任何兼容过滤;不预建"可能有用"的列——每一列都必须有当前的真实读者;不照搬外部项目的字段全集;Redis 不承载任何不可丢失数据。