Compare commits

..
5 Commits
Author SHA1 Message Date
skycurtain 1295667c94 feat(contract): amount_rmb 支持 null + 汇总增 unpriced_count;实施计划补「实现假设」机制
- API 契约:无归属购买的明细行 amount_rmb = null(展示「—」);汇总增 unpriced_count(未折算条数)
- shared 类型同步(amount_rmb: number | null · unpriced_count: number)
- 实施计划:loop 停车规则改为「自主推进 + 假设记录」(作者开发期可能缺席);新增 8 条实现假设;待定问题 #1/#3 标记已决
2026-09-15 00:12:19 +08:00
skycurtain dcf7a31c09 chore: 本地开发 PostgreSQL(docker-compose)与环境变量样例
- docker-compose.yml:postgres:17-alpine,宿主端口 5433(避开常见 5432 占用),含 healthcheck
- .env.example:DATABASE_URL 默认指向本地 compose;KEYS_MASTER_KEY 补生成提示
2026-09-15 00:03:46 +08:00
skycurtain 30e71beb09 docs: 实施计划(loop 模式 + 八阶段验收标准)
- loop 机制:取任务 → 实现 → 验证 → 记录 → 提交 → 报告;遇设计空白停车问作者
- 八阶段:数据层与密钥 → key 管理 → 管理面其余 → 同步引擎 → 查看面数据面 → /usage-log 前端 → 管理面前端 → 收尾
- 前提已定:Docker 本地 PG · 真实 key 联调 · loop 内直接提交(作者授权)
2026-09-15 00:03:46 +08:00
skycurtain 138a1b7685 chore: 初始化 monorepo 脚手架与代码骨架
- Bun workspaces:apps/web(Vue 3.5 + Vite 8 + naive-ui)· apps/server(Hono 4.13 + Drizzle)· packages/shared(契约类型)
- 代码骨架:四表 Drizzle schema · 11 个端点占位(501)· 管理面口令中间件 · 同步引擎占位(5 分钟 tick + 防重入)
- 命令链:dev / build / test / lint / type-check / db:*(见 AGENTS.md)
2026-09-14 23:54:58 +08:00
skycurtain 45b312b357 docs: 设计文档体系落定(七篇定稿)
- docs/design:四张表字段设计 · 交互设计(方向层 + 细节层)· API 契约 · 同步引擎设计
- docs/upstream:sk-key 接口实证(430 行)· 接口范围(9 个接口)
- README(全景入口)· AGENTS.md(AI 协作者指引)
- 废弃旧稿已删除(不随仓库留存)
2026-09-14 23:54:52 +08:00
64 changed files with 4505 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# 环境变量样例 —— 复制为 .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
+11
View File
@@ -6,9 +6,20 @@
.vscode/
*.swp
# Node / Bun
node_modules/
dist/
*.tsbuildinfo
# 日志
*.log
# 实测数据样例(未脱敏,不入库)
/api*.json
# AI 工具运行目录(Reasonix)
.reasonix/
# 环境变量 / 密钥(不要提交到仓库)
.env
.env.*
+42
View File
@@ -0,0 +1,42 @@
# 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/`)
- 脚手架已落地:前后端独立部署、后端单进程(API + 同步引擎同进程、单实例)
## 命令
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(开发期)
```
- 环境变量:复制 `.env.example` 为 `.env` 并填值(`DATABASE_URL` / `MANAGE_TOKEN` / `KEYS_MASTER_KEY` / `NEWAPI_BASE`)
- 目录速记:前端 `apps/web/src/{api,router,views}` · 后端 `apps/server/src/{db,routes,sync,lib}` · 契约类型在 `packages/shared`
- 开发期前端 `/api` 由 Vite 代理到 :3000(`apps/web/vite.config.ts`);生产由同域反代承担(见 README 工程注记)
+100
View File
@@ -0,0 +1,100 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到本地数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 状态 | 实现起步(脚手架已落地:`apps/web` · `apps/server` · `packages/shared`;设计文档全部定稿) |
| 技术栈 | Bun + Hono + PostgreSQL · 前端 Vite + Vue 3 + naive-ui |
| 部署 | 实例运营方提供的服务器;交付方式为推送仓库 / 产物,由运营方执行部署 |
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:字段、语义与决策理由 | 定稿(2026-09-11;09-13 修订 key 登记与表名、新增 `disabled_source`) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、去重与写入策略 | 定稿(2026-09-11) |
| [`docs/design/purchases表字段设计.md`](docs/design/purchases表字段设计.md) | 数据模型第三张表:购买记录(实付人民币的唯一落点、折算链第二条) | 定稿(2026-09-13;同日修订查看面金额口径) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 定稿(2026-09-13) |
| [`docs/design/交互设计.md`](docs/design/交互设计.md) | 界面设计:方向层 + 细节层(跨页共性、逐页细节、状态与文案) | 定稿(2026-09-13) |
| [`docs/design/API契约.md`](docs/design/API契约.md) | 前端 ↔ 服务端接口契约:约定层 + 逐页端点(前端实现看这篇) | 定稿(2026-09-13) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 采集侧引擎:调度、每轮动作、失败处理、可观测、部署初始化(后端实现看这篇) | 定稿(2026-09-13) |
| [`docs/design/实施计划.md`](docs/design/实施计划.md) | 开发推进方案:loop 模式 + 八阶段验收标准 + 进度表 | 定稿(2026-09-14;进度随 loop 更新) |
| [`docs/upstream/NewAPI-sk-key接口文档.md`](docs/upstream/NewAPI-sk-key接口文档.md) | 查 sk-key 可用接口的字段级事实(源码实证,430 行) | 事实依据 |
| [`docs/upstream/NewAPI-接口范围.md`](docs/upstream/NewAPI-接口范围.md) | 本项目对接的接口范围(9 个接口的用途与鉴权) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/upstream/`(NewAPI 的实证事实,不做设计决策)· `docs/deprecated/`(**已废弃的历史文档,只存档、不引用、不作依据**)· 未来的 `docs/delivery/`(给部署方看的部署文档)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(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. **实现**:脚手架已落地(命令见 [`AGENTS.md`](AGENTS.md))——按 [`docs/design/API契约.md`](docs/design/API契约.md) 实现 11 个端点(查看面 `/usage-log` 先行),按 [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) 实现采集引擎
2. **部署前确认清单**(开工前向实例运营方确认):服务器 OS / PostgreSQL 实例连接方式 / 有无反代与**能否配置 `/api` 反代规则** / Bun 可安装性 / **后端必须单实例运行**(同步引擎前提)
3. 后议功能:充值折算、多订阅实测等见两张折算表设计文档的「待定事项」
+2
View File
@@ -0,0 +1,2 @@
# deps
node_modules/
+11
View File
@@ -0,0 +1,11 @@
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 ?? '',
},
})
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@naku/server",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.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"
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 数据库连接(Bun 原生 SQL + Drizzle)。
* 懒初始化:首次调用 `getDb()` 时才建连接——骨架阶段缺 DATABASE_URL 也能起服务。
*/
import { drizzle, type BunSQLDatabase } from 'drizzle-orm/bun-sql'
import { env } from '../lib/env'
import * as schema from './schema'
let db: BunSQLDatabase<typeof schema> | null = null
export function getDb(): BunSQLDatabase<typeof schema> {
if (!db) {
if (!env.databaseUrl) {
throw new Error('缺少环境变量 DATABASE_URL(见 .env.example)')
}
db = drizzle(env.databaseUrl, { schema })
}
return db
}
export { schema }
+120
View File
@@ -0,0 +1,120 @@
/**
* 数据库 schema(Drizzle ORM)—— 逐列对应 `docs/design/` 的四张表字段设计:
* `api_keys`(17 列)· `usage_logs`(23 列)· `purchases`(9 列)· `qpu_history`(3 列)。
* TS 侧字段用 camelCase,列名保持 snake_case(与 API 字段同名同形,契约 约定 3)。
*/
import {
bigint,
bigserial,
boolean,
customType,
index,
integer,
jsonb,
numeric,
pgTable,
smallint,
text,
timestamp,
} from 'drizzle-orm/pg-core'
/** PostgreSQL bytea(Drizzle 无内置;用于 `key_ciphertext`) */
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
dataType() {
return 'bytea'
},
})
/** 第一张表:API key 登记表(api_keys 表字段设计,17 列) */
export const apiKeys = pgTable('api_keys', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 登记时 HMAC-SHA256(命中判定,不可逆) */
keyHash: text('key_hash').notNull().unique(),
/** 登记时 AES-256-GCM(nonce + 密文 + tag 打包;唯一用途是调上游) */
keyCiphertext: bytea('key_ciphertext').notNull(),
/** active | disabled */
status: text('status').notNull().default('active'),
/** manual | auto_401 | null(区分「已失效」与「已停用」) */
disabledSource: text('disabled_source'),
/** 连续 401 计数:401 时 +1、成功清零、其他失败不动 */
consecutive401s: integer('consecutive_401s').notNull().default(0),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
/** 「数据截止」的唯一来源(取请求发起时刻) */
logSyncedAt: timestamp('log_synced_at', { withTimezone: true }),
usageSyncedAt: timestamp('usage_synced_at', { withTimezone: true }),
// —— 上游字段(GET /api/usage/token,整组一起写) ——
tokenName: text('token_name').notNull(),
totalGranted: bigint('total_granted', { mode: 'number' }).notNull().default(0),
totalUsed: bigint('total_used', { mode: 'number' }).notNull().default(0),
totalAvailable: bigint('total_available', { mode: 'number' }).notNull().default(0),
unlimitedQuota: boolean('unlimited_quota').notNull().default(false),
modelLimits: jsonb('model_limits').notNull().default({}),
modelLimitsEnabled: boolean('model_limits_enabled').notNull().default(false),
/** 上游 0 = 永不过期 → NULL */
expiresAt: timestamp('expires_at', { withTimezone: true }),
})
/** 第二张表:调用日志明细(usage_logs 表字段设计,23 列) */
export const usageLogs = pgTable(
'usage_logs',
{
id: bigserial('id', { mode: 'number' }).primaryKey(),
keyId: bigint('key_id', { mode: 'number' })
.notNull()
.references(() => apiKeys.id),
/** 去重唯一键:`${key_id}:${request_id}`;request_id 空时退化为内容指纹 */
dedupeKey: text('dedupe_key').notNull().unique(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
type: smallint('type').notNull(),
content: text('content').notNull().default(''),
username: text('username').notNull(),
/** 日志快照名(展示取当前名的口径见 API 契约 §2.1) */
tokenName: text('token_name').notNull(),
modelName: text('model_name').notNull(),
quota: bigint('quota', { mode: 'number' }).notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
useTime: integer('use_time').notNull(),
isStream: boolean('is_stream').notNull(),
channel: integer('channel').notNull(),
channelName: text('channel_name').notNull().default(''),
tokenId: bigint('token_id', { mode: 'number' }).notNull(),
groupName: text('group_name').notNull().default('default'),
ip: text('ip').notNull().default(''),
requestId: text('request_id').notNull(),
upstreamRequestId: text('upstream_request_id'),
other: jsonb('other').notNull().default({}),
},
(t) => [
index('usage_logs_key_created_idx').on(t.keyId, t.createdAt.desc()),
index('usage_logs_created_idx').on(t.createdAt.desc()),
],
)
/** 第三张表:购买记录(purchases 表字段设计,9 列;全人工登记) */
export const purchases = pgTable('purchases', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** subscription | topup */
kind: text('kind').notNull(),
paidAt: timestamp('paid_at', { withTimezone: true }).notNull(),
/** 实付人民币(唯一「只有作者知道」的信息) */
paidAmount: numeric('paid_amount', { mode: 'number' }).notNull(),
note: text('note'),
/** 期间左闭右开 [period_start, period_end);未结束可空 */
periodStart: timestamp('period_start', { withTimezone: true }),
periodEnd: timestamp('period_end', { withTimezone: true }),
/** 上游订阅实例 ID(与日志 other.subscription_id 匹配) */
subscriptionId: bigint('subscription_id', { mode: 'number' }),
/** 本笔额度(折算链的分母) */
quotaGranted: bigint('quota_granted', { mode: 'number' }),
})
/** 第四张表:换算参数时间线(qpu_history 表字段设计,3 列;纯只读,唯一写入者是同步引擎) */
export const qpuHistory = pgTable('qpu_history', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 当时的 quota_per_unit 值 */
qpu: numeric('qpu', { mode: 'number' }).notNull(),
/** 生效时间;正常全表仅 1 行(出厂默认值从未变过) */
effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull(),
})
+37
View File
@@ -0,0 +1,37 @@
/**
* 服务入口: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()
console.log(`[server] Hono 已就绪,监听 :${env.port}`)
export default {
port: env.port,
fetch: app.fetch,
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 环境变量(部署时经 env 注入;全量清单见仓库根 `.env.example`)。
* 缺关键变量时在「首次使用处」报错,不在进程启动时硬崩(便于本地起骨架)。
*/
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 ?? '',
}
export { required }
+18
View File
@@ -0,0 +1,18 @@
import { createMiddleware } from 'hono/factory'
import { env } from '../lib/env'
/**
* 管理面口令门帘(契约 约定 6):每次请求校验 `X-Manage-Token`。
* 无登录端点、无令牌签发;常量时间比较等加固归技术稿(API 契约 §3)。
*/
export const manageAuth = createMiddleware(async (c, next) => {
const token = c.req.header('x-manage-token')
if (!env.manageToken || token !== env.manageToken) {
return c.json(
{ error: { code: 'manage_unauthorized', message: '口令无效' } },
401,
)
}
await next()
})
+50
View File
@@ -0,0 +1,50 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · key 管理(契约 §2.2):清单 / 登记 / 启停。
* 清单与 `api_keys` 表 1:1——`key_hash` / `key_ciphertext` 两个敏感列永不出接口。
*/
export const apiKeysRoutes = new Hono()
apiKeysRoutes.use(manageAuth)
/** 清单(按 id 升序,不分页) */
apiKeysRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:key 清单(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
/** 登记:body `{ key }`;上游验证通过 → 入库(不阻塞首轮同步) */
apiKeysRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记 key(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
/** 启停:body `{ status: 'active' | 'disabled' }`;启用需上游验证、成功清零 consecutive_401s */
apiKeysRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:启停(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
@@ -0,0 +1,76 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · 购买记录(契约 §2.3):清单 / 登记 / 编辑 / 删除 / 观测(核对提示)。
* 登记必填:`kind` / `paid_at` / `paid_amount` / `quota_granted`。
*/
export const purchasesRoutes = new Hono()
purchasesRoutes.use(manageAuth)
/** 清单(按 paid_at DESC,同时间按 id DESC;不分页) */
purchasesRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:purchases 清单(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 观测(核对提示的数据面):按日志 other.subscription_id 聚合 */
purchasesRoutes.get('/observations', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:观测(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 登记 */
purchasesRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 编辑(部分更新) */
purchasesRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:编辑购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 删除(物理) */
purchasesRoutes.delete('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:删除购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · 换算参数时间线(契约 §2.4):纯只读——`qpu_history` 唯一写入者是同步引擎。
*/
export const qpuRoutes = new Hono()
qpuRoutes.use(manageAuth)
/** 时间线清单(按 effective_from 升序;不分页) */
qpuRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:QPU 时间线(见 docs/design/API契约.md §2.4)',
},
},
501,
),
)
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from 'hono'
/**
* 查看面(契约 §2.1):无会话、无服务端状态——key 集合与过滤参数全在请求体。
* 两个端点:`/logs/query`(明细,分页)· `/summary`(卡片汇总);首次并行请求,翻页只调明细。
*/
export const usageRoutes = new Hono()
usageRoutes.post('/logs/query', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:明细查询(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
usageRoutes.post('/summary', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:卡片汇总(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
+45
View File
@@ -0,0 +1,45 @@
/**
* 同步引擎(见 `docs/design/同步引擎设计.md`)——骨架占位。
*
* 已定骨架(§3.2 / §3.3 / §3.4):
* - 后端进程内定时器(不引 worker、不拆进程)
* - 周期 5 分钟,固定 tick + 防重入
* - 启动立即跑一轮
* - 单轮串行遍历全部 active key
* - 每轮:日志(整批单事务入库 → log_synced_at 取请求发起时刻)· 额度(上游字段全组 + usage_synced_at)· QPU 采样(每轮一次、值变化才追加)
* - 失败:401 计数(N=3 摘除)· 403/500/网络不计数 · 不单轮重试
*/
const TICK_MS = 5 * 60 * 1000
let running = false
/** 一轮:遍历全部 active key(串行)→ 日志 / 额度;每轮一次 QPU 采样 */
async function runOnce(): Promise<void> {
// TODO(#5): 日志同步 —— GET /api/log/token → 整批单事务入库(ON CONFLICT (dedupe_key) DO NOTHING)→ 写 log_synced_at
// TODO(#6): 额度采集 —— GET /api/usage/token → 上游字段全组 + usage_synced_at(与日志各自独立)
// TODO(#7): QPU 采样 —— GET /api/status → 值变化才追加 qpu_history(每轮一次,独立于 key 循环)
}
async function tick(): Promise<void> {
if (running) {
console.log('[sync] 上一轮未结束,跳过本轮(防重入)')
return
}
running = true
try {
await runOnce()
console.log('[sync] 轮次完成(骨架占位:暂无实际采集动作)')
} catch (err) {
console.error('[sync] 轮次异常', err)
} finally {
running = false
}
}
/** 启动引擎:立即跑一轮,随后每 5 分钟一次 */
export function startSyncEngine(): void {
console.log('[sync] 引擎启动:立即跑一轮,随后每 5 分钟一次')
void tick()
setInterval(() => void tick(), TICK_MS)
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+32
View File
@@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@naku/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@naku/shared": "workspace:*",
"naive-ui": "^2.45.3",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/eslint-plugin": "^1.6.23",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.73.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^9.0.2",
"oxlint": "~1.74.0",
"prettier": "3.9.5",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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