56 KiB
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:<account_id> |
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<Utc>直接绑定,不再经 RFC3339 文本中转。 - RFC3339 文本只存在于两处边界:API 响应序列化、keyset 分页游标(游标文本解码回
DateTime<Utc>后再进 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_tokensNULL = 上游未报告,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:<token_hash> |
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 索引
- 每个索引对应一条真实查询;unique 约束已覆盖的等值查找不再建索引。
- 列表分页统一 keyset 模式,配
(排序时间 desc, id desc)复合索引。 - 可空列上的筛选索引一律部分索引
where <col> is not null。 - FK 子表必须有以 FK 列开头的索引(PG 级联删除同样会在子表无索引时全扫)。免除条件必须注明。
- 高频写入表的索引总数进 code review,新增需说明查询来源。
2.9 Redis 纪律
- 每个键有确定的过期路径:TTL(会话、租约、亲和)或"最新态覆盖"(缓存 HASH)。禁止只增不减的键空间。
- 可丢弃性是准入条件:FLUSHALL 之后系统必须无数据损失地继续运行(会话重登、租约重竞争、亲和退化为普通调度、缓存重拉)。不满足即不属于 Redis。
- 互斥必须原子:获取用
SET NX PX,续约/释放用 Lua 比较 owner 后操作,禁止 GET-判断-SET 三段式。 - 二级索引(亲和的 conversation / account ZSET)由写入方维护,读取方容忍成员悬垂(主键已过期),遇悬垂惰性清理。
- 单个业务对象统一编码为完整 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. 架构分层与关系
┌─ 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) │
└─────────────────────────────────────────────────────┘
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:<id> 索引显式清理(§4B.3)
Redis 租约.account_id → 不清理,TTL(分钟级)自然过期
事实表/聚合表不建 FK 是有意为之:审计事实的生命周期必须独立于账号与 key——删除账号或 key 不得抹掉历史用量和错误记录。运行态相反:账号没了亲和/租约就失去意义,v3 的 FK 级联在 Redis 中由"显式清理(亲和)+ TTL 自愈(租约)"等价实现。
admin_users 与 Redis 会话之间不存在级联:单管理员模型下删除用户无入口;未来多管理员需要"删用户踢会话"时,增加 cpr:admin:user_sessions:<user_id> 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)无延续关系。
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)。
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 与上游账号解耦,客户端不感知账号池。
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 状态。
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)。
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_tokennot null 是账号存在的最低要求;refresh_tokenNULL = 不可续期,到期即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 合并:更新频率差多个数量级,合表会让高频计数写污染低频实体行。
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 层。
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 各自朝自己的查询模型演进。
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)。
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 依据。
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)。
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 行,让审计深度确定且不随运行年限增长。
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 管理端会话
键 cpr:admin:session:<token_hash> 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 刷新互斥租约
键 cpr:lease:refresh:<account_id>
值 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 会话亲和
主键 cpr:affinity:v2:resp:<response_id>
值 {"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:<conversation_id> ZSET member=response_id score=createdAt(epoch ms)
索引 cpr:affinity:v2:account:<account_id> 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 模型清单缓存
键 cpr:models:plan_snapshots HASH field=<plan_type>
值 {"models":[…],"fetchedAt":"..."}
TTL 无(最新态覆盖,HSET 即替换)
缓存的正确形态,可随时清空重建(丢失 = 下次按需重拉)。刷新使用原子 DEL + HSET 全量替换,已从上游消失的 plan_type field 必须同步删除,不能只覆盖仍存在的字段。禁止当模型目录事实表用;需要 diff/审计时新建 PG 历史表。
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 ← 派生累计,不可重建(§2.10)
5.2 写入路径与事务
请求成功 ─┬─ 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 导入命令
codex-proxy-rs import-sqlite <旧库路径.sqlite>
Docker Compose 部署应先停止旧 SQLite 实例并备份源库,确认目标 PG volume 为新建空卷,
再只启动 PG/Redis 并显式执行容器内二进制;不能先启动 codex-proxy-rs 服务,否则服务
初始化的管理员与运行时设置会使目标库不再为空:
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 数据决策)
- 成功契约行迁入 usage_records:
level <> 'error'且account_id/model非空 且status_code200–399。token、requested_model/upstream_model/service_tier/first_token_ms从 metadata 提升为列(usage.*优先、顶层回退),此后不再回读 metadata;provider='openai';client_api_key_id=NULL(v3 未归因,错过不可回补——这正是归因列必须尽早存在的证明)。 level = 'error'行迁入 ops_error_logs(列同构映射,client_status_code=NULL)。- 既非成功契约也非 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 配置检查共同覆盖:
- 连接层(
infra/database.rs+infra/redis.rs):PgPool(参数见 §1)+ 迁移框架(PG 谱系 0001 起,版本从末条迁移派生,严格顺序与 checksum 校验);Redis ConnectionManager + 键前缀 +PING健康检查。Cargo.toml:sqlx 加postgres(保留sqlite仅供导入命令读源库),新增rediscrate,删除未使用的 SQLxuuidfeature。 - 配置(
bootstrap/config.rs、deploy/config.example.yaml):database.url改postgres://…,新增redis.url。 - 鉴权归因(
keys/{store,service,manage}.rs、api/client/auth.rs):key 创建落唯一key列;鉴权按完整 key 做 PG unique 点查(删除进程内鉴权缓存);鉴权返回 keyid,调用链把client_api_key_id装进请求上下文直至事件写入。管理端列表持续返回完整 key,前端长期保留复制与 CCSwitch 导入入口。 - 事件结构(
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(成功事实无等级)。 - 写入路径(
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 移除。 - 查询路径(
telemetry/{usage,ops,account_usage}、api/admin/{usage,ops,dashboard,accounts}_routes*):summary/分布/趋势全部走列,删除所有->>聚合与 metadata LIKE;新增/api/admin/ops/errors错误明细查询面;Dashboard 按 §5.3 只改取数来源,不改既有文案。 - 运行态改 Redis(
auth/store.rs会话、accounts/refresh/lease.rs、dispatch/affinity/store.rs、models/store.rs):按 §4B 契约实现;删除 admin 会话/亲和清理任务与亲和重启恢复;账号删除路径挂 Redis 亲和批量级联;模型 HASH 全量替换;持久化identity_hmac_secret。 - 账号导入(
fleet/manage/import.rs+fleet/store/write.rs):新账号身份/凭据、next_refresh_at与 quota 状态由单条原子 SQL 一次提交;已有账号更新与刷新时间同样原子,任何字段校验或数据库错误都不得留下半初始化行。 - 维护命令(
main.rsCLI):import-sqlite <path>(§6);rebuild-buckets——删除事实保留期内的桶并从两张事实表重算(15 分钟槽对齐在 SQL 侧floor(epoch/900)*900,§2.3);保留期外只读不动。范围明确不含account_usage(§2.10,不可重建)。 - 清理任务(
bootstrap/tasks/):三张时间增长表的周期 trim(读runtime_settings三列),fingerprint_update_history全局 100 行硬上限;cookie 清理保留;admin 会话与亲和清理任务删除(TTL 接管)。 - 健康检查(
api/router.rs):PGselect 1+ RedisPING,任一失败即 503。 - 测试(
backend/tests/):support 使用每测试独立 PG 数据库与随机 Redis 前缀,并在测试进程前缀级清理残留;覆盖 v1 终态 schema 与 checksum 防篡改、导入原子性、回放容量/TTL/账号删除/敏感字段、账号池与 WebSocket pool 并发不变量、存储失败降级以及保留期任务。第一条真实增量迁移上线时再增加对应的 v1→v2 演进测试,不得为尚未发布的基线制造伪历史。 - 部署(
deploy/docker-compose.yml):新增 postgres、redis 服务与健康检查依赖,PG/Redis 数据卷及/app/data身份密钥目录跨镜像持久化。
8. 扩展路径与明确不做
每个可预见的增长方向都有"只加不改"的路径:
| 未来需求 | 扩展方式 |
|---|---|
| 多管理员 | admin_users 加 username/role + PG admin_login_events + Redis cpr:admin:user_sessions:<user_id> 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 改 <provider>:<plan_type>;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 不承载任何不可丢失数据。