Files
new-api-key-usage/docs/design/实施计划.md
T
skycurtain e1c6d2153e feat(server): 同步引擎与 seed(阶段 4)
- 引擎:tick(5 分钟 + 防重入 + 启动即跑)· 单轮串行遍历 active key · 日志整批单事务入库(ON CONFLICT DO NOTHING + log_synced_at 取请求发起时刻)· 额度采集(上游字段全组,独立 try)· QPU 采样(基线 / 值变化才追加)· 每轮摘要与超期 warning
- 401 状态机:N=3 摘除(disabled_source=auto_401);403/500/网络不计数;解密失败跳过不计数
- 纯逻辑抽层:policy(计数 / 合并判定)· dedupe(去重键与内容指纹)· log-row(日志映射,与 seed 共用)
- seed 脚本(幂等):假 key + 984 条日志 + QPU 基线 + 示例购买
- 实测:401 端到端 1→2→3 摘除且摘除后不再轮询;幂等重跑新增 0 条
- 实证发现:错误行 request_id 不唯一(8 组 ×3)——记入实施计划「待定问题 #5」与 upstream 文档,待作者裁决
- AGENTS.md / 根 package.json 增 seed 命令
2026-09-15 00:46:46 +08:00

97 lines
12 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.
# 实施计划
> 开发过程的推进方案:**loop 模式**(每轮一个可验证批次)+ 八个阶段的验收标准。任务口径全部指向 `docs/design/` 的设计文档;本文件只回答「先做什么、怎样算做完、下一步做什么」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
| 当前进度 | 阶段 0–4 已完成;下一批:**阶段 5**(查看面数据面:`logs/query` + `summary` + 折算链) |
---
## 1. loop 机制(每轮固定动作)
1. **取任务**:计划表(§3)中当前批次的第一项
2. **实现**:以设计文档为准,不自由发挥
3. **验证**:跑该批次的验收命令(type-check / 单测 / 实测 curl / 浏览器走查)
4. **记录**:勾选进度表(日期 + 验证方式)
5. **提交**:一个逻辑单元一个 commit(**作者已授权 loop 内直接提交**,dev 分支;`git push` 永远由作者手工)
6. **报告**:一句话——做了什么 / 验证结果 / 下一项
7. **设计空白的处置(2026-09-14 定)**:作者可能在开发期缺席——遇到设计文档未覆盖的问题,**取最保守默认继续推进**(不破坏数据、不做不可逆操作、后续可改),写入下方「实现假设」节并在报告中显著标注,供作者事后补审。**不停摆。**
- 例外三条——与「后端单实例」「金额口径」「凭证安全」冲突时:跳过该子任务并标注,不自行决定
### 实现假设(loop 中累积,供作者补审)
| # | 假设 | 依据 / 可逆性 |
|---|------|--------------|
| 1 | server 侧单测用 `bun test`(内建);前端仍用 Vitest | 零依赖;可换(已落地:`bun test` 跑 `src/**/*.test.ts`) |
| 2 | 环境变量:全仓单一根 `.env`;服务与 drizzle-kit 经 `src/lib/load-env.ts` 显式加载(Bun 只自动加载 cwd 的 `.env`,不会向上找) | 可改;已落地 |
| 3 | 上游请求超时 30 秒;失败即本轮失败(不单轮重试) | 常量,可调 |
| 4 | key 格式校验仅「非空 + trim」(不强制 `sk-` 前缀,格式真假交上游判定) | 更宽松,避免误拒 |
| 5 | 去重指纹 = `sha256(key_id|created_at|model_name|quota|prompt_tokens|completion_tokens)` | 表设计 §3.1 已给字段清单 |
| 6 | 购买期间匹配:`subscription_id` 优先;同 ID 多条按 `[period_start, period_end)`;无期间记录仅在「同 ID 唯一」时兜底 | 边界情况,可调 |
| 7 | 登记成功后立即异步跑一次该 key 的单轮同步(不等 tick) | 契约 §2.2 已定「立即异步补一轮」 |
| 8 | 前端提交时间用 `toISOString()`(`Z` 形式) | 契约 约定 9 允许 |
| 9 | drizzle-kit 的连库驱动用 `postgres`(postgres.js,纯 JS)——仅 CLI 需要;运行时仍走 Bun 内建 SQL(`drizzle-orm/bun-sql`) | devDependency,可换 `pg` |
| 10 | 上游客户端以**接口 + 可注入实现**组织(真实 HTTP / 样例文件两种实现),引擎与路由依赖接口 | 见 §6;可改 |
| 11 | 上游实现的注入开关:env `UPSTREAM_SOURCE`(`http` 默认 / `fixture` 读仓库根快照);fixture 的错误分支由 key 内容约定触发(含 `reject` → 401、`forbidden` → 403、`unavailable` → 网络故障) | 阶段 2 落地;仅开发期使用,生产保持默认 `http` |
| 12 | 登记 / 启用验证成功即写 `usage_synced_at` + 上游字段全组(该次 `GET /api/usage/token` 本身即一次额度采集);`log_synced_at` 仍只由日志同步写入 | 契约 §2.2 已定登记响应 `log_synced_at: null`;额度侧取「数据已入库就不撒谎」口径 |
| 13 | 失败分类:401 / 403 → `key_rejected`(上游明确拒绝);其余非 2xx / 网络故障 / 超时 / 响应结构异常 → `upstream_unavailable` | 上游事实:403 = 账号封禁;可调 |
| 14 | `PATCH` 幂等分支不重复调上游验证(直接返回当前行);启用时凭证解密失败 → `500 internal`(提示需重新登记) | 与「解密失败不摘除、需人工重新登记」一致;可调 |
| 15 | 一轮内「日志 + 额度」两个调用的合并判定:**任一 401 → 本轮计 1 次 401**(401 优先);否则任一成功 → 计数清零;全失败 → 计数不动 | 设计(§3.4)未覆盖「一成功一 401」的冲突情形;401 优先 + N=3 阈值使单次误报无害 |
| 16 | seed 脚本用假 key 常量 `sk-seed-demo-key-0001`(`SEED_KEY` 可覆盖);`apps/server/scripts/seed.ts` 幂等可重跑 | 实施计划 §6 已定「假 key 常量」;仅开发期 |
## 2. 前提(2026-09-14 定)
- **开发库**:Docker 本地 PostgreSQL(`docker-compose.yml` 已落地;连接串走 `.env`,不入库)——四张表已建
- **联调策略**:**以样例数据为主**(见 §6)——作者未开测试 key;`NEWAPI_BASE` 可达性已验证(HTTP 200 / 5.2KB / 2.0s;`quota_per_unit = 500000`、`quota_display_type = USD`、`usd_exchange_rate = 6.8`,与文档一致)。真实登记 / 采集链路标「待作者补验」,**不阻塞**其他阶段
- **提交**:loop 内直接提交已授权;push 由作者手工(仓库有两个远端)
## 3. 阶段与验收标准
| 批次 | 内容 | 验收标准 | 状态 |
|------|------|---------|------|
| 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 |
| 1 | **数据层与密钥**:crypto(HKDF 派生 / HMAC-SHA256 / AES-256-GCM)· 时间工具(+08:00 序列化、UTC 秒转换)· 建表 | 单测:哈希确定性 + 加解密往返;`db:push` 后四张表在库 | ✅ 2026-09-14(15 项单测绿;四表在库——`api_keys` 17 列 / `usage_logs` 23 列 / 索引 8 个) |
| 2 | **上游客户端 + key 管理**(`GET` / `POST` / `PATCH` 三端点)· 客户端接口化(§6) | curl 实测:登记 → 清单 → 停用/启用(**注入样例实现**:登记成功路径用样例响应驱动);三类错误码分支正确(`key_rejected` / `already_registered` / `upstream_unavailable`) | ✅ 2026-09-15(28 项单测绿;curl 16 步——登记 201 / 重复 409 / `key_rejected` 400 / `upstream_unavailable` 502 / 停用与幂等 / 启用清 `consecutive_401s` / `not_found` 404 / 非法参数 400;顺带修正「`load-env` 未被服务端接线」) |
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ✅ 2026-09-15(33 项单测绿;curl 16 步——登记 201 / 缺必填与非法值与期间顺序 400 / 部分更新与显式清空 / 排序 / 404 / 物理删除 / 观测聚合 `matched_purchase_id` 判定正确) |
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3)· **seed 脚本**(§6) | 库中出现数据(seed 或真实采集);`[sync]` 日志符合引擎设计;401 状态机单测(连续 401 → 摘除且 `disabled_source = auto_401`) | ✅ 2026-09-15(45 项单测绿;seed 灌入 984 条日志 + QPU 基线 + 示例购买;引擎实测——幂等重跑新增 0 条、摘要日志符合设计、**401 端到端:1→2→3 次摘除且摘除后不再轮询**) |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ☐ |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
## 4. 待定问题
| # | 问题 | 状态 |
|---|------|------|
| 1 | `amount_rmb` 在「日志无归属购买记录」时取什么值 | **已决(2026-09-14)**:取 `null`(展示「—」),汇总增 `unpriced_count`——见 API 契约 §2.1 |
| 2 | 充值(`wallet`)路径的折算近似(设计文档标「后议」) | 先按「未折算」处理(`null` / 计入 `unpriced_count`);池子加权平均待后议 |
| 3 | `dedupe_key` 内容指纹的拼接细节 | **已决**:见「实现假设」#5(表设计 §3.1 已给字段清单) |
| 4 | 上游对同账号请求的态度(限流 / 并发被拒时的表现) | 遇错即按「其他故障」处理(不计数、不摘除),如实记录日志 |
| 5 | **`request_id` 在错误行上不唯一**(2026-09-15 实测样板:8 个 `request_id` 各对应 3 条 `type=5` 错误记录,共 24 行)——当前按表设计原样实现 `dedupe_key = key_id:request_id`,同 `request_id` 的多条错误行**合并为 1 条** | **待作者裁决**:影响 = 失败请求次数(`error_count`)少算;候选方案 = 错误行去重键后缀内容指纹 / 序号(`usage_logs` 表设计 §3.1 需相应修订) |
## 5. 风险
- 真实上游联调依赖网络可达(已实测 200);真实 key 未开——以样例数据替代(§6),真实链路标「待作者补验」
- 折算链(阶段 5)依赖已登记的购买记录——无记录时的行为已定(`null` + `unpriced_count`)
- **后端必须单实例**(同步引擎前提):部署文档(阶段 8)必须写明;本地开发也不要起两个后端
## 6. 离线开发与样例数据(2026-09-14 定)
**结论:不需要真实 key 也能跑完全部阶段。** 仓库根目录有三个**实测响应快照**——未入库(`.gitignore` 用 `/api*.json` 排除,**绝不提交**),供开发与验证使用:
| 文件 | 内容 | 用途 |
|------|------|------|
| `api.log.token.json` | `/api/log/token` 的 1000 条响应快照(≈3.2 天、含 `type=2` 与 `type=5`) | 引擎解析 / 去重 / 入库的 fixture;**seed 数据源** |
| `api.log.token.other.json` | 单条日志 `other` 字段样本(26 键:`subscription_*` 八件套、`cache_tokens`、倍率等) | 解析与展示字段核对 |
| `api.status.json` | `/api/status` 响应(含 `quota_per_unit = 500000` / `quota_display_type = USD` / `usd_exchange_rate = 6.8`) | QPU 采样逻辑的 fixture |
**开发策略**:
1. **上游客户端接口化(假设 #10)**:定义接口(如 `UpstreamClient`:`fetchTokenUsage` / `fetchLogs` / `fetchStatus`),两套实现——真实 HTTP 实现 + **样例文件实现**(从上述 JSON 读响应);引擎与路由依赖接口,联调时替换
2. **seed 脚本**(阶段 4 落地,`apps/server/scripts/seed.ts`):从样例灌入——一个占位 `api_keys` 行(**假 key 常量**,其哈希/密文走真实 crypto 逻辑,便于用固定测试 key 查询命中)+ 1000 条 `usage_logs` + `qpu_history` 基线 + 一条示例 `purchases`(用于验证折算链)——使阶段 5/6/7 的开发与验证有真实形状的数据
3. **真实链路验证清单**(标「待作者补验」):登记成功路径(真实 `/api/usage/token` 验证)、真实采集轮次、真实 401 摘除——不阻塞其他阶段,做完在报告中列出待验项