feat(contract): amount_rmb 支持 null + 汇总增 unpriced_count;实施计划补「实现假设」机制

- API 契约:无归属购买的明细行 amount_rmb = null(展示「—」);汇总增 unpriced_count(未折算条数)
- shared 类型同步(amount_rmb: number | null · unpriced_count: number)
- 实施计划:loop 停车规则改为「自主推进 + 假设记录」(作者开发期可能缺席);新增 8 条实现假设;待定问题 #1/#3 标记已决
This commit is contained in:
2026-09-15 00:12:19 +08:00
parent dcf7a31c09
commit 1295667c94
3 changed files with 41 additions and 24 deletions
+6 -5
View File
@@ -119,9 +119,9 @@
- 恒定 / 空值字段(`user_id` / `username` / `channel` / `channel_name` / `ip` / `group_name` / `upstream_request_id`)照返——展示与否是交互层决策 - 恒定 / 空值字段(`user_id` / `username` / `channel` / `channel_name` / `ip` / `group_name` / `upstream_request_id`)照返——展示与否是交互层决策
- 两个覆盖/计算字段: - 两个覆盖/计算字段:
- `token_name`——**当前名**(join `api_keys`),不用日志里的快照名(同一个 key 的每行显示同一个名字;快照列仍在库中保真) - `token_name`——**当前名**(join `api_keys`),不用日志里的快照名(同一个 key 的每行显示同一个名字;快照列仍在库中保真)
- `amount_rmb`——该行折算实付人民币(`quota ÷ 购买时刻 QPU × 购买汇率`;错误行 `quota = 0` → 0) - `amount_rmb`——该行折算实付人民币(`quota ÷ 购买时刻 QPU × 购买汇率`;错误行 `quota = 0` → 0);**匹配不到归属购买时为 `null`**(未登记购买 / 充值来源)——展示层显示「—」,绝不退化为额度美元(2026-09-14 定)
**汇总响应字段(2026-09-13 定)** **汇总响应字段(2026-09-13 定;2026-09-14 增 `unpriced_count`)**
| 字段 | 含义 | | 字段 | 含义 |
|------|------| |------|------|
@@ -129,7 +129,8 @@
| `consume_count` / `error_count` | 按 `type` 拆分(2 消费 / 5 错误) | | `consume_count` / `error_count` | 按 `type` 拆分(2 消费 / 5 错误) |
| `prompt_tokens` / `completion_tokens` / `cache_tokens` | token 三项合计(`cache_tokens ⊆ prompt_tokens`,单列可看缓存命中量) | | `prompt_tokens` / `completion_tokens` / `cache_tokens` | token 三项合计(`cache_tokens ⊆ prompt_tokens`,单列可看缓存命中量) |
| `quota` | quota 合计(原值,供核对) | | `quota` | quota 合计(原值,供核对) |
| `amount_rmb` | 折算实付人民币(跨期按购买期间分段累加;非订阅来源的折算规则后议) | | `amount_rmb` | **可折算部分**的实付人民币合计(跨期按购买期间分段累加) |
| `unpriced_count` | 无法折算的行数(`amount_rmb` 为 `null` 的行)——前端据此提示「其中 N 条未登记购买、未计入金额」 |
| `meta` | 同约定 22 | | `meta` | 同约定 22 |
**页面元素 → 数据来源** **页面元素 → 数据来源**
@@ -168,7 +169,7 @@
**响应示例(示意)** **响应示例(示意)**
```jsonc ```jsonc
// 明细 // 明细(amount_rmb 为 null = 匹配不到归属购买:未登记 / 充值来源)
{ {
"items": [ "items": [
{ "id": 8412, "key_id": 3, "user_id": 12, "username": "…", "created_at": "2026-09-11T19:37:12+08:00", { "id": 8412, "key_id": 3, "user_id": 12, "username": "…", "created_at": "2026-09-11T19:37:12+08:00",
@@ -186,7 +187,7 @@
{ {
"request_count": 1873, "consume_count": 1849, "error_count": 24, "request_count": 1873, "consume_count": 1849, "error_count": 24,
"prompt_tokens": 1234567, "completion_tokens": 234567, "cache_tokens": 89012, "prompt_tokens": 1234567, "completion_tokens": 234567, "cache_tokens": 89012,
"quota": 53939847, "amount_rmb": 23.45, "quota": 53939847, "amount_rmb": 23.45, "unpriced_count": 0,
"meta": { "…": "见约定 22" } "meta": { "…": "见约定 22" }
} }
``` ```
+30 -16
View File
@@ -6,7 +6,7 @@
|------|------| |------|------|
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) | | 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) | | 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
| 范围 | 阶段划分、每批验收标准、loop 机制与停车规则;技术细节一律以设计文档为准 | | 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
| 当前进度 | 阶段 0 已完成;下一批:**阶段 1**(数据层与密钥) | | 当前进度 | 阶段 0 已完成;下一批:**阶段 1**(数据层与密钥) |
--- ---
@@ -19,12 +19,26 @@
4. **记录**:勾选进度表(日期 + 验证方式) 4. **记录**:勾选进度表(日期 + 验证方式)
5. **提交**:一个逻辑单元一个 commit(**作者已授权 loop 内直接提交**,dev 分支;`git push` 永远由作者手工) 5. **提交**:一个逻辑单元一个 commit(**作者已授权 loop 内直接提交**,dev 分支;`git push` 永远由作者手工)
6. **报告**:一句话——做了什么 / 验证结果 / 下一项 6. **报告**:一句话——做了什么 / 验证结果 / 下一项
7. **停车规则**:遇设计文档未覆盖的问题 → **停下问作者**,不猜着往下写(已知的待定问题见 §4) 7. **设计空白的处置(2026-09-14 定)**:作者可能在开发期缺席——遇到设计文档未覆盖的问题,**取最保守默认继续推进**(不破坏数据、不做不可逆操作、后续可改),写入下方「实现假设」节并在报告中显著标注,供作者事后补审。**不停摆。**
- 例外三条——与「后端单实例」「金额口径」「凭证安全」冲突时:跳过该子任务并标注,不自行决定
### 实现假设(loop 中累积,供作者补审)
| # | 假设 | 依据 / 可逆性 |
|---|------|--------------|
| 1 | server 侧单测用 `bun test`(内建);前端仍用 Vitest | 零依赖;可换 |
| 2 | 根目录单一 `.env`;server 与 drizzle-kit 脚本显式 `--env-file=../../.env`(Bun 不向上找 .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 允许 |
## 2. 前提(2026-09-14 定) ## 2. 前提(2026-09-14 定)
- **开发库**:Docker 本地 PostgreSQL(`docker-compose.yml` 随阶段 1 落地;连接串走 `.env`,不入库) - **开发库**:Docker 本地 PostgreSQL(`docker-compose.yml` 已落地;连接串走 `.env`,不入库)
- **上游联调**:真实 sk-key(作者提供,放 `.env`;文件不入库)——阶段 2 / 4 依赖它 - **上游联调**:真实 sk-key(作者已填 `.env`)——`NEWAPI_BASE` 实测可达:HTTP 200 / 5.2KB / 2.0s;`quota_per_unit = 500000`、`quota_display_type = USD`、`usd_exchange_rate = 6.8`,与文档一致
- **提交**:loop 内直接提交已授权;push 由作者手工(仓库有两个远端) - **提交**:loop 内直接提交已授权;push 由作者手工(仓库有两个远端)
## 3. 阶段与验收标准 ## 3. 阶段与验收标准
@@ -32,26 +46,26 @@
| 批次 | 内容 | 验收标准 | 状态 | | 批次 | 内容 | 验收标准 | 状态 |
|------|------|---------|------| |------|------|---------|------|
| 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 | | 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 |
| 1 | **数据层与密钥**:crypto(HKDF 派生 / HMAC-SHA256 / AES-256-GCM)· 时间工具(+08:00 序列化、UTC 秒转换)· `docker-compose.yml` · 建表 | 单测:哈希确定性 + 加解密往返;`db:push` 后四张表在库 | ☐ | | 1 | **数据层与密钥**:crypto(HKDF 派生 / HMAC-SHA256 / AES-256-GCM)· 时间工具(+08:00 序列化、UTC 秒转换)· 建表 | 单测:哈希确定性 + 加解密往返;`db:push` 后四张表在库 | ☐ |
| 2 | **上游客户端 + key 管理**(`GET` / `POST` / `PATCH` 三端点) | curl 实测:登记 → 清单 → 停用/启用;三类错误码分支正确(`key_rejected` / `already_registered` / `upstream_unavailable`) | ☐ | | 2 | **上游客户端 + key 管理**(`GET` / `POST` / `PATCH` 三端点) | curl 实测:登记 → 清单 → 停用/启用;三类错误码分支正确(`key_rejected` / `already_registered` / `upstream_unavailable`) | ☐ |
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ☐ | | 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ☐ |
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3) | 库中出现真实数据;`[sync]` 日志符合引擎设计;连续 401 触发摘除且 `disabled_source = auto_401` | ☐ | | 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3) | 库中出现真实数据;`[sync]` 日志符合引擎设计;连续 401 触发摘除且 `disabled_source = auto_401` | ☐ |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加正确 | ☐ | | 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ☐ |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:真实数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败) | ☐ | | 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:真实数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ | | 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ | | 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
## 4. 实现中的待定问题(遇到即停车问作者) ## 4. 待定问题
| # | 问题 | 归属批次 | | # | 问题 | 状态 |
|---|------|---------| |---|------|------|
| 1 | `amount_rmb` 在「日志无归属购买记录」时取什么值(0 / 退化为额度美元 / 前端显示「—」) | 5 | | 1 | `amount_rmb` 在「日志无归属购买记录」时取什么值 | **已决(2026-09-14)**:取 `null`(展示「—」),汇总增 `unpriced_count`——见 API 契约 §2.1 |
| 2 | 充值(`wallet`)路径的折算近似(设计文档标「后议」) | 5(遇到再说) | | 2 | 充值(`wallet`)路径的折算近似(设计文档标「后议」) | 先按「未折算」处理(`null` / 计入 `unpriced_count`);池子加权平均待后议 |
| 3 | `dedupe_key` 内容指纹的拼接细节(`request_id` 为空时) | 4 | | 3 | `dedupe_key` 内容指纹的拼接细节 | **已决**:见「实现假设」#5(表设计 §3.1 已给字段清单) |
| 4 | 上游对同账号请求的态度(限流 / 并发被拒时的表现) | 4(遇到再说) | | 4 | 上游对同账号请求的态度(限流 / 并发被拒时的表现) | 遇错即按「其他故障」处理(不计数、不摘除),如实记录日志 |
## 5. 风险 ## 5. 风险
- 真实上游联调依赖作者提供的 key 与网络可达;不可用时临时以假上游过渡(不改变验收口径) - 真实上游联调依赖网络可达(已实测 200);不可用时临时以假上游过渡(不改变验收口径)
- 折算链(阶段 5)依赖已登记的购买记录——无记录时的退化行为即待定问题 #1 - 折算链(阶段 5)依赖已登记的购买记录——无记录时的行为已定(`null` + `unpriced_count`)
- **后端必须单实例**(同步引擎前提):部署文档(阶段 8)必须写明;本地开发也不要起两个后端 - **后端必须单实例**(同步引擎前提):部署文档(阶段 8)必须写明;本地开发也不要起两个后端
+5 -3
View File
@@ -91,8 +91,8 @@ export interface UsageLogRow {
ip: string ip: string
request_id: string request_id: string
upstream_request_id: string | null upstream_request_id: string | null
/** 该行折算实付人民币(错误行 quota = 0 → 0) */ /** 该行折算实付人民币(错误行 quota = 0 → 0;匹配不到归属购买时为 null——未登记 / 充值来源) */
amount_rmb: number amount_rmb: number | null
other: LogOther other: LogOther
} }
@@ -130,8 +130,10 @@ export interface SummaryResponse {
cache_tokens: number cache_tokens: number
/** quota 合计(原值,供核对) */ /** quota 合计(原值,供核对) */
quota: number quota: number
/** 折算实付人民币(跨期按购买期间分段累加) */ /** 可折算部分的实付人民币合计(跨期按购买期间分段累加) */
amount_rmb: number amount_rmb: number
/** 无法折算的行数(`amount_rmb` 为 `null` 的行——未登记购买 / 充值来源) */
unpriced_count: number
meta: QueryMeta meta: QueryMeta
} }