Files
codex-proxy-rs/docs/database-schema-audit.md
T
2026-06-20 18:16:04 +08:00

891 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 数据库表设计与字段命名审计
审计日期:2026-06-20
本文档只审计当前 Rust 项目的 SQLite 表设计、字段设计、字段冗余和后续指纹持久化方向。本文不包含真实账号、token、cookie、API key 等敏感数据。
## 目标
- 把当前表结构中不够清晰、不够稳定、容易制造重复数据或迁移债的问题固定下来。
- 给出一套可以长期维护的命名规范和表职责边界。
- 明确本轮指纹设计:`config.yaml` 只作为首次默认种子,数据库持久化后以数据库为准;后续自动更新只写数据库,运行时只从数据库加载。
- 保持项目定位为 OpenAI / Codex 相关能力,不引入通用 proxy、VPN 或其它 provider 的泛化字段。
## 本轮实施结果
项目尚未上线,本轮按“新库最终结构”直接迁移,不写旧结构兼容代码。已经落地的结构变更:
- `schema.sql` 成为新库最终结构,移除 `connect_sqlite()` 里的 ad hoc 轻量补丁逻辑。
- 新增 `schema_migrations` 表作为后续上线后的迁移账本。
- `accounts` 上游身份物理列改为 `chatgpt_account_id` / `chatgpt_user_id`,并增加 `ux_accounts_chatgpt_identity` 唯一索引。
- `account_usage` 补齐并贯通 `reasoning_tokens`、`total_tokens`,协议解析、仓储写入、管理端列表和汇总都使用同一语义。
- `fingerprints` 使用固定当前槽位 `id = 'current'`,完整保存 `originator`、默认请求头、请求头顺序和更新时间;`config.yaml` 只做首次种子。
- `fingerprint_update_history` 单独保存自动更新历史,不参与运行时当前指纹选择。
- `session_affinities.account_id` 改为本地账号外键并级联删除。
- `event_logs` 补齐 `transport`、`attempt_index`、`upstream_status_code`、`failure_class`、`response_id`、`upstream_request_id` 顶层排查字段,并保留 `metadata_json` 作为补充上下文。
仍然暂不拆 `accounts`、`account_usage` 等大表。拆表属于 P2 长期清洁项,等功能边界继续稳定后再做,避免为了形式拆表制造更多搬运代码。
## 审计基线
改造前 schema 已经能支撑基础运行,但还不是“教科书级”的表设计。主要问题不是缺表,而是职责边界和命名语义混在一起:
1. `accounts` 同时承载账号身份、token、刷新计划、配额快照、配额冷却、Cloudflare 恢复状态,字段越来越像“运行时大杂烩”。
2. `accounts.account_id` / `accounts.user_id` 命名过泛。它们实际是 ChatGPT/OpenAI 上游身份,不应该和本地账号主键 `id` 混淆。
3. 上游账号身份只有普通索引,没有唯一约束。导入相同上游账号时,数据库层不能阻止重复记录。
4. `schema.sql` 和实际运行库曾经出现 drift:运行库 `account_usage` 有 `reasoning_tokens`、`total_tokens`,但 `schema.sql` 没有;`session_affinities.function_call_ids_json` 的默认值表达也不一致。
5. 迁移机制还是 ad hoc:`connect_sqlite()` 运行 `schema.sql` 后只补一个 `accounts.next_refresh_at`。缺少 `schema_migrations` 这种可追踪迁移账本。
6. `fingerprints` 表只存了部分指纹字段。`originator`、默认请求头、请求头顺序仍从代码默认值补齐,不满足“后续都去数据库里拿”。
7. 时间字段命名不统一:`added_at` 和 `created_at` 混用;`fingerprints.created_at` 在 upsert 时被覆盖,语义更像 `updated_at`。
8. 多处 JSON 字段承担外部快照或小型结构化状态,这可以接受,但必须有命名、校验和索引边界,不能让核心查询长期依赖 `metadata_json like ...`。
## 表清单
| 表 | 当前职责 | 总体判断 |
| --- | --- | --- |
| `admin_users` | 管理员账号 | 简单可用,命名基本清晰 |
| `admin_sessions` | 管理员登录会话 | 简单可用 |
| `client_api_keys` | 客户端访问 key | 可用,但 `name` / `label` 语义重叠,唯一约束不足 |
| `accounts` | OpenAI/ChatGPT 账号、token、刷新、配额、恢复状态 | 已完成上游身份重命名和唯一约束;后续再评估拆表 |
| `account_refresh_leases` | 多任务刷新租约 | 职责清晰,字段可小幅命名优化 |
| `account_usage` | 账号累计用量和窗口用量 | 已补齐 reasoning/total;拆 totals/windows 属于 P2 |
| `account_cookies` | 账号级 cookie 持久化 | 基本合理,`id` 可选冗余 |
| `fingerprints` | Codex Desktop 请求指纹版本 | 已补齐当前槽位完整指纹字段 |
| `event_logs` | 管理端结构化事件日志 | 已补齐常用链路排查字段 |
| `model_plan_snapshots` | 套餐模型列表快照 | 简单可用 |
| `session_affinities` | response/session 到账号的亲和性 | 已补账号外键和默认值一致性 |
## 命名规范
后续新字段和迁移建议遵守以下规则。
### 标识符
- 本地表主键统一用 `id`。
- 外部系统 ID 必须加来源前缀,不使用裸 `account_id` / `user_id`:
- 推荐:`chatgpt_account_id`、`chatgpt_user_id`
- 可接受:`openai_account_id`、`openai_user_id`
- 不推荐:`account_id`、`user_id`,因为它们和本地外键语义冲突。
- 外键字段使用被引用实体名加 `_id`,例如 `account_id` 表示本地 `accounts.id`,不是上游账号 ID。
### 时间字段
- 时间字段统一使用 RFC3339 UTC 字符串,字段名以 `_at` 或 `_until` 结尾。
- 创建时间统一 `created_at`,更新时间统一 `updated_at`。
- `added_at` 应逐步迁移为 `created_at`。
- 如果字段表示“未来允许执行时间”,命名用 `next_*_at`,例如 `next_refresh_at`。
- 如果字段表示“冷却截止时间”,命名用 `*_cooldown_until`,当前命名可保留。
### 数值字段
- 计数用 `_count`,token 用 `_tokens`,毫秒用 `_ms`,秒用 `_seconds`。
- 累计总量和窗口总量不要混在命名里:
- 累计:`request_count`、`input_tokens`
- 窗口:`window_request_count`、`window_input_tokens`
- 如果保存 `total_tokens`,必须定义为“上游返回的原始 total”,不要同时把它当作 `input_tokens + output_tokens` 的派生值。
### 布尔字段
- SQLite 仍用 `integer check (value in (0, 1))`。
- 命名应表达肯定语义:
- `enabled` 可接受,但 `is_enabled` 更清晰。
- `quota_verify_required` 建议迁移为 `requires_quota_verification`。
- `quota_limit_reached` 可接受。
### 密文和 JSON
- 密文字段建议统一用 `_ciphertext`,比 `_cipher` 更准确。
- 当前字段 `access_token_cipher`、`refresh_token_cipher`、`value_cipher` 可先保留,后续大迁移再统一。
- JSON 字段必须以 `_json` 结尾。
- `_json` 字段只用于以下情况:
- 外部接口原始快照,短期不做复杂查询。
- 小型原子结构,作为整体读写,例如请求头顺序。
- 事件元数据,作为补充上下文。
- 需要筛选、排序、聚合的字段不要长期只放在 JSON 里。
## 全局问题
### 1. 缺少迁移账本
改造前启动流程:
1. `connect_sqlite()` 创建连接。
2. 执行 `schema.sql`。
3. 执行 `apply_lightweight_migrations()`。
4. 只补 `accounts.next_refresh_at`。
问题:
- 迁移没有版本号,不知道一个运行库应用过哪些结构变更。
- 迁移散在 Rust 函数里,后续越补越难审计。
- `schema.sql` 与运行库 drift 时,没有统一入口修正。
本轮处理:
- 项目未上线,直接让 `schema.sql` 表达最终新库结构。
- 移除 Rust 里的 ad hoc lightweight migration。
- 增加 `schema_migrations` 表,为上线后的版本化迁移做准备。
后续上线后建议:
```sql
create table if not exists schema_migrations (
version integer primary key,
name text not null,
applied_at text not null
);
```
后续每个结构变更用单独版本:
```text
0001_initial_schema
0002_accounts_next_refresh_at
0003_account_usage_reasoning_total_tokens
0004_fingerprint_full_profile
0005_accounts_chatgpt_identity_unique
```
`schema.sql` 保持“新库最终结构”,迁移脚本负责“旧库升级路径”。本轮不写旧库升级路径。
### 2. 运行库和 schema.sql 曾经 drift
改造前观察到的 drift:
- 实际运行库 `account_usage` 有 `reasoning_tokens`、`total_tokens`。
- `schema.sql` 的 `account_usage` 没有这两个字段。
- 实际运行库 `session_affinities.function_call_ids_json` 默认值显示为 `"[]"`。
- `schema.sql` 写的是 `'[]'`。
风险:
- 新装库、老运行库、测试库结构不一致。
- 代码一旦开始读写这些字段,某些环境会失败。
- 审计数据库时无法判断哪个结构才是事实来源。
本轮处理:
- `schema.sql` 已直接补齐新库最终字段。
- `account_usage.reasoning_tokens` / `account_usage.total_tokens` 已从协议解析贯通到 SQLite 写入、管理端列表和汇总。
- 平台层 schema 测试已覆盖关键列、唯一索引和外键。
### 3. 账号唯一身份需要数据库兜底
改造前只有普通身份索引:
```sql
create index on accounts(account_id, user_id)
where account_id is not null;
```
这是普通索引,不是唯一索引。它能加速查找,但不能阻止重复。
真实问题已经出现:同一个上游账号身份导入后出现两条本地记录,而不是更新。
本轮已落地:
```sql
create unique index if not exists ux_accounts_chatgpt_identity
on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, ''))
where chatgpt_account_id is not null;
```
本次没有旧线上库,不做重复数据兼容迁移。如果本地 `.runtime` 已有旧表,直接重建运行库。
导入适配器也必须遵守同一套身份优先级:
1. 优先用 `chatgpt_account_id + chatgpt_user_id` 更新。
2. 其次用导入文件中的稳定本地 `id` 更新。
3. 仅邮箱不可作为唯一身份,只能作为展示或弱匹配辅助。
## 逐表审计
### `admin_users`
改造前字段:
```text
id, password_hash, created_at, updated_at
```
判断:职责清晰。
建议:
- 保持当前结构即可。
- 如果后续支持多管理员,可以补 `is_enabled`、`last_login_at`。
- `id` 当前如果承载用户名,需要明确它就是登录名;如果未来支持独立用户 ID,应新增 `username` 并迁移。
### `admin_sessions`
当前字段:
```text
id, user_id, expires_at, created_at
```
判断:职责清晰。
建议:
- `user_id references admin_users(id) on delete cascade` 是正确方向。
- 可选补 `last_seen_at`,但不是当前必须项。
### `client_api_keys`
当前字段:
```text
id, name, prefix, key_hash, label, enabled, created_at, last_used_at
```
问题:
- `name` 和 `label` 语义重叠。一个面向机器标识,一个面向展示标签时可以共存,但当前文档和字段名没有说明。
- `prefix` 只有 enabled 条件索引,没有唯一约束。
- `key_hash` 没有唯一约束。
- 缺 `updated_at`,管理端修改名称、禁用 key 后无法统一排序或审计。
建议:
- 如果只保留一个展示名,保留 `name`,删除或废弃 `label`。
- 如果两个都保留,定义清楚:
- `name`:用户可见名称,必填。
- `label`:短标签或分组,可选。
- 添加:
```sql
create unique index if not exists ux_client_api_keys_key_hash
on client_api_keys(key_hash);
```
- `enabled` 可后续迁移为 `is_enabled`。
- 补 `updated_at`、`revoked_at` 会更完整。
### `accounts`
当前字段职责混合:
```text
id
email, chatgpt_account_id, chatgpt_user_id, label, plan_type
access_token_cipher, refresh_token_cipher, access_token_expires_at, next_refresh_at
status
quota_json, quota_fetched_at, quota_limit_reached, quota_verify_required, quota_cooldown_until
cloudflare_cooldown_until
added_at, updated_at
```
已处理问题:
- 上游身份物理列已改为 `chatgpt_account_id` / `chatgpt_user_id`。
- 数据库已增加 `ux_accounts_chatgpt_identity` 唯一索引,防止同一上游身份重复导入。
- 仓储 SQL 用 alias 继续向当前 Rust 领域结构返回 `account_id` / `user_id`,后续再做 Rust 字段大改名。
仍然存在的问题:
- `added_at` 和其它表的 `created_at` 不统一。
- token、刷新调度、配额、Cloudflare 恢复状态都塞在账号主表里。
- `status` 同时表达可服务状态、刷新状态、终态禁用状态,需要明确状态机。
建议命名:
| 当前字段 | 建议字段 | 说明 |
| --- | --- | --- |
| `account_id` | `chatgpt_account_id` | 对应请求头 `chatgpt-account-id` 和上游账号身份 |
| `user_id` | `chatgpt_user_id` | 上游用户身份 |
| `added_at` | `created_at` | 与全库统一 |
| `access_token_cipher` | `access_token_ciphertext` | 后续大迁移再统一 |
| `refresh_token_cipher` | `refresh_token_ciphertext` | 后续大迁移再统一 |
| `quota_verify_required` | `requires_quota_verification` | 布尔语义更自然 |
本轮方案:
- 不拆表,先把数据库物理列名和唯一约束改干净。
- Rust 领域结构暂时保留 `account_id` / `user_id`,仓储层用 SQL alias 做边界转换;这不是旧库兼容代码,而是避免本轮把本地外键 `account_id` 和上游身份重命名混在一起造成无意义 churn。
长期目标拆分:
```text
accounts
id
email
label
plan_type
status
chatgpt_account_id
chatgpt_user_id
created_at
updated_at
account_tokens
account_id
access_token_ciphertext
refresh_token_ciphertext
access_token_expires_at
next_refresh_at
token_updated_at
account_quota_state
account_id
quota_json
quota_fetched_at
quota_limit_reached
requires_quota_verification
quota_cooldown_until
updated_at
account_recovery_state
account_id
cloudflare_cooldown_until
updated_at
```
不是所有拆分都要一次做。优先级是:
1. 先加唯一身份约束,解决重复导入。
2. 再补 `created_at` 命名兼容。
3. 最后考虑 token/quota/recovery 拆表。
状态机建议:
| 状态 | 语义 |
| --- | --- |
| `active` | access token 当前可用于请求 |
| `expired` | access token 不可用,但 refresh token 可能还能自救 |
| `refreshing` | 临时刷新中,必须有租约或短期超时兜底 |
| `quota_exhausted` | 当前配额不可用,但不是账号封禁 |
| `disabled` | 本地确认不可调度,例如 refresh token 永久失效 |
| `banned` | 上游封禁或账号停用,不可再参与刷新和请求 |
关键约束:
- `disabled` / `banned` 启动时不应重新进入刷新调度。
- `expired` 是否参与恢复,必须同时看 `refresh_token_ciphertext` 和 `next_refresh_at`。
- `next_refresh_at` 到达前,启动扫描和后台扫描都不应刷新。
### `account_refresh_leases`
当前字段:
```text
account_id, owner, expires_at, updated_at
```
判断:职责清晰。
建议:
- `account_id` 在这张表里表示本地账号外键,命名正确。
- `owner` 可以更名为 `lease_owner`,但不是必须。
- 保持 `account_id primary key references accounts(id) on delete cascade`。
### `account_usage`
当前 schema.sql 字段:
```text
account_id
request_count, empty_response_count
input_tokens, output_tokens, cached_tokens
reasoning_tokens, total_tokens
image_input_tokens, image_output_tokens
image_request_count, image_request_failed_count
window_*
window_started_at, window_reset_at, limit_window_seconds
last_used_at
```
问题:
- 累计用量和窗口用量在同一行,写入路径集中,字段数量会继续膨胀。
- `total_tokens` 如果只是 `input_tokens + output_tokens`,就是冗余;如果保存上游原始 `total_tokens`,必须在字段注释和代码里明确。
- 图片用量、reasoning 用量、窗口用量混在一张表,后续每加一个计量维度都会扩字段。
本轮处理:
- `reasoning_tokens`、`total_tokens` 已写入 `schema.sql`。
- `TokenUsage` 已解析 `output_tokens_details.reasoning_tokens` 和上游 `total_tokens`。
- `AccountUsageDelta`、SQLite upsert、管理端 usage list/summary 已全部贯通。
- 明确定义:
- `input_tokens`:上游 usage 输入 token。
- `output_tokens`:上游 usage 输出 token。
- `cached_tokens`:输入侧缓存 token。
- `reasoning_tokens`:输出 token details 中的 reasoning token。
- `total_tokens`:上游返回的原始 total token;缺失时才用输入加输出兜底。
- 窗口字段继续保留,但只作为当前 rate-limit 窗口快照。
长期目标:
> 优先级说明:拆分 `account_usage_totals` / `account_usage_windows` 是 P2 长期清洁项,不进入本轮 P0/P1。本轮已解决 schema drift 和字段定义不清的问题。只有当窗口统计、历史统计、管理端聚合继续扩张时,才值得拆表。
```text
account_usage_totals
account_id
request_count
empty_response_count
input_tokens
output_tokens
cached_tokens
reasoning_tokens
total_tokens
image_input_tokens
image_output_tokens
image_request_count
image_request_failed_count
last_used_at
account_usage_windows
account_id
window_started_at
window_reset_at
limit_window_seconds
request_count
input_tokens
output_tokens
cached_tokens
image_input_tokens
image_output_tokens
image_request_count
image_request_failed_count
updated_at
```
这样累计统计和限流窗口可以独立重置、独立迁移。
### `account_cookies`
当前字段:
```text
id, account_id, domain, name, value_cipher, path, expires_at, updated_at
unique(account_id, domain, name, path)
```
判断:整体合理。
问题:
- `id` 对数据库唯一性不是必须,因为自然键已经是 `(account_id, domain, name, path)`。
- `value_cipher` 建议后续统一为 `value_ciphertext`。
- `expires_at` 可能来自 RFC2822 cookie 日期,也可能来自 RFC3339 `max-age` 计算值。代码读取时可解析两种格式,但清理 SQL 使用字符串比较,若混合格式会有边界风险。
建议:
- 如果管理端不需要按 cookie 单条 ID 操作,可以考虑去掉 `id`,以自然键为主键。
- 如果保留 `id`,当前设计也可接受。
- 将入库 `expires_at` 统一规范化为 RFC3339 UTC,再做 SQL 清理。
### `fingerprints`
当前字段:
```text
id
app_version, build_number
platform, arch, chromium_version
user_agent_template
source
created_at
```
改造前代码行为:
- `Fingerprint::default_codex_desktop()` 在 core 里硬编码默认值。
- 旧仓储只从自动更新快照里读版本、平台、架构、Chromium 和 UA 模板。
- `originator`、`default_headers`、`header_order` 仍由代码默认值补齐。
- 旧自动更新写入会覆盖 `created_at`,这个字段实际承担了 `updated_at` 的语义。
- 改造前 `server/main.rs` 构造 `AppState` 时没有加载数据库指纹,运行时仍可能使用硬编码默认指纹。
结论:改造前表不满足“指纹默认来自 config.yaml,持久化到数据库,后续都从数据库拿”。
本轮已按“当前槽位 + 可选历史”的语义落地,不再把运行时当前快照和历史记录混在同一套 `source + created_at` 查询里。
### 当前槽位表
```text
request_fingerprints
id
originator
app_version
build_number
platform
arch
chromium_version
user_agent_template
default_headers_json
header_order_json
source
created_at
updated_at
```
本轮保留现有表名 `fingerprints`,直接在该表上补齐同样字段。
字段定义:
| 字段 | 语义 |
| --- | --- |
| `id` | 指纹槽位。运行时当前槽位固定使用 `current` |
| `originator` | `originator` 请求头值,例如 `Codex Desktop` |
| `app_version` | Codex Desktop 应用版本 |
| `build_number` | Codex Desktop 构建号 |
| `platform` | UA 模板中的平台字段,例如 `darwin` |
| `arch` | UA 模板中的架构字段,例如 `arm64` |
| `chromium_version` | `sec-ch-ua` 的 Chromium 主版本 |
| `user_agent_template` | UA 模板 |
| `default_headers_json` | 默认请求头集合,建议保存为有序数组 |
| `header_order_json` | 最终请求头排序优先级 |
| `source` | 最近一次写入来源:`config_seed`、`auto_update`、`manual` |
| `created_at` | 首次创建时间 |
| `updated_at` | 最近更新时间 |
槽位规则:
- `config_seed` 首次写入时使用 `id = 'current'`。
- `auto_update` 更新时覆盖同一行 `id = 'current'`,只更新远端更新源明确提供的字段,例如 `app_version`、`build_number`、`chromium_version`、`source`、`updated_at`。
- `manual` 管理端调整也覆盖同一行 `id = 'current'`。
- `load_current()` 固定执行 `select ... from fingerprints where id = 'current'`,不按 `source` 或 `created_at desc` 猜测当前指纹。
- 项目尚未上线,本轮不保留旧自动更新槽位的兼容路径;实现时直接使用 `CURRENT_FINGERPRINT_ID = 'current'`。
- 本地开发库里如果已有旧自动更新行,可以直接清空 `fingerprints` 或重建 `.runtime` 数据库,不写生产兼容迁移。
- `source` 只是“最近一次写入来源”,不是槽位选择条件。
这样可以回答三个关键问题:
- 首次配置种子写哪一行:写 `id = 'current'`。
- 自动更新写哪一行:覆盖 `id = 'current'`。
- 运行时读哪一行:只读 `id = 'current'`。
### 可选历史表
如果需要保留每次 appcast 检查或手动变更历史,应单独建追加表,不建议继续把历史记录塞进当前槽位表:
```text
fingerprint_update_history
id
current_fingerprint_id
app_version
build_number
chromium_version
source
manifest_json
created_at
```
历史表规则:
- `id` 使用 UUID。
- 每次检查到更新可以 insert 一行历史。
- 历史只用于审计和排查,不参与运行时 `load_current()`。
- `insert_update()` 已改写到历史表,不写入当前槽位表。
不推荐的方案:
- 不推荐在同一张 `fingerprints` 表里一会儿用固定 ID 表示当前,一会儿用 UUID 表示历史,再靠 `source = 'runtime' order by created_at desc` 推断当前。这会让 `created_at` / `updated_at` / `source` 三个字段语义互相污染。
`default_headers_json` 建议格式:
```json
[
{"name": "Accept-Encoding", "value": "gzip, deflate, br, zstd"},
{"name": "Accept-Language", "value": "en-US,en;q=0.9"},
{"name": "sec-ch-ua-mobile", "value": "?0"},
{"name": "sec-ch-ua-platform", "value": "\"macOS\""}
]
```
不用单独建 `fingerprint_headers` 表的理由:
- 请求头集合是小型原子配置。
- 查询不会按单个 header 聚合。
- 保持 JSON 原子读写更简单,也更符合“运行时指纹快照”的语义。
如果后续要做管理端逐项编辑、审计每个 header 的历史,再考虑拆表。
启动流程建议:
1. 读取 `config.yaml` 的 `fingerprint` 默认配置。
2. 连接 SQLite 并初始化当前 schema。由于尚未上线,本轮可以直接更新 `schema.sql` 并重建本地运行库。
3. 调用 `fingerprints.ensure_current_seed(config_fingerprint)`。
4. 如果 `id = 'current'` 不存在,插入 config 默认值,`source = config_seed`。
5. 如果 `id = 'current'` 已存在,不用 config 覆盖数据库。
6. 从 `id = 'current'` 读取完整指纹,构造 `AppState`。
7. 后台自动更新只覆盖 `id = 'current'` 中明确由更新源提供的字段。
8. 请求链路和诊断接口只读运行时加载的数据库指纹。
重要约束:
- `config.yaml` 是首次默认值,不是持续覆盖源。
- 数据库是启动后的事实来源。
- 自动更新不得把 `originator`、`platform`、`arch`、`default_headers_json`、`header_order_json` 重置成代码默认值。
- core 层不应依赖 platform 配置类型。转换应在 runtime 或 adapters 层完成,避免依赖倒置。
### `event_logs`
当前字段:
```text
id, request_id, kind, level, account_id, route, model, status_code,
transport, attempt_index, upstream_status_code, failure_class, response_id, upstream_request_id,
latency_ms, message, metadata_json, created_at
```
判断:基础可用,常用排查字段已从 `metadata_json` 提升为顶层列。
仍需注意:
- `attempt_index` 只有请求重试上下文明示时才会有值;不会凭空生成。
- `upstream_request_id` 需要上游响应头或 metadata 提供;没有来源时保持空。
已补充高频查询字段:
```text
transport
attempt_index
upstream_status_code
failure_class
response_id
upstream_request_id
```
字段来源建议:
| 字段 | 数据来源 |
| --- | --- |
| `transport` | runtime 请求分发结果或上游客户端返回的 transport 标记,例如 `http_sse`、`websocket`、`websocket_fallback_http_sse` |
| `attempt_index` | 账号请求重试循环里的第几次尝试,从 runtime dispatch / retry context 递增产生 |
| `upstream_status_code` | `CodexClientError::Upstream` 或 HTTP response status;成功响应可为空或等于最终 `status_code` |
| `failure_class` | runtime 现有错误分类结果,例如 auth、quota、cloudflare、rate_limit、transport、empty_response、history_recovery 等 |
| `response_id` | 上游 Responses / Chat / WebSocket completed 事件里的 response id;失败前没有 response id 时为空 |
| `upstream_request_id` | 上游响应头中能拿到的 request id,例如 `x-request-id`、`cf-ray` 或 OpenAI 相关 request id;没有则为空 |
实施原则:
- 字段从现有请求上下文、上游响应头、错误类型中提取,不新增模拟值。
- 同一个用户请求的每次账号尝试都应能用同一个 `request_id` 串起来。
- `metadata_json` 继续保存完整上下文,但管理端常用筛选项必须升为顶层列。
保留 `metadata_json`,但它只做补充上下文,不承担主要筛选维度。
### `model_plan_snapshots`
当前字段:
```text
plan_type, models_json, fetched_at
```
判断:简单合理。
建议:
- `plan_type` 作为自然主键可保留。
- 如果后续需要区分来源,可加 `source`,但当前项目只做 OpenAI/Codex,不需要泛化 provider 字段。
### `session_affinities`
当前字段:
```text
response_id
account_id
conversation_id
turn_state
instructions_hash
input_tokens
function_call_ids_json
variant_hash
expires_at
created_at
```
判断:职责清晰。
问题:
- `account_id` 没有在 schema 中声明为 `references accounts(id) on delete cascade`。
- `function_call_ids_json` 的默认值在运行库和 `schema.sql` 表达不一致。
- `turn_state` 如果包含上游续链敏感状态,后续需要确认是否要加密或至少严格控制日志输出。
建议:
- 增加账号外键,账号删除时 cascade 删除 affinity。
- 统一 `function_call_ids_json default '[]'`。
- `conversation_id`、`variant_hash` 可以保留当前命名,它们表达的是本项目内部续链匹配键。
## 指纹配置和数据库持久化方案
用户要求:
- 本次指纹默认值写在 `config.yaml`。
- 启动时持久化到数据库。
- 后续更新只更新数据库。
- 后续运行都从数据库拿。
建议配置结构:
```yaml
fingerprint:
originator: Codex Desktop
app_version: 26.519.81530
build_number: "3178"
platform: darwin
arch: arm64
chromium_version: "146"
user_agent_template: "Codex Desktop/{version} ({platform}; {arch})"
default_headers:
- name: Accept-Encoding
value: gzip, deflate, br, zstd
- name: Accept-Language
value: en-US,en;q=0.9
- name: sec-ch-ua-mobile
value: "?0"
- name: sec-ch-ua-platform
value: "\"macOS\""
- name: sec-fetch-site
value: same-origin
- name: sec-fetch-mode
value: cors
- name: sec-fetch-dest
value: empty
header_order:
- authorization
- chatgpt-account-id
- originator
- x-openai-internal-codex-residency
- x-client-request-id
- x-codex-installation-id
- x-codex-turn-state
- openai-beta
- user-agent
- sec-ch-ua
- sec-ch-ua-mobile
- sec-ch-ua-platform
- accept-encoding
- accept-language
- sec-fetch-site
- sec-fetch-mode
- sec-fetch-dest
- content-type
- accept
- cookie
```
说明:
- `default_headers` 用数组,不用 map,是为了保留顺序和大小写。
- `header_order` 统一使用小写 header name,因为排序匹配通常按大小写无关处理。
- 配置文件中不放任何 token、cookie 或账号身份。
本轮数据库结构调整:
项目尚未上线,不需要为旧 `fingerprints` 表写兼容迁移。直接更新 `schema.sql` 的最终结构,并让新库包含完整字段即可。
建议最终字段约束:
```text
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
```
本地已有 `.runtime` 数据库时,处理方式是删除运行库、重建运行库,或只清空并重建 `fingerprints` 表;不写旧槽位到当前槽位的兼容升级逻辑。
已落地仓储 API:
```text
CURRENT_FINGERPRINT_ID = "current"
ensure_current_seed(default_fingerprint) -> Fingerprint
load_current() -> Option<Fingerprint>
update_current_version(app_version, build_number, chromium_version) -> ()
insert_update_history(update) -> () # 可选;写 fingerprint_update_history,不参与运行时读取
```
旧自动更新槽位相关 API 已从运行时代码移除。运行时需要的是“当前指纹”,不是“最新自动更新历史”。
## 建议目标结构
如果允许做一次较完整的 schema v2,目标结构如下。
```text
admin_users
admin_sessions
client_api_keys
accounts
account_tokens
account_quota_state
account_recovery_state
account_refresh_leases
account_usage_totals
account_usage_windows
account_cookies
request_fingerprints
event_logs
model_plan_snapshots
session_affinities
schema_migrations
```
本轮已实现这些:
1. 增加 `schema_migrations` 作为未来上线后的迁移纪律;本轮未上线,本地库可以直接重建。
2. 对齐 `schema.sql` 和运行库 drift。
3. 补全 `fingerprints` 字段和 `id = 'current'` 当前槽位加载链路。
4. 解决 `accounts` 上游身份唯一约束和重复导入。
5. 数据库物理列改为 `chatgpt_account_id` / `chatgpt_user_id`;Rust 领域结构暂时通过仓储 alias 过渡。
6. 补齐 `event_logs` 顶层排查字段。
## 实施优先级
### P0:必须先做
- 已完成:新增迁移账本表,停止继续堆 ad hoc 迁移;本轮本地库可直接重建,不写旧结构兼容升级。
- 已完成:直接对齐 `schema.sql` 与运行库 drift。
- 已完成:补全 `fingerprints` 表,使 `id = 'current'` 的完整指纹可以从数据库恢复。
- 已完成:`config.yaml` 增加 `fingerprint` 默认种子。
- 已完成:启动时 seed 数据库,运行时从数据库加载指纹。
- 已完成:账号导入按上游身份更新,数据库增加唯一约束。
### P1:命名和职责边界
- 已完成:数据库物理列使用 `chatgpt_account_id`、`chatgpt_user_id`。
- 已完成:`event_logs` 补结构化排查字段。
- 待做:逐步把 `added_at` 迁移为 `created_at`。
- 待做:明确 `status` 状态机和刷新调度不变量。
- 待做:如后续确实需要,再把 Rust 领域结构字段也改为 `chatgpt_account_id` / `chatgpt_user_id`。
### P2:拆表和长期清洁
- 从 `accounts` 拆出 `account_tokens`、`account_quota_state`、`account_recovery_state`。
- 从 `account_usage` 拆出 totals / windows。
- 统一 `_ciphertext` 命名。
- 评估 `account_cookies.id` 是否保留。
## 不建议做
- 不引入 `provider`、`provider_id`、`proxy_api_key` 之类泛化字段。当前项目只做 OpenAI/Codex。
- 不把配置文件当作每次启动的覆盖源。配置只是首次种子,数据库才是运行时事实来源。
- 不把请求头字段继续硬编码在 core 里再由数据库补一部分。要么完整默认,要么完整落库。
- 不先加唯一索引再清理历史重复数据。否则迁移会直接失败。
- 不把真实 token、cookie、账号邮箱写进文档或测试 fixture。
## 下一步建议
下一步不再补旧库兼容迁移,优先继续做真实链路验证和状态机收敛:
1. 继续用真实账号跑请求链路,验证刷新调度、禁用账号、封禁账号、配额冷却和 websocket/http_sse fallback。
2. 明确 `status` 状态机,把 disabled/banned/expired/refreshing 的启动扫描和后台调度不变量固化。
3. 如管理端长期需要窗口级 reasoning/total,再评估是否把 `account_usage` 拆成 totals/windows。