Compare commits

..
10 Commits
Author SHA1 Message Date
skycurtain 0e9db439ba docs: 收尾(阶段 8)——部署文档 + 端到端走查 + 文档状态
- docs/delivery/部署文档.md:前置条件 / 环境变量 / 一条命令链 / systemd 与 Nginx 示例 / 首次运行检查 / 运行约束(后端单实例 · 3 天窗口 · 主密钥 · 401 摘除)/ 升级 / 排查
- 从零端到端走查:清库 → db:push → 启动(空转 + QPU 基线)→ 管理面登记 → 引擎立即补一轮(984 条)→ 查看面查询(未折算「—」)→ 补录购买记录 → 金额出现 → 换算参数时间线;无 pageerror
- 8 篇设计文档状态 → 已实现(2026-09-15)
- README:状态行 / 文档地图(含 delivery)/ 下一步(部署 + 待补验与裁决清单)
- AGENTS.md:项目速览更新
2026-09-15 01:10:55 +08:00
skycurtain fca01798e2 feat(web): 管理面三页(阶段 7)
- key 管理:清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度 / 停用来源)+ 状态徽章三面孔(运行中 / 已停用 / 已失效 + tooltip);登记弹窗(三类失败文案);停用弹确认、启用直接执行
- 购买记录:清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录共用;期间顺序校验);核对提示(未登记订阅提示条 + 观测折叠区 + 补录预填订阅 ID 与观测额度)
- 换算参数:时间线两列 + 「当前生效」标记 + 页首说明(纯只读)
- 修复:补 n-config-provider 中文 locale(naive-ui 默认英文按钮);两处表格 scroll-x(列宽被压缩致表头截断)
- 走查:Playwright 15 步全操作通过(含三类登记结果、确认弹窗、补录与 CRUD)
2026-09-15 01:06:43 +08:00
skycurtain 0e499eb8e1 feat(web): 用量日志页(阶段 6)
- 筛选区:key 标签输入(粘贴多行/逗号拆分、去重、localStorage 记忆)+ datetimerange(默认近 7 天、今天/近7天/近30天 快捷项)+ 查询(点按才发请求)
- 卡片区:调用次数 / Token 用量 / 预估消费(2 位小数);未折算条数提示条;数据截止右端小字 + 悬停 per-key 明细(同步中 / 未收录 / 已停用)
- 明细表格:8 列 + 展开行(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组);错误行 Tag;分页器(页码 + 20/50/100/200)
- 六态:加载(卡片骨架 + 表格 loading)/ 初始引导 / 未收录提示 / 时段无记录 / 数据同步中… / 失败提示 + 重试
- 修复:naive-ui 展开行须写在列定义(type: 'expand');useMessage 显式导入(auto-imports 全局声明在 vue-tsc 6 下未生效)
- 走查:Playwright + 系统 Chrome 逐态断言与截图,无 console 错误;新增 format 工具单测 6 项
2026-09-15 01:00:15 +08:00
skycurtain 8252b25396 feat(server): 查看面数据面(阶段 5)
- logs/query:明细 1:1(除 dedupe_key)+ 当前 token_name(join)+ amount_rmb;分页(limit/offset,默认 50 / 上限 200);排序 created_at DESC;type 过滤
- summary:卡片汇总(含 cache_tokens 的 jsonb 聚合)+ 折算累加 + unpriced_count
- meta(约定 22/23):data_until 取参与 key 中非 null 的最慢值、per-key 状态、未收录 key_hint 即时生成;全未收录仍 200 空结果
- 折算链(pricing.ts):按归属购买取尺(quota × paid_amount ÷ quota_granted = 按 quota 占比分摊实付);wallet 路径按未折算(null);假设 #6 的多条匹配规则
- 修复 jsonb 双重编码(实测定位):drizzle + bun-sql 直接传 JS 对象会落成 jsonb string,SQL 侧 ->> / ? 全失效 → 统一 toJsonb()(sql`${obj}::jsonb`),影响 other 与 model_limits
- seed 调整为「为行数最多的订阅登记购买」,保留未折算展示样本
- 实测:jsonb_typeof=object、SQL 聚合恢复、金额与 unpriced_count 计数正确、data_until 取最慢
2026-09-15 00:51:39 +08:00
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
skycurtain 4b5d21a418 feat(server): 管理面其余端点(阶段 3)
- purchases 五端点:清单(paid_at DESC)· 登记(必填校验 + 期间顺序)· 编辑(部分更新语义,null = 显式清空)· 删除(物理)· 观测(jsonb 聚合 subscription_*)
- 观测 matched_purchase_id:按假设 #6 匹配(subscription_id 优先 / 同 ID 多条取期间最近 / 唯一无期间兜底)
- qpu 时间线只读端点(effective_from 升序)
- 单测:matchPurchaseId 规则 5 项
- 实施计划:进度表阶段 3 ✅
2026-09-15 00:39:58 +08:00
skycurtain c26012a040 feat(server): 上游客户端与 key 管理(阶段 2)
- 上游客户端接口化:UpstreamClient + HTTP / fixture 两实现(env UPSTREAM_SOURCE 切换;fixture 读仓库根样例快照,错误分支由 key 内容约定触发)
- 失败分类:401 / 403 → key_rejected;其余非 2xx / 网络故障 / 超时 / 响应异常 → upstream_unavailable
- /api/manage/api-keys 三端点:清单(按 id 升序,敏感列不出接口)· 登记(上游验证 → 上游字段全组入库)· 启停(幂等、启用需验证并清 consecutive_401s)
- 登记成功触发「立即异步补一轮」hook(引擎骨架占位,阶段 4 填充)
- 统一错误信封:onError → 500 internal · notFound → 404
- 修正:load-env 未被服务端接线(此前只有 drizzle-kit 接了),env.ts 顶部补副作用导入
- 实施计划:进度表阶段 2 ✅ · 实现假设 #11–#14
2026-09-15 00:37:22 +08:00
skycurtain 575643eb13 docs: 实施计划——离线开发与样例数据(§6),阶段 2/4 验收标准适配
- 新增 §6:样例数据(api*.json,未入库)的用途与开发策略——上游客户端接口化(真实 HTTP / 样例文件两实现)+ seed 脚本(阶段 4 落地)+ 真实链路待验清单
- 前提改为「以样例数据为主,真实 key 未开,不阻塞」
- 阶段 2 验收改为「注入样例实现」;阶段 4 增 seed 脚本与 401 状态机单测;阶段 6 用 seed 数据走查
2026-09-15 00:30:29 +08:00
skycurtain f906289f3a docs: 实施计划——阶段 1 完成(进度表 + 实现假设更新)
- 阶段 1 标记完成(15 项单测绿 + 四表在库:api_keys 17 列 / usage_logs 23 列 / 索引 8 个)
- 实现假设 #2 更新为落地形态(load-env.ts 显式加载根 .env);新增 #9(drizzle-kit 用 postgres 驱动)
- 前提补「联调用的测试 key」准备说明(建 key + 发一次成功/一次失败请求)
2026-09-15 00:18:44 +08:00
skycurtain 3ffbd030b9 feat(server): 数据层与密钥(阶段 1)——crypto / 时间工具 / env 加载 + 建表
- crypto:HKDF 派生 hmac/aes 子密钥;HMAC-SHA256 指纹(key_hash);AES-256-GCM 加解密(nonce||tag||ct)
- time:北京时间 +08:00 ISO 序列化、Unix 秒转换、expires_at 0→null、输入宽容解析
- load-env:从仓库根 .env 显式加载(Bun 只自动加载 cwd 的 .env);drizzle.config 同步接入
- 单测 15 项全绿(bun test);db:push 后四表在库(api_keys 17 列 / usage_logs 23 列 + 索引齐)
2026-09-15 00:18:21 +08:00
57 changed files with 3893 additions and 208 deletions
+7
View File
@@ -19,3 +19,10 @@ KEYS_MASTER_KEY=change-me-32-bytes-base64
# 上游 NewAPI 站点地址(同步引擎调用的 BaseURL,不带尾部斜杠)
NEWAPI_BASE=https://your-newapi-instance.example.com
# 上游客户端实现:http(默认,调真实站点)/ fixture(开发期读仓库根样例快照,见实施计划 §6)
# 仅在离线开发 / 本地验证时设为 fixture——生产必须保持 http
# UPSTREAM_SOURCE=fixture
# 样例文件目录(仅 fixture 实现使用;留空 = 仓库根)
# NEWAPI_FIXTURE_DIR=
+4 -3
View File
@@ -13,8 +13,8 @@
- 针对 NewAPI 平台的 key 用量统计工具;**只有 sk-key,没有管理员账号**,一切数据走 key 级接口
- 上游没有任何时间过滤接口 → 轮询 `/api/log/token` + `request_id` 去重攒本地库,任一时段用量 = 本地聚合
- 数据模型四张表:`api_keys` · `usage_logs` · `purchases` · `qpu_history`(**四张表设计均已定稿**);四张表各有一篇独立表字段设计文档(`docs/design/`)
- 脚手架已落地:前后端独立部署、后端单进程(API + 同步引擎同进程、单实例)
- 数据模型四张表:`api_keys` · `usage_logs` · `purchases` · `qpu_history`(**四张表设计均已实现**);四张表各有一篇独立表字段设计文档(`docs/design/`)
- **功能已实现(阶段 0–8,2026-09-15)**:前后端独立部署、后端单进程(API + 同步引擎同进程、单实例);部署见 `docs/delivery/部署文档.md`;未决事项(实现假设与待定问题)见 `docs/design/实施计划.md`
## 命令
@@ -35,8 +35,9 @@ bun run type-check # 类型检查(vue-tsc + tsc)
bun run db:generate # 生成 Drizzle 迁移(apps/server/drizzle)
bun run db:migrate # 执行迁移
bun run db:push # 直接推 schema(开发期)
bun run seed # 灌入开发数据(样例快照 → 四张表;幂等,仅本地)
```
- 环境变量:复制 `.env.example` 为 `.env` 并填值(`DATABASE_URL` / `MANAGE_TOKEN` / `KEYS_MASTER_KEY` / `NEWAPI_BASE`)
- 环境变量:复制 `.env.example` 为 `.env` 并填值(`DATABASE_URL` / `MANAGE_TOKEN` / `KEYS_MASTER_KEY` / `NEWAPI_BASE`;离线开发可加 `UPSTREAM_SOURCE=fixture` 走样例快照,见 `docs/design/实施计划.md` §6)
- 目录速记:前端 `apps/web/src/{api,router,views}` · 后端 `apps/server/src/{db,routes,sync,lib}` · 契约类型在 `packages/shared`
- 开发期前端 `/api` 由 Vite 代理到 :3000(`apps/web/vite.config.ts`);生产由同域反代承担(见 README 工程注记)
+16 -12
View File
@@ -6,7 +6,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 实现起步(脚手架已落地:`apps/web` · `apps/server` · `packages/shared`;设计文档全部定稿) |
| 状态 | **已实现(2026-09-15,阶段 0–8 全部完成)**——前后端功能齐备、实测走查通过;真实上游联调待作者补验(见实施计划 §6) |
| 技术栈 | Bun + Hono + PostgreSQL · 前端 Vite + Vue 3 + naive-ui |
| 部署 | 实例运营方提供的服务器;交付方式为推送仓库 / 产物,由运营方执行部署 |
@@ -14,18 +14,19 @@
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:字段、语义与决策理由 | 定稿(2026-09-11;09-13 修订 key 登记与表名、新增 `disabled_source`) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、去重与写入策略 | 定稿(2026-09-11) |
| [`docs/design/purchases表字段设计.md`](docs/design/purchases表字段设计.md) | 数据模型第三张表:购买记录(实付人民币的唯一落点、折算链第二条) | 定稿(2026-09-13;同日修订查看面金额口径) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 定稿(2026-09-13) |
| [`docs/design/交互设计.md`](docs/design/交互设计.md) | 界面设计:方向层 + 细节层(跨页共性、逐页细节、状态与文案) | 定稿(2026-09-13) |
| [`docs/design/API契约.md`](docs/design/API契约.md) | 前端 ↔ 服务端接口契约:约定层 + 逐页端点(前端实现看这篇) | 定稿(2026-09-13) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 采集侧引擎:调度、每轮动作、失败处理、可观测、部署初始化(后端实现看这篇) | 定稿(2026-09-13) |
| [`docs/design/实施计划.md`](docs/design/实施计划.md) | 开发推进方案:loop 模式 + 八阶段验收标准 + 进度表 | 定稿(2026-09-14;进度随 loop 更新) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:字段、语义与决策理由 | 已实现(2026-09-15;定稿 2026-09-11,09-13 修订 key 登记与表名、新增 `disabled_source`) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、去重与写入策略 | 已实现(2026-09-15;定稿 2026-09-11) |
| [`docs/design/purchases表字段设计.md`](docs/design/purchases表字段设计.md) | 数据模型第三张表:购买记录(实付人民币的唯一落点、折算链第二条) | 已实现(2026-09-15;定稿 2026-09-13) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 已实现(2026-09-15;定稿 2026-09-13) |
| [`docs/design/交互设计.md`](docs/design/交互设计.md) | 界面设计:方向层 + 细节层(跨页共性、逐页细节、状态与文案) | 已实现(2026-09-15;定稿 2026-09-13) |
| [`docs/design/API契约.md`](docs/design/API契约.md) | 前端 ↔ 服务端接口契约:约定层 + 逐页端点(前端实现看这篇) | 已实现(2026-09-15;定稿 2026-09-13) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 采集侧引擎:调度、每轮动作、失败处理、可观测、部署初始化(后端实现看这篇) | 已实现(2026-09-15;定稿 2026-09-13) |
| [`docs/design/实施计划.md`](docs/design/实施计划.md) | 开发推进方案:loop 模式 + 八阶段验收标准 + 进度表(**含实现假设与待定问题清单**) | 已实现(2026-09-15;八阶段全部完成) |
| [`docs/delivery/部署文档.md`](docs/delivery/部署文档.md) | **给实例运营方**:环境变量、一条命令链、systemd / Nginx 示例、运行约束与排查 | 定稿(2026-09-15) |
| [`docs/upstream/NewAPI-sk-key接口文档.md`](docs/upstream/NewAPI-sk-key接口文档.md) | 查 sk-key 可用接口的字段级事实(源码实证,430 行) | 事实依据 |
| [`docs/upstream/NewAPI-接口范围.md`](docs/upstream/NewAPI-接口范围.md) | 本项目对接的接口范围(9 个接口的用途与鉴权) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/upstream/`(NewAPI 的实证事实,不做设计决策)· `docs/deprecated/`(**已废弃的历史文档,只存档、不引用、不作依据**)· 未来的 `docs/delivery/`(给部署方看的部署文档)
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/upstream/`(NewAPI 的实证事实,不做设计决策)· `docs/delivery/`(给部署方看的部署文档)· `docs/deprecated/`(**已废弃的历史文档,只存档、不引用、不作依据**)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古),尚未入库的历史文档移入 `docs/deprecated/`
## 这是什么项目
@@ -95,6 +96,9 @@
## 下一步
1. **实现**:脚手架已落地(命令见 [`AGENTS.md`](AGENTS.md))——按 [`docs/design/API契约.md`](docs/design/API契约.md) 实现 11 个端点(查看面 `/usage-log` 先行),按 [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) 实现采集引擎
2. **部署前确认清单**(开工前向实例运营方确认):服务器 OS / PostgreSQL 实例连接方式 / 有无反代与**能否配置 `/api` 反代规则** / Bun 可安装性 / **后端必须单实例运行**(同步引擎前提)
1. **部署**:按 [`docs/delivery/部署文档.md`](docs/delivery/部署文档.md) 执行(一条命令链;前后端独立 + **后端单实例**注记)
2. **待作者补验 / 裁决**(清单见 [`docs/design/实施计划.md`](docs/design/实施计划.md)):
- 真实 key 的真实链路(真实登记 / 采集轮次 / 401 摘除)——本地开发以样例快照验证(实施计划 §6)
- 实现假设 #1–#20 的逐条确认
- 待定问题 #5:错误行 `request_id` 不唯一(去重合并策略待裁决)
3. 后议功能:充值折算、多订阅实测等见两张折算表设计文档的「待定事项」
+3
View File
@@ -1,3 +1,6 @@
// 从仓库根 .env 加载环境变量(Bun 只自动加载 cwd 的 .env——此处 cwd 是 apps/server)
import './src/lib/load-env'
import { defineConfig } from 'drizzle-kit'
/** drizzle-kit 配置(迁移产物在 ./drizzle;命令见根 package.json 的 db:* 脚本) */
+4 -1
View File
@@ -5,6 +5,8 @@
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"test": "bun test",
"seed": "bun run scripts/seed.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
@@ -17,6 +19,7 @@
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.10"
"drizzle-kit": "^0.31.10",
"postgres": "^3.4.9"
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* seed 脚本(实施计划 §6):从仓库根的实测响应快照灌入开发数据——**幂等**,可重复执行。
*
* 用法:在 `apps/server` 下 `bun run seed`(环境变量自动经 `src/lib/env.ts` 加载仓库根 `.env`)。
*
* 灌入内容:
* 1. 一个占位 `api_keys` 行(假 key 常量 `SEED_KEY`,哈希 / 密文走真实 crypto 逻辑——
* 查询面可用该常量 key 命中已登记 key)
* 2. `usage_logs`:`api.log.token.json` 的 1000 条样例日志(`ON CONFLICT DO NOTHING`)
* 3. `qpu_history` 基线:`api.status.json` 的 `quota_per_unit`(已有数据则跳过)
* 4. 一条示例 `purchases`:为样例中**日志行数最多**的订阅登记(**金额为占位值**);
* 其余订阅保持未登记——保留「未折算(`amount_rmb = null`)」的展示样本
*
* 注:快照文件未入库(`.gitignore` 的 `/api*.json`),本脚本只在本地开发环境使用。
*/
import { readFileSync } from 'node:fs'
import { eq } from 'drizzle-orm'
import { DEFAULT_QUOTA_PER_UNIT } from '@naku/shared'
import { getDb } from '../src/db/client'
import { apiKeys, purchases, qpuHistory, usageLogs } from '../src/db/schema'
import { getKeySecrets } from '../src/lib/keys'
import { readSubscriptionId } from '../src/lib/pricing'
import { fromUnixSeconds } from '../src/lib/time'
import { parseOther, toJsonb, toLogRow } from '../src/sync/log-row'
import type { UpstreamLogRecord } from '../src/upstream/types'
/** 假 key 常量:查看面 / 管理面用它做本地验证(可用 env SEED_KEY 覆盖) */
const SEED_KEY = process.env.SEED_KEY ?? 'sk-seed-demo-key-0001'
/** 仓库根(本文件位于 apps/server/scripts/) */
const REPO_ROOT = new URL('../../../', import.meta.url)
function readSnapshot<T>(name: string): T {
return JSON.parse(readFileSync(new URL(name, REPO_ROOT), 'utf8')) as T
}
function asNumber(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
const db = getDb()
const secrets = getKeySecrets()
// ---------- 样例快照 ----------
const logs = readSnapshot<{ data?: UpstreamLogRecord[] }>('api.log.token.json').data
if (!Array.isArray(logs) || logs.length === 0) {
console.error('[seed] api.log.token.json 缺失或结构异常(缺 data[])——请确认样例文件在仓库根')
process.exit(1)
}
const status = readSnapshot<{ data?: Record<string, unknown> }>('api.status.json').data ?? {}
// ---------- 1. api_keys ----------
const keyHash = secrets.keyHash(SEED_KEY)
let [keyRow] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, keyHash)).limit(1)
if (!keyRow) {
const first = logs[0]!
const other = parseOther(first.other)
const totalGranted = asNumber(other.subscription_total, 800_000_000)
const totalUsed = asNumber(other.subscription_used, 85_579_458)
const now = new Date()
;[keyRow] = await db
.insert(apiKeys)
.values({
keyHash,
keyCiphertext: secrets.encryptKey(SEED_KEY),
status: 'active',
disabledSource: null,
consecutive401s: 0,
logSyncedAt: now,
usageSyncedAt: now,
tokenName: first.token_name ?? 'seed-token',
totalGranted,
totalUsed,
totalAvailable: totalGranted - totalUsed,
unlimitedQuota: false,
modelLimits: toJsonb({}),
modelLimitsEnabled: false,
expiresAt: null,
})
.returning()
console.log(`[seed] api_keys:已插入 id=${keyRow!.id}(token_name=${keyRow!.tokenName})`)
} else {
console.log(`[seed] api_keys:已存在(id=${keyRow.id}),跳过`)
}
// ---------- 2. usage_logs ----------
const rows = logs.map((log) => toLogRow(keyRow!.id, log))
const inserted = await db
.insert(usageLogs)
.values(rows)
.onConflictDoNothing({ target: usageLogs.dedupeKey })
.returning({ id: usageLogs.id })
console.log(`[seed] usage_logs:新增 ${inserted.length} 条(样例共 ${logs.length} 条)`)
// ---------- 3. qpu_history 基线 ----------
const [qpuRow] = await db.select().from(qpuHistory).limit(1)
if (!qpuRow) {
const qpu = asNumber(status.quota_per_unit, DEFAULT_QUOTA_PER_UNIT)
await db.insert(qpuHistory).values({ qpu, effectiveFrom: new Date() })
console.log(`[seed] qpu_history:已插入基线 qpu=${qpu}`)
} else {
console.log(`[seed] qpu_history:已有数据(qpu=${qpuRow.qpu}),跳过`)
}
// ---------- 4. 示例 purchases(金额为占位值) ----------
// 样例快照里可能有多个订阅(实测 89 / 90 两组):只为行数最多的那笔登记购买记录,
// 其余保持未登记——既保留「未折算」样本,也贴近真实的「多订阅并存」形状
interface SubInfo {
count: number
earliest: number
total: number
plan: string | null
}
const bySubscription = new Map<number, SubInfo>()
for (const log of logs) {
const other = parseOther(log.other)
const sid = readSubscriptionId(other)
if (sid === null) continue
const cur =
bySubscription.get(sid) ??
({ count: 0, earliest: Number.POSITIVE_INFINITY, total: 0, plan: null } satisfies SubInfo)
cur.count += 1
cur.earliest = Math.min(cur.earliest, log.created_at)
cur.total = Math.max(cur.total, asNumber(other.subscription_total, 0))
cur.plan = typeof other.subscription_plan_title === 'string' ? other.subscription_plan_title : cur.plan
bySubscription.set(sid, cur)
}
const ranked = [...bySubscription.entries()].sort((a, b) => b[1].count - a[1].count)
const [purchaseRow] = await db.select().from(purchases).limit(1)
if (purchaseRow) {
console.log(`[seed] purchases:已有数据(id=${purchaseRow.id}),跳过`)
} else if (ranked.length === 0) {
console.log('[seed] purchases:样例日志中无 subscription_id,跳过')
} else {
const [mainSid, info] = ranked[0]!
const earliest = fromUnixSeconds(info.earliest)
await db.insert(purchases).values({
kind: 'subscription',
paidAt: earliest,
paidAmount: 168.5, // 占位值:实付金额只有作者知道
note: 'seed 示例(金额为占位值,非真实)',
periodStart: earliest,
periodEnd: null,
subscriptionId: mainSid,
quotaGranted: info.total > 0 ? info.total : 800_000_000,
})
console.log(
`[seed] purchases:为订阅 #${mainSid}(${info.plan ?? '未知计划'},${info.count} 行日志)插入示例一条`,
)
for (const [sid, extra] of ranked.slice(1)) {
console.log(`[seed] 订阅 #${sid}(${extra.count} 行)保持未登记——用于演示「未折算」展示`)
}
}
console.log(`[seed] 完成。查询面可用假 key「${SEED_KEY}」命中已登记 key。`)
+7
View File
@@ -29,6 +29,13 @@ app.route('/api/manage/qpu', qpuRoutes)
// 同步引擎:随进程启动,启动即跑一轮(同步引擎设计 §3.2)
startSyncEngine()
// 统一错误信封(契约 约定 20):未捕获异常 → 500 internal,未知路由 → 404 not_found
app.onError((err, c) => {
console.error('[server] 未处理异常', err)
return c.json({ error: { code: 'internal', message: '服务端内部错误' } }, 500)
})
app.notFound((c) => c.json({ error: { code: 'not_found', message: '无此端点' } }, 404))
console.log(`[server] Hono 已就绪,监听 :${env.port}`)
export default {
+34
View File
@@ -0,0 +1,34 @@
/**
* 错误响应的统一构造(API 契约 约定 20/21:非 2xx + `{ error: { code, message } }` 信封)。
*/
import type { ContentfulStatusCode } from 'hono/utils/http-status'
import type { Context } from 'hono'
import type { ApiErrorCode } from '@naku/shared'
import { UpstreamError } from '../upstream/errors'
export function apiError(
c: Context,
status: ContentfulStatusCode,
code: ApiErrorCode,
message: string,
) {
return c.json({ error: { code, message } }, status)
}
/**
* 上游错误 → 契约错误码:`key_rejected` → 400 · `upstream_unavailable` → 502。
* 非上游错误返回 null(调用方 rethrow,交给 onError)。
*/
export function upstreamErrorResponse(c: Context, err: unknown) {
if (!(err instanceof UpstreamError)) return null
if (err.kind === 'key_rejected') return apiError(c, 400, 'key_rejected', err.message)
return apiError(c, 502, 'upstream_unavailable', err.message)
}
/** PostgreSQL 唯一约束冲突(并发登记同一 key 时的兜底判定,SQLSTATE 23505) */
export function isUniqueViolation(err: unknown): boolean {
return (
typeof err === 'object' && err !== null && (err as { code?: string }).code === '23505'
)
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test } from 'bun:test'
import { createKeySecrets } from './crypto'
/** 确定性测试主密钥(32 字节 base64;仅测试用) */
const MASTER = Buffer.alloc(32, 7).toString('base64')
const OTHER_MASTER = Buffer.alloc(32, 9).toString('base64')
describe('createKeySecrets', () => {
test('同一主密钥 → 同一指纹(派生确定性)', () => {
const a = createKeySecrets(MASTER)
const b = createKeySecrets(MASTER)
expect(a.keyHash('sk-test-123')).toBe(b.keyHash('sk-test-123'))
})
test('不同 key → 不同指纹;指纹为 64 位 hex', () => {
const s = createKeySecrets(MASTER)
const h1 = s.keyHash('sk-a')
const h2 = s.keyHash('sk-b')
expect(h1).not.toBe(h2)
expect(h1).toMatch(/^[0-9a-f]{64}$/)
})
test('不同主密钥 → 不同指纹(子密钥隔离)', () => {
const h1 = createKeySecrets(MASTER).keyHash('sk-x')
const h2 = createKeySecrets(OTHER_MASTER).keyHash('sk-x')
expect(h1).not.toBe(h2)
})
test('加密 → 解密往返一致;两次密文不同(随机 nonce)', () => {
const s = createKeySecrets(MASTER)
const key = 'sk-roundtrip-测试值'
const c1 = s.encryptKey(key)
const c2 = s.encryptKey(key)
expect(c1.equals(c2)).toBe(false)
expect(s.decryptKey(c1)).toBe(key)
expect(s.decryptKey(c2)).toBe(key)
})
test('密文被篡改 → 解密抛错(GCM 防篡改)', () => {
const s = createKeySecrets(MASTER)
const packed = s.encryptKey('sk-tamper-me')
packed[packed.length - 1] ^= 0xff
expect(() => s.decryptKey(packed)).toThrow()
})
test('主密钥过短 → 报错', () => {
expect(() => createKeySecrets(Buffer.from('short').toString('base64'))).toThrow()
})
})
+65
View File
@@ -0,0 +1,65 @@
/**
* 凭证密码学(api_keys 表字段设计 §3.1):
* - `key_hash`:HMAC-SHA256——命中判定用(不可逆;掺入只有服务器持有的子密钥)
* - `key_ciphertext`:AES-256-GCM——唯一用途是调上游(可解密;防篡改)
* - 主密钥 `KEYS_MASTER_KEY` 经 HKDF 派生 hmac / aes 两个子密钥(同一主密钥下用途隔离)
*
* 密文打包布局:`nonce(12) || tag(16) || ciphertext`
*/
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes } from 'node:crypto'
/** HKDF 的固定 salt(区分本项目;子密钥再按用途用 info 隔离) */
const HKDF_SALT = Buffer.from('new-api-key-usage/keys-master/v1')
const INFO_HMAC = 'naku:key-hash:v1'
const INFO_AES = 'naku:key-cipher:v1'
const NONCE_LEN = 12
const TAG_LEN = 16
export interface KeySecrets {
/** HMAC-SHA256 指纹(hex)——入库列 `key_hash` */
keyHash(plainKey: string): string
/** AES-256-GCM 加密——入库列 `key_ciphertext`(nonce || tag || ciphertext) */
encryptKey(plainKey: string): Buffer
/** 解密(仅调上游时使用);密文被篡改会抛错 */
decryptKey(ciphertext: Buffer): string
}
/**
* 从主密钥派生操作集合。主密钥为 base64 字符串(见 `.env.example` 的生成提示)。
* 派生是确定性的:同一主密钥 → 同一组子密钥(否则重启后全部 key 失效)。
*/
export function createKeySecrets(masterKeyBase64: string): KeySecrets {
const ikm = Buffer.from(masterKeyBase64, 'base64')
if (ikm.length < 32) {
throw new Error('KEYS_MASTER_KEY 太短(需 base64 编码的 32 字节随机值,见 .env.example)')
}
const hmacKey = Buffer.from(hkdfSync('sha256', ikm, HKDF_SALT, INFO_HMAC, 32))
const aesKey = Buffer.from(hkdfSync('sha256', ikm, HKDF_SALT, INFO_AES, 32))
return {
keyHash(plainKey: string): string {
return createHmac('sha256', hmacKey).update(plainKey, 'utf8').digest('hex')
},
encryptKey(plainKey: string): Buffer {
const nonce = randomBytes(NONCE_LEN)
const cipher = createCipheriv('aes-256-gcm', aesKey, nonce)
const ciphertext = Buffer.concat([cipher.update(plainKey, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([nonce, tag, ciphertext])
},
decryptKey(packed: Buffer): string {
if (packed.length < NONCE_LEN + TAG_LEN + 1) {
throw new Error('key_ciphertext 长度非法')
}
const nonce = packed.subarray(0, NONCE_LEN)
const tag = packed.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN)
const ciphertext = packed.subarray(NONCE_LEN + TAG_LEN)
const decipher = createDecipheriv('aes-256-gcm', aesKey, nonce)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
},
}
}
+8
View File
@@ -2,6 +2,9 @@
* 环境变量(部署时经 env 注入;全量清单见仓库根 `.env.example`)。
* 缺关键变量时在「首次使用处」报错,不在进程启动时硬崩(便于本地起骨架)。
*/
// 副作用导入:显式加载仓库根 `.env`(Bun 只自动加载 cwd 的 .env,见 load-env.ts)
import './load-env'
function required(name: string): string {
const v = process.env[name]
if (!v) {
@@ -21,6 +24,11 @@ export const env = {
keysMasterKey: process.env.KEYS_MASTER_KEY ?? '',
/** 上游 NewAPI 站点地址(同步引擎调用的 BaseURL) */
newapiBase: process.env.NEWAPI_BASE ?? '',
/** 上游客户端实现:`http`(默认,真实站点)/ `fixture`(开发期样例文件,见实施计划 §6) */
upstreamSource:
process.env.UPSTREAM_SOURCE === 'fixture' ? ('fixture' as const) : ('http' as const),
/** 样例文件目录(仅 fixture 实现使用;空 = 仓库根) */
newapiFixtureDir: process.env.NEWAPI_FIXTURE_DIR ?? '',
}
export { required }
+18
View File
@@ -0,0 +1,18 @@
/**
* 凭证密码学单例:从 `KEYS_MASTER_KEY` 派生(懒初始化,缺变量的报错发生在首次使用处)。
* 主密钥丢失 = 全部 key 需重新登记(见 api_keys 表设计 §3.1 与部署文档)。
*/
import { createKeySecrets, type KeySecrets } from './crypto'
import { env } from './env'
let secrets: KeySecrets | null = null
export function getKeySecrets(): KeySecrets {
if (!secrets) {
if (!env.keysMasterKey) {
throw new Error('缺少环境变量 KEYS_MASTER_KEY(见 .env.example)')
}
secrets = createKeySecrets(env.keysMasterKey)
}
return secrets
}
+33
View File
@@ -0,0 +1,33 @@
/**
* 环境变量加载:从仓库根 `.env` 读入 `process.env`(已在环境中的变量不覆盖)。
*
* 为什么需要它:Bun 只自动加载**当前工作目录**的 `.env`——而服务与 drizzle-kit
* 都在 `apps/server/` 下运行,根 `.env` 不会被自动加载。这里显式加载,保持
* 「全仓单一 .env」的约定(见实施计划「实现假设」#2)。
*/
import { readFileSync } from 'node:fs'
/** 仓库根 .env 相对本文件的路径(src/lib/load-env.ts → ../../../../.env) */
const ENV_PATH = new URL('../../../../.env', import.meta.url)
export function loadRootEnv(): void {
let raw: string
try {
raw = readFileSync(ENV_PATH, 'utf8')
} catch {
return // .env 不存在时静默跳过(部署环境走真实 env 注入)
}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim()
const value = trimmed.slice(eq + 1).trim()
if (process.env[key] === undefined) {
process.env[key] = value
}
}
}
loadRootEnv()
+79
View File
@@ -0,0 +1,79 @@
/**
* 折算链单测(purchases 表设计 §1 · 实施计划「实现假设」#6)。
*/
import { describe, expect, test } from 'bun:test'
import { amountRmbFor, resolvePurchaseForLog, type PurchaseLite } from './pricing'
const d = (s: string) => new Date(s)
const P = (over: Partial<PurchaseLite> = {}): PurchaseLite => ({
id: 1,
paidAmount: 200,
quotaGranted: 1_000_000_000,
subscriptionId: 90,
periodStart: d('2026-09-01T00:00:00+08:00'),
periodEnd: d('2026-10-01T00:00:00+08:00'),
...over,
})
describe('resolvePurchaseForLog(日志 → 归属购买)', () => {
test('同 ID 唯一 → 该条(期间内)', () => {
const p = P()
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), 90, 'subscription', [p])).toBe(p)
})
test('充值(wallet)→ null(池子近似后议,先按未折算)', () => {
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), 90, 'wallet', [P()])).toBeNull()
})
test('无 subscription_id → null', () => {
expect(resolvePurchaseForLog(d('2026-09-10T12:00:00+08:00'), null, 'subscription', [P()])).toBeNull()
})
test('同 ID 多条:取「期间覆盖日志时刻」的那条(跨期连续两笔)', () => {
const p1 = P({ id: 1, periodStart: d('2026-08-01T00:00:00+08:00'), periodEnd: d('2026-09-01T00:00:00+08:00') })
const p2 = P({ id: 2, periodStart: d('2026-09-01T00:00:00+08:00'), periodEnd: d('2026-10-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-09-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])?.id).toBe(2)
expect(resolvePurchaseForLog(d('2026-08-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])?.id).toBe(1)
})
test('同 ID 多条且都不覆盖 → null(不猜)', () => {
const p1 = P({ id: 1, periodStart: d('2026-07-01T00:00:00+08:00'), periodEnd: d('2026-08-01T00:00:00+08:00') })
const p2 = P({ id: 2, periodStart: d('2026-08-01T00:00:00+08:00'), periodEnd: d('2026-09-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-09-10T00:00:00+08:00'), 90, 'subscription', [p1, p2])).toBeNull()
})
test('期间边界:左闭右开(period_end 时刻本身不属于该笔)', () => {
const p = P({ periodEnd: d('2026-09-01T00:00:00+08:00') })
expect(resolvePurchaseForLog(d('2026-08-31T23:59:59+08:00'), 90, 'subscription', [p])).toBe(p)
// 同 ID 唯一时不做期间判定(唯一即兜底),故用多条场景验证边界
const p2 = P({ id: 2, periodStart: d('2026-09-01T00:00:00+08:00'), periodEnd: null })
expect(resolvePurchaseForLog(d('2026-09-01T00:00:00+08:00'), 90, 'subscription', [p, p2])?.id).toBe(2)
})
})
describe('amountRmbFor(quota 占比 × 该笔实付)', () => {
test('正常折算:quota × paid_amount ÷ quota_granted', () => {
// 1e6 quota / 1e9 总额 × 200 元 = 0.2 元
expect(amountRmbFor(1_000_000, P())).toBeCloseTo(0.2, 10)
})
test('错误行 quota = 0 → 0(有归属时)', () => {
expect(amountRmbFor(0, P())).toBe(0)
})
test('无归属购买 → null(未登记 / 充值来源)', () => {
expect(amountRmbFor(1234, null)).toBeNull()
})
test('quota_granted 缺失或非正 → null(分母不成立)', () => {
expect(amountRmbFor(1234, P({ quotaGranted: null }))).toBeNull()
expect(amountRmbFor(1234, P({ quotaGranted: 0 }))).toBeNull()
})
test('全精度:不做舍入(约定 14)', () => {
const v = amountRmbFor(702, P({ paidAmount: 168.5, quotaGranted: 800_000_000 }))
expect(v).toBe((702 * 168.5) / 800_000_000)
})
})
+72
View File
@@ -0,0 +1,72 @@
/**
* 折算链(`purchases` 表设计 §1 · `qpu_history` 表设计 §3.3 · API 契约 §1.4/§2.1):
*
* 日志.quota ÷ 购买时刻的 qpu = 额度美元;× 购买汇率 = 实付人民币
* 购买汇率 = paid_amount ÷ (quota_granted ÷ 购买时刻的 qpu)
*
* 两处 qpu 同为「该日志所属购买的购买时刻」取值,代数上抵消,落到逐行计算即:
*
* amount_rmb = quota × paid_amount ÷ quota_granted (按 quota 占比分摊该笔实付)
*
* 这正是设计所称「跨期按购买期间边界分段,每段 quota 占比 × 该笔实付」的逐行等价形式
* ——每一行都按自己的归属购买取尺,跨期自然分段;`qpu_history` 不参与金额计算。
*/
export interface PurchaseLite {
id: number
paidAmount: number
quotaGranted: number | null
subscriptionId: number | null
periodStart: Date | null
periodEnd: Date | null
}
/** 期间覆盖判定:`[period_start, period_end)` 左闭右开;两端都可为空(未填 = 不设限) */
function covers(p: PurchaseLite, t: Date): boolean {
return (
(!p.periodStart || t.getTime() >= p.periodStart.getTime()) &&
(!p.periodEnd || t.getTime() < p.periodEnd.getTime())
)
}
/**
* 日志 → 归属购买(实施计划「实现假设」#6:`subscription_id` 优先;同 ID 多条按
* `[period_start, period_end)`;无期间记录仅在「同 ID 唯一」时兜底)。
* 充值(`billing_source = "wallet"`)路径的池子近似后议——先按「未折算」处理(返回 null)。
*/
export function resolvePurchaseForLog(
logTime: Date,
subscriptionId: number | null,
billingSource: unknown,
purchases: PurchaseLite[],
): PurchaseLite | null {
if (billingSource === 'wallet') return null
if (subscriptionId === null) return null
const same = purchases.filter((p) => p.subscriptionId === subscriptionId)
if (same.length === 0) return null
if (same.length === 1) return same[0]!
// 同 ID 多条(续费 / 跨期):只认「期间覆盖该日志时刻」者;都不覆盖则不猜(null)
const inPeriod = same.filter((p) => covers(p, logTime))
if (inPeriod.length === 0) return null
return [...inPeriod].sort(
(a, b) => (b.periodStart?.getTime() ?? 0) - (a.periodStart?.getTime() ?? 0),
)[0]!
}
/**
* 单行折算:无归属购买(未登记 / 充值来源 / 无法判定)→ `null`;
* 有归属但 `quota_granted` 缺失 / 非正 → 折算分母不成立 → `null`(展示「—」,计入 `unpriced_count`)。
*/
export function amountRmbFor(quota: number, purchase: PurchaseLite | null): number | null {
if (!purchase) return null
if (purchase.quotaGranted === null || purchase.quotaGranted <= 0) return null
return (quota * purchase.paidAmount) / purchase.quotaGranted
}
/** `other.subscription_id` 的宽容读取(jsonb 里可能是数字或字符串) */
export function readSubscriptionId(other: Record<string, unknown>): number | null {
const raw = other.subscription_id
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
return Number.isFinite(n) ? n : null
}
@@ -0,0 +1,43 @@
/**
* 观测项 → 购买记录匹配的单测(实施计划「实现假设」#6 的规则固化)。
*/
import { describe, expect, test } from 'bun:test'
import { matchPurchaseId, type PurchaseMatchCandidate } from './purchases-match'
const P = (
id: number,
subscriptionId: number | null,
periodStart: Date | null = null,
periodEnd: Date | null = null,
): PurchaseMatchCandidate => ({ id, subscriptionId, periodStart, periodEnd })
const d = (s: string) => new Date(s)
describe('matchPurchaseId(假设 #6)', () => {
test('无同 ID 记录 → null(观测到但未登记)', () => {
expect(matchPurchaseId(90, [P(1, 91), P(2, null)])).toBeNull()
expect(matchPurchaseId(90, [])).toBeNull()
})
test('同 ID 唯一 → 该条(即便无期间信息)', () => {
expect(matchPurchaseId(90, [P(7, 90)])).toBe(7)
})
test('同 ID 多条:优先「有期间」者,按 period_start 取最近', () => {
const rows = [
P(1, 90, d('2026-07-01'), d('2026-08-01')),
P(2, 90, d('2026-09-01'), d('2026-10-01')),
P(3, 90), // 无期间
]
expect(matchPurchaseId(90, rows)).toBe(2)
})
test('同 ID 多条且都无期间 → 取 id 最大者(最近登记)', () => {
expect(matchPurchaseId(90, [P(1, 90), P(5, 90), P(3, 90)])).toBe(5)
})
test('不同 subscription_id 不串号', () => {
expect(matchPurchaseId(91, [P(1, 90, d('2026-09-01'))])).toBeNull()
})
})
+34
View File
@@ -0,0 +1,34 @@
/**
* 观测项 ↔ 购买记录的匹配(实施计划「实现假设」#6:`subscription_id` 优先;
* 同 ID 多条按期间选最近的一条;无期间记录仅在「同 ID 唯一」时兜底)。
* 供 purchases 观测端点(`matched_purchase_id`)使用;阶段 5 的折算链按购买期间分段取尺。
*/
export interface PurchaseMatchCandidate {
id: number
subscriptionId: number | null
periodStart: Date | null
periodEnd: Date | null
}
/**
* 为观测到的 `subscription_id` 找已登记的购买记录:
* - 无同 ID 记录 → null(观测到但未登记——补录引导的目标)
* - 同 ID 唯一 → 该条(无论期间)
* - 同 ID 多条 → 优先「有期间」者,按 `period_start` 取最近的一条;全部无期间时取 id 最大者
*/
export function matchPurchaseId(
subscriptionId: number,
candidates: PurchaseMatchCandidate[],
): number | null {
const same = candidates.filter((p) => p.subscriptionId === subscriptionId)
if (same.length === 0) return null
if (same.length === 1) return same[0]!.id
const withPeriod = same.filter((p) => p.periodStart !== null || p.periodEnd !== null)
const pool = withPeriod.length > 0 ? withPeriod : same
const sorted = [...pool].sort(
(a, b) => (b.periodStart?.getTime() ?? 0) - (a.periodStart?.getTime() ?? 0) || b.id - a.id,
)
return sorted[0]!.id
}
+54
View File
@@ -0,0 +1,54 @@
/**
* 库行 → 契约响应行的序列化(API 契约 约定 3:字段 snake_case 与表列同名同形;
* 约定 9/11:时间戳一律北京时间 `+08:00`)。
* 各表 1:1 映射,敏感列(`key_hash` / `key_ciphertext`)永不出接口。
*/
import type { ApiKeyRow, PurchaseRow, QpuRow } from '@naku/shared'
import { toBeijingIso } from './time'
import type { apiKeys, purchases, qpuHistory } from '../db/schema'
/** 行内可空时间戳 → 契约输出(null 保持 null) */
const iso = (d: Date | null): string | null => (d ? toBeijingIso(d) : null)
export function toApiKeyRow(row: typeof apiKeys.$inferSelect): ApiKeyRow {
return {
id: row.id,
token_name: row.tokenName,
status: row.status as ApiKeyRow['status'],
disabled_source: row.disabledSource as ApiKeyRow['disabled_source'],
consecutive_401s: row.consecutive401s,
registered_at: toBeijingIso(row.registeredAt),
log_synced_at: iso(row.logSyncedAt),
usage_synced_at: iso(row.usageSyncedAt),
total_granted: row.totalGranted,
total_used: row.totalUsed,
total_available: row.totalAvailable,
unlimited_quota: row.unlimitedQuota,
model_limits: row.modelLimits as Record<string, unknown>,
model_limits_enabled: row.modelLimitsEnabled,
expires_at: iso(row.expiresAt),
}
}
export function toPurchaseRow(row: typeof purchases.$inferSelect): PurchaseRow {
return {
id: row.id,
kind: row.kind as PurchaseRow['kind'],
paid_at: toBeijingIso(row.paidAt),
paid_amount: row.paidAmount,
note: row.note,
period_start: iso(row.periodStart),
period_end: iso(row.periodEnd),
subscription_id: row.subscriptionId,
quota_granted: row.quotaGranted,
}
}
export function toQpuRow(row: typeof qpuHistory.$inferSelect): QpuRow {
return {
id: row.id,
qpu: row.qpu,
effective_from: toBeijingIso(row.effectiveFrom),
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { expiresAtFromUnix, fromUnixSeconds, parseInputTime, toBeijingIso } from './time'
describe('toBeijingIso', () => {
test('UTC 时刻 → 北京时间 ISO(+08:00)', () => {
// 2026-09-11T11:37:12Z = 北京时间 19:37:12
expect(toBeijingIso(new Date('2026-09-11T11:37:12Z'))).toBe('2026-09-11T19:37:12+08:00')
})
test('毫秒非零时保留 3 位', () => {
expect(toBeijingIso(new Date('2026-09-11T11:37:12.250Z'))).toBe('2026-09-11T19:37:12.250+08:00')
})
test('跨日边界:UTC 16:00 = 北京时间次日 00:00', () => {
expect(toBeijingIso(new Date('2026-09-11T16:00:00Z'))).toBe('2026-09-12T00:00:00+08:00')
})
test('跨月边界', () => {
expect(toBeijingIso(new Date('2026-09-30T16:00:00Z'))).toBe('2026-10-01T00:00:00+08:00')
})
})
describe('fromUnixSeconds / expiresAtFromUnix', () => {
test('Unix 秒 → Date', () => {
expect(fromUnixSeconds(1757590632).getTime()).toBe(1757590632 * 1000)
})
test('expires_at:0 / null → null(永不过期)', () => {
expect(expiresAtFromUnix(0)).toBeNull()
expect(expiresAtFromUnix(null)).toBeNull()
expect(expiresAtFromUnix(undefined)).toBeNull()
})
test('expires_at 非 0 → Date', () => {
expect(expiresAtFromUnix(1757590632)?.getTime()).toBe(1757590632 * 1000)
})
})
describe('parseInputTime(输入宽容,契约 约定 9)', () => {
test('接受 +08:00 与 Z,解析为同一绝对时刻', () => {
expect(parseInputTime('2026-09-11T19:37:12+08:00')?.toISOString()).toBe('2026-09-11T11:37:12.000Z')
expect(parseInputTime('2026-09-11T11:37:12Z')?.toISOString()).toBe('2026-09-11T11:37:12.000Z')
})
test('无效输入 → null', () => {
expect(parseInputTime('not-a-date')).toBeNull()
})
})
+36
View File
@@ -0,0 +1,36 @@
/**
* 时间工具(契约 约定 9/10;同步引擎设计 §3.6):
* - 对外时间一律北京时间 `+08:00` 的 ISO 8601(`+08:00` 是产品口径,与服务器时区无关)
* - 上游 `created_at` 是 UTC Unix 秒;时间范围左闭右开 `[start, end)`
*/
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000
const pad = (n: number, width = 2): string => String(n).padStart(width, '0')
/** Date → `2026-09-11T19:37:12+08:00`(毫秒非零时保留 3 位毫秒) */
export function toBeijingIso(date: Date): string {
const t = new Date(date.getTime() + BEIJING_OFFSET_MS)
const base =
`${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}` +
`T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}:${pad(t.getUTCSeconds())}`
const ms = t.getUTCMilliseconds()
return ms === 0 ? `${base}+08:00` : `${base}.${pad(ms, 3)}+08:00`
}
/** 上游 UTC Unix 秒 → Date */
export function fromUnixSeconds(seconds: number): Date {
return new Date(seconds * 1000)
}
/** 上游 `expires_at` 约定:0 = 永不过期 → null(api_keys 设计 §3.5) */
export function expiresAtFromUnix(seconds: number | null | undefined): Date | null {
if (!seconds) return null
return fromUnixSeconds(seconds)
}
/** 解析输入时间(宽容:任意带偏移的 ISO 8601,含 `Z` / 毫秒;无效返回 null) */
export function parseInputTime(input: string): Date | null {
const d = new Date(input)
return Number.isNaN(d.getTime()) ? null : d
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 上游字段全组(`api_keys` 表 8 个上游列)——登记 / 启用 / 每轮额度采集共用一处,
* 避免三处各写一份导致漏列(api_keys 表设计 §3.4/§3.5:整组一起写)。
*/
import { toJsonb } from '../sync/log-row'
import type { UpstreamTokenUsage } from '../upstream/types'
import { expiresAtFromUnix } from './time'
export function upstreamColumns(usage: UpstreamTokenUsage) {
return {
tokenName: usage.name,
totalGranted: usage.total_granted,
totalUsed: usage.total_used,
totalAvailable: usage.total_available,
unlimitedQuota: usage.unlimited_quota,
// jsonb 列需显式 ::jsonb(见 log-row.ts 文件头注释的写入陷阱)
modelLimits: toJsonb(usage.model_limits),
modelLimitsEnabled: usage.model_limits_enabled,
expiresAt: expiresAtFromUnix(usage.expires_at),
}
}
+141 -33
View File
@@ -1,6 +1,17 @@
import { Hono } from 'hono'
import { asc, eq } from 'drizzle-orm'
import type { PatchApiKeyRequest, RegisterApiKeyRequest } from '@naku/shared'
import { apiError, isUniqueViolation, upstreamErrorResponse } from '../../lib/api-error'
import { getKeySecrets } from '../../lib/keys'
import { toApiKeyRow } from '../../lib/serialize'
import { upstreamColumns } from '../../lib/upstream-columns'
import { manageAuth } from '../../middleware/manage-auth'
import { getDb } from '../../db/client'
import { apiKeys } from '../../db/schema'
import { requestImmediateSync } from '../../sync'
import { getUpstreamClient } from '../../upstream/client'
import type { UpstreamTokenUsage } from '../../upstream/types'
/**
* 管理面 · key 管理(契约 §2.2):清单 / 登记 / 启停。
@@ -11,40 +22,137 @@ export const apiKeysRoutes = new Hono()
apiKeysRoutes.use(manageAuth)
/** 清单(按 id 升序,不分页) */
apiKeysRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:key 清单(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
apiKeysRoutes.get('/', async (c) => {
const rows = await getDb().select().from(apiKeys).orderBy(asc(apiKeys.id))
return c.json(rows.map(toApiKeyRow))
})
/** 登记:body `{ key }`;上游验证通过 → 入库(不阻塞首轮同步) */
apiKeysRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记 key(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
apiKeysRoutes.post('/', async (c) => {
let body: RegisterApiKeyRequest
try {
body = (await c.req.json()) as RegisterApiKeyRequest
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
const plainKey = typeof body?.key === 'string' ? body.key.trim() : ''
if (!plainKey) {
return apiError(c, 400, 'invalid_params', 'key 缺失或为空')
}
const db = getDb()
const secrets = getKeySecrets()
const keyHash = secrets.keyHash(plainKey)
const existing = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(eq(apiKeys.keyHash, keyHash))
.limit(1)
if (existing.length > 0) {
return apiError(c, 409, 'already_registered', '该 key 已登记')
}
// 上游验证(GET /api/usage/token)——同一次成功调用即一次额度采集:
// 上游字段全组 + usage_synced_at 随登记写入;log_synced_at 由引擎首轮补同步(契约 §2.2)
const requestedAt = new Date()
let usage: UpstreamTokenUsage
try {
usage = await getUpstreamClient().fetchTokenUsage(plainKey)
} catch (err) {
const res = upstreamErrorResponse(c, err)
if (res) return res
throw err
}
try {
const [row] = await db
.insert(apiKeys)
.values({
keyHash,
keyCiphertext: secrets.encryptKey(plainKey),
status: 'active',
disabledSource: null,
consecutive401s: 0,
usageSyncedAt: requestedAt,
...upstreamColumns(usage),
})
.returning()
requestImmediateSync() // 首轮日志同步:立即异步补一轮,不阻塞登记响应(假设 #7)
return c.json(toApiKeyRow(row!), 201)
} catch (err) {
if (isUniqueViolation(err)) {
return apiError(c, 409, 'already_registered', '该 key 已登记')
}
throw err
}
})
/** 启停:body `{ status: 'active' | 'disabled' }`;启用需上游验证、成功清零 consecutive_401s */
apiKeysRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:启停(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
apiKeysRoutes.patch('/:id', async (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id) || id <= 0) {
return apiError(c, 400, 'invalid_params', 'id 非法')
}
let body: PatchApiKeyRequest
try {
body = (await c.req.json()) as PatchApiKeyRequest
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
const status = body?.status
if (status !== 'active' && status !== 'disabled') {
return apiError(c, 400, 'invalid_params', "status 必须是 'active' 或 'disabled'")
}
const db = getDb()
const [row] = await db.select().from(apiKeys).where(eq(apiKeys.id, id)).limit(1)
if (!row) {
return apiError(c, 404, 'not_found', `id=${id} 的 key 不存在`)
}
// 幂等:提交当前已是的状态直接返回(不重复验证、不覆盖停用来源)
if (row.status === status) {
return c.json(toApiKeyRow(row))
}
if (status === 'disabled') {
const [updated] = await db
.update(apiKeys)
.set({ status: 'disabled', disabledSource: 'manual' })
.where(eq(apiKeys.id, id))
.returning()
return c.json(toApiKeyRow(updated!))
}
// 启用:上游验证通过 → active + 清 disabled_source + consecutive_401s 清零
// (验证调用本身即一次成功调用,否则下一轮若 401 会立刻再次摘除——契约 §2.2)
let plainKey: string
try {
plainKey = getKeySecrets().decryptKey(Buffer.from(row.keyCiphertext))
} catch (err) {
console.error(`[manage] key_id=${id} 解密失败(KEYS_MASTER_KEY 是否变更?)`, err)
return apiError(c, 500, 'internal', '凭证解密失败:KEYS_MASTER_KEY 变更后需重新登记该 key')
}
const requestedAt = new Date()
let usage: UpstreamTokenUsage
try {
usage = await getUpstreamClient().fetchTokenUsage(plainKey)
} catch (err) {
const res = upstreamErrorResponse(c, err)
if (res) return res
throw err
}
const [updated] = await db
.update(apiKeys)
.set({
status: 'active',
disabledSource: null,
consecutive401s: 0,
usageSyncedAt: requestedAt,
...upstreamColumns(usage),
})
.where(eq(apiKeys.id, id))
.returning()
return c.json(toApiKeyRow(updated!))
})
+205 -55
View File
@@ -1,6 +1,13 @@
import { Hono } from 'hono'
import { desc, eq, sql } from 'drizzle-orm'
import { apiError } from '../../lib/api-error'
import { matchPurchaseId } from '../../lib/purchases-match'
import { toPurchaseRow } from '../../lib/serialize'
import { parseInputTime, toBeijingIso } from '../../lib/time'
import { manageAuth } from '../../middleware/manage-auth'
import { getDb } from '../../db/client'
import { purchases, usageLogs } from '../../db/schema'
/**
* 管理面 · 购买记录(契约 §2.3):清单 / 登记 / 编辑 / 删除 / 观测(核对提示)。
@@ -10,67 +17,210 @@ export const purchasesRoutes = new Hono()
purchasesRoutes.use(manageAuth)
type ColumnValues = Partial<typeof purchases.$inferInsert>
const has = (body: Record<string, unknown>, k: string) =>
Object.prototype.hasOwnProperty.call(body, k)
/** 宽严相济的字段解析:出现即校验;`null` 表示「显式清空」(仅可空列) */
function parseBody(
body: Record<string, unknown>,
partial: boolean,
): { error: string } | { values: ColumnValues } {
const values: ColumnValues = {}
if (!partial || has(body, 'kind')) {
const kind = body.kind
if (kind !== 'subscription' && kind !== 'topup') {
return { error: "kind 必须是 'subscription' 或 'topup'" }
}
values.kind = kind
}
if (!partial || has(body, 'paid_at')) {
const paidAt = typeof body.paid_at === 'string' ? parseInputTime(body.paid_at) : null
if (!paidAt) return { error: 'paid_at 缺失或无法解析(ISO 8601)' }
values.paidAt = paidAt
}
if (!partial || has(body, 'paid_amount')) {
const amount = body.paid_amount
if (typeof amount !== 'number' || !Number.isFinite(amount) || amount < 0) {
return { error: 'paid_amount 必须是非负数字' }
}
values.paidAmount = amount
}
if (has(body, 'quota_granted')) {
const q = body.quota_granted
if (q === null) {
if (!partial) return { error: 'quota_granted 必填(折算链的分母)' }
values.quotaGranted = null
} else if (typeof q !== 'number' || !Number.isInteger(q) || q < 0) {
return { error: 'quota_granted 必须是非负整数' }
} else {
values.quotaGranted = q
}
} else if (!partial) {
return { error: 'quota_granted 必填(折算链的分母)' }
}
if (has(body, 'note')) {
if (body.note === null) values.note = null
else if (typeof body.note === 'string') values.note = body.note
else return { error: 'note 必须是字符串或 null' }
}
for (const key of ['period_start', 'period_end'] as const) {
if (!has(body, key)) continue
const raw = body[key]
if (raw === null) {
values[key === 'period_start' ? 'periodStart' : 'periodEnd'] = null
continue
}
const parsed = typeof raw === 'string' ? parseInputTime(raw) : null
if (!parsed) return { error: `${key} 无法解析(ISO 8601)或为 null` }
values[key === 'period_start' ? 'periodStart' : 'periodEnd'] = parsed
}
if (has(body, 'subscription_id')) {
const sid = body.subscription_id
if (sid === null) {
values.subscriptionId = null
} else if (typeof sid === 'number' && Number.isInteger(sid) && sid > 0) {
values.subscriptionId = sid
} else {
return { error: 'subscription_id 必须是正整数或 null' }
}
}
return { values }
}
/** 清单(按 paid_at DESC,同时间按 id DESC;不分页) */
purchasesRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:purchases 清单(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.get('/', async (c) => {
const rows = await getDb()
.select()
.from(purchases)
.orderBy(desc(purchases.paidAt), desc(purchases.id))
return c.json(rows.map(toPurchaseRow))
})
/** 观测(核对提示的数据面):按日志 other.subscription_id 聚合 */
purchasesRoutes.get('/observations', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:观测(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.get('/observations', async (c) => {
const db = getDb()
const raw = (await db.execute(sql`
select
(${usageLogs.other} ->> 'subscription_id')::bigint as subscription_id,
(array_agg(${usageLogs.other} ->> 'subscription_plan_title' order by ${usageLogs.createdAt} desc))[1] as plan_title,
max((${usageLogs.other} ->> 'subscription_total')::bigint) as observed_quota_total,
min(${usageLogs.createdAt}) as first_seen_at,
max(${usageLogs.createdAt}) as last_seen_at,
count(*)::int as log_count
from ${usageLogs}
where ${usageLogs.other} ? 'subscription_id'
group by 1
order by 1
`)) as unknown
// 驱动差异兜底:bun-sql 直接返回行数组,部分驱动包一层 { rows }
const rows = (Array.isArray(raw) ? raw : ((raw as { rows?: unknown[] }).rows ?? [])) as Array<
Record<string, unknown>
>
const candidates = await db
.select({
id: purchases.id,
subscriptionId: purchases.subscriptionId,
periodStart: purchases.periodStart,
periodEnd: purchases.periodEnd,
})
.from(purchases)
const toDate = (v: unknown): Date => (v instanceof Date ? v : new Date(String(v)))
const toNumber = (v: unknown): number | null => (v === null || v === undefined ? null : Number(v))
const items = rows.map((r) => {
const subscriptionId = Number(r.subscription_id)
return {
subscription_id: subscriptionId,
plan_title: (r.plan_title ?? null) as string | null,
observed_quota_total: toNumber(r.observed_quota_total),
first_seen_at: toBeijingIso(toDate(r.first_seen_at)),
last_seen_at: toBeijingIso(toDate(r.last_seen_at)),
log_count: Number(r.log_count ?? 0),
matched_purchase_id: matchPurchaseId(subscriptionId, candidates),
}
})
return c.json({ items })
})
/** 登记 */
purchasesRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.post('/', async (c) => {
let raw: unknown
try {
raw = await c.req.json()
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON 对象')
}
const parsed = parseBody(raw as Record<string, unknown>, false)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const values = parsed.values
if (
values.periodStart &&
values.periodEnd &&
values.periodStart.getTime() >= values.periodEnd.getTime()
) {
return apiError(c, 400, 'invalid_params', 'period_start 必须早于 period_end(左闭右开)')
}
const [row] = await getDb().insert(purchases).values(values as typeof purchases.$inferInsert).returning()
return c.json(toPurchaseRow(row!), 201)
})
/** 编辑(部分更新) */
purchasesRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:编辑购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.patch('/:id', async (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id) || id <= 0) return apiError(c, 400, 'invalid_params', 'id 非法')
let raw: unknown
try {
raw = await c.req.json()
} catch {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON')
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return apiError(c, 400, 'invalid_params', '请求体必须是 JSON 对象')
}
const parsed = parseBody(raw as Record<string, unknown>, true)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const db = getDb()
const [existing] = await db.select().from(purchases).where(eq(purchases.id, id)).limit(1)
if (!existing) return apiError(c, 404, 'not_found', `id=${id} 的购买记录不存在`)
// 期间顺序用「改后」的值校验(部分更新时另一头取库中现值)
const periodStart = 'periodStart' in parsed.values ? parsed.values.periodStart : existing.periodStart
const periodEnd = 'periodEnd' in parsed.values ? parsed.values.periodEnd : existing.periodEnd
if (periodStart && periodEnd && periodStart.getTime() >= periodEnd.getTime()) {
return apiError(c, 400, 'invalid_params', 'period_start 必须早于 period_end(左闭右开)')
}
const [row] = await db
.update(purchases)
.set(parsed.values)
.where(eq(purchases.id, id))
.returning()
return c.json(toPurchaseRow(row!))
})
/** 删除(物理) */
purchasesRoutes.delete('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:删除购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
purchasesRoutes.delete('/:id', async (c) => {
const id = Number(c.req.param('id'))
if (!Number.isInteger(id) || id <= 0) return apiError(c, 400, 'invalid_params', 'id 非法')
const rows = await getDb().delete(purchases).where(eq(purchases.id, id)).returning()
if (rows.length === 0) return apiError(c, 404, 'not_found', `id=${id} 的购买记录不存在`)
return c.json(toPurchaseRow(rows[0]!))
})
+8 -11
View File
@@ -1,6 +1,10 @@
import { Hono } from 'hono'
import { asc } from 'drizzle-orm'
import { toQpuRow } from '../../lib/serialize'
import { manageAuth } from '../../middleware/manage-auth'
import { getDb } from '../../db/client'
import { qpuHistory } from '../../db/schema'
/**
* 管理面 · 换算参数时间线(契约 §2.4):纯只读——`qpu_history` 唯一写入者是同步引擎。
@@ -10,14 +14,7 @@ export const qpuRoutes = new Hono()
qpuRoutes.use(manageAuth)
/** 时间线清单(按 effective_from 升序;不分页) */
qpuRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:QPU 时间线(见 docs/design/API契约.md §2.4)',
},
},
501,
),
)
qpuRoutes.get('/', async (c) => {
const rows = await getDb().select().from(qpuHistory).orderBy(asc(qpuHistory.effectiveFrom))
return c.json(rows.map(toQpuRow))
})
+331 -22
View File
@@ -1,4 +1,25 @@
import { Hono } from 'hono'
import { and, desc, eq, gte, inArray, lt, sql } from 'drizzle-orm'
import type { Context } from 'hono'
import {
KEYS_MAX,
LOGS_DEFAULT_LIMIT,
LOGS_MAX_LIMIT,
type ApiKeyRow as ApiKeyRowContract,
} from '@naku/shared'
import type { LogOther, LogType, QueryMeta, QueryMetaKey, SummaryResponse, UsageLogRow } from '@naku/shared'
import { apiError } from '../lib/api-error'
import { getKeySecrets } from '../lib/keys'
import {
amountRmbFor,
readSubscriptionId,
resolvePurchaseForLog,
type PurchaseLite,
} from '../lib/pricing'
import { parseInputTime, toBeijingIso } from '../lib/time'
import { getDb } from '../db/client'
import { apiKeys, purchases, usageLogs } from '../db/schema'
/**
* 查看面(契约 §2.1):无会话、无服务端状态——key 集合与过滤参数全在请求体。
@@ -6,26 +27,314 @@ import { Hono } from 'hono'
*/
export const usageRoutes = new Hono()
usageRoutes.post('/logs/query', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:明细查询(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
type ApiKeyRow = typeof apiKeys.$inferSelect
type UsageLogDbRow = typeof usageLogs.$inferSelect
usageRoutes.post('/summary', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:卡片汇总(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
// ---------- 请求解析 ----------
async function readJsonObject(
c: Context,
): Promise<{ error: string } | { value: Record<string, unknown> }> {
let raw: unknown
try {
raw = await c.req.json()
} catch {
return { error: '请求体必须是 JSON' }
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return { error: '请求体必须是 JSON 对象' }
}
return { value: raw as Record<string, unknown> }
}
interface QueryInput {
keys: string[]
start: Date
end: Date
}
/** 公共参数(keys / start / end)——两个端点同一套(约定 17/19 + §2.1 参数清单) */
function parseBase(body: Record<string, unknown>): { error: string } | { input: QueryInput } {
const rawKeys = body.keys
if (!Array.isArray(rawKeys) || rawKeys.length === 0) {
return { error: 'keys 必填(非空数组)' }
}
const keys = [
...new Set(
rawKeys
.filter((k): k is string => typeof k === 'string')
.map((k) => k.trim())
.filter((k) => k.length > 0),
),
]
if (keys.length === 0) return { error: 'keys 必填(非空数组)' }
if (keys.length > KEYS_MAX) {
return { error: `keys 数量超过上限(去重后最多 ${KEYS_MAX} 个)` }
}
const start = typeof body.start === 'string' ? parseInputTime(body.start) : null
if (!start) return { error: 'start 缺失或无法解析(ISO 8601)' }
const end = typeof body.end === 'string' ? parseInputTime(body.end) : null
if (!end) return { error: 'end 缺失或无法解析(ISO 8601)' }
if (start.getTime() >= end.getTime()) {
return { error: 'start 必须早于 end(左闭右开)' }
}
return { input: { keys, start, end } }
}
// ---------- key 命中与 meta(约定 5 / 22 / 23) ----------
interface ResolvedKey {
plain: string
row: ApiKeyRow | null
}
/** 未收录 key 的即时提示:`sk-…a1b2`(即时生成、不落库——api_keys 表设计 §5) */
function keyHint(plainKey: string): string {
const tail = plainKey.slice(-4)
return plainKey.length <= 8 ? `…${tail}` : `${plainKey.slice(0, 3)}…${tail}`
}
async function resolveKeys(keys: string[]): Promise<ResolvedKey[]> {
const secrets = getKeySecrets()
const hashes = keys.map((k) => secrets.keyHash(k))
const rows = await getDb().select().from(apiKeys).where(inArray(apiKeys.keyHash, hashes))
const byHash = new Map(rows.map((r) => [r.keyHash, r]))
return keys.map((plain, i) => ({ plain, row: byHash.get(hashes[i]!) ?? null }))
}
/** meta:`data_until` 取参与 key 中「非 null 的最慢 `log_synced_at`」;全部未同步时为 null */
function buildMeta(resolved: ResolvedKey[]): QueryMeta {
const keys: QueryMetaKey[] = resolved.map(({ plain, row }) =>
row
? ({
key_id: row.id,
token_name: row.tokenName,
registered: true,
status: row.status as ApiKeyRowContract['status'],
log_synced_at: row.logSyncedAt ? toBeijingIso(row.logSyncedAt) : null,
} satisfies QueryMetaKey)
: ({ registered: false, key_hint: keyHint(plain) } satisfies QueryMetaKey),
)
const syncedMs = resolved
.map((r) => r.row?.logSyncedAt)
.filter((d): d is Date => d instanceof Date)
.map((d) => d.getTime())
return {
data_until: syncedMs.length > 0 ? toBeijingIso(new Date(Math.max(...syncedMs))) : null,
served_at: toBeijingIso(new Date()),
keys,
}
}
// ---------- 行映射与折算 ----------
async function loadPurchases(): Promise<PurchaseLite[]> {
return await getDb()
.select({
id: purchases.id,
paidAmount: purchases.paidAmount,
quotaGranted: purchases.quotaGranted,
subscriptionId: purchases.subscriptionId,
periodStart: purchases.periodStart,
periodEnd: purchases.periodEnd,
})
.from(purchases)
}
/** 明细行 → 契约行:表 1:1(除 `dedupe_key`)+ `token_name` 取当前名 + `amount_rmb` 折算 */
function toUsageLogRow(
log: UsageLogDbRow,
currentTokenName: string,
allPurchases: PurchaseLite[],
): UsageLogRow {
const other = (log.other ?? {}) as LogOther
const purchase = resolvePurchaseForLog(
log.createdAt,
readSubscriptionId(other),
other.billing_source,
allPurchases,
)
return {
id: log.id,
key_id: log.keyId,
user_id: log.userId,
username: log.username,
created_at: toBeijingIso(log.createdAt),
type: log.type as LogType,
content: log.content,
token_name: currentTokenName,
model_name: log.modelName,
quota: log.quota,
prompt_tokens: log.promptTokens,
completion_tokens: log.completionTokens,
use_time: log.useTime,
is_stream: log.isStream,
channel: log.channel,
channel_name: log.channelName,
token_id: log.tokenId,
group_name: log.groupName,
ip: log.ip,
request_id: log.requestId,
upstream_request_id: log.upstreamRequestId,
amount_rmb: amountRmbFor(log.quota, purchase),
other,
}
}
// ---------- 端点 ----------
/** 明细(分页;排序固定 created_at DESC——约定 16) */
usageRoutes.post('/logs/query', async (c) => {
const body = await readJsonObject(c)
if ('error' in body) return apiError(c, 400, 'invalid_params', body.error)
const parsed = parseBase(body.value)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const { keys, start, end } = parsed.input
let types: number[] | null = null
if (body.value.type !== undefined) {
const raw = body.value.type
if (
!Array.isArray(raw) ||
raw.length === 0 ||
raw.some((t) => !Number.isInteger(t) || (t as number) < 0 || (t as number) > 7)
) {
return apiError(c, 400, 'invalid_params', 'type 必须是非空的 0–7 整数数组')
}
types = [...new Set(raw as number[])]
}
const limit = body.value.limit === undefined ? LOGS_DEFAULT_LIMIT : Number(body.value.limit)
if (!Number.isInteger(limit) || limit <= 0) {
return apiError(c, 400, 'invalid_params', 'limit 必须是正整数')
}
if (limit > LOGS_MAX_LIMIT) {
return apiError(c, 400, 'invalid_params', `limit 超过上限(最多 ${LOGS_MAX_LIMIT})`)
}
const offset = body.value.offset === undefined ? 0 : Number(body.value.offset)
if (!Number.isInteger(offset) || offset < 0) {
return apiError(c, 400, 'invalid_params', 'offset 必须是非负整数')
}
const db = getDb()
const resolved = await resolveKeys(keys)
const keyIds = resolved.filter((r) => r.row !== null).map((r) => r.row!.id)
const meta = buildMeta(resolved)
// 全部未收录:200 + 空结果(约定 23——未收录是结果、不是错误)
if (keyIds.length === 0) {
return c.json({ items: [], total: 0, meta })
}
const conditions = [
inArray(usageLogs.keyId, keyIds),
gte(usageLogs.createdAt, start),
lt(usageLogs.createdAt, end),
]
if (types) conditions.push(inArray(usageLogs.type, types))
const where = and(...conditions)
const totalRows = await db
.select({ total: sql<number>`count(*)::int` })
.from(usageLogs)
.where(where)
const rows = await db
.select({ log: usageLogs, currentTokenName: apiKeys.tokenName })
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.keyId, apiKeys.id))
.where(where)
.orderBy(desc(usageLogs.createdAt))
.limit(limit)
.offset(offset)
const allPurchases = await loadPurchases()
const items = rows.map(({ log, currentTokenName }) =>
toUsageLogRow(log, currentTokenName, allPurchases),
)
return c.json({ items, total: Number(totalRows[0]?.total ?? 0), meta })
})
/** 汇总(卡片;无 type / limit / offset) */
usageRoutes.post('/summary', async (c) => {
const body = await readJsonObject(c)
if ('error' in body) return apiError(c, 400, 'invalid_params', body.error)
const parsed = parseBase(body.value)
if ('error' in parsed) return apiError(c, 400, 'invalid_params', parsed.error)
const { keys, start, end } = parsed.input
const db = getDb()
const resolved = await resolveKeys(keys)
const keyIds = resolved.filter((r) => r.row !== null).map((r) => r.row!.id)
const meta = buildMeta(resolved)
const empty: SummaryResponse = {
request_count: 0,
consume_count: 0,
error_count: 0,
prompt_tokens: 0,
completion_tokens: 0,
cache_tokens: 0,
quota: 0,
amount_rmb: 0,
unpriced_count: 0,
meta,
}
if (keyIds.length === 0) return c.json(empty)
const where = and(
inArray(usageLogs.keyId, keyIds),
gte(usageLogs.createdAt, start),
lt(usageLogs.createdAt, end),
)
const aggRows = await db
.select({
request_count: sql<number>`count(*)::int`,
consume_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)::int`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} = 5)::int`,
prompt_tokens: sql<number>`coalesce(sum(${usageLogs.promptTokens}), 0)::bigint`,
completion_tokens: sql<number>`coalesce(sum(${usageLogs.completionTokens}), 0)::bigint`,
cache_tokens: sql<number>`coalesce(sum((${usageLogs.other} ->> 'cache_tokens')::bigint), 0)::bigint`,
quota: sql<number>`coalesce(sum(${usageLogs.quota}), 0)::bigint`,
})
.from(usageLogs)
.where(where)
const agg = aggRows[0]
// 折算:按行取归属购买(跨期自然分段);`unpriced_count` = 无法折算的行数
const pricingRows = await db
.select({ quota: usageLogs.quota, createdAt: usageLogs.createdAt, other: usageLogs.other })
.from(usageLogs)
.where(where)
const allPurchases = await loadPurchases()
let amountRmb = 0
let unpricedCount = 0
for (const r of pricingRows) {
const other = (r.other ?? {}) as LogOther
const amount = amountRmbFor(
r.quota,
resolvePurchaseForLog(
r.createdAt,
readSubscriptionId(other),
other.billing_source,
allPurchases,
),
)
if (amount === null) unpricedCount++
else amountRmb += amount
}
const response: SummaryResponse = {
request_count: Number(agg?.request_count ?? 0),
consume_count: Number(agg?.consume_count ?? 0),
error_count: Number(agg?.error_count ?? 0),
prompt_tokens: Number(agg?.prompt_tokens ?? 0),
completion_tokens: Number(agg?.completion_tokens ?? 0),
cache_tokens: Number(agg?.cache_tokens ?? 0),
quota: Number(agg?.quota ?? 0),
amount_rmb: amountRmb,
unpriced_count: unpricedCount,
meta,
}
return c.json(response)
})
+53
View File
@@ -0,0 +1,53 @@
/**
* 去重键单测(usage_logs 表设计 §3.1 + 实施计划「实现假设」#5)。
*/
import { describe, expect, test } from 'bun:test'
import { contentFingerprint, dedupeKeyFor } from './dedupe'
import type { UpstreamLogRecord } from '../upstream/types'
const log = (over: Partial<UpstreamLogRecord> = {}): UpstreamLogRecord => ({
id: 1,
user_id: 5,
created_at: 1789126642,
type: 2,
content: '',
username: 'chendong',
token_name: 'yangsiyue',
model_name: 'glm-5.3-flash',
quota: 702,
prompt_tokens: 29157,
completion_tokens: 1036,
use_time: 30,
is_stream: true,
channel: 7,
channel_name: '',
token_id: 29,
group: 'default',
ip: '',
request_id: '202609111136522416558528268d9d6CRxC30kh',
other: '{}',
...over,
})
describe('dedupeKeyFor', () => {
test('request_id 存在 → `${key_id}:${request_id}`', () => {
expect(dedupeKeyFor(3, log())).toBe('3:202609111136522416558528268d9d6CRxC30kh')
})
test('跨 key 隔离:同一 request_id 在不同 key 下不同键', () => {
expect(dedupeKeyFor(3, log())).not.toBe(dedupeKeyFor(4, log()))
})
test('request_id 为空 → 内容指纹(同输入稳定)、前缀区分 `fp:`', () => {
const a = dedupeKeyFor(3, log({ request_id: '' }))
const b = dedupeKeyFor(3, log({ request_id: '' }))
expect(a).toBe(b)
expect(a.startsWith('fp:')).toBe(true)
})
test('指纹随内容变化(真实不同请求不同键)', () => {
expect(contentFingerprint(3, log())).not.toBe(contentFingerprint(3, log({ quota: 703 })))
expect(contentFingerprint(3, log())).not.toBe(contentFingerprint(4, log()))
})
})
+26
View File
@@ -0,0 +1,26 @@
/**
* 去重键生成(usage_logs 表设计 §3.1 + 实施计划「实现假设」#5):
* `dedupe_key = ${key_id}:${request_id}`;`request_id` 为空时退化为内容指纹
* `sha256(key_id|created_at|model_name|quota|prompt_tokens|completion_tokens)`。
*/
import { createHash } from 'node:crypto'
import type { UpstreamLogRecord } from '../upstream/types'
/** 内容指纹(仅 `request_id` 缺失时启用) */
export function contentFingerprint(keyId: number, log: UpstreamLogRecord): string {
const parts = [
keyId,
log.created_at,
log.model_name ?? '',
log.quota,
log.prompt_tokens,
log.completion_tokens,
].join('|')
return createHash('sha256').update(parts, 'utf8').digest('hex')
}
export function dedupeKeyFor(keyId: number, log: UpstreamLogRecord): string {
const requestId = (log.request_id ?? '').trim()
return requestId ? `${keyId}:${requestId}` : `fp:${contentFingerprint(keyId, log)}`
}
+213 -18
View File
@@ -1,24 +1,206 @@
/**
* 同步引擎(见 `docs/design/同步引擎设计.md`)——骨架占位。
* 同步引擎(见 `docs/design/同步引擎设计.md`):调度 + 单轮执行。
*
* 已定骨架(§3.2 / §3.3 / §3.4):
* - 后端进程内定时器(不引 worker、不拆进程)
* - 周期 5 分钟,固定 tick + 防重入
* - 启动立即跑一轮
* - 单轮串行遍历全部 active key
* - 每轮:日志(整批单事务入库 → log_synced_at 取请求发起时刻)· 额度(上游字段全组 + usage_synced_at)· QPU 采样(每轮一次、值变化才追加)
* - 失败:401 计数(N=3 摘除)· 403/500/网络不计数 · 不单轮重试
* 结构(§3.2 / §3.3 / §3.4):
* - 后端进程内定时器(不引 worker、不拆进程);周期 5 分钟,固定 tick + 防重入;启动立即跑一轮
* - 单轮串行遍历全部 `active` key;每 key 依次:日志(整批单事务入库 → `log_synced_at` 取请求发起时刻)
* → 额度(上游字段全组 + `usage_synced_at`,与日志各自独立 try)
* - 每轮一次 QPU 采样(独立于 key 循环;`qpu_history` 为空时插基线)
* - 失败:401 计数(N=3 摘除,计数只认 401)· 403/500/网络不计数 · 不单轮重试
* - 可观测:`[sync]` 前缀一行式日志;每轮摘要;`log_synced_at` 落后 > 24h 每轮 warning
*/
import { asc, desc, eq } from 'drizzle-orm'
const TICK_MS = 5 * 60 * 1000
import { getDb } from '../db/client'
import { apiKeys, qpuHistory, usageLogs } from '../db/schema'
import { getKeySecrets } from '../lib/keys'
import { upstreamColumns } from '../lib/upstream-columns'
import { getUpstreamClient } from '../upstream/client'
import { errorMessage, UpstreamError } from '../upstream/errors'
import type { UpstreamLogRecord } from '../upstream/types'
import { toLogRow } from './log-row'
import {
combineRoundResult,
nextKeyCounter,
STALE_WARN_MS,
TICK_MS,
type CallResult,
} from './policy'
type KeyRow = typeof apiKeys.$inferSelect
interface RoundSummary {
keyCount: number
okCount: number
failCount: number
inserted: number
}
let running = false
/** 一轮:遍历全部 active key(串行)→ 日志 / 额度;每轮一次 QPU 采样 */
async function runOnce(): Promise<void> {
// TODO(#5): 日志同步 —— GET /api/log/token → 整批单事务入库(ON CONFLICT (dedupe_key) DO NOTHING)→ 写 log_synced_at
// TODO(#6): 额度采集 —— GET /api/usage/token → 上游字段全组 + usage_synced_at(与日志各自独立)
// TODO(#7): QPU 采样 —— GET /api/status → 值变化才追加 qpu_history(每轮一次,独立于 key 循环)
/** 上游错误 → 本轮结果:只有 401 是「key 失效」信号(403 = 账号封禁,不计数) */
function classify(err: unknown): CallResult {
if (err instanceof UpstreamError && err.kind === 'key_rejected' && err.status === 401) {
return 'unauthorized'
}
return 'failed'
}
/**
* 日志整批单事务入库(`ON CONFLICT (dedupe_key) DO NOTHING`)+ 写 `log_synced_at`
* ——全成或全不写:失败则「数据截止」不前移,下一轮幂等重来(§3.3)。
*/
async function persistLogs(
keyId: number,
logs: UpstreamLogRecord[],
syncedAt: Date,
): Promise<number> {
const db = getDb()
const rows = logs.map((log) => toLogRow(keyId, log))
return await db.transaction(async (tx) => {
let inserted = 0
if (rows.length > 0) {
const res = await tx
.insert(usageLogs)
.values(rows)
.onConflictDoNothing({ target: usageLogs.dedupeKey })
.returning({ id: usageLogs.id })
inserted = res.length
}
await tx.update(apiKeys).set({ logSyncedAt: syncedAt }).where(eq(apiKeys.id, keyId))
return inserted
})
}
/** 单 key 一轮:日志 → 额度(各自独立 try)→ 状态机(每轮一次) */
async function syncOneKey(key: KeyRow): Promise<{ round: CallResult; inserted: number }> {
const db = getDb()
let plainKey: string
try {
plainKey = getKeySecrets().decryptKey(Buffer.from(key.keyCiphertext))
} catch (err) {
// 解密失败:记日志、跳过、不影响其他 key;不计数、不摘除(§3.4)
console.error(
`[sync] key_id=${key.id} 凭证解密失败,跳过本轮(需人工重新登记):${errorMessage(err)}`,
)
return { round: 'failed', inserted: 0 }
}
const results: CallResult[] = []
let inserted = 0
// —— 日志同步(独立 try)——
const logRequestedAt = new Date()
const logStartedMs = Date.now()
try {
const logs = await getUpstreamClient().fetchLogs(plainKey)
inserted = await persistLogs(key.id, logs, logRequestedAt)
results.push('ok')
console.log(
`[sync] key_id=${key.id} 日志同步:上游 ${logs.length} 条,新增 ${inserted} 条(${Date.now() - logStartedMs}ms)`,
)
} catch (err) {
results.push(classify(err))
console.error(
`[sync] key_id=${key.id} 日志同步失败(${Date.now() - logStartedMs}ms):${errorMessage(err)}`,
)
}
// —— 额度采集(独立 try;与日志互不影响、各自写各自的时间戳)——
const usageRequestedAt = new Date()
const usageStartedMs = Date.now()
try {
const usage = await getUpstreamClient().fetchTokenUsage(plainKey)
await db
.update(apiKeys)
.set({ usageSyncedAt: usageRequestedAt, ...upstreamColumns(usage) })
.where(eq(apiKeys.id, key.id))
results.push('ok')
} catch (err) {
results.push(classify(err))
console.error(
`[sync] key_id=${key.id} 额度采集失败(${Date.now() - usageStartedMs}ms):${errorMessage(err)}`,
)
}
// —— 401 状态机(每轮一次;401 优先于成功——见实施计划「实现假设」#15)——
const round = combineRoundResult(results)
const next = nextKeyCounter(
{
consecutive401s: key.consecutive401s,
status: key.status as 'active' | 'disabled',
disabledSource: key.disabledSource as 'manual' | 'auto_401' | null,
},
round,
)
const patch: Partial<typeof apiKeys.$inferInsert> = {}
if (next.consecutive401s !== key.consecutive401s) patch.consecutive401s = next.consecutive401s
if (next.status !== key.status) patch.status = next.status
if (next.disabledSource !== key.disabledSource) patch.disabledSource = next.disabledSource
if (Object.keys(patch).length > 0) {
await db.update(apiKeys).set(patch).where(eq(apiKeys.id, key.id))
}
if (next.removed) {
console.warn(
`[sync] key_id=${key.id} 连续 ${next.consecutive401s} 次 401 → 自动摘除(disabled_source=auto_401)`,
)
}
return { round, inserted }
}
/** QPU 采样:每轮一次;`qpu_history` 为空时插基线,值变化才追加(§3.3 / §3.6) */
async function sampleQpu(): Promise<void> {
const db = getDb()
try {
const status = await getUpstreamClient().fetchStatus()
const qpu = status.quota_per_unit
const [last] = await db
.select()
.from(qpuHistory)
.orderBy(desc(qpuHistory.effectiveFrom))
.limit(1)
const effectiveFrom = new Date()
if (!last) {
await db.insert(qpuHistory).values({ qpu, effectiveFrom })
console.log(`[sync] QPU 基线已插入:${qpu}`)
} else if (Number(last.qpu) !== qpu) {
await db.insert(qpuHistory).values({ qpu, effectiveFrom })
console.log(`[sync] QPU 变化:${last.qpu} → ${qpu}`)
}
} catch (err) {
console.error(`[sync] QPU 采样失败:${errorMessage(err)}`)
}
}
/** 一轮:遍历全部 active key(串行)→ 日志 / 额度;随后 QPU 采样(无 key 也跑) */
async function runOnce(): Promise<RoundSummary> {
const db = getDb()
const keys = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.status, 'active'))
.orderBy(asc(apiKeys.id))
const summary: RoundSummary = { keyCount: keys.length, okCount: 0, failCount: 0, inserted: 0 }
const nowMs = Date.now()
for (const key of keys) {
// 「3 天窗口」警示:落后 > 24h 每轮 warning(§3.5)
if (key.logSyncedAt && nowMs - key.logSyncedAt.getTime() > STALE_WARN_MS) {
const hours = Math.floor((nowMs - key.logSyncedAt.getTime()) / 3_600_000)
console.warn(
`[sync] key_id=${key.id} 数据截止已落后 ${hours} 小时(>24h;窗口上限约 3 天,请尽快恢复同步)`,
)
}
const { round, inserted } = await syncOneKey(key)
summary.inserted += inserted
if (round === 'ok') summary.okCount++
else summary.failCount++
}
await sampleQpu()
return summary
}
async function tick(): Promise<void> {
@@ -27,19 +209,32 @@ async function tick(): Promise<void> {
return
}
running = true
const startedMs = Date.now()
try {
await runOnce()
console.log('[sync] 轮次完成(骨架占位:暂无实际采集动作)')
const s = await runOnce()
console.log(
`[sync] 轮次完成:${s.keyCount} key,成功 ${s.okCount},失败 ${s.failCount},新增日志 ${s.inserted} 条,耗时 ${((Date.now() - startedMs) / 1000).toFixed(1)} 秒`,
)
} catch (err) {
console.error('[sync] 轮次异常', err)
console.error(`[sync] 轮次异常:${errorMessage(err)}`)
} finally {
running = false
}
}
/** 启动引擎:立即跑一轮,随后每 5 分钟一次 */
/**
* 启动引擎:立即跑一轮,随后每 5 分钟一次
*/
export function startSyncEngine(): void {
console.log('[sync] 引擎启动:立即跑一轮,随后每 5 分钟一次')
void tick()
setInterval(() => void tick(), TICK_MS)
}
/**
* 「立即异步补一轮」——登记 / 启用成功后触发(API 契约 §2.2:不阻塞响应、不等 tick)。
* 防重入由 tick 自身承担:轮次进行中触发时本轮自然跳过。
*/
export function requestImmediateSync(): void {
void tick()
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 上游日志行 → `usage_logs` 列值(同步引擎与 seed 脚本共用,避免两份映射漂移)。
*
* ⚠️ jsonb 写入陷阱(2026-09-15 实测):drizzle + `bun-sql` 组合下直接传 JS 对象会
* **双重编码**(落库成 `jsonb` 的 *string* 值,`other->>'k'` / `other ? 'k'` 全部失效)。
* 正确写法是 `sql\`${obj}::jsonb\``——由驱动序列化对象一次并由 PG 解析为 object。
*/
import { sql } from 'drizzle-orm'
import { fromUnixSeconds } from '../lib/time'
import type { usageLogs } from '../db/schema'
import type { UpstreamLogRecord } from '../upstream/types'
import { dedupeKeyFor } from './dedupe'
/** 上游 `other`(JSON 字符串)→ jsonb 值;解析失败退化为空对象(保真优先,不丢整条日志) */
export function parseOther(raw: string | undefined | null): Record<string, unknown> {
if (!raw) return {}
try {
const v = JSON.parse(raw) as unknown
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
} catch {
return {}
}
}
/** JS 对象 → 正确的 jsonb 列值(见文件头注释的写入陷阱) */
export function toJsonb(value: unknown) {
return sql`${value}::jsonb`
}
export function toLogRow(keyId: number, log: UpstreamLogRecord): typeof usageLogs.$inferInsert {
return {
keyId,
dedupeKey: dedupeKeyFor(keyId, log),
userId: log.user_id,
createdAt: fromUnixSeconds(log.created_at),
type: log.type,
content: log.content ?? '',
username: log.username ?? '',
tokenName: log.token_name ?? '',
modelName: log.model_name ?? '',
quota: log.quota,
promptTokens: log.prompt_tokens,
completionTokens: log.completion_tokens,
useTime: log.use_time,
isStream: log.is_stream,
channel: log.channel,
channelName: log.channel_name ?? '',
tokenId: log.token_id,
groupName: log.group ?? 'default',
ip: log.ip ?? '',
requestId: log.request_id ?? '',
upstreamRequestId: log.upstream_request_id ?? null,
other: toJsonb(parseOther(log.other)),
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 401 状态机与轮次结果合并的单测(同步引擎设计 §3.4 + 实施计划「实现假设」#15)。
*/
import { describe, expect, test } from 'bun:test'
import {
combineRoundResult,
MAX_CONSECUTIVE_401S,
nextKeyCounter,
type KeyCounterNext,
} from './policy'
const active = (consecutive401s = 0): KeyCounterNext => ({
consecutive401s,
status: 'active',
disabledSource: null,
removed: false,
})
describe('combineRoundResult(两个调用合并为「本轮结果」)', () => {
test('任一 401 → unauthorized(401 优先于成功)', () => {
expect(combineRoundResult(['ok', 'unauthorized'])).toBe('unauthorized')
expect(combineRoundResult(['unauthorized', 'failed'])).toBe('unauthorized')
})
test('无 401 且任一成功 → ok', () => {
expect(combineRoundResult(['ok', 'failed'])).toBe('ok')
expect(combineRoundResult(['ok', 'ok'])).toBe('ok')
})
test('全失败 → failed(不动计数)', () => {
expect(combineRoundResult(['failed', 'failed'])).toBe('failed')
})
})
describe('nextKeyCounter(401 计数 / 摘除 / 清零)', () => {
test('成功 → 计数清零(不摘除)', () => {
const next = nextKeyCounter(active(2), 'ok')
expect(next.consecutive401s).toBe(0)
expect(next.status).toBe('active')
expect(next.removed).toBe(false)
})
test('其他失败(403/500/网络)→ 计数不动', () => {
const next = nextKeyCounter(active(1), 'failed')
expect(next.consecutive401s).toBe(1)
expect(next.removed).toBe(false)
})
test('连续 401 累加;未达阈值不摘除', () => {
let state = active(0)
for (let i = 1; i < MAX_CONSECUTIVE_401S; i++) {
const next = nextKeyCounter(state, 'unauthorized')
expect(next.consecutive401s).toBe(i)
expect(next.removed).toBe(false)
expect(next.status).toBe('active')
state = next
}
})
test('第 N 次(N=3)连续 401 → 摘除且 disabled_source = auto_401', () => {
let state: KeyCounterNext = active(0)
for (let i = 1; i <= MAX_CONSECUTIVE_401S; i++) {
state = nextKeyCounter(state, 'unauthorized')
}
expect(state.consecutive401s).toBe(MAX_CONSECUTIVE_401S)
expect(state.status).toBe('disabled')
expect(state.disabledSource).toBe('auto_401')
expect(state.removed).toBe(true)
})
test('中途成功会打断连续性(计数回零)', () => {
let state: KeyCounterNext = active(2)
state = nextKeyCounter(state, 'ok')
state = nextKeyCounter(state, 'unauthorized')
expect(state.consecutive401s).toBe(1)
expect(state.removed).toBe(false)
})
})
+60
View File
@@ -0,0 +1,60 @@
/**
* 同步策略常量与纯决策函数(同步引擎设计 §3.2/§3.4/§3.5)。
* 抽成纯函数是为了把「401 计数 / 摘除」与「本轮结果合并」的规则钉在单测里。
*/
/** 一轮的固定周期:5 分钟(§3.2) */
export const TICK_MS = 5 * 60 * 1000
/** 连续 401 摘除阈值 N = 3(§3.4) */
export const MAX_CONSECUTIVE_401S = 3
/** 落后警示阈值:`log_synced_at` 落后超过 24 小时 → 每轮 warning(§3.5,距 3 天窗口留 2 天余量) */
export const STALE_WARN_MS = 24 * 60 * 60 * 1000
/** 单个上游调用在本轮的结果 */
export type CallResult = 'ok' | 'unauthorized' | 'failed'
/**
* 一轮内两个调用(日志 / 额度)的合并判定(实施计划「实现假设」#15):
* 任一 401 → `unauthorized`(key 失效信号优先);否则任一成功 → `ok`;全失败 → `failed`(不动计数)。
*/
export function combineRoundResult(results: CallResult[]): CallResult {
if (results.includes('unauthorized')) return 'unauthorized'
if (results.includes('ok')) return 'ok'
return 'failed'
}
export interface KeyCounterState {
consecutive401s: number
status: 'active' | 'disabled'
disabledSource: 'manual' | 'auto_401' | null
}
export interface KeyCounterNext extends KeyCounterState {
/** 本轮是否触发自动摘除 */
removed: boolean
}
/**
* 401 状态机(§3.4):401 → 计数 +1(达 N 摘除,`disabled_source = auto_401`);
* 成功 → 计数清零;其他失败(403 / 500 / 网络 / 解密失败)→ 计数不动。
*/
export function nextKeyCounter(state: KeyCounterState, round: CallResult): KeyCounterNext {
if (round === 'ok') {
return { ...state, consecutive401s: 0, removed: false }
}
if (round === 'failed') {
return { ...state, removed: false }
}
const consecutive401s = state.consecutive401s + 1
if (consecutive401s >= MAX_CONSECUTIVE_401S) {
return {
consecutive401s,
status: 'disabled',
disabledSource: 'auto_401',
removed: true,
}
}
return { ...state, consecutive401s, removed: false }
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 上游客户端:接口 + 工厂(实施计划 §6「上游客户端接口化」)。
* 引擎与路由依赖本接口;实现按 `UPSTREAM_SOURCE` 选择——`http`(默认,真实站点)
* / `fixture`(开发期样例文件,见 fixture.ts)。
*/
import { env } from '../lib/env'
import { createFixtureClient } from './fixture'
import { createHttpClient } from './http'
import type { UpstreamLogRecord, UpstreamStatus, UpstreamTokenUsage } from './types'
export interface UpstreamClient {
/** 接口⑧:token 级用量汇总(登记 / 启用验证 + 额度采集);失败抛 UpstreamError */
fetchTokenUsage(plainKey: string): Promise<UpstreamTokenUsage>
/** 接口⑨:最近 1000 条调用日志(同步引擎唯一数据源) */
fetchLogs(plainKey: string): Promise<UpstreamLogRecord[]>
/** 接口④:站点配置(QPU 采样;无需 key) */
fetchStatus(): Promise<UpstreamStatus>
}
let client: UpstreamClient | null = null
/** 进程级单例(实现选择只发生在首次调用) */
export function getUpstreamClient(): UpstreamClient {
if (!client) {
client =
env.upstreamSource === 'fixture' ? createFixtureClient() : createHttpClient()
}
return client
}
export type { UpstreamLogRecord, UpstreamStatus, UpstreamTokenUsage }
+26
View File
@@ -0,0 +1,26 @@
/**
* 上游调用失败的两种对外语义(API 契约 约定 21):
* - `key_rejected`:上游明确拒绝该 key(HTTP 401 / 403——key 不存在、显式禁用、账号被封禁)
* - `upstream_unavailable`:上游故障 / 超时 / 响应异常(无法完成验证,非 key 的问题)
*
* 唯一 401 信号的使用见同步引擎设计 §3.4(摘除计数只认 401,故 `status` 原样带上)。
*/
export type UpstreamErrorKind = 'key_rejected' | 'upstream_unavailable'
export class UpstreamError extends Error {
readonly kind: UpstreamErrorKind
/** 上游 HTTP 状态码;网络层故障(超时 / 拒连)时为 undefined */
readonly status?: number
constructor(kind: UpstreamErrorKind, message: string, status?: number) {
super(message)
this.name = 'UpstreamError'
this.kind = kind
this.status = status
}
}
/** 把任意抛出物转成一行可读消息(用于日志 / 错误 message) */
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
+107
View File
@@ -0,0 +1,107 @@
/**
* 上游客户端 · 样例文件实现(实施计划 §6:仓库根实测响应快照 —— `api.log.token.json`
* / `api.status.json`,未入库、绝不提交)。开发期以 `UPSTREAM_SOURCE=fixture` 启用。
*
* 快照缺失的接口(`/api/usage/token` 无快照)就地按实测结构合成:总量类字段取自
* `api.log.token.json` 首条日志的 `other.subscription_*`(真实形状),保证 fixture
* 下的登记结果与 seed 数据(阶段 4)同一形状。
*
* 错误分支的仿真约定(key 内容大小写不敏感;仅 fixture 模式生效,见实施计划「实现假设」):
* 含 `reject` → 401 key_rejected · 含 `forbidden` → 403 key_rejected · 含 `unavailable` → 网络故障
*/
import { readFileSync } from 'node:fs'
import { DEFAULT_QUOTA_PER_UNIT } from '@naku/shared'
import { env } from '../lib/env'
import { errorMessage, UpstreamError } from './errors'
import type { UpstreamClient } from './client'
import type { UpstreamLogRecord, UpstreamStatus, UpstreamTokenUsage } from './types'
/** 仓库根(本文件位于 apps/server/src/upstream/)——样例快照的默认目录 */
const REPO_ROOT = new URL('../../../../', import.meta.url)
export interface FixtureUpstreamOptions {
/** 样例文件目录;缺省取 env.newapiFixtureDir,再退到仓库根 */
dir?: string | URL
}
function readSnapshot<T>(dir: string | URL, name: string): T {
try {
return JSON.parse(readFileSync(new URL(name, dir), 'utf8')) as T
} catch (err) {
throw new UpstreamError(
'upstream_unavailable',
`样例文件不可用(${name}):${errorMessage(err)}`,
)
}
}
function asNumber(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
export function createFixtureClient(options: FixtureUpstreamOptions = {}): UpstreamClient {
// env.newapiFixtureDir 为「空 = 仓库根」的语义,故用 ||(空字符串要退到 REPO_ROOT)
const dir = options.dir || env.newapiFixtureDir || REPO_ROOT
/** 仿真约定:错误分支由 key 内容触发 */
function simulate(key: string): void {
const k = key.toLowerCase()
if (k.includes('reject')) throw new UpstreamError('key_rejected', 'fixture:模拟上游拒绝该 key(401)', 401)
if (k.includes('forbidden')) throw new UpstreamError('key_rejected', 'fixture:模拟账号被封禁(403)', 403)
if (k.includes('unavailable')) {
throw new UpstreamError('upstream_unavailable', 'fixture:模拟上游不可达(网络故障)')
}
}
function loadLogs(): UpstreamLogRecord[] {
const snap = readSnapshot<{ data?: UpstreamLogRecord[] }>(dir, 'api.log.token.json')
if (!Array.isArray(snap.data)) {
throw new UpstreamError('upstream_unavailable', '样例文件结构异常(api.log.token.json 缺 data[])')
}
return snap.data
}
return {
async fetchTokenUsage(plainKey: string): Promise<UpstreamTokenUsage> {
simulate(plainKey)
const first = loadLogs()[0]
let other: Record<string, unknown> = {}
if (first?.other) {
try {
other = JSON.parse(first.other) as Record<string, unknown>
} catch {
other = {} // 样例 other 解析失败不致命:退到兜底值
}
}
const totalGranted = asNumber(other.subscription_total, 800_000_000)
const totalUsed = asNumber(other.subscription_used, 85_579_458)
return {
name: first?.token_name ?? 'fixture-token',
total_granted: totalGranted,
total_used: totalUsed,
total_available: totalGranted - totalUsed,
unlimited_quota: false,
model_limits: {},
model_limits_enabled: false,
expires_at: 0, // 永不过期
}
},
async fetchLogs(plainKey: string): Promise<UpstreamLogRecord[]> {
simulate(plainKey)
return loadLogs()
},
async fetchStatus(): Promise<UpstreamStatus> {
const snap = readSnapshot<{ data?: Record<string, unknown> }>(dir, 'api.status.json')
const data = snap.data
if (!data || typeof data !== 'object') {
throw new UpstreamError('upstream_unavailable', '样例文件结构异常(api.status.json 缺 data)')
}
return {
...data,
quota_per_unit: asNumber(data.quota_per_unit, DEFAULT_QUOTA_PER_UNIT),
} as UpstreamStatus
},
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 上游客户端 · 真实 HTTP 实现(实施计划 §6「接口化」的两套实现之一)。
* 三个调用(usage / logs / status)共用同一套失败分类:401 / 403 → key_rejected,
* 其余非 2xx、网络故障、超时、响应结构异常 → upstream_unavailable(同步引擎设计 §3.4)。
*/
import { env } from '../lib/env'
import { errorMessage, UpstreamError } from './errors'
import type { UpstreamClient } from './client'
import type { UpstreamLogRecord, UpstreamStatus, UpstreamTokenUsage } from './types'
/** 上游请求超时(实施计划「实现假设」#3:30 秒;失败即本轮失败,不单轮重试) */
export const UPSTREAM_TIMEOUT_MS = 30_000
/** 只用到 fetch 的最小子集(便于单测注入打桩) */
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>
export interface HttpUpstreamOptions {
/** BaseURL;缺省取 env.newapiBase */
base?: string
/** 便于单测注入;缺省用全局 fetch */
fetchImpl?: FetchLike
timeoutMs?: number
}
/** 剥掉 `{ code/success, message, data }` 传输层包装;业务失败或结构异常抛 upstream_unavailable */
function unwrapData(body: unknown, path: string, status: number): unknown {
if (body && typeof body === 'object') {
const b = body as Record<string, unknown>
if (b.code === false || b.success === false) {
throw new UpstreamError(
'upstream_unavailable',
`上游业务失败(${path}):${typeof b.message === 'string' ? b.message : '未知原因'}`,
status,
)
}
if ('data' in b) return b.data
}
throw new UpstreamError('upstream_unavailable', `上游响应结构异常(${path})`, status)
}
export function createHttpClient(options: HttpUpstreamOptions = {}): UpstreamClient {
const base = (options.base ?? env.newapiBase).replace(/\/+$/, '')
const doFetch = options.fetchImpl ?? fetch
const timeoutMs = options.timeoutMs ?? UPSTREAM_TIMEOUT_MS
async function request(path: string, key?: string): Promise<unknown> {
if (!base) {
throw new UpstreamError('upstream_unavailable', '未配置 NEWAPI_BASE(见 .env.example)')
}
let res: Response
try {
res = await doFetch(`${base}${path}`, {
headers: key ? { Authorization: `Bearer ${key}` } : undefined,
signal: AbortSignal.timeout(timeoutMs),
})
} catch (err) {
throw new UpstreamError(
'upstream_unavailable',
`请求上游失败(${path}):${errorMessage(err)}`,
)
}
if (res.status === 401 || res.status === 403) {
throw new UpstreamError('key_rejected', `上游拒绝该 key(HTTP ${res.status})`, res.status)
}
if (!res.ok) {
throw new UpstreamError('upstream_unavailable', `上游返回 HTTP ${res.status}(${path})`, res.status)
}
let body: unknown
try {
body = await res.json()
} catch (err) {
throw new UpstreamError(
'upstream_unavailable',
`上游响应不是合法 JSON(${path}):${errorMessage(err)}`,
res.status,
)
}
return unwrapData(body, path, res.status)
}
return {
async fetchTokenUsage(plainKey: string): Promise<UpstreamTokenUsage> {
return (await request('/api/usage/token', plainKey)) as UpstreamTokenUsage
},
async fetchLogs(plainKey: string): Promise<UpstreamLogRecord[]> {
return (await request('/api/log/token', plainKey)) as UpstreamLogRecord[]
},
async fetchStatus(): Promise<UpstreamStatus> {
return (await request('/api/status')) as UpstreamStatus
},
}
}
+53
View File
@@ -0,0 +1,53 @@
/**
* 上游 NewAPI 响应类型(字段级事实见 `docs/upstream/NewAPI-sk-key接口文档.md`)。
* 只声明本项目消费的字段,其余原样透传(索引签名兜底)。
*/
/** 接口⑧ `GET /api/usage/token` → `data`(`api_keys` 表 8 个上游字段的来源) */
export interface UpstreamTokenUsage {
name: string
total_granted: number
total_used: number
total_available: number
unlimited_quota: boolean
model_limits: Record<string, unknown>
model_limits_enabled: boolean
/** Unix 秒;0 = 永不过期(落库转 NULL,见 api_keys 表设计 §3.5) */
expires_at: number
}
/**
* 接口⑨ `GET /api/log/token` → `data[]` 的一行:上游 21 字段原形。
* `other` 是 JSON **字符串**(解析入库归同步引擎);`upstream_request_id` 为 omitempty 可选字段。
*/
export interface UpstreamLogRecord {
id: number
user_id: number
created_at: number
type: number
content: string
username: string
token_name: string
model_name: string
quota: number
prompt_tokens: number
completion_tokens: number
use_time: number
is_stream: boolean
channel: number
channel_name: string
token_id: number
group: string
ip: string
request_id: string
upstream_request_id?: string
other: string
}
/** 接口④ `GET /api/status` → `data`(QPU 采样只需个别字段,其余透传) */
export interface UpstreamStatus {
quota_per_unit: number
quota_display_type?: string
usd_exchange_rate?: number
[key: string]: unknown
}
+129
View File
@@ -0,0 +1,129 @@
/**
* 上游客户端单测(bun test):失败分类 + 样例实现。
* HTTP 实现用注入的 fetch 打桩;样例实现读仓库根快照(缺失时跳过文件相关断言)。
*/
import { describe, expect, test } from 'bun:test'
import { existsSync } from 'node:fs'
import { UpstreamError } from './errors'
import { createFixtureClient } from './fixture'
import { createHttpClient, type FetchLike } from './http'
const jsonRes = (body: unknown, status = 200): Response =>
new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
function httpWith(fetchImpl: FetchLike) {
return createHttpClient({ base: 'https://upstream.test', fetchImpl })
}
describe('HTTP 实现 · 失败分类', () => {
test('401 → key_rejected(带状态码)', async () => {
const client = httpWith(async () => jsonRes({ message: 'invalid token' }, 401))
const err = await client.fetchTokenUsage('sk-x').catch((e: unknown) => e)
expect(err).toBeInstanceOf(UpstreamError)
expect((err as UpstreamError).kind).toBe('key_rejected')
expect((err as UpstreamError).status).toBe(401)
})
test('403 → key_rejected(账号封禁同样视为「上游拒绝该 key」)', async () => {
const client = httpWith(async () => jsonRes({ message: 'banned' }, 403))
const err = await client.fetchLogs('sk-x').catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('key_rejected')
expect((err as UpstreamError).status).toBe(403)
})
test('500 → upstream_unavailable', async () => {
const client = httpWith(async () => jsonRes({ message: 'db error' }, 500))
const err = await client.fetchStatus().catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('upstream_unavailable')
expect((err as UpstreamError).status).toBe(500)
})
test('网络故障 → upstream_unavailable(无状态码)', async () => {
const client = httpWith(async () => {
throw new TypeError('fetch failed')
})
const err = await client.fetchLogs('sk-x').catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('upstream_unavailable')
expect((err as UpstreamError).status).toBeUndefined()
expect((err as UpstreamError).message).toContain('fetch failed')
})
test('HTTP 200 但 code/success 为 false → upstream_unavailable(取上游 message)', async () => {
const client = httpWith(async () => jsonRes({ success: false, message: '内部错误', data: null }))
const err = await client.fetchTokenUsage('sk-x').catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('upstream_unavailable')
expect((err as UpstreamError).message).toContain('内部错误')
})
test('成功:剥掉传输层包装返回 data;请求头带 Bearer', async () => {
let seenAuth: string | undefined
const client = httpWith(async (_url, init) => {
const headers = new Headers(init?.headers)
seenAuth = headers.get('authorization') ?? undefined
return jsonRes({ code: true, message: 'ok', data: { total_used: 42 } })
})
const usage = await client.fetchTokenUsage('sk-abc')
expect(seenAuth).toBe('Bearer sk-abc')
expect((usage as unknown as { total_used: number }).total_used).toBe(42)
})
test('未配置 BaseURL → upstream_unavailable', async () => {
const client = createHttpClient({ base: '', fetchImpl: async () => jsonRes({}) })
const err = await client.fetchStatus().catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('upstream_unavailable')
})
})
const FIXTURE_DIR = new URL('../../../../', import.meta.url)
const hasSnapshots = existsSync(new URL('api.log.token.json', FIXTURE_DIR))
describe('样例实现 · 错误仿真约定', () => {
test('含 reject → 401 key_rejected', async () => {
const client = createFixtureClient({ dir: FIXTURE_DIR })
const err = await client.fetchTokenUsage('sk-fixture-reject-1').catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('key_rejected')
expect((err as UpstreamError).status).toBe(401)
})
test('含 unavailable → upstream_unavailable', async () => {
const client = createFixtureClient({ dir: FIXTURE_DIR })
const err = await client.fetchLogs('sk-fixture-unavailable').catch((e: unknown) => e)
expect((err as UpstreamError).kind).toBe('upstream_unavailable')
})
test('不传 dir 时退到仓库根(env.newapiFixtureDir 为空串的兜底)', async () => {
const client = createFixtureClient()
const status = await client.fetchStatus()
expect(status.quota_per_unit).toBe(500000)
})
})
describe.if(hasSnapshots)('样例实现 · 快照读取', () => {
test('fetchLogs 返回 1000 条且 other 保持 JSON 字符串', async () => {
const client = createFixtureClient({ dir: FIXTURE_DIR })
const logs = await client.fetchLogs('sk-fixture-ok')
expect(logs.length).toBe(1000)
expect(typeof logs[0]!.other).toBe('string')
expect(typeof logs[0]!.request_id).toBe('string')
})
test('fetchTokenUsage 按实测形状合成(总量取自快照 other.subscription_*)', async () => {
const client = createFixtureClient({ dir: FIXTURE_DIR })
const usage = await client.fetchTokenUsage('sk-fixture-ok')
expect(usage.unlimited_quota).toBe(false)
expect(usage.expires_at).toBe(0)
expect(usage.total_granted).toBeGreaterThan(0)
expect(usage.total_available).toBe(usage.total_granted - usage.total_used)
expect(typeof usage.name).toBe('string')
})
test('fetchStatus 读取 quota_per_unit', async () => {
const client = createFixtureClient({ dir: FIXTURE_DIR })
const status = await client.fetchStatus()
expect(status.quota_per_unit).toBe(500000)
})
})
+11 -8
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
// 根组件:全局 provider(message / dialog / notification)+ 路由出口
// 根组件:全局 provider(message / dialog / notification)+ naive-ui 中文 locale + 路由出口
import { RouterView } from 'vue-router'
import { dateZhCN, zhCN } from 'naive-ui'
</script>
<template>
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<RouterView />
</n-notification-provider>
</n-dialog-provider>
</n-message-provider>
<n-config-provider :locale="zhCN" :date-locale="dateZhCN">
<n-message-provider>
<n-dialog-provider>
<n-notification-provider>
<RouterView />
</n-notification-provider>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<style>
+18
View File
@@ -11,19 +11,37 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAlert: typeof import('naive-ui')['NAlert']
NButton: typeof import('naive-ui')['NButton']
NCard: typeof import('naive-ui')['NCard']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDataTable: typeof import('naive-ui')['NDataTable']
NDatePicker: typeof import('naive-ui')['NDatePicker']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDrawerContent: typeof import('naive-ui')['NDrawerContent']
NDynamicTags: typeof import('naive-ui')['NDynamicTags']
NEmpty: typeof import('naive-ui')['NEmpty']
NForm: typeof import('naive-ui')['NForm']
NFormItem: typeof import('naive-ui')['NFormItem']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NLayoutContent: typeof import('naive-ui')['NLayoutContent']
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NPagination: typeof import('naive-ui')['NPagination']
NRadioButton: typeof import('naive-ui')['NRadioButton']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSpace: typeof import('naive-ui')['NSpace']
NText: typeof import('naive-ui')['NText']
NTooltip: typeof import('naive-ui')['NTooltip']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
describe('formatTime', () => {
it('ISO(+08:00)→ `YYYY-MM-DD HH:mm:ss`', () => {
expect(formatTime('2026-09-11T19:37:12+08:00')).toBe('2026-09-11 19:37:12')
})
it('空值 → 「—」;无法识别原样返回', () => {
expect(formatTime(null)).toBe('—')
expect(formatTime(undefined)).toBe('—')
expect(formatTime('not-a-time')).toBe('not-a-time')
})
})
describe('formatTimeShort(当天简写)', () => {
const now = new Date('2026-09-11T13:00:00Z') // 北京时间 21:00
it('当天 → `HH:mm:ss`', () => {
expect(formatTimeShort('2026-09-11T19:37:12+08:00', now)).toBe('19:37:12')
})
it('跨日 → 完整形式', () => {
expect(formatTimeShort('2026-09-10T23:59:59+08:00', now)).toBe('2026-09-10 23:59:59')
})
})
describe('formatCount / formatAmount', () => {
it('千分位;null → 「—」', () => {
expect(formatCount(53939847)).toBe('53,939,847')
expect(formatCount(0)).toBe('0')
expect(formatCount(null)).toBe('—')
})
it('金额:汇总 2 位 / 明细 4 位;null → 「—」(不退化为额度美元)', () => {
expect(formatAmount(23.456)).toBe('23.46')
expect(formatAmount(0.008079785625, 4)).toBe('0.0081')
expect(formatAmount(0)).toBe('0.00')
expect(formatAmount(null)).toBe('—')
})
})
+39
View File
@@ -0,0 +1,39 @@
/**
* 展示格式化(交互设计 §2 #4/#5):
* - 时间统一到秒:`2026-09-11 19:37:12`(当天语境简写 `HH:mm:ss`)
* - 金额分层:汇总 2 位小数 / 明细行 4 位小数;`null` → 「—」(绝不退化为额度美元)
* - token 数与 quota 原值千分位;不标币种
*/
/** ISO 8601(后端统一 `+08:00`)→ `2026-09-11 19:37:12`;无法识别原样返回;空值 → 「—」 */
export function formatTime(iso: string | null | undefined): string {
if (!iso) return '—'
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/.exec(iso)
if (!m) return iso
return `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}`
}
/** 「今天」的北京时间日期串(`YYYY-MM-DD`)——输出时间自带 +08:00,按字符串前缀比较即可 */
function beijingDateString(now: Date): string {
return new Date(now.getTime() + 8 * 3_600_000).toISOString().slice(0, 10)
}
/** 当天语境简写 `HH:mm:ss`;跨日回落到完整形式 */
export function formatTimeShort(iso: string | null | undefined, now: Date = new Date()): string {
if (!iso) return '—'
const full = formatTime(iso)
if (!/^\d{4}-\d{2}-\d{2} /.test(full)) return full
return iso.slice(0, 10) === beijingDateString(now) ? full.slice(11) : full
}
/** 千分位整数(token 数 / quota 原值 / 条数);null → 「—」 */
export function formatCount(n: number | null | undefined): string {
if (n === null || n === undefined) return '—'
return n.toLocaleString('en-US')
}
/** 实付人民币:汇总 2 位 / 明细行 4 位;null → 「—」 */
export function formatAmount(v: number | null | undefined, digits = 2): string {
if (v === null || v === undefined) return '—'
return v.toFixed(digits)
}
+485 -5
View File
@@ -1,13 +1,412 @@
<script setup lang="ts">
// 用量日志页(交互设计 §3.1)——骨架占位:
// 筛选区(key 标签输入 + datetimerange + 查询)→ 卡片区(次数 / 用量 / 消费)→ 未收录提示 → 明细表格 + 分页器
// 数据形状见 docs/design/API契约.md §2.1
// 用量日志页(交互设计 §3.1):筛选区(key 标签 + datetimerange + 快捷项)→ 卡片区
// → 未收录提示 → 明细表格(展开行)+ 分页器;六种状态模式见交互设计 §2 #6
import { computed, h, onMounted, ref } from 'vue'
import {
NDescriptions,
NDescriptionsItem,
NTag,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import {
KEYS_MAX,
LOGS_DEFAULT_LIMIT,
LOGS_LIMIT_OPTIONS,
LOG_TYPE,
LOG_TYPE_LABEL,
type LogsQueryResponse,
type QueryMetaKey,
type QueryMetaKeyRegistered,
type SummaryResponse,
type UsageLogRow,
} from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatAmount, formatCount, formatTime, formatTimeShort } from '@/utils/format'
const message = useMessage()
const DAY_MS = 24 * 3_600_000
const STORAGE_KEY = 'naku:usage-log-keys'
// ---- 筛选区状态(key 输入 localStorage 记住上次输入;点「查询」才发请求) ----
const keyTags = ref<string[]>([])
const range = ref<[number, number] | null>([Date.now() - 7 * DAY_MS, Date.now()])
const shortcuts = {
今天: (): [number, number] => {
const start = new Date()
start.setHours(0, 0, 0, 0)
return [start.getTime(), Date.now()]
},
近7天: (): [number, number] => [Date.now() - 7 * DAY_MS, Date.now()],
近30天: (): [number, number] => [Date.now() - 30 * DAY_MS, Date.now()],
}
onMounted(() => {
keyTags.value = loadStoredKeys()
})
function loadStoredKeys(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY)
const arr = raw ? (JSON.parse(raw) as unknown) : []
return Array.isArray(arr) ? arr.filter((k): k is string => typeof k === 'string') : []
} catch {
return []
}
}
/** 粘贴多行 / 逗号 / 分号自动拆分并按 key 去重 */
function normalizeTags(tags: string[]): string[] {
const out: string[] = []
for (const tag of tags) {
for (const part of String(tag).split(/[\s,,;;]+/)) {
const k = part.trim()
if (k && !out.includes(k)) out.push(k)
}
}
return out
}
function onTagsUpdate(value: string[]): void {
keyTags.value = normalizeTags(value)
}
// ---- 查询状态 ----
const loading = ref(false)
const error = ref<string | null>(null)
/** 是否已发起过查询(false = 初始态:页面中央引导,不渲染卡片与表格) */
const queried = ref(false)
const summary = ref<SummaryResponse | null>(null)
const logs = ref<LogsQueryResponse | null>(null)
const page = ref(1)
const pageSize = ref<number>(LOGS_DEFAULT_LIMIT)
/** 最近一次查询参数——翻页只复用(不重算汇总、不读输入框) */
const activeQuery = ref<{ keys: string[]; start: string; end: string } | null>(null)
function describeError(e: unknown): string {
if (e instanceof ApiError) return e.message
return e instanceof Error ? e.message : '未知错误'
}
async function runQuery(): Promise<void> {
const keys = normalizeTags(keyTags.value)
if (keys.length === 0) {
message.warning('请先输入至少一个 key')
return
}
if (keys.length > KEYS_MAX) {
message.warning(`一次最多查询 ${KEYS_MAX} 个 key(当前 ${keys.length} 个)`)
return
}
if (!range.value) {
message.warning('请选择时间范围')
return
}
const [startMs, endMs] = range.value
if (startMs >= endMs) {
message.warning('时间范围非法:开始必须早于结束')
return
}
keyTags.value = keys
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
} catch {
/* localStorage 不可用(隐私模式)时静默跳过 */
}
const q = {
keys,
start: new Date(startMs).toISOString(),
end: new Date(endMs).toISOString(),
}
activeQuery.value = q
page.value = 1
queried.value = true
loading.value = true
error.value = null
try {
// 约定 5 / §2.1:首次并行请求,翻页只调明细
const [summaryRes, logsRes] = await Promise.all([
api.usageSummary<SummaryResponse>(q),
api.usageLogsQuery<LogsQueryResponse>({
...q,
limit: pageSize.value,
offset: 0,
}),
])
summary.value = summaryRes
logs.value = logsRes
} catch (e) {
error.value = describeError(e)
summary.value = null
logs.value = null
} finally {
loading.value = false
}
}
/** 翻页 / 改每页条数:只调明细端点(汇总不重算) */
async function loadLogs(): Promise<void> {
const q = activeQuery.value
if (!q) return
loading.value = true
error.value = null
try {
logs.value = await api.usageLogsQuery<LogsQueryResponse>({
...q,
limit: pageSize.value,
offset: (page.value - 1) * pageSize.value,
})
} catch (e) {
error.value = describeError(e)
} finally {
loading.value = false
}
}
function onPageSizeChange(): void {
page.value = 1
void loadLogs()
}
// ---- 派生状态 ----
const unregistered = computed(() =>
(summary.value?.meta.keys ?? []).filter((k): k is Extract<QueryMetaKey, { registered: false }> => !k.registered),
)
const registeredKeys = computed(() =>
(summary.value?.meta.keys ?? []).filter((k): k is QueryMetaKeyRegistered => k.registered),
)
/** 数据截止:null = 参与 key 全部未首同步(交互设计「数据同步中…」) */
const dataUntil = computed(() => summary.value?.meta.data_until ?? null)
const dataUntilText = computed(() =>
dataUntil.value ? formatTimeShort(dataUntil.value) : '数据同步中…',
)
// ---- 明细表格 ----
const columns = computed<DataTableColumns<UsageLogRow>>(() => [
// 展开列(naive-ui 要求展开能力写在列定义里:type: 'expand' + renderExpand)
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
{ title: '时间', key: 'created_at', width: 176, render: (row) => formatTime(row.created_at) },
{ title: 'key', key: 'token_name', width: 120, ellipsis: { tooltip: true } },
{ title: '模型', key: 'model_name', minWidth: 160, ellipsis: { tooltip: true } },
{
title: '类型',
key: 'type',
width: 88,
render: (row) =>
h(
NTag,
{
size: 'small',
bordered: false,
type: row.type === LOG_TYPE.Error ? 'error' : 'default',
},
{ default: () => LOG_TYPE_LABEL[row.type] ?? String(row.type) },
),
},
{
title: '输入 tokens',
key: 'prompt_tokens',
width: 116,
align: 'right',
render: (row) => formatCount(row.prompt_tokens),
},
{
title: '输出 tokens',
key: 'completion_tokens',
width: 116,
align: 'right',
render: (row) => formatCount(row.completion_tokens),
},
{
title: '消费',
key: 'amount_rmb',
width: 104,
align: 'right',
render: (row) => formatAmount(row.amount_rmb, 4),
},
{ title: '耗时', key: 'use_time', width: 80, align: 'right', render: (row) => `${row.use_time}s` },
])
/** 展开行:完整字段(缓存 / 流式 / request_id / 错误详情 / 请求路径 / quota 原值 / 订阅归属完整组) */
function renderExpand(row: UsageLogRow): ReturnType<typeof h> {
const other = row.other ?? {}
const text = (v: unknown): string =>
v === null || v === undefined || v === '' ? '—' : typeof v === 'number' ? formatCount(v) : String(v)
const items: Array<{ label: string; value: string }> = [
{ label: '缓存 tokens', value: text(other.cache_tokens) },
{ label: '流式', value: row.is_stream ? '是' : '否' },
{ label: 'quota 原值', value: formatCount(row.quota) },
{ label: '请求路径', value: text(other.request_path) },
{ label: 'request_id', value: text(row.request_id) },
{ label: 'upstream_request_id', value: text(row.upstream_request_id) },
{ label: '渠道', value: `${row.channel}${row.channel_name ? `(${row.channel_name})` : ''}` },
{ label: '分组', value: text(row.group_name) },
{ label: 'token_id', value: String(row.token_id) },
{ label: '账号', value: `${row.username}(#${row.user_id})` },
{ label: 'IP', value: text(row.ip) },
{ label: '订阅', value: text(other.subscription_plan_title) },
{ label: '订阅 ID', value: text(other.subscription_id) },
{ label: '订阅总额度', value: text(other.subscription_total) },
{ label: '订阅已用', value: text(other.subscription_used) },
{ label: '订阅剩余', value: text(other.subscription_remain) },
{ label: '计费来源', value: text(other.billing_source) },
{ label: '模型倍率', value: text(other.model_ratio) },
]
if (row.content) items.push({ label: '错误详情', value: row.content })
return h(
NDescriptions,
{ column: 3, size: 'small', bordered: true, labelPlacement: 'top' },
{
default: () =>
items.map((item) =>
h(NDescriptionsItem, { label: item.label, key: item.label }, { default: () => item.value }),
),
},
)
}
</script>
<template>
<div class="usage-log">
<h1>用量日志</h1>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.1" />
<h1 class="page-title">用量查询</h1>
<!-- 筛选区 -->
<n-card size="small" class="filter-card">
<div class="filter-row">
<n-dynamic-tags
class="key-input"
:value="keyTags"
:disabled="loading"
:max="20"
@update:value="onTagsUpdate"
/>
<n-date-picker
v-model:value="range"
type="datetimerange"
clearable
:shortcuts="shortcuts"
class="range-input"
/>
<n-button type="primary" :loading="loading" @click="runQuery">查询</n-button>
</div>
<div class="filter-hint">粘贴 sk-key(多行 / 逗号一次输入多个);时间范围默认最近 7 天</div>
</n-card>
<!-- 初始态(未输入 key) -->
<div v-if="!queried" class="initial-empty">
<n-empty size="large" description="输入你的 key 开始查询" />
</div>
<template v-else>
<!-- 查询失败(网络 / 5xx) -->
<n-alert v-if="error" type="error" class="alert" title="查询失败">
<div class="alert-body">
<span>{{ error }}</span>
<n-button size="tiny" @click="activeQuery ? loadLogs() : runQuery">重试</n-button>
</div>
</n-alert>
<!-- 卡片区(加载中显示骨架) -->
<div class="cards">
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">调用次数</div>
<div class="card-value">{{ formatCount(summary?.request_count ?? 0) }}</div>
<div class="card-sub">
消费 {{ formatCount(summary?.consume_count ?? 0) }} · 错误
{{ formatCount(summary?.error_count ?? 0) }}
</div>
</template>
</n-card>
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">Token 用量</div>
<div class="card-value">
{{
formatCount(
(summary?.prompt_tokens ?? 0) + (summary?.completion_tokens ?? 0),
)
}}
</div>
<div class="card-sub">缓存命中 {{ formatCount(summary?.cache_tokens ?? 0) }}</div>
</template>
</n-card>
<n-card size="small" class="card">
<n-skeleton v-if="loading && !summary" text :repeat="3" />
<template v-else>
<div class="card-label">预估消费</div>
<div class="card-value amount">{{ formatAmount(summary?.amount_rmb ?? 0) }}</div>
<div class="card-sub">额度记账 {{ formatCount(summary?.quota ?? 0) }}</div>
</template>
</n-card>
<div class="data-until">
<n-tooltip trigger="hover" :disabled="registeredKeys.length === 0">
<template #trigger>
<span class="data-until-text">数据截止 {{ dataUntilText }}</span>
</template>
<div class="until-detail">
<div v-for="k in summary?.meta.keys ?? []" :key="k.registered ? k.key_id : k.key_hint">
<template v-if="k.registered">
{{ k.token_name }}:
{{ k.log_synced_at ? formatTimeShort(k.log_synced_at) : '数据同步中…' }}
<span class="until-status">
({{ k.status === 'disabled' ? '已停用' : '运行中' }})
</span>
</template>
<template v-else>{{ k.key_hint }}:未收录</template>
</div>
</div>
</n-tooltip>
</div>
</div>
<!-- 未收录提示条 -->
<n-alert v-if="unregistered.length > 0" type="warning" class="alert">
以下 key 未收录:{{ unregistered.map((k) => k.key_hint).join('、') }}(请联系管理员登记)
</n-alert>
<!-- 未折算提示(金额口径说明) -->
<n-alert v-if="(summary?.unpriced_count ?? 0) > 0" type="info" class="alert" :bordered="false">
其中 {{ formatCount(summary?.unpriced_count) }} 条未登记购买、未计入金额(明细中显示「—」)
</n-alert>
<!-- 明细表格 -->
<n-data-table
:columns="columns"
:data="logs?.items ?? []"
:loading="loading"
:row-key="(row: UsageLogRow) => row.id"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '该时段没有记录'" />
</template>
</n-data-table>
<!-- 分页器 -->
<div class="pager">
<n-pagination
v-model:page="page"
v-model:page-size="pageSize"
:item-count="logs?.total ?? 0"
:page-sizes="[...LOGS_LIMIT_OPTIONS]"
show-size-picker
@update:page="loadLogs"
@update:page-size="onPageSizeChange"
/>
</div>
</template>
</div>
</template>
@@ -17,4 +416,85 @@
margin: 0 auto;
padding: 24px;
}
.page-title {
margin: 0 0 16px;
font-size: 20px;
font-weight: 600;
}
.filter-row {
display: flex;
gap: 12px;
align-items: flex-start;
flex-wrap: wrap;
}
.key-input {
flex: 1 1 420px;
min-width: 320px;
}
.range-input {
width: 380px;
}
.filter-hint {
margin-top: 8px;
color: #999;
font-size: 12px;
}
.initial-empty {
padding: 120px 0;
}
.alert {
margin-top: 16px;
}
.alert-body {
display: flex;
align-items: center;
gap: 12px;
}
.cards {
display: flex;
gap: 12px;
align-items: stretch;
margin-top: 16px;
}
.card {
flex: 1 1 0;
}
.card-label {
color: #666;
font-size: 13px;
}
.card-value {
margin-top: 6px;
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.card-sub {
margin-top: 6px;
color: #999;
font-size: 12px;
}
.data-until {
display: flex;
align-items: flex-end;
padding-bottom: 6px;
white-space: nowrap;
}
.data-until-text {
color: #999;
font-size: 12px;
cursor: default;
}
.until-detail {
font-size: 12px;
line-height: 1.7;
}
.until-status {
color: #bbb;
}
.pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
</style>
+265 -6
View File
@@ -1,13 +1,272 @@
<script setup lang="ts">
// key 管理页(交互设计 §3.2)——骨架占位:
// 清单 8 列(key 名 / 状态徽章 / 已用 / 剩余 / 到期 / 登记时间 / 数据截止 / 操作)
// 登记:页头「+ 登记 key」→ n-modal;启停:行内按钮(停用弹确认、启用直接执行)
// 数据形状见 docs/design/API契约.md §2.2
// key 管理页(交互设计 §3.2):清单 8 列 + 展开行(总额度 / 模型限制 / 401 计数 / 额度新鲜度);
// 登记(n-modal,三类失败文案);启停(停用弹确认、启用直接执行)
import { computed, h, onMounted, ref } from 'vue'
import {
NButton,
NPopconfirm,
NTag,
NTooltip,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import type { ApiKeyRow } from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatCount, formatTime, formatTimeShort } from '@/utils/format'
const message = useMessage()
const rows = ref<ApiKeyRow[]>([])
const loading = ref(false)
const patchingId = ref<number | null>(null)
const showModal = ref(false)
const keyInput = ref('')
const registering = ref(false)
const registerError = ref('')
onMounted(load)
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await api.apiKeysList<ApiKeyRow[]>()
} catch (e) {
message.error(e instanceof ApiError ? e.message : '加载失败')
} finally {
loading.value = false
}
}
/** 状态徽章三面孔:运行中(绿)· 已停用(灰,manual)· 已失效(红,auto_401 + tooltip) */
function renderStatus(row: ApiKeyRow) {
if (row.status === 'active') {
return h(NTag, { type: 'success', size: 'small', bordered: false }, { default: () => '运行中' })
}
if (row.disabled_source === 'auto_401') {
return h(
NTooltip,
{ trigger: 'hover' },
{
trigger: () =>
h(NTag, { type: 'error', size: 'small', bordered: false }, { default: () => '已失效' }),
default: () => '连续 401 自动摘除——请确认 key 是否已被上游禁用',
},
)
}
return h(NTag, { size: 'small', bordered: false }, { default: () => '已停用' })
}
async function patch(row: ApiKeyRow, status: 'active' | 'disabled'): Promise<void> {
patchingId.value = row.id
try {
const updated = await api.apiKeyPatch<ApiKeyRow>(row.id, { status })
rows.value = rows.value.map((r) => (r.id === updated.id ? updated : r))
message.success(status === 'active' ? '已启用' : '已停用')
} catch (e) {
if (e instanceof ApiError) {
if (e.code === 'key_rejected') message.error('启用失败:上游拒绝该 key')
else if (e.code === 'upstream_unavailable') message.error('启用失败:上游暂不可用,请稍后重试')
else if (e.code === 'not_found') message.error('记录不存在,请刷新')
else message.error(e.message)
} else {
message.error('操作失败')
}
} finally {
patchingId.value = null
}
}
function describeRegisterError(e: unknown): string {
if (e instanceof ApiError) {
if (e.code === 'key_rejected') return '上游拒绝该 key(请确认 key 有效、未被上游禁用)'
if (e.code === 'already_registered') return '该 key 已登记'
if (e.code === 'upstream_unavailable') return '上游暂不可用,请稍后重试'
return e.message
}
return '登记失败'
}
async function submitRegister(): Promise<void> {
const key = keyInput.value.trim()
if (!key) {
registerError.value = '请粘贴 sk-key'
return
}
registering.value = true
registerError.value = ''
try {
const created = await api.apiKeyRegister<ApiKeyRow>({ key })
rows.value = [...rows.value, created]
showModal.value = false
keyInput.value = ''
message.success('登记成功,首轮数据同步中…')
} catch (e) {
registerError.value = describeRegisterError(e)
} finally {
registering.value = false
}
}
const columns = computed<DataTableColumns<ApiKeyRow>>(() => [
{ type: 'expand', renderExpand: (row) => renderExpand(row), expandable: () => true },
{ title: 'key 名', key: 'token_name', minWidth: 140, ellipsis: { tooltip: true } },
{ title: '状态', key: 'status', width: 96, render: (row) => renderStatus(row) },
{
title: '累计已用',
key: 'total_used',
width: 120,
align: 'right',
render: (row) => formatCount(row.total_used),
},
{
title: '累计剩余',
key: 'total_available',
width: 120,
align: 'right',
render: (row) => (row.unlimited_quota ? '不限' : formatCount(row.total_available)),
},
{
title: '到期',
key: 'expires_at',
width: 176,
render: (row) => (row.expires_at ? formatTime(row.expires_at) : '不过期'),
},
{
title: '登记时间',
key: 'registered_at',
width: 176,
render: (row) => formatTime(row.registered_at),
},
{
title: '数据截止',
key: 'log_synced_at',
width: 120,
render: (row) => (row.log_synced_at ? formatTimeShort(row.log_synced_at) : '同步中'),
},
{
title: '操作',
key: 'actions',
width: 110,
render: (row) => {
const busy = patchingId.value === row.id
if (row.status === 'active') {
return h(
NPopconfirm,
{ onPositiveClick: () => patch(row, 'disabled') },
{
trigger: () => h(NButton, { size: 'tiny', loading: busy }, { default: () => '停用' }),
default: () => '确认停用该 key?停用后不再轮询。',
},
)
}
return h(
NButton,
{
size: 'tiny',
type: 'primary',
loading: busy,
onClick: () => patch(row, 'active'),
},
{ default: () => '启用' },
)
},
},
])
/** 展开行:其余字段(总额度 / 模型限制 / 401 计数 / 额度新鲜度) */
function renderExpand(row: ApiKeyRow): ReturnType<typeof h> {
const limits = Object.keys(row.model_limits ?? {})
const items = [
{ label: '总额度', value: formatCount(row.total_granted) },
{ label: '无限额度', value: row.unlimited_quota ? '是' : '否' },
{ label: '模型限制', value: row.model_limits_enabled ? limits.join('、') || '(空)' : '未启用' },
{ label: '连续 401 计数', value: String(row.consecutive_401s) },
{
label: '额度新鲜度',
value: row.usage_synced_at ? formatTime(row.usage_synced_at) : '未采集',
},
{ label: '停用来源', value: row.disabled_source ?? '—' },
]
return h('div', { class: 'expand-grid' }, items.map((i) => h('div', [
h('span', { class: 'expand-label' }, `${i.label}:`),
h('span', i.value),
])))
}
</script>
<template>
<div class="api-keys">
<h2>key 管理</h2>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.2" />
<div class="header">
<h2>key 管理</h2>
<n-button type="primary" @click="showModal = true">+ 登记 key</n-button>
</div>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row: ApiKeyRow) => row.id"
:scroll-x="1280"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '还没有登记任何 key'" />
</template>
</n-data-table>
<n-modal
v-model:show="showModal"
preset="card"
title="登记 key"
class="register-modal"
:style="{ width: '480px' }"
>
<n-input
v-model:value="keyInput"
type="textarea"
:rows="3"
placeholder="粘贴 sk-key(仅登记本次粘贴的 key)"
/>
<n-text v-if="registerError" type="error" class="register-error">{{ registerError }}</n-text>
<template #footer>
<div class="modal-footer">
<n-button @click="showModal = false">取消</n-button>
<n-button type="primary" :loading="registering" @click="submitRegister">登记</n-button>
</div>
</template>
</n-modal>
</div>
</template>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header h2 {
margin: 0;
font-size: 18px;
}
.register-error {
display: block;
margin-top: 10px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.expand-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px 16px;
font-size: 13px;
}
:deep(.expand-label) {
color: #999;
}
</style>
+348 -5
View File
@@ -1,12 +1,355 @@
<script setup lang="ts">
// 购买记录页(交互设计 §3.3)——骨架占位:
// 清单 8 列 + 核对提示(未登记观测提示条 + 折叠区 + 补录)+ 登记/编辑用右侧 n-drawer
// 数据形状见 docs/design/API契约.md §2.3
// 购买记录页(交互设计 §3.3):清单 8 列 + 右侧抽屉表单(登记 / 编辑 / 补录);
// 核对提示:观测到未登记的订阅 → 提示条 + 折叠区(补录预填 subscription_id 与观测额度)
import { computed, h, onMounted, reactive, ref } from 'vue'
import { NButton, NPopconfirm, NTag, useMessage, type DataTableColumns } from 'naive-ui'
import type { ObservationItem, PurchaseKind, PurchaseRow } from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatAmount, formatCount, formatTime } from '@/utils/format'
const message = useMessage()
const rows = ref<PurchaseRow[]>([])
const observations = ref<ObservationItem[]>([])
const loading = ref(false)
onMounted(() => {
void load()
void loadObservations()
})
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await api.purchasesList<PurchaseRow[]>()
} catch (e) {
message.error(e instanceof ApiError ? e.message : '加载失败')
} finally {
loading.value = false
}
}
async function loadObservations(): Promise<void> {
try {
const res = await api.purchasesObservations<{ items: ObservationItem[] }>()
observations.value = res.items
} catch {
observations.value = [] // 观测是辅助信息,失败不打扰主流程
}
}
/** 观测到但未登记(补录引导的目标) */
const unregisteredObs = computed(() =>
observations.value.filter((o) => o.matched_purchase_id === null),
)
// ---- 抽屉表单(登记 / 编辑 / 补录共用) ----
const showDrawer = ref(false)
const saving = ref(false)
const editingId = ref<number | null>(null)
const formError = ref('')
const form = reactive({
kind: 'subscription' as PurchaseKind,
paidAt: null as number | null,
paidAmount: null as number | null,
quotaGranted: null as number | null,
subscriptionId: null as number | null,
periodStart: null as number | null,
periodEnd: null as number | null,
note: '',
})
function resetForm(): void {
form.kind = 'subscription'
form.paidAt = Date.now()
form.paidAmount = null
form.quotaGranted = null
form.subscriptionId = null
form.periodStart = null
form.periodEnd = null
form.note = ''
formError.value = ''
}
function openCreate(): void {
editingId.value = null
resetForm()
showDrawer.value = true
}
/** 补录:预填 subscription_id 与观测额度(期间不预填——观测范围 ≠ 订阅期间) */
function openBackfill(obs: ObservationItem): void {
editingId.value = null
resetForm()
form.subscriptionId = obs.subscription_id
form.quotaGranted = obs.observed_quota_total ?? null
form.note = `补录(观测名称:${obs.plan_title ?? '未知'})`
showDrawer.value = true
}
function openEdit(row: PurchaseRow): void {
editingId.value = row.id
form.kind = row.kind
form.paidAt = row.paid_at ? new Date(row.paid_at).getTime() : null
form.paidAmount = row.paid_amount
form.quotaGranted = row.quota_granted
form.subscriptionId = row.subscription_id
form.periodStart = row.period_start ? new Date(row.period_start).getTime() : null
form.periodEnd = row.period_end ? new Date(row.period_end).getTime() : null
form.note = row.note ?? ''
formError.value = ''
showDrawer.value = true
}
async function submit(): Promise<void> {
if (!form.paidAt) {
formError.value = '请选择支付时间'
return
}
if (form.paidAmount === null) {
formError.value = '请填写实付金额'
return
}
if (form.quotaGranted === null) {
formError.value = '请填写本笔额度(折算链的分母)'
return
}
if (form.periodStart && form.periodEnd && form.periodStart >= form.periodEnd) {
formError.value = '期间起点必须早于终点(左闭右开)'
return
}
const payload = {
kind: form.kind,
paid_at: new Date(form.paidAt).toISOString(),
paid_amount: form.paidAmount,
quota_granted: form.quotaGranted,
subscription_id: form.subscriptionId,
period_start: form.periodStart ? new Date(form.periodStart).toISOString() : null,
period_end: form.periodEnd ? new Date(form.periodEnd).toISOString() : null,
note: form.note.trim() ? form.note.trim() : null,
}
saving.value = true
formError.value = ''
try {
if (editingId.value === null) {
await api.purchasesCreate<PurchaseRow>(payload)
message.success('已登记')
} else {
await api.purchasesPatch<PurchaseRow>(editingId.value, payload)
message.success('已保存')
}
showDrawer.value = false
await Promise.all([load(), loadObservations()])
} catch (e) {
formError.value = e instanceof ApiError ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function remove(row: PurchaseRow): Promise<void> {
try {
await api.purchasesDelete<PurchaseRow>(row.id)
message.success('已删除')
await Promise.all([load(), loadObservations()])
} catch (e) {
message.error(e instanceof ApiError ? e.message : '删除失败')
}
}
function formatPeriod(row: PurchaseRow): string {
if (!row.period_start && !row.period_end) return '—'
const start = row.period_start ? formatTime(row.period_start) : '…'
const end = row.period_end ? formatTime(row.period_end) : '未结束'
return `${start} ~ ${end}`
}
const columns = computed<DataTableColumns<PurchaseRow>>(() => [
{ title: '时间', key: 'paid_at', width: 176, render: (row) => formatTime(row.paid_at) },
{
title: '类型',
key: 'kind',
width: 88,
render: (row) =>
h(
NTag,
{ size: 'small', bordered: false, type: row.kind === 'subscription' ? 'info' : 'default' },
{ default: () => (row.kind === 'subscription' ? '订阅' : '充值') },
),
},
{
title: '实付金额',
key: 'paid_amount',
width: 110,
align: 'right',
render: (row) => formatAmount(row.paid_amount),
},
{
title: '订阅',
key: 'subscription_id',
width: 80,
render: (row) => (row.subscription_id === null ? '—' : `#${row.subscription_id}`),
},
{ title: '期间', key: 'period', minWidth: 300, render: (row) => formatPeriod(row) },
{
title: '额度',
key: 'quota_granted',
width: 120,
align: 'right',
render: (row) => formatCount(row.quota_granted),
},
{ title: '备注', key: 'note', minWidth: 140, ellipsis: { tooltip: true }, render: (row) => row.note ?? '—' },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) =>
h('div', { class: 'row-actions' }, [
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
h(
NPopconfirm,
{ onPositiveClick: () => remove(row) },
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删除' }),
default: () => '确认删除该笔记录?(物理删除,其日志金额随即算不出来)',
},
),
]),
},
])
</script>
<template>
<div class="purchases">
<h2>购买记录</h2>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.3" />
<div class="header">
<h2>购买记录</h2>
<n-button type="primary" @click="openCreate">+ 登记购买</n-button>
</div>
<!-- 核对提示:观测到未登记的订阅 -->
<n-alert v-if="unregisteredObs.length > 0" type="warning" class="alert">
观测到 {{ unregisteredObs.length }} 个未登记的订阅——补录后其日志即可折算金额
</n-alert>
<n-collapse v-if="observations.length > 0" class="obs-collapse">
<n-collapse-item :title="`订阅观测(${observations.length} 项)`" name="obs">
<n-data-table
size="small"
:bordered="false"
:data="observations"
:row-key="(row: ObservationItem) => row.subscription_id"
:columns="[
{ title: '订阅', key: 'subscription_id', render: (r: ObservationItem) => `#${r.subscription_id}` },
{ title: '名称', key: 'plan_title', render: (r: ObservationItem) => r.plan_title ?? '—' },
{ title: '日志条数', key: 'log_count', render: (r: ObservationItem) => formatCount(r.log_count) },
{
title: '观测范围',
key: 'range',
render: (r: ObservationItem) => `${formatTime(r.first_seen_at)} ~ ${formatTime(r.last_seen_at)}`,
},
{
title: '观测额度',
key: 'observed_quota_total',
render: (r: ObservationItem) => formatCount(r.observed_quota_total),
},
{
title: '状态',
key: 'matched',
render: (r: ObservationItem) =>
r.matched_purchase_id === null
? h(NButton, { size: 'tiny', type: 'primary', onClick: () => openBackfill(r) }, { default: () => '补录' })
: `已登记(#${r.matched_purchase_id})`,
},
]"
/>
</n-collapse-item>
</n-collapse>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row: PurchaseRow) => row.id"
:scroll-x="1180"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '还没有购买记录'" />
</template>
</n-data-table>
<!-- 登记 / 编辑 / 补录:右侧抽屉 -->
<n-drawer v-model:show="showDrawer" :width="420">
<n-drawer-content :title="editingId === null ? '登记购买记录' : '编辑购买记录'" closable>
<n-form label-placement="top" size="small">
<n-form-item label="类型">
<n-radio-group v-model:value="form.kind">
<n-radio-button value="subscription">订阅</n-radio-button>
<n-radio-button value="topup">充值</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="支付时间(必填)">
<n-date-picker v-model:value="form.paidAt" type="datetime" clearable class="full" />
</n-form-item>
<n-form-item label="实付金额 / 元(必填)">
<n-input-number v-model:value="form.paidAmount" :min="0" :precision="2" class="full" />
</n-form-item>
<n-form-item label="本笔额度 quota(必填)">
<n-input-number v-model:value="form.quotaGranted" :min="0" class="full" />
</n-form-item>
<n-form-item label="订阅 ID(钱包页「订阅 #N」)">
<n-input-number v-model:value="form.subscriptionId" :min="1" class="full" />
</n-form-item>
<n-form-item label="期间起点">
<n-date-picker v-model:value="form.periodStart" type="datetime" clearable class="full" />
</n-form-item>
<n-form-item label="期间终点(未结束可空)">
<n-date-picker v-model:value="form.periodEnd" type="datetime" clearable class="full" />
</n-form-item>
<n-form-item label="备注">
<n-input v-model:value="form.note" type="textarea" :rows="2" />
</n-form-item>
</n-form>
<n-text v-if="formError" type="error">{{ formError }}</n-text>
<template #footer>
<div class="drawer-footer">
<n-button @click="showDrawer = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
</div>
</template>
</n-drawer-content>
</n-drawer>
</div>
</template>
<style scoped>
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header h2 {
margin: 0;
font-size: 18px;
}
.alert {
margin-bottom: 12px;
}
.obs-collapse {
margin-bottom: 16px;
}
.full {
width: 100%;
}
.drawer-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.row-actions) {
display: flex;
gap: 8px;
}
</style>
+70 -4
View File
@@ -1,12 +1,78 @@
<script setup lang="ts">
// 换算参数时间线页(交互设计 §3.3)——骨架占位:
// 表格两列(生效时间 / QPU 值)+「当前生效」标记 + 页首说明文案;纯只读
// 数据形状见 docs/design/API契约.md §2.4
// 换算参数时间线页(交互设计 §3.3):表格两列(生效时间 / QPU 值)+「当前生效」标记;纯只读
import { computed, h, onMounted, ref } from 'vue'
import { NTag, useMessage, type DataTableColumns } from 'naive-ui'
import type { QpuRow } from '@naku/shared'
import { ApiError, api } from '@/api/client'
import { formatTime } from '@/utils/format'
const message = useMessage()
const rows = ref<QpuRow[]>([])
const loading = ref(false)
onMounted(async () => {
loading.value = true
try {
rows.value = await api.qpuList<QpuRow[]>()
} catch (e) {
message.error(e instanceof ApiError ? e.message : '加载失败')
} finally {
loading.value = false
}
})
/** 当前生效 = 时间线最后一行 */
const currentId = computed(() => rows.value[rows.value.length - 1]?.id ?? null)
const columns = computed<DataTableColumns<QpuRow>>(() => [
{ title: '生效时间', key: 'effective_from', render: (row) => formatTime(row.effective_from) },
{
title: 'QPU 值',
key: 'qpu',
render: (row) => row.qpu.toLocaleString('en-US'),
},
{
title: '',
key: 'current',
width: 100,
render: (row) =>
row.id === currentId.value
? h(NTag, { size: 'small', type: 'success', bordered: false }, { default: () => '当前生效' })
: null,
},
])
</script>
<template>
<div class="qpu">
<h2>换算参数时间线</h2>
<n-empty description="骨架占位——页面结构见 docs/design/交互设计.md §3.3" />
<p class="intro">
quota → 额度美元的换算比率历史;引擎自动采样、只记变化点;当前生效值 = 最近一行。
</p>
<n-data-table
:columns="columns"
:data="rows"
:loading="loading"
:row-key="(row: QpuRow) => row.id"
size="small"
>
<template #empty>
<n-empty :description="loading ? '加载中…' : '暂无采样数据(引擎首次运行后出现基线)'" />
</template>
</n-data-table>
</div>
</template>
<style scoped>
h2 {
margin: 0 0 8px;
font-size: 18px;
}
.intro {
margin: 0 0 16px;
color: #666;
font-size: 13px;
}
</style>
+3
View File
@@ -19,6 +19,7 @@
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.10",
"postgres": "^3.4.9",
},
},
"apps/web": {
@@ -808,6 +809,8 @@
"postcss-selector-parser": ["postcss-selector-parser@7.1.6", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw=="],
"postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="],
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+152
View File
@@ -0,0 +1,152 @@
# 部署文档
> 交付给实例运营方的安装与运行说明——**一条命令链**可跑(见 §3)。前后端**独立部署**:后端单进程常驻;前端为静态产物,由反代托管。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-15) |
| 适用 | new-api-key-usage(针对 NewAPI 的 key 用量统计工具) |
| 关联 | `README.md`(项目全景与设计依据)· 仓库根 `.env.example`(环境变量样例) |
| 不含 | 反向代理软件本身的安装(假定服务器已有 Nginx 或等价物) |
---
## 0. 前置条件
| # | 项 | 要求 |
|---|----|------|
| 1 | 服务器 | Linux x86_64;可**出网访问 NewAPI 站点**(工具需轮询其接口) |
| 2 | 运行时 | **Bun ≥ 1.4**(安装:`curl -fsSL https://bun.sh/install \| bash`) |
| 3 | 数据库 | **PostgreSQL ≥ 14**(可用服务器现有实例;建议新建独立库与独立账号) |
| 4 | 反向代理 | Nginx 或等价物——**必须能配置 `/api` 反代规则**(前后端连线以同域反代为主,见 README 工程注记) |
## 1. 交付物
- 源码(git 仓库或压缩包),根目录为 Bun workspaces monorepo:
- `apps/server` —— 后端(Hono API + 同步引擎同进程)
- `apps/web` —— 前端(构建为静态产物 `apps/web/dist`)
- `packages/shared` —— 前后端共享的契约类型
- 无预编译产物要求:后端由 Bun 直接运行 TypeScript;前端产物在部署机执行一次构建即可。
## 2. 环境变量
在仓库根创建 `.env`(**不入库**):
| 变量 | 必填 | 说明 | 生成 / 填法 |
|------|------|------|------------|
| `PORT` | 否 | 后端监听端口(默认 3000) | 如 `3000` |
| `DATABASE_URL` | **是** | PostgreSQL 连接串 | `postgres://用户:密码@主机:5432/库名` |
| `MANAGE_TOKEN` | **是** | 管理面口令(`X-Manage-Token`) | `openssl rand -hex 16` |
| `KEYS_MASTER_KEY` | **是** | 凭证主密钥(**丢失 = 全部 key 需重新登记**) | `openssl rand -base64 32`(32 字节 base64) |
| `NEWAPI_BASE` | **是** | 上游 NewAPI 站点地址(不带尾斜杠) | 如 `https://your-instance.example.com` |
> `UPSTREAM_SOURCE` / `NEWAPI_FIXTURE_DIR` 仅开发期使用(见实施计划 §6),**生产不要设置**(默认走真实站点)。
## 3. 一条命令链
在仓库根依次执行:
```bash
bun install # 1) 安装全部依赖(含前端)
bun run db:push # 2) 建表(首次;四张表 + 索引)
# 生产首次也可用 db:generate + db:migrate(迁移文件入库)
bun run --filter '@naku/server' start # 3) 启动后端(前台;常驻见 §4)
# 启动即跑一轮同步,随后每 5 分钟一轮
bun run build # 4) 构建前端产物 → apps/web/dist
```
## 4. 常驻运行(systemd 示例)
`/etc/systemd/system/naku-server.service`:
```ini
[Unit]
Description=new-api-key-usage server
After=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/new-api-key-usage/apps/server
# .env 由应用从仓库根读取(apps/server 的上两级)
ExecStart=/root/.bun/bin/bun run /opt/new-api-key-usage/apps/server/src/index.ts
Restart=always
RestartSec=5
# 可选:环境变量也可直接注入(优先级高于 .env)
# Environment=PORT=3000
# Environment=DATABASE_URL=postgres://...
[Install]
WantedBy=multi-user.target
```
```bash
systemctl daemon-reload && systemctl enable --now naku-server
journalctl -u naku-server -f # 观察 [sync] 日志
```
## 5. 反向代理(Nginx 示例)
```nginx
server {
listen 80;
server_name usage.example.com;
# 前端静态产物(同源,前端用相对路径 /api)
root /opt/new-api-key-usage/apps/web/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html; # SPA 路由回退
}
# 后端 API(前后端连线:同域反代)
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
> 跨源直连(前端与后端不同域)亦可,但需后端 CORS 白名单与前端运行时 API base(契约 约定 24);同域反代是默认形态、无 CORS 问题。
## 6. 首次运行检查
1. 后端日志出现两行:`[sync] 引擎启动:立即跑一轮,随后每 5 分钟一次` 与 `[sync] 轮次完成:0 key,成功 0,失败 0,新增日志 0 条…`(无 key 时正常空转)
2. 打开站点 → 管理面 `/manage/api-keys`(界面上不暴露入口,直接输入 URL)→ 输入 `MANAGE_TOKEN` 进入
3. 点「+ 登记 key」粘贴一个 sk-key → 登记成功后该 key 出现在清单(「数据截止」先显示「同步中」,一轮后变为时间)
4. 回到查看面 `/usage-log`,粘贴刚登记的 key → 查询 → 卡片与明细出现数据
## 7. 必须知道的运行约束
| # | 约束 | 说明 |
|---|------|------|
| 1 | **后端必须单实例** | 同步引擎随进程启动(5 分钟一轮)。多实例会重复轮询、互相覆盖 `log_synced_at`,并可能触发上游限流。systemd 单实例 + 不做水平扩展即可 |
| 2 | **3 天窗口** | 上游 `/api/log/token` 固定只返回最近 1000 条(当前速率下 ≈ 3 天)。同步停摆超过约 3 天,旧日志**永久丢失**(上游不提供回填接口)。日志落后 > 24h 时每轮打印 warning |
| 3 | **`KEYS_MASTER_KEY` 不可丢** | 该密钥加密存储全部 sk-key。丢失后无法解密 → 需在管理面重新登记全部 key(历史日志数据仍在) |
| 4 | **401 自动摘除** | 连续 3 轮返回 401 的 key 会被自动停用(`disabled_source = auto_401`);在管理面「启用」即可恢复(启用时会向上游验证) |
| 5 | **上游同站点共享** | 工具对上游的请求是只读查询(每 key 每 5 分钟 2 个请求),量级极小;若上游有更严格的限流策略,可调低 key 数量或延长周期(`apps/server/src/sync/policy.ts` 的 `TICK_MS`) |
| 6 | 真实 key 联调 | **待作者补验**:本地开发以实测响应快照验证(实施计划 §6),首次对真实站点登记时请在管理面确认「上游验证通过 + 首轮同步完成」 |
## 8. 升级流程
```bash
git pull # 或替换源码目录
bun install # 依赖有变化时
bun run db:push # schema 有变化时(会提示确认)
bun run build # 重建前端产物
systemctl restart naku-server # 重启后端(引擎随启动立即补一轮)
```
## 9. 故障排查
| 现象 | 排查方向 |
|------|---------|
| 查看面「数据截止」显示「数据同步中…」 | 后端日志 `[sync]` 是否有该 key 的失败行;新登记 key 等一轮即可 |
| 查看面长时间数据不更新 | 是否「已失效(auto_401)」——管理面看状态徽章;或后端是否在运行 |
| 登记返回「上游暂不可用」 | 服务器到 `NEWAPI_BASE` 的网络连通性(`curl -sS $NEWAPI_BASE/api/status`) |
| 页面加载但查询 502 | 反代 `/api` 规则与后端端口是否一致 |
| 金额列显示「—」 | 该日志所属订阅尚未登记购买记录 → 管理面「购买记录」按观测提示补录 |
| 管理面全部 401 | `MANAGE_TOKEN` 与请求头是否一致(口令存 sessionStorage,关标签后需重新输入) |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-13:约定层 23 条 + 逐页端点——四页推完、`/dashboard` 二期占位) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/交互设计.md`(页面清单与职责)· 四张表字段设计(数据形状)· `docs/design/交互设计.md` §0(管理面边界与口令门帘) |
| 范围 | 路径、鉴权形态、时间 / 数字形态、分页、错误与响应形状、逐页端点;校验细节、存储、调度归技术稿 |
| 不含 | 上游 NewAPI 的调用方式(同步引擎设计)· 展示文案与交互细节(交互设计细节层) |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-11,逐字段讨论收敛;2026-09-13 修订:key 登记改由管理通道执行、表名更改为 `api_keys`、新增 `disabled_source` 列) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/upstream/NewAPI-sk-key接口文档.md`(接口①字段依据)· `docs/upstream/NewAPI-接口范围.md` · `docs/design/API契约.md` §2.2(登记 / 启停端点) |
| 定稿范围 | 列结构 + 语义 + 写入时机;同步引擎细节(401 阈值等)不在此文 |
| 字段数 | 17 = 9 本系统管理字段 + 8 上游接口字段 |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-13,多轮讨论收敛;同日修订:查看面金额口径改「折算实付人民币」,§6 边界相应放开) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/qpu_history表字段设计.md`(折算链第一条:quota → 额度美元)· `docs/design/API契约.md` §2.3(登记 / 编辑端点)· `docs/upstream/NewAPI-sk-key接口文档.md`(`other.subscription_*` 字段依据) |
| 范围 | 列结构 + 语义 + 写入时机;登记入口见 API 契约 §2.3 |
| 字段数 | 9 = 1 主键 + 8 人工录入(登记 4 · 期间 2 · 订阅 2) |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-13,多轮讨论收敛:只读化、按购买时刻取尺) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/purchases表字段设计.md`(折算链第二条:额度美元 → 实付人民币)· `docs/design/API契约.md` §2.4(只读查看端点)· `docs/upstream/NewAPI-接口范围.md`(采集来源 `/api/status`) |
| 范围 | 列结构 + 语义 + 写入规则;采样调度(同步引擎)不在此文 |
| 字段数 | 3 = 1 主键 + 2 业务字段 |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-11,逐字段讨论收敛) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/upstream/NewAPI-sk-key接口文档.md`(接口②字段依据)· `docs/design/api_keys表字段设计.md`(`key_id` 外键指向) |
| 定稿范围 | 列结构 + 去重与写入策略;同步引擎细节(调度、401 处理)不在此文 |
| 字段数 | 23 = 20 上游字段 + 3 系统字段(上游响应共 21 个字段,其中展示序号 `id` 不落表,见 3.2) |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-13:方向层 + 跨页共性 10 条 + 逐页细节五节全部落定) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `README.md`(决策与事实)· `docs/design/API契约.md`(数据形状依据) |
| 范围 | 方向层(页面集合 + 职责)+ 细节层(跨页共性 + 逐页细节) |
+1 -1
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-13:17 条议题全部落定——见 §2 结论索引与 §3 决策记录) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/upstream/NewAPI-接口范围.md`(上游调用与失败语义)· 四张表字段设计(写入时机)· `docs/design/API契约.md`(查看面消费的数据形状)· `README.md`(既定决策与关键事实) |
| 范围 | 调度与执行、失败处理、可观测、部署初始化 |
| 不含 | 前端交互(交互设计)· HTTP 契约(API 契约,已定稿)· 多实例协调 / 历史回填 / 告警渠道(见 §4) |
+44 -15
View File
@@ -4,10 +4,10 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-09-14;**进度表随 loop 持续更新**) |
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
| 当前进度 | 阶段 0 已完成;下一批:**阶段 1**(数据层与密钥) |
| 当前进度 | 阶段 0–7 已完成;下一批:**阶段 8**(收尾:部署文档 + 端到端走查 + 文档状态更新) |
---
@@ -26,19 +26,31 @@
| # | 假设 | 依据 / 可逆性 |
|---|------|--------------|
| 1 | server 侧单测用 `bun test`(内建);前端仍用 Vitest | 零依赖;可换 |
| 2 | 根目录单一 `.env`;server 与 drizzle-kit 脚本显式 `--env-file=../../.env`(Bun 不向上找 .env) | 可改 |
| 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 常量」;仅开发期 |
| 17 | **jsonb 列写入统一走 `sql\`${obj}::jsonb\``**(`toJsonb()`,见 `src/sync/log-row.ts`)——drizzle + `bun-sql` 组合直接传 JS 对象会**双重编码**成 `jsonb` 的 *string* 值,SQL 侧 `other->>'k'` / `? 'k'` 全部失效(2026-09-15 实测定位) | 已在 `other` / `model_limits` 落地;新增 jsonb 列时照此办理 |
| 18 | seed 为「日志行数最多的订阅」登记示例购买,其余订阅保持未登记(保留 `amount_rmb = null` 的展示样本) | 样例实测含两个订阅(89 / 90);可改 |
| 19 | naive-ui composable(`useMessage` 等)在页面里**显式 import**——`auto-imports.d.ts` 的 `declare global` 在 vue-tsc 6 / TS 6 下未被识别(2026-09-15 实测;`components.d.ts` 的模板组件自动导入不受影响) | 若将来工具链修复可回退;显式 import 无副作用 |
| 20 | 表格展开行写在列定义里(`{ type: 'expand', renderExpand }`)——naive-ui 的表级 `render-expand` prop 不生成展开列(2026-09-15 实测) | naive-ui 行为,非项目决策 |
## 2. 前提(2026-09-14 定)
- **开发库**:Docker 本地 PostgreSQL(`docker-compose.yml` 已落地;连接串走 `.env`,不入库)
- **上游联调**:真实 sk-key(作者已填 `.env`)——`NEWAPI_BASE` 实测可达:HTTP 200 / 5.2KB / 2.0s;`quota_per_unit = 500000`、`quota_display_type = USD`、`usd_exchange_rate = 6.8`,与文档一致
- **开发库**: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. 阶段与验收标准
@@ -46,14 +58,14 @@
| 批次 | 内容 | 验收标准 | 状态 |
|------|------|---------|------|
| 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 |
| 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`) | ☐ |
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ☐ |
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3) | 库中出现真实数据;`[sync]` 日志符合引擎设计;连续 401 触发摘除且 `disabled_source = auto_401` | ☐ |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ☐ |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:真实数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ☐ |
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ☐ |
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ☐ |
| 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 定) | ✅ 2026-09-15(56 项单测绿;curl 17 步——明细字段齐全 / 分页 / type 过滤 / 参数校验 6 类 400 / 未收录 200 空结果 / `data_until` 取最慢 / 金额按归属购买折算、未折算行 `null` + `unpriced_count` 计数正确) |
| 6 | **前端 `/usage-log`**:筛选区(key 标签 + datetimerange + 快捷项)· 卡片 · 表格 + 展开行 · 分页器 · 六种状态模式 | 浏览器实测:seed 数据走一遍;六态逐一可见(加载 / 初始 / 未收录 / 无记录 / 同步中 / 失败);金额为 `null` 显示「—」 | ✅ 2026-09-15(Playwright + 系统 Chrome 逐态断言:初始 / 加载 / 查询(卡片 984 · 298,041,838 · 4.90)/ 展开行 / 翻页 / 未收录 / 无记录 / 同步中 / 失败,无 console 错误;金额 `null` 显示「—」并伴未折算提示条) |
| 7 | **前端管理面**:口令门帘 + 侧边栏 + 三页(key 管理 / 购买记录含核对提示与补录 / 换算参数) | 浏览器实测:登记、启停、CRUD、补录、时间线全操作 | ✅ 2026-09-15(Playwright 走查 15 步:错口令 → 门帘提示 / 正确口令进入 / 展开行 / 登记三类结果(拒绝 · 成功 · 已登记)/ 停用(确认弹窗)与启用 / 核对提示 / 补录(预填订阅 ID 与观测额度)/ 编辑 / 删除(确认弹窗)/ 换算参数「当前生效」/ 返回查看面;顺带补 naive-ui 中文 locale 与表格横向滚动) |
| 8 | **收尾**:部署文档(`docs/delivery/`)· 端到端走查 · 文档状态更新(设计文档 → 已实现) | 一条命令链可部署(前后端独立 + 后端单实例注记);走查无阻 | ✅ 2026-09-15(`docs/delivery/部署文档.md` 交付;**从零端到端走查**:`db:push` 建表 → 启动(空转 + QPU 基线)→ 管理面登记 → 引擎立即补一轮(984 条)→ 查看面查询(未折算「—」)→ 补录购买记录 → 金额出现 → 换算参数时间线;8 篇设计文档状态改「已实现」) |
## 4. 待定问题
@@ -63,9 +75,26 @@
| 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);不可用时临时以假上游过渡(不改变验收口径)
- 真实上游联调依赖网络可达(已实测 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 摘除——不阻塞其他阶段,做完在报告中列出待验项
+1 -1
View File
@@ -196,7 +196,7 @@ Authorization: Bearer sk-xxxx
| `token_id` | int | 上游 token ID(对账用) |
| `group` | string | 分组名(默认 `default`) |
| `ip` | string | 请求 IP(普通用户视角为空串) |
| `request_id` | string | 请求 ID(格式:`yyyyMMddHHmmss` 前缀 + 随机串);JSON tag 为 `omitempty`,但源码写入时有兜底保证非空——**去重唯一依据** |
| `request_id` | string | 请求 ID(格式:`yyyyMMddHHmmss` 前缀 + 随机串);JSON tag 为 `omitempty`,但源码写入时有兜底保证非空——**去重唯一依据**。**补充实测(2026-09-15)**:错误场景下 `request_id` **不保证行级唯一**——1000 条实测样本中有 8 个 `request_id` 各对应 3 条 `type=5` 错误记录(同一请求的多次失败 / 重试,`content` 的 `status_code` 可不同);按 `request_id` 去重会把它们合并为 1 条(消费类 `type=2` 样本中未观察到重复) |
| `upstream_request_id` | string | 上游请求 ID;JSON tag 为 `omitempty`——**空值时不出现、有值时才出现**(实测样本中缺席);消费方需按「可选字段」处理 |
| `other` | string | JSON 字符串,扩展信息(结构见下) |
+3 -2
View File
@@ -11,13 +11,14 @@
"dev:web": "bun run --filter '@naku/web' dev",
"dev:server": "bun run --filter '@naku/server' dev",
"build": "bun run --filter '@naku/web' build",
"test": "bun run --filter '@naku/web' test:unit",
"test": "bun run --filter '@naku/server' test && bun run --filter '@naku/web' test:unit",
"lint": "bun run --filter '@naku/web' lint",
"format": "bun run --filter '@naku/web' format",
"type-check": "bun run --filter '@naku/web' type-check && bunx tsc --noEmit -p apps/server",
"db:generate": "bun run --filter '@naku/server' db:generate",
"db:migrate": "bun run --filter '@naku/server' db:migrate",
"db:push": "bun run --filter '@naku/server' db:push"
"db:push": "bun run --filter '@naku/server' db:push",
"seed": "bun run --filter '@naku/server' seed"
},
"devDependencies": {
"typescript": "^6.0.0"