Compare commits
15
Commits
init
..
0e9db439ba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e9db439ba | ||
|
|
fca01798e2 | ||
|
|
0e499eb8e1 | ||
|
|
8252b25396 | ||
|
|
e1c6d2153e | ||
|
|
4b5d21a418 | ||
|
|
c26012a040 | ||
|
|
575643eb13 | ||
|
|
f906289f3a | ||
|
|
3ffbd030b9 | ||
|
|
1295667c94 | ||
|
|
dcf7a31c09 | ||
|
|
30e71beb09 | ||
|
|
138a1b7685 | ||
|
|
45b312b357 |
@@ -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
@@ -6,9 +6,20 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# Node / Bun
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# 实测数据样例(未脱敏,不入库)
|
||||||
|
/api*.json
|
||||||
|
|
||||||
|
# AI 工具运行目录(Reasonix)
|
||||||
|
.reasonix/
|
||||||
|
|
||||||
# 环境变量 / 密钥(不要提交到仓库)
|
# 环境变量 / 密钥(不要提交到仓库)
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -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 工程注记)
|
||||||
@@ -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. 后议功能:充值折算、多订阅实测等见两张折算表设计文档的「待定事项」
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
@@ -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 ?? '',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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。`)
|
||||||
@@ -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 }
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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!))
|
||||||
|
})
|
||||||
@@ -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]!))
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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()))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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 }),
|
||||||
|
}
|
||||||
Vendored
+15
@@ -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
|
||||||
|
}
|
||||||
Vendored
+48
@@ -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']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -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('—')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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:
|
||||||
@@ -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,关标签后需重新输入) |
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 已废弃文档归档
|
||||||
|
|
||||||
|
> **本目录只作存档**:其中的文档均已作废,**不要引用、不要作为任何设计依据**。
|
||||||
|
|
||||||
|
- 现行文档一律以 [`docs/design/`](../design/)(我们定的方案与决策)与 [`docs/upstream/`](../upstream/)(NewAPI 实证事实)为准,入口见仓库根 [`README.md`](../../README.md) 的「文档地图」
|
||||||
|
- 本目录不随项目演进维护;仅供追溯历史
|
||||||
|
|
||||||
|
## 归档清单
|
||||||
|
|
||||||
|
(当前无归档文档)
|
||||||
@@ -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` 边界) | 技术稿 |
|
||||||
@@ -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` |
|
||||||
@@ -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 ↔ 已录入记录比对)的交互 | 交互设计 |
|
||||||
@@ -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. 待定事项(关联后续环节)
|
||||||
|
|
||||||
|
| 事项 | 归属 |
|
||||||
|
|------|------|
|
||||||
|
| 采样调度(读取频率、失败重试) | 同步引擎设计 |
|
||||||
@@ -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`) | 出现真实分析需求时再评估 |
|
||||||
|
| 数据保留策略(是否清理旧数据) | 后议 |
|
||||||
@@ -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 → 输入框下方「口令不正确」;不做「记住我」
|
||||||
@@ -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`)
|
||||||
@@ -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 摘除——不阻塞其他阶段,做完在报告中列出待验项
|
||||||
@@ -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
|
||||||
@@ -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`)
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@naku/shared",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './constants'
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user