Compare commits

..
15 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
skycurtain 1295667c94 feat(contract): amount_rmb 支持 null + 汇总增 unpriced_count;实施计划补「实现假设」机制
- API 契约:无归属购买的明细行 amount_rmb = null(展示「—」);汇总增 unpriced_count(未折算条数)
- shared 类型同步(amount_rmb: number | null · unpriced_count: number)
- 实施计划:loop 停车规则改为「自主推进 + 假设记录」(作者开发期可能缺席);新增 8 条实现假设;待定问题 #1/#3 标记已决
2026-09-15 00:12:19 +08:00
skycurtain dcf7a31c09 chore: 本地开发 PostgreSQL(docker-compose)与环境变量样例
- docker-compose.yml:postgres:17-alpine,宿主端口 5433(避开常见 5432 占用),含 healthcheck
- .env.example:DATABASE_URL 默认指向本地 compose;KEYS_MASTER_KEY 补生成提示
2026-09-15 00:03:46 +08:00
skycurtain 30e71beb09 docs: 实施计划(loop 模式 + 八阶段验收标准)
- loop 机制:取任务 → 实现 → 验证 → 记录 → 提交 → 报告;遇设计空白停车问作者
- 八阶段:数据层与密钥 → key 管理 → 管理面其余 → 同步引擎 → 查看面数据面 → /usage-log 前端 → 管理面前端 → 收尾
- 前提已定:Docker 本地 PG · 真实 key 联调 · loop 内直接提交(作者授权)
2026-09-15 00:03:46 +08:00
skycurtain 138a1b7685 chore: 初始化 monorepo 脚手架与代码骨架
- Bun workspaces:apps/web(Vue 3.5 + Vite 8 + naive-ui)· apps/server(Hono 4.13 + Drizzle)· packages/shared(契约类型)
- 代码骨架:四表 Drizzle schema · 11 个端点占位(501)· 管理面口令中间件 · 同步引擎占位(5 分钟 tick + 防重入)
- 命令链:dev / build / test / lint / type-check / db:*(见 AGENTS.md)
2026-09-14 23:54:58 +08:00
skycurtain 45b312b357 docs: 设计文档体系落定(七篇定稿)
- docs/design:四张表字段设计 · 交互设计(方向层 + 细节层)· API 契约 · 同步引擎设计
- docs/upstream:sk-key 接口实证(430 行)· 接口范围(9 个接口)
- README(全景入口)· AGENTS.md(AI 协作者指引)
- 废弃旧稿已删除(不随仓库留存)
2026-09-14 23:54:52 +08:00
92 changed files with 8190 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# 环境变量样例 —— 复制为 .env 并填真实值(.env 不入库;部署时由实例运营方经 env 注入)
# ---- 服务端(apps/server) ----
# 监听端口(Vite dev 代理与生产反代都指向它)
PORT=3000
# PostgreSQL 连接串
# 本地开发:与仓库根 docker-compose.yml 配套(宿主端口 5433)
# 生产:改为实例运营方提供的连接串
DATABASE_URL=postgres://naku:naku-dev@localhost:5433/naku
# 管理面口令(请求头 X-Manage-Token;见 API 契约 约定 6)
MANAGE_TOKEN=change-me
# 凭证主密钥(HKDF 派生 hmac / aes 子密钥;丢失 = 全部 key 需重新登记)
# 生成示例:openssl rand -base64 32
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=
+11
View File
@@ -6,9 +6,20 @@
.vscode/
*.swp
# Node / Bun
node_modules/
dist/
*.tsbuildinfo
# 日志
*.log
# 实测数据样例(未脱敏,不入库)
/api*.json
# AI 工具运行目录(Reasonix)
.reasonix/
# 环境变量 / 密钥(不要提交到仓库)
.env
.env.*
+43
View File
@@ -0,0 +1,43 @@
# AGENTS.md
给 AI 协作者的仓库指引。**开始任何工作前,先读 [`README.md`](README.md)**——它是全景入口:项目是什么、为什么长这样、走到哪了。
## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术方案 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/upstream/`(NewAPI 的实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古),尚未入库的历史文档移入 `docs/deprecated/`
- **部署**:项目作者不接触服务器,一切部署由实例运营方执行;交付物需保证「一条命令链」可跑
## 项目速览(速记)
- 针对 NewAPI 平台的 key 用量统计工具;**只有 sk-key,没有管理员账号**,一切数据走 key 级接口
- 上游没有任何时间过滤接口 → 轮询 `/api/log/token` + `request_id` 去重攒本地库,任一时段用量 = 本地聚合
- 数据模型四张表:`api_keys` · `usage_logs` · `purchases` · `qpu_history`(**四张表设计均已实现**);四张表各有一篇独立表字段设计文档(`docs/design/`)
- **功能已实现(阶段 0–8,2026-09-15)**:前后端独立部署、后端单进程(API + 同步引擎同进程、单实例);部署见 `docs/delivery/部署文档.md`;未决事项(实现假设与待定问题)见 `docs/design/实施计划.md`
## 命令
monorepo(Bun workspaces:`apps/web` · `apps/server` · `packages/shared`)——根目录执行:
```bash
bun install # 安装全部依赖(一次搞定)
bun run dev # 并行起前端(Vite :5173)与后端(Hono :3000)
bun run dev:web # 只起前端
bun run dev:server # 只起后端(--hot 热重载)
bun run build # 构建前端产物(apps/web/dist)
bun run test # 前端单测(Vitest)
bun run lint # 前端 lint(oxlint + eslint)
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`;离线开发可加 `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 工程注记)
+104
View File
@@ -0,0 +1,104 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到本地数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 状态 | **已实现(2026-09-15,阶段 0–8 全部完成)**——前后端功能齐备、实测走查通过;真实上游联调待作者补验(见实施计划 §6) |
| 技术栈 | Bun + Hono + PostgreSQL · 前端 Vite + Vue 3 + naive-ui |
| 部署 | 实例运营方提供的服务器;交付方式为推送仓库 / 产物,由运营方执行部署 |
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`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/delivery/`(给部署方看的部署文档)· `docs/deprecated/`(**已废弃的历史文档,只存档、不引用、不作依据**)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古),尚未入库的历史文档移入 `docs/deprecated/`
## 这是什么项目
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage)
- 全场景统一小写 kebab-case:仓库名 / 包名 / 文档标题一致,无大写变体
## 背景故事
- 本工具源于一个真实场景:[NewAPI](https://github.com/QuantumNous/new-api)(Go + GORM 的开源 LLM 网关)实例提供订阅服务,多个使用者通过分发 API key 共享同一份订阅额度
- 项目作者是该实例的**普通用户**:购买了订阅额度,创建多个 sk- API key 分发给多名使用者分摊使用成本——**非商业,纯成本分担**
- 关键身份约束:项目作者**只有 sk-key,没有实例的管理员账号**。因此:
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
- 工具部署在**实例运营方提供的服务器**上,作者不直接接触服务器——**交付方式是推送仓库 / 产物,由运营方执行部署**
## 核心需求(v2 定稿)
| # | 需求 | 说明 |
|---|------|------|
| 1 | key 用量查询 | 输入 sk-key + 时间范围 → 查模型请求日志与用量。支持一次输入多个 key 合并查询(项目作者借此获得全员视角) |
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) |
**明确不做的**(v1 → v2 的收缩边界):费用分摊计算(后议;折算基础已就绪——两张折算表设计见文档地图)、计费周期管理、多组支持、登录页、admin 角色、会话记忆。
## 架构的根本动因:为什么必须本地攒数据
这不是优化选项,是上游接口的物理约束决定的**唯一路径**:
1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤
2. 真正适合算"当月用量"的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是"按时间段统计用量"无法纯客户端实现的**核心障碍**
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
→ 结论:轮询同步 + `request_id` 去重入库 + 本地数据库。上游接口的完整实证(含源码出处)见 [`docs/upstream/NewAPI-sk-key接口文档.md`](docs/upstream/NewAPI-sk-key接口文档.md)。
## 已定技术决策
| # | 项 | 决策 | 备注 |
|---|----|------|------|
| 1 | 部署形态 | 实例运营方提供的服务器,标准前后端 | 弃 Cloudflare Workers(v1 方案),因运营方愿意提供服务器资源 |
| 2 | 运行时 / 框架 | **Bun + Hono** | 单进程部署、原生 TS;Hono 轻量路由 |
| 3 | 数据库 | **PostgreSQL** | 服务器现有实例 |
| 4 | 鉴权 | 无 admin、无登录页;查看面凭已登记的 key 查询;管理面由 env 口令 | 写操作全部收拢在管理面(key 管理 + 折算数据);key 失效由轮询 401 自动摘除、管理面手动启停补位 |
| 5 | 信任模型 | 实例运营方(= 网关管理员 = 服务器管理员)不是威胁对象——他们本就能看到一切;真正的防护对象是第三方拖库(sk-key 应加密存库,实现时落地) | 无法在对方控制的机器上对抗对方,接受此公理 |
| 6 | 计费口径 | 展示 quota + $ 参考换算(÷500000),并支持折算为实付人民币 | 折算机制(两层时间线)见文档地图中的两张折算表设计 |
| 7 | 代码组织 | 前后端分离 **monorepo**(Bun workspaces:`apps/web` · `apps/server` · `packages/shared`) | **前后端独立部署、分别更新**(2026-09-13 修订:前端静态产物独立托管;后端不托管前端产物);**后端单进程**(API + 同步引擎同进程、单实例);不引入 turbo 等编排工具 |
| 8 | 数据库访问 | **Drizzle ORM + drizzle-kit** | TS 定义 schema;迁移由 drizzle-kit 生成 |
| 9 | 前端 | **Vite + Vue 3** | 页面结构与 UI 组件库选型留技术稿定 |
| 10 | 人民币折算 | 两层时间线(`quota_per_unit` 自动采样 + 购买记录人工登记),**按购买时刻取尺** | 整笔订阅一把尺;历史永远用当时的尺子量;见文档地图中的两张折算表设计 |
| 11 | 管理通道 | `/manage/*` 独立路由 + env 口令;key 管理(登记 / 清单 / 启停)+ 购买记录的 CRUD + 换算参数时间线只读查看 | 本工具唯一写操作入口;查询域保持完全只读;见 [`docs/design/交互设计.md`](docs/design/交互设计.md) §0 |
## 关键事实清单(实测 / 源码确认,实现时不得违反)
> 出处均见 [`docs/upstream/NewAPI-sk-key接口文档.md`](docs/upstream/NewAPI-sk-key接口文档.md),此处仅索引。
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元(接口①与④交叉实测吻合)——**但它不是实付金额**:折算到实付人民币还需乘「购买汇率」(由运营方定价、可变,历史上调整过一次)。完整折算机制见文档地图中的两张折算表设计
2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重
3. **`created_at` 是 UTC Unix 秒**(三字段交叉实测),北京时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——实测当前站点的日志速率下约等于 3 天;5 分钟级同步余量充分,但同步停摆超过约 3 天会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询(用完也能对账);**显式禁用会被 401 拒绝**(2026-09-13 源码复核)
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
## 工程注记
- CORS 必须由服务端代理解决(浏览器直连 NewAPI 网关会被拦)——**指上游网关**;本项目前后端之间的连线见下条
- **前后端连线(2026-09-13 修订)**:**同域反代为主**(`/api/*` 反代到后端端口——浏览器视角同源,无 CORS、前端用相对路径)、**跨源直连为辅**(后端 CORS 白名单可配 + 前端运行时 API base;`X-Manage-Token` 自定义头会触发 OPTIONS 预检;凭证不进 Cookie,无需 credentials 模式)
- bun runtime 跑 vue-tsc 存在已知 bug([bun#4754](https://github.com/oven-sh/bun/issues/4754)),类型检查与构建需解耦
## 下一步
1. **部署**:按 [`docs/delivery/部署文档.md`](docs/delivery/部署文档.md) 执行(一条命令链;前后端独立 + **后端单实例**注记)
2. **待作者补验 / 裁决**(清单见 [`docs/design/实施计划.md`](docs/design/实施计划.md)):
- 真实 key 的真实链路(真实登记 / 采集轮次 / 401 摘除)——本地开发以样例快照验证(实施计划 §6)
- 实现假设 #1–#20 的逐条确认
- 待定问题 #5:错误行 `request_id` 不唯一(去重合并策略待裁决)
3. 后议功能:充值折算、多订阅实测等见两张折算表设计文档的「待定事项」
+2
View File
@@ -0,0 +1,2 @@
# deps
node_modules/
+14
View File
@@ -0,0 +1,14 @@
// 从仓库根 .env 加载环境变量(Bun 只自动加载 cwd 的 .env——此处 cwd 是 apps/server)
import './src/lib/load-env'
import { defineConfig } from 'drizzle-kit'
/** drizzle-kit 配置(迁移产物在 ./drizzle;命令见根 package.json 的 db:* 脚本) */
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? '',
},
})
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@naku/server",
"private": true,
"type": "module",
"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"
},
"dependencies": {
"@naku/shared": "workspace:*",
"drizzle-orm": "^0.45.2",
"hono": "^4.13.7",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "latest",
"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。`)
+21
View File
@@ -0,0 +1,21 @@
/**
* 数据库连接(Bun 原生 SQL + Drizzle)。
* 懒初始化:首次调用 `getDb()` 时才建连接——骨架阶段缺 DATABASE_URL 也能起服务。
*/
import { drizzle, type BunSQLDatabase } from 'drizzle-orm/bun-sql'
import { env } from '../lib/env'
import * as schema from './schema'
let db: BunSQLDatabase<typeof schema> | null = null
export function getDb(): BunSQLDatabase<typeof schema> {
if (!db) {
if (!env.databaseUrl) {
throw new Error('缺少环境变量 DATABASE_URL(见 .env.example)')
}
db = drizzle(env.databaseUrl, { schema })
}
return db
}
export { schema }
+120
View File
@@ -0,0 +1,120 @@
/**
* 数据库 schema(Drizzle ORM)—— 逐列对应 `docs/design/` 的四张表字段设计:
* `api_keys`(17 列)· `usage_logs`(23 列)· `purchases`(9 列)· `qpu_history`(3 列)。
* TS 侧字段用 camelCase,列名保持 snake_case(与 API 字段同名同形,契约 约定 3)。
*/
import {
bigint,
bigserial,
boolean,
customType,
index,
integer,
jsonb,
numeric,
pgTable,
smallint,
text,
timestamp,
} from 'drizzle-orm/pg-core'
/** PostgreSQL bytea(Drizzle 无内置;用于 `key_ciphertext`) */
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
dataType() {
return 'bytea'
},
})
/** 第一张表:API key 登记表(api_keys 表字段设计,17 列) */
export const apiKeys = pgTable('api_keys', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 登记时 HMAC-SHA256(命中判定,不可逆) */
keyHash: text('key_hash').notNull().unique(),
/** 登记时 AES-256-GCM(nonce + 密文 + tag 打包;唯一用途是调上游) */
keyCiphertext: bytea('key_ciphertext').notNull(),
/** active | disabled */
status: text('status').notNull().default('active'),
/** manual | auto_401 | null(区分「已失效」与「已停用」) */
disabledSource: text('disabled_source'),
/** 连续 401 计数:401 时 +1、成功清零、其他失败不动 */
consecutive401s: integer('consecutive_401s').notNull().default(0),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
/** 「数据截止」的唯一来源(取请求发起时刻) */
logSyncedAt: timestamp('log_synced_at', { withTimezone: true }),
usageSyncedAt: timestamp('usage_synced_at', { withTimezone: true }),
// —— 上游字段(GET /api/usage/token,整组一起写) ——
tokenName: text('token_name').notNull(),
totalGranted: bigint('total_granted', { mode: 'number' }).notNull().default(0),
totalUsed: bigint('total_used', { mode: 'number' }).notNull().default(0),
totalAvailable: bigint('total_available', { mode: 'number' }).notNull().default(0),
unlimitedQuota: boolean('unlimited_quota').notNull().default(false),
modelLimits: jsonb('model_limits').notNull().default({}),
modelLimitsEnabled: boolean('model_limits_enabled').notNull().default(false),
/** 上游 0 = 永不过期 → NULL */
expiresAt: timestamp('expires_at', { withTimezone: true }),
})
/** 第二张表:调用日志明细(usage_logs 表字段设计,23 列) */
export const usageLogs = pgTable(
'usage_logs',
{
id: bigserial('id', { mode: 'number' }).primaryKey(),
keyId: bigint('key_id', { mode: 'number' })
.notNull()
.references(() => apiKeys.id),
/** 去重唯一键:`${key_id}:${request_id}`;request_id 空时退化为内容指纹 */
dedupeKey: text('dedupe_key').notNull().unique(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
type: smallint('type').notNull(),
content: text('content').notNull().default(''),
username: text('username').notNull(),
/** 日志快照名(展示取当前名的口径见 API 契约 §2.1) */
tokenName: text('token_name').notNull(),
modelName: text('model_name').notNull(),
quota: bigint('quota', { mode: 'number' }).notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
useTime: integer('use_time').notNull(),
isStream: boolean('is_stream').notNull(),
channel: integer('channel').notNull(),
channelName: text('channel_name').notNull().default(''),
tokenId: bigint('token_id', { mode: 'number' }).notNull(),
groupName: text('group_name').notNull().default('default'),
ip: text('ip').notNull().default(''),
requestId: text('request_id').notNull(),
upstreamRequestId: text('upstream_request_id'),
other: jsonb('other').notNull().default({}),
},
(t) => [
index('usage_logs_key_created_idx').on(t.keyId, t.createdAt.desc()),
index('usage_logs_created_idx').on(t.createdAt.desc()),
],
)
/** 第三张表:购买记录(purchases 表字段设计,9 列;全人工登记) */
export const purchases = pgTable('purchases', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** subscription | topup */
kind: text('kind').notNull(),
paidAt: timestamp('paid_at', { withTimezone: true }).notNull(),
/** 实付人民币(唯一「只有作者知道」的信息) */
paidAmount: numeric('paid_amount', { mode: 'number' }).notNull(),
note: text('note'),
/** 期间左闭右开 [period_start, period_end);未结束可空 */
periodStart: timestamp('period_start', { withTimezone: true }),
periodEnd: timestamp('period_end', { withTimezone: true }),
/** 上游订阅实例 ID(与日志 other.subscription_id 匹配) */
subscriptionId: bigint('subscription_id', { mode: 'number' }),
/** 本笔额度(折算链的分母) */
quotaGranted: bigint('quota_granted', { mode: 'number' }),
})
/** 第四张表:换算参数时间线(qpu_history 表字段设计,3 列;纯只读,唯一写入者是同步引擎) */
export const qpuHistory = pgTable('qpu_history', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 当时的 quota_per_unit 值 */
qpu: numeric('qpu', { mode: 'number' }).notNull(),
/** 生效时间;正常全表仅 1 行(出厂默认值从未变过) */
effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull(),
})
+44
View File
@@ -0,0 +1,44 @@
/**
* 服务入口:Hono 应用 + 同步引擎定时器(后端单进程、单实例——见同步引擎设计 §3.1)。
* 路由骨架按 `docs/design/API契约.md` 的 11 个端点预留,业务逻辑逐步实现。
*/
import { Hono } from 'hono'
import { env } from './lib/env'
import { apiKeysRoutes } from './routes/manage/api-keys'
import { purchasesRoutes } from './routes/manage/purchases'
import { qpuRoutes } from './routes/manage/qpu'
import { usageRoutes } from './routes/usage'
import { startSyncEngine } from './sync'
const app = new Hono()
/** 健康检查(非契约端点,运维用) */
app.get('/api/health', (c) =>
c.json({ ok: true, served_at: new Date().toISOString() }),
)
// 查看面(无会话、无状态;key 集合随请求体,见契约 约定 5 / 19)
app.route('/api/usage', usageRoutes)
// 管理面(X-Manage-Token 口令门帘;见契约 约定 6)
app.route('/api/manage/api-keys', apiKeysRoutes)
app.route('/api/manage/purchases', purchasesRoutes)
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 {
port: env.port,
fetch: app.fetch,
}
+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')
},
}
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 环境变量(部署时经 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) {
throw new Error(`缺少环境变量 ${name}(见 .env.example)`)
}
return v
}
export const env = {
/** 监听端口(Vite dev 代理与生产反代都指向它) */
port: Number(process.env.PORT ?? 3000),
/** PostgreSQL 连接串 */
databaseUrl: process.env.DATABASE_URL ?? '',
/** 管理面口令(X-Manage-Token;见 API 契约 约定 6) */
manageToken: process.env.MANAGE_TOKEN ?? '',
/** 凭证主密钥(HKDF 派生 hmac / aes 子密钥;丢失 = 全部 key 需重新登记) */
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),
}
}
+18
View File
@@ -0,0 +1,18 @@
import { createMiddleware } from 'hono/factory'
import { env } from '../lib/env'
/**
* 管理面口令门帘(契约 约定 6):每次请求校验 `X-Manage-Token`。
* 无登录端点、无令牌签发;常量时间比较等加固归技术稿(API 契约 §3)。
*/
export const manageAuth = createMiddleware(async (c, next) => {
const token = c.req.header('x-manage-token')
if (!env.manageToken || token !== env.manageToken) {
return c.json(
{ error: { code: 'manage_unauthorized', message: '口令无效' } },
401,
)
}
await next()
})
+158
View File
@@ -0,0 +1,158 @@
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):清单 / 登记 / 启停。
* 清单与 `api_keys` 表 1:1——`key_hash` / `key_ciphertext` 两个敏感列永不出接口。
*/
export const apiKeysRoutes = new Hono()
apiKeysRoutes.use(manageAuth)
/** 清单(按 id 升序,不分页) */
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('/', 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', 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!))
})
+226
View File
@@ -0,0 +1,226 @@
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):清单 / 登记 / 编辑 / 删除 / 观测(核对提示)。
* 登记必填:`kind` / `paid_at` / `paid_amount` / `quota_granted`。
*/
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('/', 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', 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('/', 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', 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', 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]!))
})
+20
View File
@@ -0,0 +1,20 @@
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` 唯一写入者是同步引擎。
*/
export const qpuRoutes = new Hono()
qpuRoutes.use(manageAuth)
/** 时间线清单(按 effective_from 升序;不分页) */
qpuRoutes.get('/', async (c) => {
const rows = await getDb().select().from(qpuHistory).orderBy(asc(qpuHistory.effectiveFrom))
return c.json(rows.map(toQpuRow))
})
+340
View File
@@ -0,0 +1,340 @@
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 集合与过滤参数全在请求体。
* 两个端点:`/logs/query`(明细,分页)· `/summary`(卡片汇总);首次并行请求,翻页只调明细。
*/
export const usageRoutes = new Hono()
type ApiKeyRow = typeof apiKeys.$inferSelect
type UsageLogDbRow = typeof usageLogs.$inferSelect
// ---------- 请求解析 ----------
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)}`
}
+240
View File
@@ -0,0 +1,240 @@
/**
* 同步引擎(见 `docs/design/同步引擎设计.md`):调度 + 单轮执行。
*
* 结构(§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'
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
/** 上游错误 → 本轮结果:只有 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> {
if (running) {
console.log('[sync] 上一轮未结束,跳过本轮(防重入)')
return
}
running = true
const startedMs = Date.now()
try {
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] 轮次异常:${errorMessage(err)}`)
} finally {
running = false
}
}
/**
* 启动引擎:立即跑一轮,随后每 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)
})
})
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+32
View File
@@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@naku/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@naku/shared": "workspace:*",
"naive-ui": "^2.45.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/eslint-plugin": "^1.6.23",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.73.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^9.0.2",
"oxlint": "~1.74.0",
"prettier": "3.9.5",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
// 根组件:全局 provider(message / dialog / notification)+ naive-ui 中文 locale + 路由出口
import { RouterView } from 'vue-router'
import { dateZhCN, zhCN } from 'naive-ui'
</script>
<template>
<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>
/* 全局基础样式(浅色单主题,交互设计 §2 #2) */
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family:
system-ui,
-apple-system,
'Segoe UI',
'PingFang SC',
'Microsoft YaHei',
sans-serif;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import router from '@/router'
describe('router', () => {
it('路由表包含查看面与管理面三页', () => {
const paths = router.getRoutes().map((r) => r.path)
for (const p of ['/usage-log', '/manage/api-keys', '/manage/purchases', '/manage/qpu']) {
expect(paths).toContain(p)
}
})
it(
'根路径重定向到 /usage-log(真实导航;首次含懒加载组件转换,放宽超时)',
async () => {
await router.push('/')
expect(router.currentRoute.value.path).toBe('/usage-log')
},
20_000,
)
})
+101
View File
@@ -0,0 +1,101 @@
/**
* API 客户端(契约 约定 20 / 24):
* - 成功 = 裸数据体;失败 = `{ error: { code, message } }`(非 2xx)
* - 管理面请求自动附加 `X-Manage-Token`(存 sessionStorage,关标签即忘)
* - 401 `manage_unauthorized` → 通知口令门帘(ManageLayout 监听)
*/
import { MANAGE_TOKEN_HEADER, type ApiErrorBody } from '@naku/shared'
export class ApiError extends Error {
constructor(
readonly code: string,
readonly status: number,
message: string,
) {
super(message)
this.name = 'ApiError'
}
}
const MANAGE_TOKEN_KEY = 'naku:manage-token'
export function getManageToken(): string {
return sessionStorage.getItem(MANAGE_TOKEN_KEY) ?? ''
}
export function setManageToken(token: string): void {
sessionStorage.setItem(MANAGE_TOKEN_KEY, token)
}
export function clearManageToken(): void {
sessionStorage.removeItem(MANAGE_TOKEN_KEY)
}
/** 口令失效通知(订阅返回取消函数) */
const unauthorizedListeners = new Set<() => void>()
export function onManageUnauthorized(fn: () => void): () => void {
unauthorizedListeners.add(fn)
return () => {
unauthorizedListeners.delete(fn)
}
}
interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
body?: unknown
/** 管理面请求:附加 X-Manage-Token */
manage?: boolean
}
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, manage = false } = options
const headers: Record<string, string> = {}
if (body !== undefined) headers['content-type'] = 'application/json'
if (manage) headers[MANAGE_TOKEN_HEADER] = getManageToken()
const res = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!res.ok) {
// 失败体未必是我们的 JSON(反代 502 / 网关错误页)——兜底解析
const parsed = (await res.json().catch(() => null)) as ApiErrorBody | null
const code = parsed?.error?.code ?? 'unknown'
const message = parsed?.error?.message ?? `HTTP ${res.status}`
if (manage && res.status === 401 && code === 'manage_unauthorized') {
unauthorizedListeners.forEach((fn) => fn())
}
throw new ApiError(code, res.status, message)
}
return (await res.json()) as T
}
// ---- 查看面(无会话;key 集合随请求体,契约 约定 5 / 19) ----
export const api = {
usageLogsQuery: <T>(body: unknown) => request<T>('/api/usage/logs/query', { method: 'POST', body }),
usageSummary: <T>(body: unknown) => request<T>('/api/usage/summary', { method: 'POST', body }),
// ---- 管理面(manage: true 自动带口令) ----
apiKeysList: <T>() => request<T>('/api/manage/api-keys', { manage: true }),
apiKeyRegister: <T>(body: unknown) =>
request<T>('/api/manage/api-keys', { method: 'POST', body, manage: true }),
apiKeyPatch: <T>(id: number, body: unknown) =>
request<T>(`/api/manage/api-keys/${id}`, { method: 'PATCH', body, manage: true }),
purchasesList: <T>() => request<T>('/api/manage/purchases', { manage: true }),
purchasesCreate: <T>(body: unknown) =>
request<T>('/api/manage/purchases', { method: 'POST', body, manage: true }),
purchasesPatch: <T>(id: number, body: unknown) =>
request<T>(`/api/manage/purchases/${id}`, { method: 'PATCH', body, manage: true }),
purchasesDelete: <T>(id: number) =>
request<T>(`/api/manage/purchases/${id}`, { method: 'DELETE', manage: true }),
purchasesObservations: <T>() => request<T>('/api/manage/purchases/observations', { manage: true }),
qpuList: <T>() => request<T>('/api/manage/qpu', { manage: true }),
}
+15
View File
@@ -0,0 +1,15 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const useDialog: typeof import('naive-ui').useDialog
const useLoadingBar: typeof import('naive-ui').useLoadingBar
const useMessage: typeof import('naive-ui').useMessage
const useNotification: typeof import('naive-ui').useNotification
}
+48
View File
@@ -0,0 +1,48 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
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']
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
+42
View File
@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from 'vue-router'
/**
* 路由(交互设计 §0.1 / §1):
* - 无前缀 = 查看面(一期仅 `/usage-log`,`/` 重定向到它;二期候选 `/dashboard`)
* - `/manage` 前缀 = 管理面(口令门帘由 ManageLayout 承担;入口在查看面不暴露)
*/
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/usage-log' },
{
path: '/usage-log',
name: 'usage-log',
component: () => import('@/views/UsageLogView.vue'),
},
{
path: '/manage',
component: () => import('@/views/manage/ManageLayout.vue'),
children: [
{ path: '', redirect: '/manage/api-keys' },
{
path: 'api-keys',
name: 'manage-api-keys',
component: () => import('@/views/manage/ApiKeysView.vue'),
},
{
path: 'purchases',
name: 'manage-purchases',
component: () => import('@/views/manage/PurchasesView.vue'),
},
{
path: 'qpu',
name: 'manage-qpu',
component: () => import('@/views/manage/QpuView.vue'),
},
],
},
],
})
export default router
@@ -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)
}
+500
View File
@@ -0,0 +1,500 @@
<script setup lang="ts">
// 用量日志页(交互设计 §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 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>
<style scoped>
.usage-log {
max-width: 1200px;
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>
+272
View File
@@ -0,0 +1,272 @@
<script setup lang="ts">
// 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">
<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>
+140
View File
@@ -0,0 +1,140 @@
<script setup lang="ts">
/**
* 管理面布局(交互设计 §2 #7 + 口令门帘细节):
* - 未验证:全屏居中卡片(口令 + 「进入」)——校验直接用管理面请求,401 即口令错
* - 已验证:侧边栏菜单(三页)+ 内容区;底部「← 查看面」
* - 口令存 sessionStorage(关标签即忘);401 时自动退回门帘
*/
import { onMounted, onUnmounted, ref } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ApiError,
api,
clearManageToken,
getManageToken,
onManageUnauthorized,
setManageToken,
} from '@/api/client'
const router = useRouter()
const route = useRoute()
const unlocked = ref(false)
const checking = ref(false)
const tokenInput = ref('')
const errorText = ref('')
async function verifyAndUnlock(): Promise<void> {
checking.value = true
errorText.value = ''
try {
await api.apiKeysList()
unlocked.value = true
} catch (err) {
clearManageToken()
errorText.value =
err instanceof ApiError && err.code === 'manage_unauthorized'
? '口令不正确'
: '校验失败,请稍后重试'
unlocked.value = false
} finally {
checking.value = false
}
}
async function tryUnlock(): Promise<void> {
if (!tokenInput.value) {
errorText.value = '请输入口令'
return
}
setManageToken(tokenInput.value)
await verifyAndUnlock()
}
let off: (() => void) | null = null
onMounted(() => {
off = onManageUnauthorized(() => {
unlocked.value = false
errorText.value = '口令已失效,请重新输入'
})
if (getManageToken()) {
void verifyAndUnlock()
}
})
onUnmounted(() => {
off?.()
})
const menuOptions = [
{ label: 'key 管理', key: 'manage-api-keys' },
{ label: '购买记录', key: 'manage-purchases' },
{ label: '换算参数', key: 'manage-qpu' },
]
</script>
<template>
<div v-if="!unlocked" class="gate">
<n-card class="gate-card" title="管理通道">
<n-space vertical :size="16">
<n-input
v-model:value="tokenInput"
type="password"
show-password-on="click"
placeholder="部署口令"
@keyup.enter="tryUnlock"
/>
<n-text v-if="errorText" type="error">{{ errorText }}</n-text>
<n-button type="primary" block :loading="checking" @click="tryUnlock">进入</n-button>
</n-space>
</n-card>
</div>
<n-layout v-else has-sider class="manage">
<n-layout-sider bordered :width="200">
<div class="sider">
<n-menu
:value="String(route.name ?? '')"
:options="menuOptions"
@update:value="(key: string) => router.push({ name: key })"
/>
<n-button text class="back" @click="router.push('/usage-log')">← 查看面</n-button>
</div>
</n-layout-sider>
<n-layout-content class="content">
<RouterView />
</n-layout-content>
</n-layout>
</template>
<style scoped>
.gate {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.gate-card {
width: 360px;
}
.manage {
height: 100vh;
}
.sider {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding: 16px 0;
}
.back {
margin-left: 24px;
align-self: flex-start;
}
.content {
padding: 24px;
overflow: auto;
}
</style>
+355
View File
@@ -0,0 +1,355 @@
<script setup lang="ts">
// 购买记录页(交互设计 §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">
<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>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
// 换算参数时间线页(交互设计 §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>
<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>
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
+43
View File
@@ -0,0 +1,43 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
// naive-ui 按需引入(交互设计 §2 #3)
AutoImport({
imports: [
{
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
},
],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [NaiveUiResolver()],
dts: 'src/components.d.ts',
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
// 开发期把 /api 代理到后端;生产由同域反代承担(见 README 工程注记)
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)
+1166
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
# 本地开发用 PostgreSQL(仅开发;生产用实例运营方的 PG 实例)
# 起库:docker compose up -d 查看:docker compose logs -f db
services:
db:
image: postgres:17-alpine
container_name: naku-db
environment:
POSTGRES_USER: naku
POSTGRES_PASSWORD: naku-dev
POSTGRES_DB: naku
ports:
# 宿主端口用 5433,避开本机可能已有的 5432
- '5433:5432'
volumes:
- naku-db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U naku -d naku']
interval: 5s
timeout: 3s
retries: 10
volumes:
naku-db-data:
+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,关标签后需重新输入) |
+10
View File
@@ -0,0 +1,10 @@
# 已废弃文档归档
> **本目录只作存档**:其中的文档均已作废,**不要引用、不要作为任何设计依据**。
- 现行文档一律以 [`docs/design/`](../design/)(我们定的方案与决策)与 [`docs/upstream/`](../upstream/)(NewAPI 实证事实)为准,入口见仓库根 [`README.md`](../../README.md) 的「文档地图」
- 本目录不随项目演进维护;仅供追溯历史
## 归档清单
(当前无归档文档)
+272
View File
@@ -0,0 +1,272 @@
# API 契约
> 前端 ↔ 服务端的接口契约:**约定层**(跨页共性)+ **逐页端点**(按 `docs/design/交互设计.md` 的页面清单推导)。只描述「前端能用到的 HTTP 接口」;上游侧调用不在本文(见 `docs/upstream/NewAPI-接口范围.md`)。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/交互设计.md`(页面清单与职责)· 四张表字段设计(数据形状)· `docs/design/交互设计.md` §0(管理面边界与口令门帘) |
| 范围 | 路径、鉴权形态、时间 / 数字形态、分页、错误与响应形状、逐页端点;校验细节、存储、调度归技术稿 |
| 不含 | 上游 NewAPI 的调用方式(同步引擎设计)· 展示文案与交互细节(交互设计细节层) |
---
## 1. 约定层(跨页共性)
> 2026-09-13 逐条过审:第 3 / 9 / 14 / 18 / 19 / 20 / 22 条专项讨论后落定,其余默认通过。本文已定稿,后续如需修订就地更新并注明日期。
### 1.1 路径与命名
| # | 约定 | 理由 |
|---|------|------|
| 1 | 查看面 API 前缀 `/api/*`;管理面 API 前缀 `/api/manage/*` | 两套鉴权模型物理隔离,口径一刀切在路径上;前端路由(`/usage-log`、`/manage/*`)与 API 路径不冲突 |
| 2 | 不加版本号前缀(不做 `/api/v1`) | 单实例小工具、前后端同仓同发,无多版本并行需求 |
| 3 | JSON 字段一律 `snake_case`,与库表列名 / 上游字段同名同形;前端 TS 类型不做 camelCase 转换 | 全链路零改名,看文档、看库、看日志是同一套词;`other`(jsonb 透传)内层无法转换,转换方案必然产生两套命名混用 |
| 4 | 字段命名以事实为准(如换算值带 `_usd` 后缀);「不标注币种」是展示层措辞约定,不约束字段名 | 契约给开发者看,语义准确优先 |
### 1.2 鉴权与身份
| # | 约定 | 理由 |
|---|------|------|
| 5 | 查看面**无会话、无服务端状态**:每个查询请求自带 key 集合,服务端仅以 `key_hash` 命中判定(HMAC),不存、不回显 key 原文 | 与「无登录页、无账户体系、查询域完全平等」的既定边界一致;服务端不留任何使用者痕迹 |
| 6 | 管理面单口令:每次请求携带 `X-Manage-Token: <env 口令>`,校验失败 → `401 manage_unauthorized`;**无登录端点、无令牌签发** | 口令门帘是「访问校验」而非会话;有会话就长出了用户体系的影子 |
| 7 | **任何凭证不进 Cookie、不进 URL**(不进 access log / 浏览器历史 / Referer) | 不依赖 Cookie 即天然免疫 CSRF(跨站表单无法设置自定义头、JSON 体需预检);凭证不进 URL 是安全基线 |
| 8 | 查看面对「未收录 / 已停用」的 key **不返回 HTTP 错误**:查询照常 200,per-key 状态在响应 `meta` 中反馈 | key 未收录是查询结果的一部分(交互设计 §0.2 的「未收录」提示),不是接口失败 |
### 1.3 时间与时区
| # | 约定 | 理由 |
|---|------|------|
| 9 | 时间一律 **ISO 8601 带显式偏移**:**输出**统一北京时间 `+08:00`(`+08:00` 是产品口径、非服务器时区);**输入宽容**——接受任何带偏移的 ISO 8601(含 `Z` / 毫秒),服务端按绝对时刻解析 | 使用者全是北京时间语境,输出即可直接展示(前端零时区转换);输入宽容免去前端格式化负担(`toISOString()` 的 `Z` 形式可直接提交);显式偏移杜绝歧义(2026-09-13 作者裁决) |
| 10 | 所有时间范围左闭右开 `[start, end)` | 全项目期间惯例(`purchases` 设计 §3.4 同款) |
| 11 | 时间戳类字段(`registered_at` / `log_synced_at` / `usage_synced_at` / `paid_at` / `effective_from`)同制输出 | 一处规则管全部,前端只有一个时间处理分支 |
### 1.4 数字与折算
| # | 约定 | 理由 |
|---|------|------|
| 12 | 整数(quota / token 计数)以 JSON number 传输,不做字符串化 | 最大量级 ~1e9(实测 `subscription_total = 8e8`),远小于 2^53 安全域 |
| 13 | **折算一律在服务端完成**:折尺信息(购买期间 + QPU 时间线)只有服务端持有;聚合响应给折算数值,同时保留 quota 原值 | 前端拿不到折尺;折算语义(按购买时刻取尺、跨期按购买期间边界分段)是服务端职责,前端只消费结果 |
| 14 | 服务端不做舍入、不做千分位/币种格式化:数值全精度回传 | 格式化归展示层,避免「服务端先舍入、前端再格式化」的双重失真(2026-09-13 作者裁决:保持全精度) |
| 15 | 折算不落在日志行上、不新增日志列(沿用 `purchases` 设计 §3.2):日志响应只给 quota 原值与 `other` 原始内容 | 折算随时间线变化,任何固化到日志行的值都会与时间线脱钩 |
> **金额展示口径(2026-09-13 作者裁决)**:查看面的「消费」类金额一律为**折算实付人民币**(按购买期间分段,每段 `quota 占比 × 该笔实付`)——`purchases` 设计 §6 的「天然不含实付金额」边界就此放开;**仍不上查看面**的是实付总额、购买比率与购买记录本身(管理面独占)。已知代价:明细带 quota 列、QPU 公开,使用者理论上可从两者反推购买比率——接受。
### 1.5 查询形态与分页
| # | 约定 | 理由 |
|---|------|------|
| 16 | 明细排序固定 `created_at DESC`(不开放排序参数) | 日志天然时序;开放排序只增加索引负担 |
| 17 | key 集合在请求内先去重、设数量上限(超限 → `400 invalid_params`) | 防御性校验:避免单请求打爆 `IN (...) + HMAC` 循环 |
| 18 | 分页:请求 `limit` + `offset`,响应含 `total`(总条数);分页响应形态 `{ "items": [...], "total": N, "meta": {...} }` | 条数对使用者有信息价值(「这段时间共多少次调用」);时间范围过滤下深翻风险小、前端页码器直观(2026-09-13 裁决;cursor 方案已否决) |
| 19 | 查看面查询**一律 POST + JSON 请求体**:key 集合与过滤参数统一进体(`{"keys": ["sk-…"], "start": …, "end": …, "type": [...]}`) | 多 key 数组最自然、复杂过滤(`type` 列表)好承载;密钥不进 URL、不进 access log(2026-09-13 裁决) |
### 1.6 响应与错误
| # | 约定 | 理由 |
|---|------|------|
| 20 | 成功 = 2xx + **裸数据体**(无 `{ data: ... }` 包装);失败 = 非 2xx + `{ "error": { "code": "...", "message": "..." } }` | 前端判断简单(`res.ok`);`code` 供分支文案,`message` 供人读(2026-09-13 作者裁决:保持此形态,信封方案已否决) |
| 21 | `code` 为机器可读枚举:`invalid_params` · `manage_unauthorized` · `not_found` · `internal` · `key_rejected`(上游拒绝该 key;400)· `already_registered`(重复登记;409)· `upstream_unavailable`(上游故障 / 超时;502) | 按需增补;未知 code 前端兜底为通用错误文案(2026-09-13 扩展后三枚:前端可分清「输入错 / 重复 / 系统故障」三种处置) |
| 22 | 查看面查询响应统一带 `meta` 块:`data_until`(参与 key 中**最慢**的 `log_synced_at`)、`served_at`、`keys[]`(per-key:`key_id` / `token_name` / `registered` / `status` / 各自的 `log_synced_at`;未收录项 = `{ registered: false, key_hint: "sk-…a1b2" }`) | `log_synced_at` 是「数据截止」唯一来源(`api_keys` 设计 §3.3);取最慢 = 声称的截止对每个 key 都成立——统一 tick 下正常差异为秒级(同一轮内完成先后),某 key 失败或停用才拉大,per-key 字段负责解释;`key_hint` 即时生成、不落库(`api_keys` §5 否决的是存库,不是响应携带);新登记尚未首同步的 key `log_synced_at` 为 `null`——顶层 `data_until` 取各 key 中**非 null 的最慢值**,全部未同步时为 `null` |
| 23 | 参与 key 全部未收录时同样 200(空结果 + `meta.keys[]` 标未收录) | 与约定 8 一致:未收录是结果、不是错误;前端据此渲染「未收录」提示 |
| 24 | 前后端连线(部署层约定):**同域反代为主**(`/api/*` 反代到后端——浏览器视角同源,无 CORS、前端用相对路径);**跨源直连为辅**——后端 CORS 白名单可配(允许 `X-Manage-Token` 等自定义头与 `OPTIONS` 预检),前端以**运行时**配置读取 API base(同一份产物可换环境) | 前后端独立部署(README 决策 7 修订);凭证不进 Cookie → CORS 无需 credentials 模式;运行时 base 免去「改地址要重新构建」(2026-09-13 作者裁决) |
**响应示例(形状示意;`items` 内字段级细节以逐页推导为准)**
```jsonc
// 成功:查看面查询(分页 + meta)
{
"items": [
{ "id": 8412, "key_id": 3, "created_at": "2026-09-11T19:37:12+08:00", "type": 2,
"model_name": "gpt-5.6-terra", "quota": 12000 }
],
"total": 1873,
"meta": {
"data_until": "2026-09-13T21:10:00+08:00",
"served_at": "2026-09-13T21:36:00+08:00",
"keys": [
{ "key_id": 3, "token_name": "小王", "registered": true, "status": "active", "log_synced_at": "2026-09-13T21:35:03+08:00" },
{ "key_id": 7, "token_name": "小李", "registered": true, "status": "disabled", "log_synced_at": "2026-09-13T21:10:00+08:00" },
{ "registered": false, "key_hint": "sk-…a1b2" }
]
}
}
// 失败:error 信封
{ "error": { "code": "invalid_params", "message": "keys 数量超过上限(最多 20 个)" } }
```
---
## 2. 逐页端点
按 `交互设计.md` 的页面清单逐页推导,每页记录:端点(方法 / 路径 / 请求 / 响应 / 错误分支)+ 页面元素到字段的映射。
### 2.1 `/usage-log`(2026-09-13 推完)
**页职责**(交互设计):key 输入(多个)+ 时间范围 → 明细表格;表格上方汇总卡片(累计用量、预估消费)。
**端点划分(2026-09-13 定)**:明细与汇总**分两个端点**——首次并行请求(`Promise.all`),翻页只调明细、不重算汇总。
| # | 端点 | 职责 | 响应形状 |
|---|------|------|---------|
| 1 | `POST /api/usage/logs/query` | 明细行(分页) | `{ items, total, meta }` |
| 2 | `POST /api/usage/summary` | 卡片汇总 | `{ request_count, …, meta }` |
**明细行字段(2026-09-13 定)**
- **与表 1:1 全量返回**(除本系统内部列 `dedupe_key`):`id` / `key_id` / `user_id` / `username` / `created_at` / `type` / `content` / `model_name` / `quota` / `prompt_tokens` / `completion_tokens` / `use_time` / `is_stream` / `channel` / `channel_name` / `token_id` / `group_name` / `ip` / `request_id` / `upstream_request_id` / `other`
- 恒定 / 空值字段(`user_id` / `username` / `channel` / `channel_name` / `ip` / `group_name` / `upstream_request_id`)照返——展示与否是交互层决策
- 两个覆盖/计算字段:
- `token_name`——**当前名**(join `api_keys`),不用日志里的快照名(同一个 key 的每行显示同一个名字;快照列仍在库中保真)
- `amount_rmb`——该行折算实付人民币(`quota ÷ 购买时刻 QPU × 购买汇率`;错误行 `quota = 0` → 0);**匹配不到归属购买时为 `null`**(未登记购买 / 充值来源)——展示层显示「—」,绝不退化为额度美元(2026-09-14 定)
**汇总响应字段(2026-09-13 定;2026-09-14 增 `unpriced_count`)**
| 字段 | 含义 |
|------|------|
| `request_count` | 时段内总行数 |
| `consume_count` / `error_count` | 按 `type` 拆分(2 消费 / 5 错误) |
| `prompt_tokens` / `completion_tokens` / `cache_tokens` | token 三项合计(`cache_tokens ⊆ prompt_tokens`,单列可看缓存命中量) |
| `quota` | quota 合计(原值,供核对) |
| `amount_rmb` | **可折算部分**的实付人民币合计(跨期按购买期间分段累加) |
| `unpriced_count` | 无法折算的行数(`amount_rmb` 为 `null` 的行)——前端据此提示「其中 N 条未登记购买、未计入金额」 |
| `meta` | 同约定 22 |
**页面元素 → 数据来源**
| 页面元素 | 来源 |
|---------|------|
| key 输入(多个)+ 时间范围 | 前端持有(输入即查询;服务端无状态) |
| 卡片:调用次数 | 汇总:`request_count` / `consume_count` / `error_count` |
| 卡片:累计用量 | 汇总:token 三项 + `quota` |
| 卡片:预估消费 | 汇总:`amount_rmb`(口径见 §1.4 说明) |
| 卡片:数据截止 | `meta.data_until` |
| 「未收录」提示 | `meta.keys[].registered` / `key_hint` |
| 明细表格 | `items[]` |
| 分页器 | `total` + `limit` / `offset` |
**请求参数(2026-09-13 定)**
```jsonc
// POST /api/usage/logs/query
{
"keys": ["sk-…", "sk-…"], // 必填;去重后最多 20 个(初值,超限 → invalid_params)
"start": "2026-09-06T00:00:00+08:00", // 必填;左闭右开 [start, end)
"end": "2026-09-13T00:00:00+08:00",
"type": [2, 5], // 可选;缺省 = 全部类型(0–7);只作用于本端点
"limit": 50, // 可选;默认 50,上限 200(超限 → invalid_params)
"offset": 0 // 可选;默认 0
}
// POST /api/usage/summary —— 同一套 keys / start / end;无 type / limit / offset
{ "keys": ["sk-…"], "start": "2026-09-06T00:00:00+08:00", "end": "2026-09-13T00:00:00+08:00" }
```
- **字段集稳定**:可空列(如 `upstream_request_id`)返回 `null` 而非省略字段——前端类型可直接写 `string | null`
- 不设跨度上限(2026-09-13 定);非法参数(`start ≥ end`、无法解析、`keys` 空数组)→ `400 invalid_params`
**响应示例(示意)**
```jsonc
// 明细(amount_rmb 为 null = 匹配不到归属购买:未登记 / 充值来源)
{
"items": [
{ "id": 8412, "key_id": 3, "user_id": 12, "username": "…", "created_at": "2026-09-11T19:37:12+08:00",
"type": 2, "content": "", "token_name": "小王", "model_name": "gpt-5.6-terra", "quota": 12000,
"prompt_tokens": 1500, "completion_tokens": 500, "use_time": 2, "is_stream": true,
"channel": 0, "channel_name": "", "token_id": 100, "group_name": "default", "ip": "",
"request_id": "20260911193652…", "upstream_request_id": null, "amount_rmb": 0.017,
"other": { "cache_tokens": 29056, "model_ratio": 0.075, "subscription_id": 90, "…": "…" } }
],
"total": 1873,
"meta": { "…": "见约定 22" }
}
// 汇总
{
"request_count": 1873, "consume_count": 1849, "error_count": 24,
"prompt_tokens": 1234567, "completion_tokens": 234567, "cache_tokens": 89012,
"quota": 53939847, "amount_rmb": 23.45, "unpriced_count": 0,
"meta": { "…": "见约定 22" }
}
```
### 2.2 `/manage/api-keys`(2026-09-13 推完)
**端点(2026-09-13 定)**
| # | 端点 | 职责 |
|---|------|------|
| 1 | `GET /api/manage/api-keys` | 清单(按 `id` 升序,不分页) |
| 2 | `POST /api/manage/api-keys` | 登记(body 只带 `key`;上游验证 → 入库) |
| 3 | `PATCH /api/manage/api-keys/:id` | 启停(`{"status": "active" | "disabled"}`;启用需上游验证) |
**清单响应字段(2026-09-13 定)**:与表 1:1(17 列,除 `key_hash` / `key_ciphertext` 两个敏感列——**永不出接口**)。`disabled_source` 使「已失效(`auto_401`)」与「已停用(`manual`)」可辨(该列为此于同日补进 `api_keys` 表设计,见其 §3.8)。
**登记与启停细节(2026-09-13 定)**
| 项 | 定法 |
|----|------|
| 登记请求 | `POST {"key": "sk-…"}` |
| 登记响应 | `201` + 新行完整对象(同清单形状;此时 `log_synced_at: null`) |
| 登记后的可见时机 | **不阻塞**:验证(`GET /api/usage/token`)+ INSERT 即返回;首轮日志同步由同步引擎**立即异步补一轮**——未完成时 `meta` 如实给 `null`,前端显示「数据同步中」 |
| 启停请求 / 响应 | `PATCH {"status": …}` → `200` + 更新后的完整行;**幂等**(提交当前已是的状态不报错) |
| 启用成功的副作用 | `consecutive_401s` 清零——验证调用本身即一次成功调用,「连续」已断(否则下一轮若 401 会立刻再次摘除) |
| 错误分支 | `key_rejected`(400,上游拒绝该 key:登记验证 / 启用验证失败)· `already_registered`(409,key 已在库)· `upstream_unavailable`(502,上游故障、无法完成验证)· `not_found`(404,`PATCH` 的 id 不存在)· `invalid_params`(400,`key` 缺失 / 格式非法) |
### 2.3 `/manage/purchases`(2026-09-13 推完)
**端点(2026-09-13 定)**
| # | 端点 | 职责 |
|---|------|------|
| 1 | `GET /api/manage/purchases` | 清单(按 `paid_at` DESC,同时间按 `id` DESC;不分页) |
| 2 | `POST /api/manage/purchases` | 登记 |
| 3 | `PATCH /api/manage/purchases/:id` | 编辑(部分更新) |
| 4 | `DELETE /api/manage/purchases/:id` | 删除(物理) |
| 5 | `GET /api/manage/purchases/observations` | 观测(核对提示的数据面) |
**字段与必填(2026-09-13 定)**
- 清单 / 登记响应:与表 1:1(9 列)
- 登记必填:`kind` / `paid_at` / `paid_amount` / `quota_granted`(前三为支付事实;`quota_granted` 是折算链的分母);`note` 与订阅标识 / 期间(`subscription_id` / `period_start` / `period_end`)**可后补**——缺失时该笔暂无法自动匹配日志,由观测端点引导补录
- 编辑:`PATCH` 部分更新语义,8 个录入字段全部可改(含 `kind`)
- 删除:物理删除;该笔对应日志的 `amount_rmb` 随即算不出来——折算在展示层实时算、不落库,此为预期行为(亦可视为「改错即修」的即时反馈)
**观测响应(示意)**
```jsonc
{
"items": [
{ "subscription_id": 90, "plan_title": "Max", "observed_quota_total": 800000000,
"first_seen_at": "2026-09-01T00:00:00+08:00", "last_seen_at": "2026-09-13T21:10:00+08:00",
"log_count": 976, "matched_purchase_id": null }
]
}
```
- 数据源:日志 `other` 的 `subscription_id` / `subscription_plan_title` / `subscription_total` 按 ID 聚合(jsonb)
- `matched_purchase_id` 非空 = 已对应某条购买记录;`null` = 观测到但未登记(核对提示的目标)
**错误码**:`invalid_params`(校验失败)· `not_found`(编辑 / 删除目标不存在)
### 2.4 `/manage/qpu`(2026-09-13 推完;无实质分叉,按默认定)
- **端点**:`GET /api/manage/qpu`——时间线清单(按 `effective_from` 升序;不分页)
- **字段**:与表 1:1(`id` / `qpu` / `effective_from`,3 列)
- **纯只读**:无任何写端点(`qpu_history` 表设计 §3.5 已锁定只读;管理通道仅提供查看)
### 2.5 `/dashboard`(二期候选 · 占位)
本页为二期候选(一期可不实现),**本期不推导**;届期按同一流程补端点与字段。数据面天然具备(本地全量日志 + 聚合能力),无需预留接口。
---
## 3. 待裁决与待定
| 事项 | 归属 |
|------|------|
| 同步引擎调度口径(一轮 = 遍历全部 `active` key 的统一 tick——2026-09-13 作者确认方向) | 同步引擎设计(失败重试策略等细节随行) |
| 管理面口令的传输加固(常量时间比较等) | 技术稿 |
| 各端点参数校验细则(key 格式与条数、时间解析失败、`type` 取值校验、`limit` / `offset` 边界) | 技术稿 |
+137
View File
@@ -0,0 +1,137 @@
# api_keys 表字段设计
> 数据模型第一张表:API key 登记表——每个被登记的 API key 一行,**由管理通道登记**(2026-09-13 起,取代原「输入即注册」)。本文记录字段清单、语义与决策理由;`usage_logs` 表待对齐后另文记录。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/upstream/NewAPI-sk-key接口文档.md`(接口①字段依据)· `docs/upstream/NewAPI-接口范围.md` · `docs/design/API契约.md` §2.2(登记 / 启停端点) |
| 定稿范围 | 列结构 + 语义 + 写入时机;同步引擎细节(401 阈值等)不在此文 |
| 字段数 | 17 = 9 本系统管理字段 + 8 上游接口字段 |
---
## 1. 表定位
- **一行 = 一个登记过的 API key**:在管理通道登记一次,这里多一行;之后所有轮询同步、查询都围绕它转
- 它是三个基本问题的答案所在地:
1. 「这个 key 登记过吗」→ `key_hash` 查询命中(支撑「本地查库、未命中即提示未收录」的判定流程)
2. 「该轮询哪些 key」→ `status = active` 的行
3. 「凭证安全存哪」→ `key_ciphertext`(加密存储)
- 与 `usage_logs` 一对多:每条日志经 `key_id` 指向本表
## 2. 字段清单
### 2.1 管理字段(9 个,本系统产生)
| 列 | 类型 | 值来源 | 消费方 / 用途 |
|----|------|--------|---------------|
| `id` | bigserial PK | 自增 | `usage_logs.key_id` 外键指向 |
| `key_hash` | text UNIQUE | 登记时 HMAC-SHA256(管理员粘贴输入) | 查询命中判定(「查库优先」);不可逆 |
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 401 摘除 / 手动启停 | `active` / `disabled`;「key 已失效」提示与轮询范围 |
| `disabled_source` | text NULL | 401 摘除 → `auto_401`;手动停用 → `manual`;启用 / 登记时清 NULL | 管理面区分「已失效」与「已停用」(见 3.8) |
| `consecutive_401s` | int | 每轮同步:401 时 +1,成功清零 | 「连续 401 自动摘除」的判定状态 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源** |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
### 2.2 上游字段(8 个,取自 `GET /api/usage/token`,除 `object` 外全部落库)
| 列 | 类型 | 上游字段 | 说明 |
|----|------|---------|------|
| `token_name` | text | `name` | 展示名(约定:展示名 = token_name) |
| `total_granted` | bigint | `total_granted` | 总额度(上游原值;无限额度时无意义,照存) |
| `total_used` | bigint | `total_used` | 累计已用 →「累计已用」列 |
| `total_available` | bigint | `total_available` | 按上游原值存(数字上等于 granted − used,但保留原值,含无限额度时的负数行为) |
| `unlimited_quota` | bool | `unlimited_quota` | 驱动展示分支(「累计剩余」显示「不限」而非数字) |
| `model_limits` | jsonb | `model_limits` | map 结构的自然落法 |
| `model_limits_enabled` | bool | `model_limits_enabled` | |
| `expires_at` | timestamptz NULL | `expires_at` | 上游 0 = 永不过期 → 存 NULL(见 3.5) |
## 3. 决策记录(含理由)
### 3.1 凭证双列分离:`key_hash` + `key_ciphertext`
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
- 密钥 `KEYS_MASTER_KEY` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
> **注解:为什么是两个字段(给非密码学背景的读者)**
>
> 两列各管一件事——`key_hash` 管「找得到」,`key_ciphertext` 管「用得起来」:
>
> - `key_hash` 像**单向粉碎机**:同样的输入永远产出同样的指纹,指纹却反推不出原文。输入 key 时(登记或查询),服务端算出的指纹与库中比对,命中即「已登记」,零上游调用;即便整库泄露,外人拿到的也只是指纹。选用 HMAC(而非裸哈希)是多掺一味只有服务器持有的「调料」:没有它,就无法用「猜测输入 → 对照指纹」的方式离线验证。
> - `key_ciphertext` 像**保险箱**:用 `KEYS_MASTER_KEY` 加密锁存,轮询调上游时开锁取明文;AES-GCM 自带防篡改,密文被动过则解密直接报错。
> - **两列并存的原因**:加密每次都掺随机数(同一 key 两次密文不同)→ 密文不能用于查找;哈希不可逆 → 指纹还原不出明文。「可查找」与「可还原」两个需求互斥,只能分存两份形态。
> - **防线边界**:能同时接触数据库与环境变量的人(服务器运营方)防不住——这是已接受的信任模型前提;本设计防的是「数据库单独落到第三方手里」的常见拖库场景(SQL 注入、备份泄露)。
> - 实现提示:两种算法均为标准件,Bun 内置 `crypto` 直接提供,无需第三方库。
### 3.2 命名:与上游对齐
- 上游字段直接采用原名(`total_granted` 等),对照 `docs/upstream/NewAPI-sk-key接口文档.md` 看表即懂
- 例外:`name` → `token_name`(`name` 太泛;本工具以 token_name 指代展示名)
### 3.3 两个同步时间戳,不合并
- 两个上游调用(日志 / 额度)独立成功、独立失败——如实建模、数据溯源
- 「数据截止」语义钉死:**只随日志同步成功前进**;合并会导致额度接口故障时反向失真(日志已新,却报旧时间)
- 为未来的额度信息展示留新鲜度表达位(UI 方案未稳定,不轻易断言其无消费方)
### 3.4 上游字段「全体落库」
- `/api/usage/token` 除 `object` 外 8 个字段全部入列(含当前无消费方的 `model_limits` 等)
- 这类数据随时可从上游重新获取,入列本质是「本地最新快照」;当前策略为宁多勿缺,待 UI 稳定后回审
### 3.5 `expires_at` 的 0 → NULL 转换
- 上游 0 = 永不过期;转 NULL 表达该语义
- 安全性依据:8 个上游字段整组一起写入(同一条 UPDATE + `usage_synced_at`),「未获取」由 `usage_synced_at IS NULL` 承载——`expires_at` 的 NULL 专用于「永不过期」,语义不重叠
### 3.6 生命周期
- `active` →(连续 N 次 401)→ `disabled` + `disabled_source = auto_401`(数据保留、不再轮询)→(管理通道手动启用、上游验证通过)→ `active` + 清 `disabled_source`
- 管理通道也可手动停用(`disabled_source = manual`)——与 401 摘除是同一状态、不同来源(见 `docs/design/交互设计.md` §3.2)
- N 的取值属同步引擎设计(不在此文)
### 3.7 表名:从 `sk_keys` 更名 `api_keys`(2026-09-13)
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余三表(`usage_logs` / `purchases` / `qpu_history`)风格一致
### 3.8 新增 `disabled_source`:停用来源可辨(2026-09-13)
- 起因:「已失效(401 自动摘除)」与「已停用(手动)」需要在管理面区分——`status` 只有 `active` / `disabled` 两值,401 自动摘除与手动停用落在同一个值上,事后无法区分
- 方案对比:
- ① 用 `consecutive_401s` 推断——不可靠(手动停用会保留停用前的任意值,成功同步又会清零)
- ② 拆分 `status` 取值(如 `auto_disabled` / `manual_disabled`)——污染状态机,启停与轮询逻辑分叉
- ③ **新增一列记录来源**——最直接;取值 `manual` / `auto_401`,`active` 时为 NULL
- 代价:字段数 16 → 17(2026-09-13 由契约推导回头补列)
## 4. 写入时机(两个写入者:管理通道 / 同步引擎)
| 时机 | 动作 |
|------|------|
| **登记**(管理通道,上游验证通过) | INSERT 一行:管理字段(HMAC / 加密 / registered_at,`disabled_source` 为 NULL)+ 上游字段全组(一次 `/api/usage/token` 拿全)+ 两个 synced_at;**首轮日志同步不阻塞登记响应**(由同步引擎立即异步补一轮,见 `docs/design/API契约.md` §2.2) |
| **启停**(管理通道) | UPDATE `status` + `disabled_source`:启用(上游验证通过 → `active` + 清 NULL + `consecutive_401s` 清零——验证调用本身即一次成功调用)/ 停用(→ `disabled` + `manual`)——数据保留、不删除 |
| **每轮轮询** | UPDATE:上游字段全组 + `usage_synced_at`;日志同步成功后写 `log_synced_at`;401 计数随结果更新;连续 N 次 401 → `status = disabled` + `disabled_source = auto_401` |
- 两个上游调用各自独立:**各自的成功写各自的时间戳,互不阻塞、互不污染**
## 5. 明确不存的(已否决字段与理由)
| 字段 | 否决理由 |
|------|----------|
| `key_hint`(`sk-…a1b2` 尾号) | 登记即有 `token_name`,回显价值归零(注:查看面响应里的 `key_hint` 是**即时生成**的,与本列否决无关) |
| 别名 / 备注名 | 上游 `token_name` 已提供展示名(本地已同步该字段),再记一份是平行冗余;要改名去上游站点改(2026-09-13 定) |
| `upstream_token_id` | 本地 `id` 足够;对账时日志行自有 `token_id` |
| `last_sync_error` | 错误详情进服务器日志即可;UI 无展示位(有需求再加) |
| `object` / `code` / `message` | 固定值 / 传输层包装,无信息量 |
## 6. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 401 摘除阈值 N | 同步引擎设计 |
| 多 key 时「数据截止」的聚合语义 | 已定:取最慢(`docs/design/API契约.md` 约定 22) |
| 「累计已用/剩余」展示格式与无限额度分支 | 交互细节层 |
| `usage_logs` 表字段清单 | 已定稿:见 `docs/design/usage_logs表字段设计.md` |
+93
View File
@@ -0,0 +1,93 @@
# purchases 表字段设计
> 数据模型第三张表:购买记录——每笔支付事件(订阅购买 / 充值)一行,是「实付人民币」这一唯一只有作者知道的信息的落点,也是折算链第二层(额度美元 → 实付人民币)的事实来源。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(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. 表定位
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态;**实现先做订阅路径**(实测当前用量 100% 为订阅)
- **折算链全貌**:`日志.quota ÷ quota_per_unit` = 额度美元 → `× 购买汇率` = 实付人民币;两条链的系数**都取该日志所属购买的购买时刻**(整笔订阅一把尺,见 `qpu_history表字段设计.md` §3.3)。购买比率 = `paid_amount ÷ (quota_granted ÷ quota_per_unit)`——**只存事实、不存比率**(见 3.2)
- 与日志的分流:日志 `other.billing_source = "subscription"` → 按本表匹配(优先 `subscription_id`,同 ID 跨期时按时间区间);`"wallet"`(充值)→ 池子近似(后议)
- 数据规模:年个位数行(订阅按周期购买)——无需索引设计
## 2. 字段清单
### 2.1 主键与登记字段(5 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
| `paid_amount` | numeric | **人工必填** | **实付人民币**——唯一「只有作者知道」的信息 |
| `note` | text | 人工填写 | 备注 |
### 2.2 期间与订阅字段(4 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;多订阅并存时用于精确分流(与日志 `other.subscription_id` 匹配) |
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」) | 本笔额度(订阅:钱包页面总额度;充值:充值记录) |
## 3. 决策记录(含理由)
### 3.1 登记 = 手工抄录(来源只有两处)
购买记录全部由作者手工录入,来源仅两处:**支付记录**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
### 3.2 折算比率不存储——存事实、不存派生数
- `paid_amount` 是唯一不可替代的人工信息;比率是派生数——「记实付」比「记汇率」更省心(不用心算)且可事后核对;只存派生比率会丢失核对依据
- **日志同样不加汇率列**(`usage_logs` 零 schema 改动):入库有滞后(1000 条窗口内可能几天后才补回)→「入库时的配置值」会错标给「更早消费的日志」,且**无从发现**;半准确的数字比没有数字更危险
- 折算全部发生在展示 / 聚合层;关联线索(`billing_source` / `subscription_id` / 金额分解)本就在日志 `other` 里,零 schema 改动即可用
### 3.3 为什么额度也手工录入(不采用日志感知)
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对购买记录零回填、零推断**,观测仅用于核对(见 §4)。
### 3.4 命名与类型
- 金额列用 `numeric`(人民币金额不用浮点);时间列统一 `timestamptz`
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(分流匹配即用此列)
## 4. 写入时机与维护
| 时机 | 动作 |
|------|------|
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 实付)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何购买记录」),不参与回填 |
| 编辑 / 删除 | 数据为人工登记,改错即修(见 API 契约 §2.3) |
## 5. 明确不存的(已否决字段与理由)
| 字段 | 否决理由 |
|------|----------|
| 折算比率(购买汇率) | 派生数——存事实(实付 + 额度)即可事后核对,只存比率会丢失核对依据(见 3.2) |
## 6. 展示层边界(界面设计的输入)
- **明细应展示「订阅归属」**(数据源 = 日志 `other`):订阅名称、额度实况、(本表登记后)期间范围——给所有使用者可见
- **查看面金额口径(2026-09-13 修订)**:卡片 / 明细的「消费」类金额 = **折算实付人民币**(按购买期间分段,每段 `quota 占比 × 该笔实付`);原「天然不含实付金额」的边界就此放开。**仍不上查看面**:实付总额、购买比率、购买记录(含 `note`)本身——管理面独占
- 已知代价(接受):明细含 quota 列、QPU 公开(500000 可查),使用者理论上可由两者反推购买比率——本工具是成本分担场景、非对抗关系
- 「按订阅维度统计」(本期用量/占比)数据天然支持——**待评估**,暂不列入
- 管理页相关见 `docs/design/交互设计.md` §3.3 与 `docs/design/API契约.md` §2.3
## 7. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 充值(`topup`)路径的折算近似算法 | 池子加权平均等方案,后议 |
| 多订阅并存的日志行为 | 按源码结构推理(每笔标所属 ID);实现期以真实场景实测验证 |
| 期间与订阅 ID 的匹配细节 | 技术稿细化(ID 优先、同 ID 跨期按时间) |
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计 |
+76
View File
@@ -0,0 +1,76 @@
# qpu_history 表字段设计
> 数据模型第四张表:换算参数时间线(表名 `qpu_history`,QPU = QuotaPerUnit 缩写)——`quota_per_unit`(quota → 额度美元)的历史取值,折算链第一层的系数来源。历史值在 sk-key 视角无从观测,本表由同步引擎自动采样、只记变化点;**纯只读**(唯一写入者是同步引擎)。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/purchases表字段设计.md`(折算链第二条:额度美元 → 实付人民币)· `docs/design/API契约.md` §2.4(只读查看端点)· `docs/upstream/NewAPI-接口范围.md`(采集来源 `/api/status`) |
| 范围 | 列结构 + 语义 + 写入规则;采样调度(同步引擎)不在此文 |
| 字段数 | 3 = 1 主键 + 2 业务字段 |
---
## 1. 表定位
- **一行 = 一次「换算单位取值变化」事件**(值 + 生效时间)——正常情况全表仅 1 行(当前站点为出厂默认值,从未变过)
- 它是折算链第一条(quota → 额度美元)的**时间线**:任何时刻的换算取值 = 「该时刻之前最近一条」
- **为什么要自己攒**:`/api/status` 只能看到当前值,历史值无从观测(上游不留历史、管理审计不记值)——没有本表,历史日志只能用今天的尺子量,**账会串**
- 与日志零耦合:折算全部发生在展示 / 聚合层——本表任何变动都不触发 `usage_logs` 写入
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `qpu` | numeric | `/api/status.quota_per_unit` | 当时的 `quota_per_unit` 值 |
| `effective_from` | timestamptz | 首次采样或检测到变化的时间 | 生效时间;采样误差 ≤ 同步周期 |
## 3. 决策记录(含理由)
### 3.1 为什么自动采样(而非人工登记)
它是「可登记的事实」的**反面**——历史值无从观测(只能看到当前值);自动采样(每轮同步)是唯一能沿时间轴捕获它的方式。采样误差 ≤ 同步周期(边界影响可忽略)。
### 3.2 只记变化点,不记每次采样
部署时插基线;此后每轮同步顺带读 `/api/status`,**值变化才追加一行**——行数 = 调价次数 + 1(正常为 1)。
### 3.3 取值语义与跨期统计
- **取尺基准 = 购买时刻(`paid_at`)**:一条日志的折算 = 先按 `subscription_id` 归属到购买记录(见 `purchases表字段设计.md`),再取**该购买的购买时刻**在本表中的当值——同一笔订阅的全部日志共用一把尺子。完整链:`日志.quota ÷ 购买时刻的 quota_per_unit × 购买汇率` = 实付人民币
- **为什么不按日志自身的时刻**:订阅期内若发生重定标,同一笔订阅的「已用美元」会在调价点跳变,而额度与实付没变——分子分母尺子不一致,账就不自洽;按购买时刻取尺等价于「按 quota 占比分摊实付」,恒定成立
- **跨期统计**:把查询时间范围按**购买期间边界**切成若干段,各段用该笔购买的系数聚合——`quota_per_unit` 只在每笔购买处取一次值,调价点不再制造分段
- **边界**:购买时刻早于本表最早记录时(如补录历史购买),回退用「最早已知值」(近似规则)
- **充值(`wallet`)路径**:同样取「充值执行时刻」的尺子(池子近似算法后议)
### 3.4 `quota_per_unit` 的可变性说明(本表存在的理由)
它是「全站计价单位的定义」——改它 ≈ 重定义货币单位(全站一致重定标);管理员极少动(当前站点为出厂默认值)。本设计以「按时间线折算」从根上免疫它的任何变更。
### 3.5 为什么不做人工修正(只读设计,2026-09-13 定)
- 原设计预留「修正生效时间 / 删除」等人工介入能力;源码评估后判定场景不成立——`quota_per_unit` 在上游**无编辑入口(前台默认隐藏且只读)、管理审计不记值、历史上从未变化**(见 `docs/upstream/NewAPI-接口范围.md`)
- 本表由此定型为**纯只读时间线**:唯一写入者是同步引擎(值变化才追加);管理通道仅提供只读查看
- 未来若真出现需要人工介入的场景(如运营方主动告知一次精确切换时刻),再评估启用
### 3.6 命名:表名 `qpu_history`、值列 `qpu`(2026-09-13 改)
- **表名**:原名 `quota_per_unit_history` 过长、逐处引用啰嗦 → 改为 `qpu_history`——QPU 是 QuotaPerUnit 的通用缩写,短且醒目
- **值列**:原名 `value` 过泛("什么的值"不自明)→ 改为 `qpu`,与表名一体、一眼即知
- 通用约定:**表名 / 列名用 QPU 缩写,引用上游字段 `quota_per_unit` 时保持全称**
## 4. 写入时机(唯一写入者:同步引擎)
| 时机 | 动作 |
|------|------|
| 部署(基线) | INSERT 基线一行(当时 `/api/status` 取值) |
| 每轮同步 | 读 `/api/status`:**值变化才追加一行** |
- 除追加外无 UPDATE / DELETE——数据不可变
## 5. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 采样调度(读取频率、失败重试) | 同步引擎设计 |
+114
View File
@@ -0,0 +1,114 @@
# usage_logs 表字段设计
> 数据模型第二张表:调用日志明细——增量同步的落点,本项目「任意时间范围用量」的唯一数据来源。字段清单依据 `/api/log/token` 实测响应(2026-09-11,1000 条样本)与上游源码(`model/log.go`)双重校对;上游只有 1000 条滚动窗口,**错过即永久丢失**。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(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. 表定位
- **一行 = 一次上游调用记录**(消费 / 错误等类型全覆盖)
- 它是本项目最核心的数据资产:上游 `/api/log/token` 只保留最近 1000 条(实测当前速率下 ≈ 3 天),**错过即永久丢失**——本地全量累积是"任意时间范围用量"的唯一实现路径
- 与 `api_keys` 多对一:每条日志经 `key_id` 指向其所属 key
- 数据规模:实测约 300+ 条/天,量级为数十万行/年——单表 + 时间索引完全够用,无需分区或预聚合
## 2. 字段清单
### 2.1 系统字段(3 个,本系统产生)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | 本地主键(与上游的展示序号 `id` 无关) |
| `key_id` | bigint FK | 同步引擎 | → `api_keys.id`;日志归属与索引锚点 |
| `dedupe_key` | text UNIQUE | 写入前生成 | **去重唯一键**:`${key_id}:${request_id}`;`request_id` 为空时退化为内容指纹(见 3.1) |
### 2.2 上游字段(20 个,`/api/log/token` 响应照收)
| 列 | 类型 | 上游字段 | 说明 |
|----|------|---------|------|
| `user_id` | bigint | `user_id` | 账号 ID(同一实例内恒定) |
| `created_at` | timestamptz | `created_at` | 上游 UTC Unix 秒,入库转换;范围查询主维度 |
| `type` | smallint | `type` | 日志类型 0-7 枚举(完整表见接口文档);照存不过滤 |
| `content` | text | `content` | 消费记录为空串;错误记录为错误详情 |
| `username` | text | `username` | 账号用户名 |
| `token_name` | text | `token_name` | 快照存储(保真;展示取当前名口径由 API 契约另定) |
| `model_name` | text | `model_name` | |
| `quota` | bigint | `quota` | 原始 quota |
| `prompt_tokens` | int | `prompt_tokens` | |
| `completion_tokens` | int | `completion_tokens` | |
| `use_time` | int | `use_time` | 秒 |
| `is_stream` | bool | `is_stream` | |
| `channel` | int | `channel` | 渠道 ID |
| `channel_name` | text | `channel_name` | 普通用户视角为空串 |
| `token_id` | bigint | `token_id` | 上游 token ID(对账用) |
| `group_name` | text | `group` | PG 保留字改名 |
| `ip` | text | `ip` | 普通用户视角为空串 |
| `request_id` | text | `request_id` | 原值保留;JSON tag 为 `omitempty`,源码写入时有兜底 |
| `upstream_request_id` | text NULL | `upstream_request_id` | **可选字段**:`omitempty`——空值时不出现、有值才出现(实测样本中缺席) |
| `other` | jsonb | `other` | JSON 字符串解析入库(可查询;含缓存/倍率/延迟等扩展信息) |
> 上游响应中的展示序号 `id`(每次从 1 重编)不落表——见 3.2。
### 2.3 索引
| 索引 | 列 | 用途 |
|------|-----|------|
| PRIMARY KEY | `id` | |
| UNIQUE | `dedupe_key` | 去重(`ON CONFLICT` 的落点) |
| 普通 | `(key_id, created_at DESC)` | 明细页 + 按 key 聚合 |
| 普通 | `(created_at DESC)` | 跨 key 合计与统计 |
## 3. 决策记录(含理由)
### 3.1 去重:单列 `dedupe_key`(即 `request_id` 的落库形态)
- 去重依据就是 `request_id`(上游为每次请求生成的唯一编号);`dedupe_key` 是它在库中的形态,99.9% 的情况下等于 `${key_id}:${request_id}`
- **为什么不把 UNIQUE 直接挂在 `request_id` 列上**:PostgreSQL 的空值语义两难——`NULL` 在唯一索引中互不冲突(空值行会重复入库);而把空值转成空串则过度冲突(第二条空值记录被静默丢弃)。单列 `dedupe_key` + 空值指纹兜底从根上规避这两种坏结局
- `key_id:` 前缀:跨 key 隔离的零成本保险
- 内容指纹(仅 `request_id` 为空时启用):`sha256(key_id, created_at, model_name, quota, prompt_tokens, completion_tokens)`——同一条日志的重复拉取必然同指纹(正确去重),真实不同的请求指纹不同(都保留)
- `request_id` 原值仍单独成列(2.2 之一)——两者并行,互不替代
- **为什么它不做主键**:主键是「行身份」(短、稳定、永不变化),`dedupe_key` 是「防重签名」(长、由业务规则拼接、可能随上游演化)——让可变规则决定行身份是耦合隐患,且长字符串在引用场景(外键、调试、游标)更笨重;代理键(自增 `id`)+ 独立唯一约束是标准做法
### 3.2 上游展示序号 `id` 不落表
它是每次响应从 1 重编的展示序号(源码 `assignDisplayLogIds`),跨请求无意义——绝不能用于去重,也没有存储价值。
### 3.3 `other` 用 jsonb
- 原样保真(解析后完整存储)+ 查询能力(`other->>'cache_tokens'`);jsonb 是 PostgreSQL 内置类型
- 普通用户视角的 `other` 已由服务端做过一层「可见性过滤」——我们存的就是用户实际可见的部分
### 3.4 `raw` 兜底列:不加
21 个候选字段(响应理论全集)已由实测 + 源码双重校对齐全,且 `other` 已单独 jsonb 保真(信息富集区);字段集变化的风险由「同步持续运行 + 代码随上游更新」覆盖,接受历史缺值的可能性。
### 3.5 命名与类型
- 上游字段采用原名;例外:`group` → `group_name`(PG 保留字)
- 时间列统一 `timestamptz`(应用层做 UTC ↔ 北京时间转换);`quota` 用 `bigint`
- `type` 用 `smallint` 存原值(0-7 为稳定枚举),展示层翻译(未知值原样显示数字)
## 4. 写入时机与策略
| 时机 | 动作 |
|------|------|
| 登记首轮同步 | 拉最近 1000 条 → 逐条入库 |
| 每轮轮询 | 同上;`INSERT ... ON CONFLICT (dedupe_key) DO NOTHING`——幂等,只落新行 |
- 写入者唯一:同步引擎(不存在其他写入路径)
- 日志不可变:已入库的行不做 UPDATE
- 与本表相关的读取链:从 `api_keys` 取密文 → 解密 → 调上游 → 新日志写本表
## 5. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 明细/统计的查询口径(`type` 过滤策略) | API 契约 |
| `other` 内高频键是否提为独立列(如 `cache_tokens`) | 出现真实分析需求时再评估 |
| 数据保留策略(是否清理旧数据) | 后议 |
+140
View File
@@ -0,0 +1,140 @@
# 交互设计
> 本工具的界面与页面设计——**方向层**(页面集合 + 职责)+ **细节层**(跨页共性 + 逐页细节)。历史旧稿已删除(2026-09-14,内容已全部并入本文与 README / API 契约)。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `README.md`(决策与事实)· `docs/design/API契约.md`(数据形状依据) |
| 范围 | 方向层(页面集合 + 职责)+ 细节层(跨页共性 + 逐页细节) |
---
## 0. 大前提(2026-09-13)
### 0.1 路由分区与可见性
- **`/manage` 前缀 = 管理面**:管理通道的页面路由
- **无前缀 = 查看面**:给使用者直接查看的页面路由
- **管理面入口隐藏**:查看面不放任何指向管理面的入口——使用者无论拥有多少查看 / 统计功能,都无法从界面中进入管理面;直连管理面 URL 则由口令门帘拦截(两层:入口找不到 + 撞门要口令)
### 0.2 查看面职责:纯查询(对 key 只读)
- 查看面输入 key **仅用于查询**——凭手里已登记的 key 看用量;不再触发登记 / 轮询;输入未登记的 key → 提示「未收录」(措辞归细节层)
- key 的登记与启停全部在管理面完成——**写操作只发生在管理面**(2026-09-13 定,取代原「输入即注册」流程)
## 1. 页面集合(方向层 · 已收口)
### 1.1 查看面(无前缀)
| 页面 | 路由 | 职责 | 阶段 |
|------|------|------|------|
| 用量日志 | `/usage-log` | 指定时间范围查看模型请求日志(数据表格呈现);表格上方以数据卡片显示该时段累计用量、预估消费等汇总 | 一期 |
| 数据看板 | `/dashboard` | 固定时间窗口的统计视图(如模型消耗分布) | 二期候选(一期可不实现) |
- key 维度:输入式(一次可输入多个——延续核心需求「合并查询」);输入未登记的 key → 提示「未收录」
- 根路径 `/` 重定向到 `/usage-log`
- 卡片口径:累计用量与预估消费直接呈现折算数值(延续全项目「不标注币种」的基调)
### 1.2 管理面(`/manage` 前缀)
| 页面 | 路由 | 职责 |
|------|------|------|
| key 管理 | `/manage/api-keys` | 登记 / 清单 / 启停 |
| 购买记录 | `/manage/purchases` | 登记 / 编辑删除 / 核对提示 |
| 换算参数时间线 | `/manage/qpu` | 只读查看 |
- 根路径 `/manage` 重定向到 `/manage/api-keys`
- 能力边界见 API 契约 §2.2–2.4 与本文件 §3
## 2. 跨页共性(细节层,2026-09-13 起逐条推)
| # | 项 | 定法 | 状态 |
|---|----|------|------|
| 1 | 使用场景与适配 | **桌面优先**(使用者 2–3 人、以看报表为主);不做移动适配 | 已定 |
| 2 | 视觉基调 | **浅色单主题**(不做暗色切换);组件库默认主题 + 少量调色 | 已定 |
| 3 | UI 组件库 | **naive-ui**(2026-09-13 定)——成熟度 / 文档 / 主题变量系统更稳,表格与日期快捷选项能力已验证满足本项目;antdv-next 未采纳(社区新项目:上游为 React 版 antd 6、非 antdv 继任,生态与文档面未成熟) | 已定 |
| 4 | 时间显示格式 | **统一到秒**:`2026-09-11 19:37:12`(当天语境可简写 `HH:mm:ss`) | 已定 |
| 5 | 金额与数字格式 | **分层**:折算金额汇总 2 位小数 / 明细行 4 位小数;token 数与 quota 原值千分位(`53,939,847`);不标币种不变 | 已定 |
| 6 | 通用状态模式(加载 / 空 / 未收录 / 错误) | 见下方细则(2026-09-13 定) | 已定 |
| 7 | 导航 | 查看面:一期单页**不做导航**;管理面:**侧边栏菜单 + 内容区**(传统后台布局);「查看面 →」入口的位置形态随逐页细节 | 已定 |
| 8 | 表格通用交互 | **分页器**:页码器 + 每页条数选择(20/50/100/200,默认 50);**行详情**:展开行(`n-data-table` 内置),展示完整字段 | 已定 |
| 9 | 时间范围控件 | `n-date-picker type="datetimerange"` + 快捷项(今天 / 近 7 天 / 近 30 天);**默认 = 最近 7 天** | 已定 |
| 10 | key 输入控件与前端持久化 | 标签式(`n-dynamic-tags`),粘贴多行 / 逗号自动拆分;localStorage 记住上次输入、打开自动带出;**点「查询」才发请求** | 已定 |
**状态模式细则(#6,2026-09-13 定)**
| 状态 | 呈现 |
|------|------|
| 加载中 | 表格 `n-data-table` 内置 loading;卡片区 `n-skeleton` |
| 初始(未输入 key) | 页面中央引导「输入你的 key 开始查询」——不渲染卡片与表格 |
| 未收录 | 卡片区下方 warning 提示条:「以下 key 未收录:sk-…a1b2(请联系管理员登记)」;已收录部分照常展示 |
| 时段无记录 | 表格空态「该时段没有记录」;卡片数值显示 0 |
| 新 key 未首同步 | `data_until` 为 `null` → 卡片「数据截止」位显示「数据同步中…」 |
| 查询失败(网络 / 5xx) | 页面内 error 提示条「查询失败:<message>」+「重试」按钮 |
| 操作反馈(管理面) | `n-message` toast(登记 / 启停 / 保存的成功与失败) |
## 3. 逐页细节(细节层)
### 3.1 `/usage-log`(推导中)
**布局(2026-09-13 定)**
```
┌────────────────────────────────────────────────────────────┐
│ 筛选区:[key 标签输入框] [时间范围 datetimerange] [查询] │
├────────────────────────────────────────────────────────────┤
│ 卡片区:┌调用次数┐ ┌Token 用量┐ ┌预估消费┐ 数据截止 21:10:00 │
│ │ 1,873 │ │1,469,134│ │ 23.45 │ (小字 + 悬停明细)│
│ │消费·错误│ │ 缓存 89k │ │额度 53.9M│ │
├────────────────────────────────────────────────────────────┤
│ (如有)未收录提示条 │
├────────────────────────────────────────────────────────────┤
│ 明细表格(展开行详情) │
│ 分页器(页码 + 每页条数) │
└────────────────────────────────────────────────────────────┘
```
- 「数据截止」不进卡片(它是注记不是指标):卡片区右端小字 + **悬停弹出 per-key 明细**(各 key 同步进度 / 未收录 / 已停用)——`meta` 信息完整呈现且不挤占卡片位
- 卡片主 / 副行:调用次数(主总行数,副「消费 X · 错误 Y」)· Token 用量(主 prompt+completion,副缓存命中)· 预估消费(主金额,副「额度记账」quota 原值,供对账)
**明细列与呈现(2026-09-13 定)**
- **默认 8 列**:时间(到秒)· key(`token_name`)· 模型 · 类型(Tag)· 输入 tokens · 输出 tokens · 消费(`amount_rmb`,4 位小数)· 耗时
- **展开行**(点行展开):完整字段——缓存 tokens · 流式 · `request_id` · `content`(错误详情)· 请求路径 · quota 原值 · **订阅归属完整组**
- **错误行**:类型列 `n-tag`(消费 = 中性 / 错误 = 红),**无整行底色**;错误详情在展开行
- **订阅归属**:默认列展示订阅名(`other.subscription_plan_title`,如「Max」);完整归属(额度实况 / 期间范围)在展开行——`purchases` §6 的「明细展示订阅归属」完整落地
### 3.2 `/manage/api-keys`(2026-09-13 推完)
- **清单 8 列**:key 名(`token_name`)· 状态徽章 · 累计已用 · 累计剩余(无限额度显示「不限」)· 到期 · 登记时间 · 数据截止 · 操作;其余字段(总额度 / 模型限制 / `consecutive_401s` / 额度新鲜度)归展开行
- **状态徽章三面孔**:运行中(绿)· 已停用(灰,`manual`)· 已失效(红,`auto_401`,tooltip 注明「连续 401 自动摘除」);数据未首同步的状态体现在「数据截止」列(「同步中」)
- **登记**:页头「+ 登记 key」→ `n-modal` 弹窗(粘贴 sk-key)→ 提交;三类失败各有文案——上游拒绝该 key / 该 key 已登记 / 上游暂不可用(稍后重试);成功后 toast + 清单插入新行(`log_synced_at: null` → 「同步中」)
- **启停**:行内按钮;**停用弹确认**(`n-popconfirm`,防误点);**启用直接执行**(安全动作,成功后 toast + 行内状态更新;失败按错误码提示)
- 「查看面 →」入口放侧边栏底部(低调,见 §2 #7)
### 3.3 其余页(待推)
| 页 | 重点 | 状态 |
|----|------|------|
| `/manage/purchases` | — | **2026-09-13 推完** |
| `/manage/qpu` | — | **2026-09-13 推完** |
| 口令门帘 | — | **2026-09-13 推完** |
**`/manage/purchases` 细节(2026-09-13 定)**
- **清单 8 列**:时间 · 类型(订阅 / 充值)· 实付金额 · 订阅(`#N`)· 期间 · 额度(`quota_granted`)· 备注 · 操作(编辑 · 删除;删除弹确认)
- **表单用右侧抽屉**(`n-drawer`):`kind` 单选 · `paid_at` · `paid_amount`(必填)· `quota_granted`(必填)· `subscription_id` · 期间(`period_end` 未结束可空)· `note`;校验含期间顺序(start < end)
- **核对提示与补录**:存在「观测到但未登记」时清单上方提示条「观测到 N 个未登记的订阅」;下方折叠区列出全部观测项(`#N` · 名称 · 日志条数 · 时间范围 · 观测额度 · 已登记 / 未登记);点「补录」开表单,**预填 `subscription_id` 与 `quota_granted`(观测值)**,期间不预填(观测范围 ≠ 订阅期间)
**`/manage/qpu` 细节(2026-09-13 定)**
- 表格两列:生效时间 · QPU 值(`id` 不显示);最新一行标「当前生效」
- 页首说明文案:「quota → 额度美元的换算比率历史;引擎自动采样、只记变化点;当前生效值 = 最近一行」
- 只有基线一行也属正常态(无特殊空态)
**口令门帘(2026-09-13 定)**
- 形态:访问 `/manage/*` 未验证时 → 全屏居中卡片(口令输入 + 「进入」);进入后跳转原目标页
- 校验:直接用任一管理面请求校验(401 即口令错)——**不需新端点**;口令存 **sessionStorage**(关标签即忘、刷新仍在),请求拦截器自动附加 `X-Manage-Token`
- 错误:401 → 输入框下方「口令不正确」;不做「记住我」
+112
View File
@@ -0,0 +1,112 @@
# 同步引擎设计
> 采集侧的引擎设计:调度、每轮动作、失败与状态机、可观测、部署初始化。它是「数据流动」的实现依据——**写入者只有它**(`api_keys` / `usage_logs` / `qpu_history` 三张表的写入时机见各表设计);查看面完全只读。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/upstream/NewAPI-接口范围.md`(上游调用与失败语义)· 四张表字段设计(写入时机)· `docs/design/API契约.md`(查看面消费的数据形状)· `README.md`(既定决策与关键事实) |
| 范围 | 调度与执行、失败处理、可观测、部署初始化 |
| 不含 | 前端交互(交互设计)· HTTP 契约(API 契约,已定稿)· 多实例协调 / 历史回填 / 告警渠道(见 §4) |
---
## 1. 前提(已由其他文档锁定,不在此重新讨论)
- **统一 tick 方向已定**(2026-09-13 作者确认):一轮 = 遍历全部 `active` key,逐个查日志 → 去重入库
- **后端单进程**(README 决策 7,2026-09-13 修订):API 与同步引擎同进程、**单实例**;前端静态产物**独立部署**(后端不托管前端产物);「定时器唯一」的前提就是这**一个**后端进程
- **三个上游调用、失败互不影响**(`api_keys` §3.3):日志(写 `log_synced_at`)· 额度(写 `usage_synced_at`)· `/api/status`(QPU 采样)
- **物理约束**:`/api/log/token` 固定返回最近 1000 条(实测 ≈ 3.2 天);同步停摆超约 3 天即永久丢数据(README 事实 4)
- **失败语义**(`docs/upstream/NewAPI-接口范围.md` §1):401 = key 不存在 / 显式禁用(**唯一计入摘除的**);403 = 账号封禁;500 = 数据库错误;过期 / 额度耗尽在宽松档**仍可查**
- **登录 / 登记不阻塞**:登记只做验证 + INSERT,首轮同步由引擎立即异步补一轮(API 契约 §2.2)
- **启用需验证**:管理面手动启用 = 上游验证通过(`api_keys` §3.6);启用成功清零 `consecutive_401s`
## 2. 议题清单(2026-09-13 范围确认;逐条推导,结论逐步回填)
### 2.1 运行形态与调度
| # | 议题 | 初步倾向 | 状态 |
|---|------|---------|------|
| 1 | 载体:定时器跑在哪 | **已定(2026-09-13)**:随 Hono 进程内的定时器(后端单进程);**不引入 worker 线程、不拆独立进程**——影响评估见 §3 | 已定 |
| 2 | 周期值 | **已定(2026-09-13):5 分钟**(固定间隔 tick + 防重入)——见 §3.2 | 已定 |
| 3 | 启动行为 | **已定(2026-09-13):进程启动立即跑一轮**,随后进常规节奏——见 §3.2 | 已定 |
| 4 | 单轮内执行顺序 | **已定(2026-09-13):全部 `active` key 串行遍历**——见 §3.2 | 已定 |
### 2.2 每轮的动作
| # | 议题 | 初步倾向 | 状态 |
|---|------|---------|------|
| 5 | 日志同步 | **已定(2026-09-13)**:每 key 拉最近 1000 条 → **整批单事务**入库(`ON CONFLICT (dedupe_key) DO NOTHING`;全成或全不写)→ 写 `log_synced_at`(**取请求发起时刻**)——见 §3.3 | 已定 |
| 6 | 额度采集 | **已定(2026-09-13)**:每 key 取上游字段全组 + `usage_synced_at`(同口径);与日志**各自独立**(先日志后额度)——见 §3.3 | 已定 |
| 7 | QPU 采样 | **已定(2026-09-13)**:独立于 key 循环,每轮一次读 `/api/status`;值变化才追加——见 §3.3 | 已定 |
### 2.3 失败与状态机
| # | 议题 | 初步倾向 | 状态 |
|---|------|---------|------|
| 8 | 401 摘除阈值 N | **已定(2026-09-13):N = 3**——见 §3.4 | 已定 |
| 9 | 失败状态区分 | **已定(2026-09-13)**:只有 401 计数;403 / 500 / 网络不动计数——见 §3.4 | 已定 |
| 10 | 单轮内重试 | **已定(2026-09-13):不重试**,等下一轮——见 §3.4 | 已定 |
| 11 | 解密失败(`KEYS_MASTER_KEY` 变更) | **已定(2026-09-13)**:记日志、跳过、不影响其他 key;不计数——见 §3.4 | 已定 |
### 2.4 可观测与完整性
| # | 议题 | 初步倾向 | 状态 |
|---|------|---------|------|
| 12 | 错误日志 | **已定(2026-09-13)**:人类可读一行(`[sync]` 前缀 + `key_id` / 阶段 / 上游状态 / 耗时);不引日志库、不输出 JSON——见 §3.5 | 已定 |
| 13 | 每轮摘要日志 | **已定(2026-09-13)**:一行「轮次完成:N key,成功 X,失败 Y,新增 Z 条,耗时 W 秒」——见 §3.5 | 已定 |
| 14 | 「3 天窗口」警示 | **已定(2026-09-13)**:落后 > 24 小时则每轮打 warning(留 2 天人工余量);不做告警渠道——见 §3.5 | 已定 |
### 2.5 部署与初始化
| # | 议题 | 初步倾向 | 状态 |
|---|------|---------|------|
| 15 | QPU 基线 | **已定(2026-09-13)**:首次启动且 `qpu_history` 为空时插入当时取值——见 §3.6 | 已定 |
| 16 | 无 key 时 | **已定(2026-09-13)**:空转不报错;QPU 采样照跑(独立于 key 循环)——见 §3.6 | 已定 |
| 17 | 时间处理 | **已定(2026-09-13)**:上游 UTC 秒 → `timestamptz`;同步时刻取服务端时钟;输出统一 `+08:00`——见 §3.6 | 已定 |
## 3. 决策记录(逐条推导后回填)
### 1. 载体:后端进程内的定时器(2026-09-13 定)
- 定时器跑在 API 进程内(后端单进程、单实例);**不引入 worker 线程、不拆独立进程**(2026-09-13 作者裁决:现在没必要)
- 影响评估结论:一轮的 CPU 占用只在「JSON 解析 + 入库参数组装」两个小片段(各几十 ms 级、5 分钟级低频);且最重的活(SQL 聚合)在 PostgreSQL 进程里跑——对前端响应时间的影响可忽略
- 升级路径(真需要时再走,现在不做):同进程 **Worker 线程**(先挪 parse / 组装)→ 独立进程
### 2. 调度节奏(2026-09-13 定,议题 #2–#4)
- **周期 5 分钟**:固定间隔 tick(与上一轮耗时无关);**防重入**——上一轮未结束时本轮跳过并记一条日志(避免轮次叠加)
- **启动立即跑一轮**:进程重启(含部署更新)后不等第一个 tick,尽快补齐窗口
- **单轮内串行**:全部 `active` key 逐个处理——对上游友好、日志按 key 顺序可读;数量增长到影响单轮时长时再评估并发
### 3. 每轮的动作(2026-09-13 定,议题 #5–#7)
- **日志同步(#5)**:每 key 拉最近 1000 条(上游不支持参数、无法增量)→ **整批单事务入库**(`ON CONFLICT (dedupe_key) DO NOTHING`)——全成或全不写:失败则 `log_synced_at` 不前移「数据截止」,下一轮幂等重来
- **额度采集(#6)**:每 key 取上游字段全组 + `usage_synced_at`;与日志**各自独立**(先日志、后额度,两个独立 try——谁失败都不影响对方写自己的时间戳)
- **QPU 采样(#7)**:独立于 key 循环,每轮一次读 `/api/status`;值变化才追加一行(`qpu_history` §4)
- **时间戳口径(#5/#6 共同)**:一律取**该次请求的发起时刻**——上游响应内容的上限是「处理请求那一刻」;请求发起至入库完成之间产生的日志不在本次响应内,不能许诺(与「数据截止取最慢」同一保守精神)
### 4. 失败与状态机(2026-09-13 定,议题 #8–#11)
- **401 摘除阈值 N = 3**:任一调用返回 401 → 本轮 `consecutive_401s` +1;**成功即清零**;其他失败不动计数。累计到 3 → `status = disabled` + `disabled_source = auto_401`(数据保留、不再轮询;复活走管理面启用 = 上游验证通过 + 计数清零)
- **失败区分(#9)**:只有 401 是「key 失效」信号(上游实证:宽松档下过期 / 额度耗尽仍可查,唯一 401 = key 不存在 / 显式禁用);403 / 500 / 网络 = 其他故障,不计数
- **不重试(#10)**:单轮内失败不立即重试,等下一轮(5 分钟后自然重试);3 天窗口余量下延迟无碍
- **解密失败(#11)**:视为该 key 的故障——记服务器日志、跳过本轮、不影响其他 key;不计数、不摘除(非 401 语义),需人工重新登记
### 5. 可观测(2026-09-13 定,议题 #12–#14)
- **错误日志(#12)**:人类可读一行——`[sync]` 前缀 + `key_id` / 阶段(日志·额度·QPU)/ 上游状态或错误 / 耗时;输出 stdout / stderr 由 systemd 收集。**不引日志库、不输出 JSON**(单人维护,`journalctl` 直读)
- **每轮摘要(#13)**:一行——`轮次完成:N key,成功 X,失败 Y,新增日志 Z 条,耗时 W 秒`
- **超期警示(#14)**:`log_synced_at` 落后超过 **24 小时**时每轮打一条 warning——距 3 天极限留 2 天人工介入余量;不做告警渠道(超期的可见性已由 UI 的 `data_until` 承担)
### 6. 部署与初始化(2026-09-13 定,议题 #15–#17)
- **QPU 基线(#15)**:首次启动时若 `qpu_history` 为空,插入当时 `/api/status` 取值(`qpu_history` §4)
- **无 key 时(#16)**:空转不报错;**QPU 采样照跑**(它独立于 key 循环)
- **时间处理(#17)**:上游 `created_at`(UTC Unix 秒)→ `timestamptz`;同步时刻类时间戳取服务端时钟;对外输出统一 `+08:00`(API 契约 约定 9)
## 4. 明确不做
- 多实例协调(单进程部署,无并发实例)
- 历史回填 / 补数(上游无此能力,1000 条窗口之外永久不可得)
- 告警通知渠道(无通知对象与渠道;超期信息进服务器日志与 UI 的 `data_until`)
+100
View File
@@ -0,0 +1,100 @@
# 实施计划
> 开发过程的推进方案:**loop 模式**(每轮一个可验证批次)+ 八个阶段的验收标准。任务口径全部指向 `docs/design/` 的设计文档;本文件只回答「先做什么、怎样算做完、下一步做什么」。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-09-15) |
| 关联 | `docs/design/API契约.md` · `docs/design/同步引擎设计.md` · `docs/design/交互设计.md` · 四张表字段设计 · `AGENTS.md`(命令) |
| 范围 | 阶段划分、每批验收标准、loop 机制与推进规则;技术细节一律以设计文档为准 |
| 当前进度 | 阶段 0–7 已完成;下一批:**阶段 8**(收尾:部署文档 + 端到端走查 + 文档状态更新) |
---
## 1. loop 机制(每轮固定动作)
1. **取任务**:计划表(§3)中当前批次的第一项
2. **实现**:以设计文档为准,不自由发挥
3. **验证**:跑该批次的验收命令(type-check / 单测 / 实测 curl / 浏览器走查)
4. **记录**:勾选进度表(日期 + 验证方式)
5. **提交**:一个逻辑单元一个 commit(**作者已授权 loop 内直接提交**,dev 分支;`git push` 永远由作者手工)
6. **报告**:一句话——做了什么 / 验证结果 / 下一项
7. **设计空白的处置(2026-09-14 定)**:作者可能在开发期缺席——遇到设计文档未覆盖的问题,**取最保守默认继续推进**(不破坏数据、不做不可逆操作、后续可改),写入下方「实现假设」节并在报告中显著标注,供作者事后补审。**不停摆。**
- 例外三条——与「后端单实例」「金额口径」「凭证安全」冲突时:跳过该子任务并标注,不自行决定
### 实现假设(loop 中累积,供作者补审)
| # | 假设 | 依据 / 可逆性 |
|---|------|--------------|
| 1 | server 侧单测用 `bun test`(内建);前端仍用 Vitest | 零依赖;可换(已落地:`bun test` 跑 `src/**/*.test.ts`) |
| 2 | 环境变量:全仓单一根 `.env`;服务与 drizzle-kit 经 `src/lib/load-env.ts` 显式加载(Bun 只自动加载 cwd 的 `.env`,不会向上找) | 可改;已落地 |
| 3 | 上游请求超时 30 秒;失败即本轮失败(不单轮重试) | 常量,可调 |
| 4 | key 格式校验仅「非空 + trim」(不强制 `sk-` 前缀,格式真假交上游判定) | 更宽松,避免误拒 |
| 5 | 去重指纹 = `sha256(key_id|created_at|model_name|quota|prompt_tokens|completion_tokens)` | 表设计 §3.1 已给字段清单 |
| 6 | 购买期间匹配:`subscription_id` 优先;同 ID 多条按 `[period_start, period_end)`;无期间记录仅在「同 ID 唯一」时兜底 | 边界情况,可调 |
| 7 | 登记成功后立即异步跑一次该 key 的单轮同步(不等 tick) | 契约 §2.2 已定「立即异步补一轮」 |
| 8 | 前端提交时间用 `toISOString()`(`Z` 形式) | 契约 约定 9 允许 |
| 9 | drizzle-kit 的连库驱动用 `postgres`(postgres.js,纯 JS)——仅 CLI 需要;运行时仍走 Bun 内建 SQL(`drizzle-orm/bun-sql`) | devDependency,可换 `pg` |
| 10 | 上游客户端以**接口 + 可注入实现**组织(真实 HTTP / 样例文件两种实现),引擎与路由依赖接口 | 见 §6;可改 |
| 11 | 上游实现的注入开关:env `UPSTREAM_SOURCE`(`http` 默认 / `fixture` 读仓库根快照);fixture 的错误分支由 key 内容约定触发(含 `reject` → 401、`forbidden` → 403、`unavailable` → 网络故障) | 阶段 2 落地;仅开发期使用,生产保持默认 `http` |
| 12 | 登记 / 启用验证成功即写 `usage_synced_at` + 上游字段全组(该次 `GET /api/usage/token` 本身即一次额度采集);`log_synced_at` 仍只由日志同步写入 | 契约 §2.2 已定登记响应 `log_synced_at: null`;额度侧取「数据已入库就不撒谎」口径 |
| 13 | 失败分类:401 / 403 → `key_rejected`(上游明确拒绝);其余非 2xx / 网络故障 / 超时 / 响应结构异常 → `upstream_unavailable` | 上游事实:403 = 账号封禁;可调 |
| 14 | `PATCH` 幂等分支不重复调上游验证(直接返回当前行);启用时凭证解密失败 → `500 internal`(提示需重新登记) | 与「解密失败不摘除、需人工重新登记」一致;可调 |
| 15 | 一轮内「日志 + 额度」两个调用的合并判定:**任一 401 → 本轮计 1 次 401**(401 优先);否则任一成功 → 计数清零;全失败 → 计数不动 | 设计(§3.4)未覆盖「一成功一 401」的冲突情形;401 优先 + N=3 阈值使单次误报无害 |
| 16 | seed 脚本用假 key 常量 `sk-seed-demo-key-0001`(`SEED_KEY` 可覆盖);`apps/server/scripts/seed.ts` 幂等可重跑 | 实施计划 §6 已定「假 key 常量」;仅开发期 |
| 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`,不入库)——四张表已建
- **联调策略**:**以样例数据为主**(见 §6)——作者未开测试 key;`NEWAPI_BASE` 可达性已验证(HTTP 200 / 5.2KB / 2.0s;`quota_per_unit = 500000`、`quota_display_type = USD`、`usd_exchange_rate = 6.8`,与文档一致)。真实登记 / 采集链路标「待作者补验」,**不阻塞**其他阶段
- **提交**:loop 内直接提交已授权;push 由作者手工(仓库有两个远端)
## 3. 阶段与验收标准
| 批次 | 内容 | 验收标准 | 状态 |
|------|------|---------|------|
| 0 | 脚手架 + 契约类型 + 501 骨架 | 命令链全绿(type-check / test / build) | ✅ 2026-09-14 |
| 1 | **数据层与密钥**:crypto(HKDF 派生 / HMAC-SHA256 / AES-256-GCM)· 时间工具(+08:00 序列化、UTC 秒转换)· 建表 | 单测:哈希确定性 + 加解密往返;`db:push` 后四张表在库 | ✅ 2026-09-14(15 项单测绿;四表在库——`api_keys` 17 列 / `usage_logs` 23 列 / 索引 8 个) |
| 2 | **上游客户端 + key 管理**(`GET` / `POST` / `PATCH` 三端点)· 客户端接口化(§6) | curl 实测:登记 → 清单 → 停用/启用(**注入样例实现**:登记成功路径用样例响应驱动);三类错误码分支正确(`key_rejected` / `already_registered` / `upstream_unavailable`) | ✅ 2026-09-15(28 项单测绿;curl 16 步——登记 201 / 重复 409 / `key_rejected` 400 / `upstream_unavailable` 502 / 停用与幂等 / 启用清 `consecutive_401s` / `not_found` 404 / 非法参数 400;顺带修正「`load-env` 未被服务端接线」) |
| 3 | **管理面其余**:purchases 五端点 + qpu 一端点 | curl 实测:CRUD 全流程 + 观测聚合(`matched_purchase_id` 判定正确) | ✅ 2026-09-15(33 项单测绿;curl 16 步——登记 201 / 缺必填与非法值与期间顺序 400 / 部分更新与显式清空 / 排序 / 404 / 物理删除 / 观测聚合 `matched_purchase_id` 判定正确) |
| 4 | **同步引擎**:tick(5 分钟 + 防重入 + 启动即跑)· 日志整批事务入库 · 额度采集 · QPU 采样 · 401 状态机(N=3)· **seed 脚本**(§6) | 库中出现数据(seed 或真实采集);`[sync]` 日志符合引擎设计;401 状态机单测(连续 401 → 摘除且 `disabled_source = auto_401`) | ✅ 2026-09-15(45 项单测绿;seed 灌入 984 条日志 + QPU 基线 + 示例购买;引擎实测——幂等重跑新增 0 条、摘要日志符合设计、**401 端到端:1→2→3 次摘除且摘除后不再轮询**) |
| 5 | **查看面数据面**:`logs/query` + `summary` + 折算链(按购买期间分段取尺) | curl 实测:字段齐全、`meta.data_until` 取最慢、金额按购买分段累加;**无归属购买的行 `amount_rmb = null` 且计入 `unpriced_count`**(2026-09-14 定) | ✅ 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. 待定问题
| # | 问题 | 状态 |
|---|------|------|
| 1 | `amount_rmb` 在「日志无归属购买记录」时取什么值 | **已决(2026-09-14)**:取 `null`(展示「—」),汇总增 `unpriced_count`——见 API 契约 §2.1 |
| 2 | 充值(`wallet`)路径的折算近似(设计文档标「后议」) | 先按「未折算」处理(`null` / 计入 `unpriced_count`);池子加权平均待后议 |
| 3 | `dedupe_key` 内容指纹的拼接细节 | **已决**:见「实现假设」#5(表设计 §3.1 已给字段清单) |
| 4 | 上游对同账号请求的态度(限流 / 并发被拒时的表现) | 遇错即按「其他故障」处理(不计数、不摘除),如实记录日志 |
| 5 | **`request_id` 在错误行上不唯一**(2026-09-15 实测样板:8 个 `request_id` 各对应 3 条 `type=5` 错误记录,共 24 行)——当前按表设计原样实现 `dedupe_key = key_id:request_id`,同 `request_id` 的多条错误行**合并为 1 条** | **待作者裁决**:影响 = 失败请求次数(`error_count`)少算;候选方案 = 错误行去重键后缀内容指纹 / 序号(`usage_logs` 表设计 §3.1 需相应修订) |
## 5. 风险
- 真实上游联调依赖网络可达(已实测 200);真实 key 未开——以样例数据替代(§6),真实链路标「待作者补验」
- 折算链(阶段 5)依赖已登记的购买记录——无记录时的行为已定(`null` + `unpriced_count`)
- **后端必须单实例**(同步引擎前提):部署文档(阶段 8)必须写明;本地开发也不要起两个后端
## 6. 离线开发与样例数据(2026-09-14 定)
**结论:不需要真实 key 也能跑完全部阶段。** 仓库根目录有三个**实测响应快照**——未入库(`.gitignore` 用 `/api*.json` 排除,**绝不提交**),供开发与验证使用:
| 文件 | 内容 | 用途 |
|------|------|------|
| `api.log.token.json` | `/api/log/token` 的 1000 条响应快照(≈3.2 天、含 `type=2` 与 `type=5`) | 引擎解析 / 去重 / 入库的 fixture;**seed 数据源** |
| `api.log.token.other.json` | 单条日志 `other` 字段样本(26 键:`subscription_*` 八件套、`cache_tokens`、倍率等) | 解析与展示字段核对 |
| `api.status.json` | `/api/status` 响应(含 `quota_per_unit = 500000` / `quota_display_type = USD` / `usd_exchange_rate = 6.8`) | QPU 采样逻辑的 fixture |
**开发策略**:
1. **上游客户端接口化(假设 #10)**:定义接口(如 `UpstreamClient`:`fetchTokenUsage` / `fetchLogs` / `fetchStatus`),两套实现——真实 HTTP 实现 + **样例文件实现**(从上述 JSON 读响应);引擎与路由依赖接口,联调时替换
2. **seed 脚本**(阶段 4 落地,`apps/server/scripts/seed.ts`):从样例灌入——一个占位 `api_keys` 行(**假 key 常量**,其哈希/密文走真实 crypto 逻辑,便于用固定测试 key 查询命中)+ 1000 条 `usage_logs` + `qpu_history` 基线 + 一条示例 `purchases`(用于验证折算链)——使阶段 5/6/7 的开发与验证有真实形状的数据
3. **真实链路验证清单**(标「待作者补验」):登记成功路径(真实 `/api/usage/token` 验证)、真实采集轮次、真实 401 摘除——不阻塞其他阶段,做完在报告中列出待验项
+478
View File
@@ -0,0 +1,478 @@
# NewAPI sk-key 查询接口文档
> 基于源码实证调研(QuantumNous/new-api),整理所有可通过 `sk-` API key 直接访问的查询接口。
>
> 调研日期:2025-07 月 · 源码版本:main 分支
---
## 0. 通用说明
### 认证方式
所有接口均使用 API key 鉴权,在请求头中携带:
```
Authorization: Bearer sk-xxxxxxxxxxxx
```
**不需要**账号登录态(access token / session)。这是仅凭 sk-key 即可查询的关键前提。
### BaseURL
即 NewAPI 站点地址,例如 `https://your-newapi-instance.com`。所有路径前不带多余前缀,直接拼接。
### 两种鉴权中间件
| 中间件 | 说明 |
|--------|------|
| `TokenAuth()` | 标准版:校验 key 有效、未过期、有剩余额度。过期或耗尽的 key 会被拒绝。 |
| `TokenAuthReadOnly()` | 宽松版:只校验 key 存在、token 未被显式禁用、用户未封禁。**key 已过期、额度耗尽仍可查询**;**显式禁用(Disabled)的 key 会被 401 拒绝**(2026-09-13 源码复核,`middleware/auth.go` 注释原文:only explicitly disabled tokens are denied)。 |
> `/api/usage/token` 和 `/api/log/token` 用的是 `TokenAuthReadOnly()`,所以 key 额度耗尽、甚至过期后,仍能查到自己的用量记录;但 key 被管理员**显式禁用**后会收到 401(见上表)。
### 已确认的换算参数
| 参数 | 值 | 来源 |
|------|-----|------|
| `QuotaPerUnit` | 500000 | 实测验证:接口① `total_used` ÷ 500000 = 接口④ `total_usage` ÷ 100 |
### 关键开关:DisplayTokenStatEnabled
| 接口 | 受开关影响? |
|------|-------------|
| billing 接口(③④⑤⑥) | ✅ 受影响。开启 -> 返回 token 级额度;关闭 -> 回退到账号级额度(暴露整个账号余额) |
| `/api/usage/token`、`/api/log/token` | ❌ 不受影响,恒返回 token 级数据 |
**实测已确认当前平台开关为开启状态**(billing 接口返回的 `hard_limit_usd` 是 token 级占位值,非账号总额)。
开关位于后台:设置 -> 计费设置(Billing)-> "货币与显示"(Currency & Display)-> `Display Token Statistics`。仅管理员可改。
---
## 1. 接口清单总览
| 序号 | 方法 | 路径 | 鉴权 | 用途 |
|------|------|------|------|------|
| ① | GET | `/api/usage/token` | TokenAuthReadOnly | 查 token 的累计已用/剩余/总额(原始 quota) |
| ② | GET | `/api/log/token` | TokenAuthReadOnly | 查该 key 最近 1000 条调用日志 |
| ③ | GET | `/v1/dashboard/billing/subscription` | TokenAuth | 查总额度上限(OpenAI 兼容,美元) |
| ④ | GET | `/v1/dashboard/billing/usage` | TokenAuth | 查累计已用(OpenAI 兼容,美分) |
| ⑤ | GET | `/dashboard/billing/subscription` | TokenAuth | 同 ③,不带 /v1 前缀 |
| ⑥ | GET | `/dashboard/billing/usage` | TokenAuth | 同 ④,不带 /v1 前缀 |
| ⑦ | GET | `/v1/models` | TokenAuth | 列出可用模型(OpenAI 格式) |
| ⑧ | GET | `/v1/models/:model` | TokenAuth | 查单个模型详情 |
| ⑨ | GET | `/v1beta/models` | TokenAuth | 列出可用模型(Gemini 格式) |
| ⑩ | GET | `/v1beta/openai/models` | TokenAuth | 列出可用模型(OpenAI 格式) |
> ③和⑤是同一个接口,④和⑥是同一个接口,只是路径前缀不同(为兼容不同客户端写法)。测一组即可。
>
> ⑦~⑩是模型列表接口,与用量统计无关,列出仅为完整覆盖。
---
## 2. 用量/额度查询接口详解
### ① GET `/api/usage/token` - token 级用量汇总
**鉴权**:`TokenAuthReadOnly()`(宽松版:key 过期/额度耗尽也能查;被显式禁用则 401)
**请求参数**:无
**请求示例**:
```
GET /api/usage/token
Authorization: Bearer sk-xxxx
```
**返回结构**(源码:`controller/token.go` -> `GetTokenUsage`):
```json
{
"code": true,
"message": "ok",
"data": {
"object": "token_usage",
"name": "令牌名称",
"total_granted": 6012,
"total_used": 673419457,
"total_available": -673413445,
"unlimited_quota": true,
"model_limits": {},
"model_limits_enabled": false,
"expires_at": 0
}
}
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 令牌名称 |
| `total_granted` | int | 总额度 = RemainQuota + UsedQuota(原始 quota 单位) |
| `total_used` | int | **累计已用**(原始 quota 单位)。从 key 创建以来累计,非当月。换算美元:`total_used ÷ 500000` |
| `total_available` | int | 剩余 = total_granted − total_used。无限额度时为负数,无实际意义 |
| `unlimited_quota` | bool | 是否无限额度。true 时 total_granted/total_available 无实际意义 |
| `model_limits` | object | 模型限制(map) |
| `model_limits_enabled` | bool | 是否启用模型限制 |
| `expires_at` | int | 过期时间(Unix 秒),0 = 永不过期 |
**注意事项**:
- `total_used` 是**累计值**,从 key 创建以来累计,不是当月用量。
- 额度单位是**原始 quota 整数**,需自行除以 `QuotaPerUnit`(默认 500000)换算美元。
- 禁用再解禁不会重置 `total_used`(源码验证:`Update()` 的 `.Select(...)` 列表不含 `used_quota`)。
---
### ② GET `/api/log/token` - token 级调用日志
**鉴权**:`TokenAuthReadOnly()`
**请求参数**:无(不支持任何 query 参数)
**请求示例**:
```
GET /api/log/token
Authorization: Bearer sk-xxxx
```
**返回结构**(源码:`controller/log.go:62` -> `GetLogByKey`):
```json
{
"success": true,
"message": "",
"data": [
{
"id": 123456,
"user_id": 12,
"created_at": 1751328000,
"type": 2,
"content": "模型倍率 1.0,分组倍率 1.0",
"username": "用户名",
"token_name": "令牌名",
"model_name": "gpt-4o",
"quota": 2500,
"prompt_tokens": 1500,
"completion_tokens": 500,
"use_time": 2,
"is_stream": true,
"channel": 0,
"channel_name": "",
"token_id": 100,
"group": "default",
"ip": "",
"request_id": "20260911113652…",
"other": "{\"billing_source\":\"subscription\",\"cache_tokens\":29056,\"frt\":3931,\"model_ratio\":0.075,\"request_path\":\"/v1/messages\",\"stream_status\":{\"status\":\"ok\"},…}"
}
]
}
```
**字段说明**(共 21 个字段;已按实测响应 + 上游源码校正,2026-09-11):
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | int | 展示序号,**每次从 1 重编**——跨请求无意义,绝不能用于去重 |
| `user_id` | int | 账号 ID(同一实例内恒定) |
| `created_at` | int | 调用时间(UTC Unix 秒) |
| `type` | int | 日志类型(完整取值见下);token 日志实见 2=消费、5=错误 |
| `content` | string | 消费记录为空串;错误记录为错误详情文本 |
| `username` | string | 账号用户名 |
| `token_name` | string | 令牌名 |
| `model_name` | string | 调用的模型名 |
| `quota` | int | 本次消耗的 quota(换算美元:`quota ÷ 500000`);错误记录为 0 |
| `prompt_tokens` | int | 输入 token 数;错误记录为 0 |
| `completion_tokens` | int | 输出 token 数;错误记录为 0 |
| `use_time` | int | 耗时(秒);错误记录为 0 |
| `is_stream` | bool | 是否流式 |
| `channel` | int | 渠道 ID |
| `channel_name` | string | 渠道名(普通用户视角为空串) |
| `token_id` | int | 上游 token ID(对账用) |
| `group` | string | 分组名(默认 `default`) |
| `ip` | string | 请求 IP(普通用户视角为空串) |
| `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 字符串,扩展信息(结构见下) |
**`type` 完整取值(上游源码 `model/log.go`;常量注释明确「刻意不用 iota,以保证枚举值永不改变」)**:
| 值 | 常量 | 含义 | 可能出现在 token 日志? |
|----|------|------|------------------------|
| 0 | `LogTypeUnknown` | 未知 | 兜底值 |
| 1 | `LogTypeTopup` | 充值 | 否(账号级,无 token_id) |
| 2 | `LogTypeConsume` | 消费 | **是**(实测) |
| 3 | `LogTypeManage` | 管理操作 | 否(审计类) |
| 4 | `LogTypeSystem` | 系统 | 否 |
| 5 | `LogTypeError` | 错误 | **是**(实测) |
| 6 | `LogTypeRefund` | 退款 | 可能(任务计费链路带 token_id) |
| 7 | `LogTypeLogin` | 登录 | 否(账号级) |
> 本接口按 `token_id` 过滤、不按 `type` 过滤——凡是「带 token_id 写入」的类型都可能返回。
**`other` 字段(JSON 字符串,内容随 `type` 变化,实测 2026-09-11)**:
- 消费记录(`type=2`):`billing_source`、`cache_ratio`、`cache_tokens`、`completion_ratio`、`frt`、`group_ratio`、`model_price`、`model_ratio`、`reasoning_effort`、`request_conversion`、`request_path`、`stream_status`(对象)、`subscription_*`(订阅维度,共 8 个)、`user_group_ratio`、`wallet_quota_deducted`
- 错误记录(`type=5`):`error_code`、`error_type`、`request_path`、`status_code`
- 注:普通用户视角下,服务端会先对 `other` 做一层「可见性过滤」再返回——上表为过滤后实际可见的键
**关键限制**:
- **硬上限 1000 条**:固定返回最近 1000 条日志(`Limit(common.MaxRecentItems)`,`MaxRecentItems = 1000`,定义于 `common/constants.go`)。
- **不支持任何请求参数**:`GetLogByKey` 不读任何 `c.Query()`,没有分页(page/limit/offset),没有时间过滤(start_timestamp/end_timestamp)。
- **固定按 `id desc` 排序**:即最新 1000 条。
- **超过 1000 条的旧记录无法获取**:无法翻页,无法扩大 limit。
- **当月调用超 1000 次时,此接口无法覆盖完整当月数据**。
---
### ③ GET `/v1/dashboard/billing/subscription` - 总额度上限
**鉴权**:`TokenAuth()`
**请求参数**:无
**请求示例**:
```
GET /v1/dashboard/billing/subscription
Authorization: Bearer sk-xxxx
```
**返回结构**(源码:`controller/billing.go:13` -> `GetSubscription`):
```json
{
"object": "billing_subscription",
"has_payment_method": true,
"soft_limit_usd": 100000000,
"hard_limit_usd": 100000000,
"system_hard_limit_usd": 100000000,
"access_until": 0
}
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `hard_limit_usd` | float | **总额度上限**(美元)。无限额度 key 返回 `100000000`(占位值,无实际意义) |
| `soft_limit_usd` | float | 软上限(同 hard_limit_usd) |
| `system_hard_limit_usd` | float | 系统上限(同 hard_limit_usd) |
| `access_until` | int | token 过期时间(Unix 秒),0 = 永不过期 |
**注意事项**:
- 受 `DisplayTokenStatEnabled` 开关影响。开启时返回 token 级额度;关闭时返回账号级额度(会暴露整个账号余额)。
- 无限额度 key 返回 `100000000`(一亿)作为占位值,无参考意义。
- 受 `quota_display_type` 影响:USD 模式直接是美元;CNY 模式会做货币转换;Tokens 模式是原始 token 数。
---
### ④ GET `/v1/dashboard/billing/usage` - 累计已用
**鉴权**:`TokenAuth()`
**请求参数**:无
**请求示例**:
```
GET /v1/dashboard/billing/usage
Authorization: Bearer sk-xxxx
```
**返回结构**(源码:`controller/billing.go:75` -> `GetUsage`):
```json
{
"object": "list",
"total_usage": 134683.8914
}
```
**字段说明**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `total_usage` | float | **累计已用**(单位:**美分**)。换算美元:`total_usage ÷ 100` |
**实测验证**:
```
total_usage = 134683.8914 美分 ÷ 100 = 1346.84 美元
与接口① total_used(673419457) ÷ 500000 = 1346.84 美元 完全吻合
```
**注意事项**:
- `total_usage` 是**累计值**(从 key 创建至今),不是当月用量。
- 受 `DisplayTokenStatEnabled` 开关影响(开启时为 token 级)。
- 受 `quota_display_type` 影响(USD 模式才是美元,否则需另行换算)。
- `hard_limit_usd`(接口③)− `total_usage ÷ 100`(接口④)= 剩余额度(仅有限额 key 有意义)。
---
## 3. 模型列表接口(与用量无关)
> 以下接口 sk-key 可访问,但返回的是模型列表,与用量统计无关。列出仅为完整覆盖。
### ⑦ GET `/v1/models`
返回当前 key 可用的模型列表,OpenAI 兼容格式。
```json
{
"object": "list",
"data": [
{
"id": "gpt-4o",
"object": "model",
"created": 1686935000,
"owned_by": "openai"
}
]
}
```
### ⑧ GET `/v1/models/:model`
返回单个模型详情。将 `:model` 替换为模型名,如 `/v1/models/gpt-4o`。
> **已知限制**:此接口与 `/v1/models` 数据源不同。`/v1/models` 查数据库 `abilities` 表(反映渠道实际配置的模型),而此接口查代码内硬编码的静态模型列表(`openAIModelsMap`)。当渠道配了新模型或自定义模型名但代码未收录时,会出现"列表里有但单查返回 `model_not_found`"的现象。不影响实际模型调用。
### ⑨ GET `/v1beta/models`
返回模型列表(Gemini 兼容格式)。
### ⑩ GET `/v1beta/openai/models`
返回模型列表(OpenAI 格式,Gemini 路由下)。
---
## 4. 确认不存在的接口(排除清单)
以下接口在源码中**不存在**或**不可用 sk-key 访问**,测试时应返回 404 或 401:
### 4.1 不存在的接口(返回 404)
| 路径 | 状态 |
|------|------|
| `/v1/usage` | 不存在 |
| `/v1/quota` | 不存在 |
| `/api/token/self` | 不存在 |
### 4.2 存在但需要登录态的接口(sk-key 访问返回 401)
这些接口支持时间范围查询和 `token_name` 过滤,但**必须用账号登录态**(access token + `New-Api-User` header),sk-key 无法访问:
| 路径 | 鉴权 | 能力 |
|------|------|------|
| `/api/log/self` | `UserAuth()` | 支持 `start_timestamp`、`end_timestamp`、`token_name` 过滤,返回日志明细 |
| `/api/log/self/stat` | `UserAuth()` | **支持时间范围 + token_name,返回 quota 汇总**(调 `SumUsedQuota`) |
| `/api/data/self` | `UserAuth()` | 按日聚合的看板数据 |
| `/api/token/` (GET/POST/PUT/DELETE) | `UserAuth()` | token 的增删改查 |
| `/api/user/self` | `UserAuth()` | 用户自身信息 |
> **特别说明**:`/api/log/self/stat` 是 NewAPI 中最适合算"当月用量"的接口--传当月起止时间 + `token_name` 即可返回该 key 当月消耗的 quota 总和。但它需要登录态,是"按时间段统计用量"难以纯客户端实现的核心障碍。
### 4.3 参数被忽略的情况
| 请求 | 实际行为 |
|------|---------|
| `/api/log/token?p=2` | 参数被忽略,仍返回最近 1000 条 |
| `/api/log/token?limit=5000` | 参数被忽略,仍返回最近 1000 条 |
| `/api/log/token?start_timestamp=1751328000` | 参数被忽略,仍返回最近 1000 条 |
| `/v1/dashboard/billing/usage?start_date=2025-07-01` | 参数被忽略 |
---
## 5. 用量换算速查
### 核心换算公式
```
原始 quota ÷ 500000 = 美元
```
### 消费记录的 `quota` 是怎么算出来的(实测反推,2026-09-13)
按 tokens 计费的模型,`quota` 在**请求时**由「倍率 × 用量」算出并固化进日志(此后不再变化):
```
quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未命中缓存部分)
+ cache_tokens × cache_ratio ← 缓存命中部分按折扣
+ completion_tokens × completion_ratio ) ← 补全 tokens 按倍率
× model_ratio ← 模型倍率
× group_ratio ← 分组倍率
```
- 验证:6/6 条实测日志逐条吻合(差值仅为四舍五入)
- 按次计费模型走 `model_price` 分支:`quota = model_price × QuotaPerUnit × group_ratio`(源码 `relay/helper/price.go`)
- **公式中没有 `QuotaPerUnit`**:倍率与美元的锚定(`model_ratio = 1` ⇔ `$0.002 / 1K tokens`)已隐含它——倍率制下改 `QuotaPerUnit` 等于全站重定标;美元定价路径(`model_price` / 表达式 `$/1M`)才显式乘 `QuotaPerUnit`
### 实测数据验证
| 来源 | 原始值 | 换算 | 结果 |
|------|--------|------|------|
| 接口① `total_used` | 673,419,457 quota | ÷ 500000 | 1346.84 美元 |
| 接口④ `total_usage` | 134,683.8914 美分 | ÷ 100 | 1346.84 美元 |
两者完全吻合,确认 `QuotaPerUnit = 500000`。
---
## 6. 关键限制总结
### 6.1 时间维度
- **所有 sk-key 接口返回的用量都是累计值**(从 key 创建至今),没有任何接口直接返回"当月用量"。
- 唯一带时间戳的是 `/api/log/token`,但硬限 1000 条且不支持时间过滤。
- 支持时间范围的接口(`/api/log/self`、`/api/log/self/stat`)需要登录态,sk-key 无法访问。
### 6.2 日志条数
- `/api/log/token` 硬上限 **1000 条**(`MaxRecentItems = 1000`,定义于 `common/constants.go`)。
- 不支持分页、不支持时间过滤、不支持改变 limit。
- 月调用量超过 1000 次时,此接口无法覆盖完整当月数据。
### 6.3 写操作
- **sk-key 没有任何写接口**(没有 POST/PUT/DELETE 能改 token 自身)。
- token 的增删改查(`/api/token/`)全部需要登录态 `UserAuth()`。
- 无法通过 sk-key 重置 `used_quota`(没有"重置用量"接口,`Update()` 的 Select 列表不含 `used_quota`)。
### 6.4 禁用/解禁行为
- 禁用 token 只改 `status` 字段,**不重置 `used_quota`**,不清零 `remain_quota`,不丢历史日志。
- 解禁后 `used_quota`、`remain_quota`、历史日志全部原封不动。
- 唯一让 `used_quota` 归零的途径是**删除 token 后新建**(产生全新 Id/Key,旧日志按旧 token_id 关联,新 token 查不到旧日志)。
- 解禁保护:额度耗尽(`Exhausted`)且 `RemainQuota ≤ 0` 且非无限额度的 token 不能直接解禁,需先充值;已过期的 token 也不能直接解禁,需改过期时间。
---
## 7. 源码参考
| 关注点 | 文件路径 | 关键函数/位置 |
|--------|---------|--------------|
| 路由注册 | `router/api-router.go` | usageRoute、logRoute 组 |
| 路由注册 | `router/dashboard.go:14-23` | billing 路由组 |
| 路由注册 | `router/relay-router.go` | models 路由 |
| API key 鉴权 | `middleware/auth.go:171` | `TokenAuthReadOnly()` |
| API key 鉴权 | `middleware/auth.go` | `TokenAuth()` / `SetupContextForToken` |
| token 用量查询 | `controller/token.go` | `GetTokenUsage` |
| 日志查询 | `controller/log.go:62` | `GetLogByKey` |
| 日志查询(model) | `model/log.go` | `GetLogByTokenId`(Limit 1000) |
| billing 查询 | `controller/billing.go:13` | `GetSubscription` |
| billing 查询 | `controller/billing.go:75` | `GetUsage` |
| 显示开关 | `common/constants.go` | `DisplayTokenStatEnabled` |
| 日志上限 | `common/constants.go` | `MaxRecentItems = 1000` |
| 配额单位 | `common/constants.go` | `QuotaPerUnit`(默认 500000) |
| token 更新 | `model/token.go` | `Update()`(Select 不含 used_quota) |
| 额度增减 | `model/token.go` | `increaseTokenQuota` / `decreaseTokenQuota` |
> 仓库地址:https://github.com/QuantumNous/new-api
+93
View File
@@ -0,0 +1,93 @@
# NewAPI 接口范围
> new-api-key-usage 使用的 NewAPI 接口范围:本项目对接的全部 9 个接口,含鉴权档位与各自对本项目的用途——本项目所有上游调用都出自这份范围。与 `NewAPI-sk-key接口文档.md` 的分工:那边是「sk-key 能访问什么」的源码实证全集(430 行,字段级细节);本文是「本项目实际使用哪些接口」的范围清单。
>
> 整理日期:2026-09-09 · 重定位:2026-09-11 · 2026-09-13 增补 §3(范围之外)与 ④ 行为事实 · 依据:NewAPI 源码考证 + 接口实测 · 实测环境:NewAPI 实例
---
## 1. 鉴权三态总览
本项目对接的接口按鉴权中间件分三档:
| 鉴权档 | 中间件 | 语义 | 本项目的请求方式 |
|--------|--------|------|----------------|
| **公开**(none) | 无 / `HeaderNavModulePublicOrUserAuth` | 不持有任何凭证即可访问 | 直接请求,无需 key |
| **严格**(strict) | `TokenAuth` | 校验 key 有效、未过期、有剩余额度;无效直接被拒 | 携带 sk-key 请求 |
| **宽松**(lenient) | `TokenAuthReadOnly` | 只验 key 存在且用户未封禁;**过期/额度耗尽仍可查**,但**显式禁用(Disabled)会被 401 拒绝** | 携带 sk-key 请求;**本项目主要依赖此档** |
> 宽松档的「显式禁用被拒」是源码考证的补充结论(`middleware/auth.go` 的 `TokenAuthReadOnly`),影响本项目的 401 摘除逻辑:收到 401 应区分「key 被禁用」(摘除)与「瞬时故障」(重试)。
>
> 失败状态码全集(2026-09-13 源码复核):**401** = key 不存在(`MsgTokenInvalid`)或 token 被显式禁用(`MsgTokenStatusUnavailable`);**403** = 账号被封禁;**500** = 数据库错误。→ 摘除计数只认 401;403 / 500 视为其他故障,不计入。另:宽松档**允许** key 过期(Expired)与额度耗尽(Exhausted)状态查询,源码注释明确「only explicitly disabled tokens are denied」。
## 2. 接口清单
### 公开接口组(无需 sk-key)
#### ① `GET /api/user/groups` — 用户分组与倍率
- **返回**:站点配置的用户分组(default / vip 等)与倍率;**未启用分组时 `data` 为空对象**(当前站点实测即此状态)
- **用途**:解释「同一模型为什么计费不同」——分组倍率影响同一模型的实际消耗
- **本项目用途**:低优先级;仅当站点启用分组倍率后才有内容
#### ② `GET /api/perf-metrics/summary` — 全站模型服务质量
- **参数**:`hours`(统计窗口小时数,默认 24)
- **返回**:每个模型的 `avg_latency_ms`(平均延迟)、`success_rate`(成功率 %)、`avg_tps`(每秒 token 数)、`recent_success_series`(按小时采样的成功率时间序列);明细版 `/api/perf-metrics?model=xxx`(model 必填,另支持 group/hours)
- **鉴权细节**:`HeaderNavModulePublicOrUserAuth("pricing")`——默认公开,管理员可为 pricing 模块开启 requireAuth
- **实测**:HTTP 200,含站点真实模型的服务质量数据
- **本项目用途**:**高**——本工具独有视角。单个 sk-key 日志看不到全局服务质量;使用者反馈「慢/报错多」时,此接口能定位是模型问题还是自身问题。技术设计时可作为「模型健康度」功能的数据源
#### ③ `GET /api/rankings` — 全站模型用量排行
- **参数**:`period`(统计周期,默认 week)
- **返回**:每个模型的 `rank`(名次)、`previous_rank`(上期名次)、`total_tokens`(总用量)、`share`(份额)、`growth_pct`(环比增长 %)
- **实测**:HTTP 200,14KB 数据
- **本项目用途**:中——项目作者选「主推模型」(便宜且稳定)的数据依据;配合②的成功率可识别衰退模型
#### ④ `GET /api/status` — 站点配置与计费参数
- **返回**:60+ 字段,与本项目相关的计费字段:`quota_per_unit`(**换算比率当前生效值,管理员可改**)、`quota_display_type`(USD/CNY/TOKENS,影响 billing 接口语义)、`usd_exchange_rate`、`price` 等
- **本项目用途**:**关键**——换算比率的唯一权威来源。`QuotaPerUnit = 500000` 只是 NewAPI 默认值(源码 `common/constants.go:22`,`$0.002 / 1K tokens` 锚点),实现时必须从本接口实时读取、不得硬编码;同时感知 `quota_display_type` 切换(若切到 CNY/TOKENS,billing 接口数字语义会变)
- **补充:`quota_per_unit` 的行为事实(2026-09-13 源码评估)**:它由全局变量承载,改设置是「先写库、再热更新内存」(`model/option.go` 的 `UpdateOption` → `updateOptionMap` 的 `case "QuotaPerUnit"`)——改后**立即全站生效、无需重启**。三条关键行为:
- **无历史**:`options` 表一 key 一行、改即覆盖——任何接口都读不到过去的取值;管理审计只记"键名被改过"、**不记值**
- **无编辑入口**:后台设置页该字段默认隐藏且只读(`web/src/features/system-settings/general/pricing-section.tsx`),修改只能走 API
- **解读跟随当前值**:一切对外的金额换算(历史记录解读、billing 接口、界面显示)都在读取时实时使用当前值——改它 = 全站金额表达整体平移(数据库里的 quota 整数不受影响)
### 严格鉴权组(TokenAuth,sk-key)
#### ⑤ `GET /v1/dashboard/billing/subscription` — 总额度上限(美元)
- 返回 `hard_limit_usd` 等;无限额度 key 返回 `100000000`(一亿占位值,无参考意义)
- 受 `DisplayTokenStatEnabled` 与 `quota_display_type` 影响;与⑥相减可得剩余额度
- **本项目用途**:中——按 key 统计表的「累计剩余」列候选数据源
#### ⑥ `GET /v1/dashboard/billing/usage` — 累计已用(美分)
- 返回 `total_usage`(美分,÷100 = 美元);可与⑧的 `total_used ÷ quota_per_unit` 交叉对账(不一致 = 站点改过比率,见④)
- **本项目用途**:中——同上,对账与校验用途
#### ⑦ `GET /v1/models` — 可用模型列表
- 数据源是数据库(渠道实际配置);与 `/v1/models/:model` 单查接口数据源不同
- **本项目用途**:低——调试期验证渠道配置用
### 宽松鉴权组(TokenAuthReadOnly,sk-key)
#### ⑧ `GET /api/usage/token` — token 级用量汇总(原始 quota)
- 返回 `total_used`(累计,非当月)、`total_granted`、`total_available`、`unlimited_quota`
- **本项目用途**:**高**——按 key 统计表「全周期累计(已用/剩余)」两列的直接数据源;key 耗尽后仍可查(用完也能对账)
#### ⑨ `GET /api/log/token` — 最近 1000 条调用日志
- **硬上限 1000 条**(`common.MaxRecentItems = 1000`)、不支持任何参数、最新在前;响应里的 `id` 是每次从 1 重编的展示序号,**跨请求无意义,去重必须用 `request_id`**
- 大响应:pretty-print 后 2~4MB——本项目同步引擎需直面的单次响应体积
- **本项目用途**:**核心**——增量同步引擎的唯一数据源;本地攒全量后,任意时间范围的用量/明细/模型统计都是本地聚合查询
## 3. 范围之外(sk-key 不可达)
1. **账号级统计接口**(如 `/api/log/self/stat`,支持时间范围 + quota 汇总):需账号登录态,sk-key 访问 401——本项目以「本地攒日志 + 聚合」替代
2. **订阅信息**(订阅编号 / 生效期间 / 额度):**没有免 auth 的接口可直接查询**(作者实测,2026-09-13);登录态的**「钱包」页面**可见——显示订阅编号(形如「订阅 #N」)、状态(生效中 / 已过期 + 过期时间)、总额度与已用 / 剩余:
- 订阅编号**推测**与日志 `other.subscription_id` 为同一体系(实现期以真实数据核对)
- 对应设计:订阅信息由购买者手工录入(见 `docs/design/purchases表字段设计.md`)
+26
View File
@@ -0,0 +1,26 @@
{
"name": "new-api-key-usage",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"dev": "bun run --filter '*' dev",
"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/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",
"seed": "bun run --filter '@naku/server' seed"
},
"devDependencies": {
"typescript": "^6.0.0"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@naku/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 契约常量 —— 与 `docs/design/API契约.md` 保持同步。
*/
/** 明细分页:默认每页条数(契约 §2.1 请求参数) */
export const LOGS_DEFAULT_LIMIT = 50
/** 明细分页:每页条数上限 */
export const LOGS_MAX_LIMIT = 200
/** 每页条数选择器的可选值 */
export const LOGS_LIMIT_OPTIONS = [20, 50, 100, 200] as const
/** 单次查询的 key 集合上限(初值,技术稿可调) */
export const KEYS_MAX = 20
/** 日志类型(上游 model/log.go;常量注释明确「刻意不用 iota」,枚举值永不改变) */
export const LOG_TYPE = {
Unknown: 0,
Topup: 1,
Consume: 2,
Manage: 3,
System: 4,
Error: 5,
Refund: 6,
Login: 7,
} as const
/** 日志类型的中文标签(展示层用;未知值原样显示数字) */
export const LOG_TYPE_LABEL: Record<number, string> = {
[LOG_TYPE.Unknown]: '未知',
[LOG_TYPE.Topup]: '充值',
[LOG_TYPE.Consume]: '消费',
[LOG_TYPE.Manage]: '管理',
[LOG_TYPE.System]: '系统',
[LOG_TYPE.Error]: '错误',
[LOG_TYPE.Refund]: '退款',
[LOG_TYPE.Login]: '登录',
}
/** 额度美元换算的出厂默认(仅兜底展示;实际取值一律从 qpu_history 时间线来) */
export const DEFAULT_QUOTA_PER_UNIT = 500000
/** 管理面请求头(契约 约定 6) */
export const MANAGE_TOKEN_HEADER = 'x-manage-token'
+2
View File
@@ -0,0 +1,2 @@
export * from './types'
export * from './constants'
+225
View File
@@ -0,0 +1,225 @@
/**
* API 契约类型 —— 与 `docs/design/API契约.md` 保持同步(全链路 snake_case,契约 约定 3)。
* 字段集稳定:可空列返回 `null` 而非省略(约定「字段集稳定」)。
*/
// ---------- 通用:错误与元信息 ----------
/** 错误码枚举(契约 约定 21) */
export type ApiErrorCode =
| 'invalid_params'
| 'manage_unauthorized'
| 'not_found'
| 'internal'
| 'key_rejected'
| 'already_registered'
| 'upstream_unavailable'
/** 失败响应体(约定 20:非 2xx + error 信封) */
export interface ApiErrorBody {
error: {
code: ApiErrorCode | (string & {})
message: string
}
}
/** 查看面查询响应统一携带的 meta(约定 22) */
export interface QueryMetaKeyRegistered {
key_id: number
token_name: string
registered: true
status: 'active' | 'disabled'
/** 该 key 自己的同步进度(新登记未首同步时为 null) */
log_synced_at: string | null
}
export interface QueryMetaKeyUnregistered {
registered: false
/** 即时生成、不落库:`sk-…a1b2` */
key_hint: string
}
export type QueryMetaKey = QueryMetaKeyRegistered | QueryMetaKeyUnregistered
export interface QueryMeta {
/** 参与 key 中最慢的 log_synced_at(取非 null 的最慢值);全部未同步时为 null */
data_until: string | null
served_at: string
keys: QueryMetaKey[]
}
// ---------- /usage-log ----------
/** 日志类型(上游 model/log.go 的稳定枚举,照存不过滤) */
export type LogType = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
/** 日志行的 `other`(实测 26 键中的常用项;完整键集合见 docs/upstream 实测记录) */
export interface LogOther {
billing_source?: 'subscription' | 'wallet'
cache_tokens?: number
model_ratio?: number
request_path?: string
subscription_id?: number
subscription_plan_title?: string
subscription_total?: number
subscription_used?: number
subscription_remain?: number
[key: string]: unknown
}
/** 明细行 = `usage_logs` 表 1:1(除内部列 `dedupe_key`)+ 计算字段 */
export interface UsageLogRow {
id: number
key_id: number
user_id: number
username: string
created_at: string
type: LogType
content: string
/** 当前名(join api_keys),非日志快照名 */
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_name: string
ip: string
request_id: string
upstream_request_id: string | null
/** 该行折算实付人民币(错误行 quota = 0 → 0;匹配不到归属购买时为 null——未登记 / 充值来源) */
amount_rmb: number | null
other: LogOther
}
export interface LogsQueryRequest {
/** 必填;去重后最多 KEYS_MAX 个 */
keys: string[]
/** 必填;ISO 8601(输出 +08:00,输入宽容),左闭右开 [start, end) */
start: string
end: string
/** 可选;缺省 = 全部类型;只作用于本端点 */
type?: number[]
/** 可选;默认 50,上限 200 */
limit?: number
offset?: number
}
export interface LogsQueryResponse {
items: UsageLogRow[]
total: number
meta: QueryMeta
}
export interface SummaryRequest {
keys: string[]
start: string
end: string
}
export interface SummaryResponse {
request_count: number
consume_count: number
error_count: number
prompt_tokens: number
completion_tokens: number
cache_tokens: number
/** quota 合计(原值,供核对) */
quota: number
/** 可折算部分的实付人民币合计(跨期按购买期间分段累加) */
amount_rmb: number
/** 无法折算的行数(`amount_rmb` 为 `null` 的行——未登记购买 / 充值来源) */
unpriced_count: number
meta: QueryMeta
}
// ---------- /manage/api-keys ----------
/** 清单行 = `api_keys` 表 1:1(除 key_hash / key_ciphertext 两个永不出接口的敏感列) */
export interface ApiKeyRow {
id: number
token_name: string
status: 'active' | 'disabled'
/** 停用来源(可辨「已失效」与「已停用」) */
disabled_source: 'manual' | 'auto_401' | null
consecutive_401s: number
registered_at: string
log_synced_at: string | null
usage_synced_at: string | null
total_granted: number
total_used: number
total_available: number
unlimited_quota: boolean
model_limits: Record<string, unknown>
model_limits_enabled: boolean
expires_at: string | null
}
export interface RegisterApiKeyRequest {
key: string
}
export interface PatchApiKeyRequest {
status: 'active' | 'disabled'
}
// ---------- /manage/purchases ----------
export type PurchaseKind = 'subscription' | 'topup'
/** 清单行 = `purchases` 表 1:1(9 列) */
export interface PurchaseRow {
id: number
kind: PurchaseKind
paid_at: string
paid_amount: number
note: string | null
period_start: string | null
period_end: string | null
subscription_id: number | null
quota_granted: number | null
}
/** 登记请求(必填:kind / paid_at / paid_amount / quota_granted) */
export interface PurchaseCreateRequest {
kind: PurchaseKind
paid_at: string
paid_amount: number
quota_granted: number
note?: string | null
period_start?: string | null
period_end?: string | null
subscription_id?: number | null
}
/** 编辑请求:部分更新语义(提交哪些改哪些) */
export type PurchasePatchRequest = Partial<PurchaseCreateRequest>
/** 观测项(核对提示的数据面) */
export interface ObservationItem {
subscription_id: number
plan_title: string | null
observed_quota_total: number | null
first_seen_at: string
last_seen_at: string
log_count: number
/** null = 观测到但未登记(补录引导的目标) */
matched_purchase_id: number | null
}
export interface ObservationsResponse {
items: ObservationItem[]
}
// ---------- /manage/qpu ----------
/** 时间线行 = `qpu_history` 表 1:1(3 列,纯只读) */
export interface QpuRow {
id: number
qpu: number
effective_from: string
}