Compare commits
5
Commits
init
...
1295667c94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1295667c94 | ||
|
|
dcf7a31c09 | ||
|
|
30e71beb09 | ||
|
|
138a1b7685 | ||
|
|
45b312b357 |
@@ -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
@@ -6,9 +6,20 @@
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# Node / Bun
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
# 实测数据样例(未脱敏,不入库)
|
||||
/api*.json
|
||||
|
||||
# AI 工具运行目录(Reasonix)
|
||||
.reasonix/
|
||||
|
||||
# 环境变量 / 密钥(不要提交到仓库)
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -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 工程注记)
|
||||
@@ -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. 后议功能:充值折算、多订阅实测等见两张折算表设计文档的「待定事项」
|
||||
@@ -0,0 +1,2 @@
|
||||
# deps
|
||||
node_modules/
|
||||
@@ -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 ?? '',
|
||||
},
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 数据库连接(Bun 原生 SQL + Drizzle)。
|
||||
* 懒初始化:首次调用 `getDb()` 时才建连接——骨架阶段缺 DATABASE_URL 也能起服务。
|
||||
*/
|
||||
import { drizzle, type BunSQLDatabase } from 'drizzle-orm/bun-sql'
|
||||
import { env } from '../lib/env'
|
||||
import * as schema from './schema'
|
||||
|
||||
let db: BunSQLDatabase<typeof schema> | null = null
|
||||
|
||||
export function getDb(): BunSQLDatabase<typeof schema> {
|
||||
if (!db) {
|
||||
if (!env.databaseUrl) {
|
||||
throw new Error('缺少环境变量 DATABASE_URL(见 .env.example)')
|
||||
}
|
||||
db = drizzle(env.databaseUrl, { schema })
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
export { schema }
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 数据库 schema(Drizzle ORM)—— 逐列对应 `docs/design/` 的四张表字段设计:
|
||||
* `api_keys`(17 列)· `usage_logs`(23 列)· `purchases`(9 列)· `qpu_history`(3 列)。
|
||||
* TS 侧字段用 camelCase,列名保持 snake_case(与 API 字段同名同形,契约 约定 3)。
|
||||
*/
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
customType,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
pgTable,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
|
||||
/** PostgreSQL bytea(Drizzle 无内置;用于 `key_ciphertext`) */
|
||||
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
|
||||
dataType() {
|
||||
return 'bytea'
|
||||
},
|
||||
})
|
||||
|
||||
/** 第一张表:API key 登记表(api_keys 表字段设计,17 列) */
|
||||
export const apiKeys = pgTable('api_keys', {
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
/** 登记时 HMAC-SHA256(命中判定,不可逆) */
|
||||
keyHash: text('key_hash').notNull().unique(),
|
||||
/** 登记时 AES-256-GCM(nonce + 密文 + tag 打包;唯一用途是调上游) */
|
||||
keyCiphertext: bytea('key_ciphertext').notNull(),
|
||||
/** active | disabled */
|
||||
status: text('status').notNull().default('active'),
|
||||
/** manual | auto_401 | null(区分「已失效」与「已停用」) */
|
||||
disabledSource: text('disabled_source'),
|
||||
/** 连续 401 计数:401 时 +1、成功清零、其他失败不动 */
|
||||
consecutive401s: integer('consecutive_401s').notNull().default(0),
|
||||
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
/** 「数据截止」的唯一来源(取请求发起时刻) */
|
||||
logSyncedAt: timestamp('log_synced_at', { withTimezone: true }),
|
||||
usageSyncedAt: timestamp('usage_synced_at', { withTimezone: true }),
|
||||
// —— 上游字段(GET /api/usage/token,整组一起写) ——
|
||||
tokenName: text('token_name').notNull(),
|
||||
totalGranted: bigint('total_granted', { mode: 'number' }).notNull().default(0),
|
||||
totalUsed: bigint('total_used', { mode: 'number' }).notNull().default(0),
|
||||
totalAvailable: bigint('total_available', { mode: 'number' }).notNull().default(0),
|
||||
unlimitedQuota: boolean('unlimited_quota').notNull().default(false),
|
||||
modelLimits: jsonb('model_limits').notNull().default({}),
|
||||
modelLimitsEnabled: boolean('model_limits_enabled').notNull().default(false),
|
||||
/** 上游 0 = 永不过期 → NULL */
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
})
|
||||
|
||||
/** 第二张表:调用日志明细(usage_logs 表字段设计,23 列) */
|
||||
export const usageLogs = pgTable(
|
||||
'usage_logs',
|
||||
{
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
keyId: bigint('key_id', { mode: 'number' })
|
||||
.notNull()
|
||||
.references(() => apiKeys.id),
|
||||
/** 去重唯一键:`${key_id}:${request_id}`;request_id 空时退化为内容指纹 */
|
||||
dedupeKey: text('dedupe_key').notNull().unique(),
|
||||
userId: bigint('user_id', { mode: 'number' }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
type: smallint('type').notNull(),
|
||||
content: text('content').notNull().default(''),
|
||||
username: text('username').notNull(),
|
||||
/** 日志快照名(展示取当前名的口径见 API 契约 §2.1) */
|
||||
tokenName: text('token_name').notNull(),
|
||||
modelName: text('model_name').notNull(),
|
||||
quota: bigint('quota', { mode: 'number' }).notNull(),
|
||||
promptTokens: integer('prompt_tokens').notNull(),
|
||||
completionTokens: integer('completion_tokens').notNull(),
|
||||
useTime: integer('use_time').notNull(),
|
||||
isStream: boolean('is_stream').notNull(),
|
||||
channel: integer('channel').notNull(),
|
||||
channelName: text('channel_name').notNull().default(''),
|
||||
tokenId: bigint('token_id', { mode: 'number' }).notNull(),
|
||||
groupName: text('group_name').notNull().default('default'),
|
||||
ip: text('ip').notNull().default(''),
|
||||
requestId: text('request_id').notNull(),
|
||||
upstreamRequestId: text('upstream_request_id'),
|
||||
other: jsonb('other').notNull().default({}),
|
||||
},
|
||||
(t) => [
|
||||
index('usage_logs_key_created_idx').on(t.keyId, t.createdAt.desc()),
|
||||
index('usage_logs_created_idx').on(t.createdAt.desc()),
|
||||
],
|
||||
)
|
||||
|
||||
/** 第三张表:购买记录(purchases 表字段设计,9 列;全人工登记) */
|
||||
export const purchases = pgTable('purchases', {
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
/** subscription | topup */
|
||||
kind: text('kind').notNull(),
|
||||
paidAt: timestamp('paid_at', { withTimezone: true }).notNull(),
|
||||
/** 实付人民币(唯一「只有作者知道」的信息) */
|
||||
paidAmount: numeric('paid_amount', { mode: 'number' }).notNull(),
|
||||
note: text('note'),
|
||||
/** 期间左闭右开 [period_start, period_end);未结束可空 */
|
||||
periodStart: timestamp('period_start', { withTimezone: true }),
|
||||
periodEnd: timestamp('period_end', { withTimezone: true }),
|
||||
/** 上游订阅实例 ID(与日志 other.subscription_id 匹配) */
|
||||
subscriptionId: bigint('subscription_id', { mode: 'number' }),
|
||||
/** 本笔额度(折算链的分母) */
|
||||
quotaGranted: bigint('quota_granted', { mode: 'number' }),
|
||||
})
|
||||
|
||||
/** 第四张表:换算参数时间线(qpu_history 表字段设计,3 列;纯只读,唯一写入者是同步引擎) */
|
||||
export const qpuHistory = pgTable('qpu_history', {
|
||||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||||
/** 当时的 quota_per_unit 值 */
|
||||
qpu: numeric('qpu', { mode: 'number' }).notNull(),
|
||||
/** 生效时间;正常全表仅 1 行(出厂默认值从未变过) */
|
||||
effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull(),
|
||||
})
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
|
||||
import { env } from '../lib/env'
|
||||
|
||||
/**
|
||||
* 管理面口令门帘(契约 约定 6):每次请求校验 `X-Manage-Token`。
|
||||
* 无登录端点、无令牌签发;常量时间比较等加固归技术稿(API 契约 §3)。
|
||||
*/
|
||||
export const manageAuth = createMiddleware(async (c, next) => {
|
||||
const token = c.req.header('x-manage-token')
|
||||
if (!env.manageToken || token !== env.manageToken) {
|
||||
return c.json(
|
||||
{ error: { code: 'manage_unauthorized', message: '口令无效' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
await next()
|
||||
})
|
||||
@@ -0,0 +1,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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["bun"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,32 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
|
||||
skipFormatting,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@naku/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s \"lint:*\"",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@naku/shared": "workspace:*",
|
||||
"naive-ui": "^2.45.3",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitest/eslint-plugin": "^1.6.23",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.73.0",
|
||||
"eslint-plugin-vue": "~10.9.2",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"oxlint": "~1.74.0",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "~6.0.0",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-eslint-parser": "^10.4.1",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.18.0 || >=24.12.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,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>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import router from '@/router'
|
||||
|
||||
describe('router', () => {
|
||||
it('路由表包含查看面与管理面三页', () => {
|
||||
const paths = router.getRoutes().map((r) => r.path)
|
||||
for (const p of ['/usage-log', '/manage/api-keys', '/manage/purchases', '/manage/qpu']) {
|
||||
expect(paths).toContain(p)
|
||||
}
|
||||
})
|
||||
|
||||
it(
|
||||
'根路径重定向到 /usage-log(真实导航;首次含懒加载组件转换,放宽超时)',
|
||||
async () => {
|
||||
await router.push('/')
|
||||
expect(router.currentRoute.value.path).toBe('/usage-log')
|
||||
},
|
||||
20_000,
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* API 客户端(契约 约定 20 / 24):
|
||||
* - 成功 = 裸数据体;失败 = `{ error: { code, message } }`(非 2xx)
|
||||
* - 管理面请求自动附加 `X-Manage-Token`(存 sessionStorage,关标签即忘)
|
||||
* - 401 `manage_unauthorized` → 通知口令门帘(ManageLayout 监听)
|
||||
*/
|
||||
import { MANAGE_TOKEN_HEADER, type ApiErrorBody } from '@naku/shared'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
const MANAGE_TOKEN_KEY = 'naku:manage-token'
|
||||
|
||||
export function getManageToken(): string {
|
||||
return sessionStorage.getItem(MANAGE_TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setManageToken(token: string): void {
|
||||
sessionStorage.setItem(MANAGE_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearManageToken(): void {
|
||||
sessionStorage.removeItem(MANAGE_TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 口令失效通知(订阅返回取消函数) */
|
||||
const unauthorizedListeners = new Set<() => void>()
|
||||
|
||||
export function onManageUnauthorized(fn: () => void): () => void {
|
||||
unauthorizedListeners.add(fn)
|
||||
return () => {
|
||||
unauthorizedListeners.delete(fn)
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
/** 管理面请求:附加 X-Manage-Token */
|
||||
manage?: boolean
|
||||
}
|
||||
|
||||
export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, manage = false } = options
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (body !== undefined) headers['content-type'] = 'application/json'
|
||||
if (manage) headers[MANAGE_TOKEN_HEADER] = getManageToken()
|
||||
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// 失败体未必是我们的 JSON(反代 502 / 网关错误页)——兜底解析
|
||||
const parsed = (await res.json().catch(() => null)) as ApiErrorBody | null
|
||||
const code = parsed?.error?.code ?? 'unknown'
|
||||
const message = parsed?.error?.message ?? `HTTP ${res.status}`
|
||||
if (manage && res.status === 401 && code === 'manage_unauthorized') {
|
||||
unauthorizedListeners.forEach((fn) => fn())
|
||||
}
|
||||
throw new ApiError(code, res.status, message)
|
||||
}
|
||||
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
// ---- 查看面(无会话;key 集合随请求体,契约 约定 5 / 19) ----
|
||||
|
||||
export const api = {
|
||||
usageLogsQuery: <T>(body: unknown) => request<T>('/api/usage/logs/query', { method: 'POST', body }),
|
||||
usageSummary: <T>(body: unknown) => request<T>('/api/usage/summary', { method: 'POST', body }),
|
||||
|
||||
// ---- 管理面(manage: true 自动带口令) ----
|
||||
apiKeysList: <T>() => request<T>('/api/manage/api-keys', { manage: true }),
|
||||
apiKeyRegister: <T>(body: unknown) =>
|
||||
request<T>('/api/manage/api-keys', { method: 'POST', body, manage: true }),
|
||||
apiKeyPatch: <T>(id: number, body: unknown) =>
|
||||
request<T>(`/api/manage/api-keys/${id}`, { method: 'PATCH', body, manage: true }),
|
||||
|
||||
purchasesList: <T>() => request<T>('/api/manage/purchases', { manage: true }),
|
||||
purchasesCreate: <T>(body: unknown) =>
|
||||
request<T>('/api/manage/purchases', { method: 'POST', body, manage: true }),
|
||||
purchasesPatch: <T>(id: number, body: unknown) =>
|
||||
request<T>(`/api/manage/purchases/${id}`, { method: 'PATCH', body, manage: true }),
|
||||
purchasesDelete: <T>(id: number) =>
|
||||
request<T>(`/api/manage/purchases/${id}`, { method: 'DELETE', manage: true }),
|
||||
purchasesObservations: <T>() => request<T>('/api/manage/purchases/observations', { manage: true }),
|
||||
|
||||
qpuList: <T>() => request<T>('/api/manage/qpu', { manage: true }),
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
/* oxlint-disable */
|
||||
/* oxfmt-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const useDialog: typeof import('naive-ui').useDialog
|
||||
const useLoadingBar: typeof import('naive-ui').useLoadingBar
|
||||
const useMessage: typeof import('naive-ui').useMessage
|
||||
const useNotification: typeof import('naive-ui').useNotification
|
||||
}
|
||||
Vendored
+30
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
/**
|
||||
* 路由(交互设计 §0.1 / §1):
|
||||
* - 无前缀 = 查看面(一期仅 `/usage-log`,`/` 重定向到它;二期候选 `/dashboard`)
|
||||
* - `/manage` 前缀 = 管理面(口令门帘由 ManageLayout 承担;入口在查看面不暴露)
|
||||
*/
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/usage-log' },
|
||||
{
|
||||
path: '/usage-log',
|
||||
name: 'usage-log',
|
||||
component: () => import('@/views/UsageLogView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/manage',
|
||||
component: () => import('@/views/manage/ManageLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/manage/api-keys' },
|
||||
{
|
||||
path: 'api-keys',
|
||||
name: 'manage-api-keys',
|
||||
component: () => import('@/views/manage/ApiKeysView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'purchases',
|
||||
name: 'manage-purchases',
|
||||
component: () => import('@/views/manage/PurchasesView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'qpu',
|
||||
name: 'manage-qpu',
|
||||
component: () => import('@/views/manage/QpuView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Path mapping for cleaner imports.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||
// Bundler mode provides a smoother developer experience.
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||
"types": ["node"],
|
||||
|
||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||
"noEmit": true,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
|
||||
// Override to include only test files and clear exclusions.
|
||||
// Application code imported in tests is automatically included via module resolution.
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
|
||||
"compilerOptions": {
|
||||
// Vitest runs in a different environment than the application code.
|
||||
// Adjust lib and types accordingly.
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"],
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { defineConfig } from 'vite'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
// naive-ui 按需引入(交互设计 §2 #3)
|
||||
AutoImport({
|
||||
imports: [
|
||||
{
|
||||
'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'],
|
||||
},
|
||||
],
|
||||
dts: 'src/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()],
|
||||
dts: 'src/components.d.ts',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// 开发期把 /api 代理到后端;生产由同域反代承担(见 README 工程注记)
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
# 本地开发用 PostgreSQL(仅开发;生产用实例运营方的 PG 实例)
|
||||
# 起库:docker compose up -d 查看:docker compose logs -f db
|
||||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
container_name: naku-db
|
||||
environment:
|
||||
POSTGRES_USER: naku
|
||||
POSTGRES_PASSWORD: naku-dev
|
||||
POSTGRES_DB: naku
|
||||
ports:
|
||||
# 宿主端口用 5433,避开本机可能已有的 5432
|
||||
- '5433:5432'
|
||||
volumes:
|
||||
- naku-db-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U naku -d naku']
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
naku-db-data:
|
||||
@@ -0,0 +1,10 @@
|
||||
# 已废弃文档归档
|
||||
|
||||
> **本目录只作存档**:其中的文档均已作废,**不要引用、不要作为任何设计依据**。
|
||||
|
||||
- 现行文档一律以 [`docs/design/`](../design/)(我们定的方案与决策)与 [`docs/upstream/`](../upstream/)(NewAPI 实证事实)为准,入口见仓库根 [`README.md`](../../README.md) 的「文档地图」
|
||||
- 本目录不随项目演进维护;仅供追溯历史
|
||||
|
||||
## 归档清单
|
||||
|
||||
(当前无归档文档)
|
||||
@@ -0,0 +1,272 @@
|
||||
# API 契约
|
||||
|
||||
> 前端 ↔ 服务端的接口契约:**约定层**(跨页共性)+ **逐页端点**(按 `docs/design/交互设计.md` 的页面清单推导)。只描述「前端能用到的 HTTP 接口」;上游侧调用不在本文(见 `docs/upstream/NewAPI-接口范围.md`)。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 定稿(2026-09-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` 边界) | 技术稿 |
|
||||
@@ -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` |
|
||||
@@ -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 ↔ 已录入记录比对)的交互 | 交互设计 |
|
||||
@@ -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. 待定事项(关联后续环节)
|
||||
|
||||
| 事项 | 归属 |
|
||||
|------|------|
|
||||
| 采样调度(读取频率、失败重试) | 同步引擎设计 |
|
||||
@@ -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`) | 出现真实分析需求时再评估 |
|
||||
| 数据保留策略(是否清理旧数据) | 后议 |
|
||||
@@ -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 → 输入框下方「口令不正确」;不做「记住我」
|
||||
@@ -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`)
|
||||
@@ -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)必须写明;本地开发也不要起两个后端
|
||||
@@ -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
|
||||
@@ -0,0 +1,93 @@
|
||||
# NewAPI 接口范围
|
||||
|
||||
> new-api-key-usage 使用的 NewAPI 接口范围:本项目对接的全部 9 个接口,含鉴权档位与各自对本项目的用途——本项目所有上游调用都出自这份范围。与 `NewAPI-sk-key接口文档.md` 的分工:那边是「sk-key 能访问什么」的源码实证全集(430 行,字段级细节);本文是「本项目实际使用哪些接口」的范围清单。
|
||||
>
|
||||
> 整理日期:2026-09-09 · 重定位:2026-09-11 · 2026-09-13 增补 §3(范围之外)与 ④ 行为事实 · 依据:NewAPI 源码考证 + 接口实测 · 实测环境:NewAPI 实例
|
||||
|
||||
---
|
||||
|
||||
## 1. 鉴权三态总览
|
||||
|
||||
本项目对接的接口按鉴权中间件分三档:
|
||||
|
||||
| 鉴权档 | 中间件 | 语义 | 本项目的请求方式 |
|
||||
|--------|--------|------|----------------|
|
||||
| **公开**(none) | 无 / `HeaderNavModulePublicOrUserAuth` | 不持有任何凭证即可访问 | 直接请求,无需 key |
|
||||
| **严格**(strict) | `TokenAuth` | 校验 key 有效、未过期、有剩余额度;无效直接被拒 | 携带 sk-key 请求 |
|
||||
| **宽松**(lenient) | `TokenAuthReadOnly` | 只验 key 存在且用户未封禁;**过期/额度耗尽仍可查**,但**显式禁用(Disabled)会被 401 拒绝** | 携带 sk-key 请求;**本项目主要依赖此档** |
|
||||
|
||||
> 宽松档的「显式禁用被拒」是源码考证的补充结论(`middleware/auth.go` 的 `TokenAuthReadOnly`),影响本项目的 401 摘除逻辑:收到 401 应区分「key 被禁用」(摘除)与「瞬时故障」(重试)。
|
||||
>
|
||||
> 失败状态码全集(2026-09-13 源码复核):**401** = key 不存在(`MsgTokenInvalid`)或 token 被显式禁用(`MsgTokenStatusUnavailable`);**403** = 账号被封禁;**500** = 数据库错误。→ 摘除计数只认 401;403 / 500 视为其他故障,不计入。另:宽松档**允许** key 过期(Expired)与额度耗尽(Exhausted)状态查询,源码注释明确「only explicitly disabled tokens are denied」。
|
||||
|
||||
## 2. 接口清单
|
||||
|
||||
### 公开接口组(无需 sk-key)
|
||||
|
||||
#### ① `GET /api/user/groups` — 用户分组与倍率
|
||||
|
||||
- **返回**:站点配置的用户分组(default / vip 等)与倍率;**未启用分组时 `data` 为空对象**(当前站点实测即此状态)
|
||||
- **用途**:解释「同一模型为什么计费不同」——分组倍率影响同一模型的实际消耗
|
||||
- **本项目用途**:低优先级;仅当站点启用分组倍率后才有内容
|
||||
|
||||
#### ② `GET /api/perf-metrics/summary` — 全站模型服务质量
|
||||
|
||||
- **参数**:`hours`(统计窗口小时数,默认 24)
|
||||
- **返回**:每个模型的 `avg_latency_ms`(平均延迟)、`success_rate`(成功率 %)、`avg_tps`(每秒 token 数)、`recent_success_series`(按小时采样的成功率时间序列);明细版 `/api/perf-metrics?model=xxx`(model 必填,另支持 group/hours)
|
||||
- **鉴权细节**:`HeaderNavModulePublicOrUserAuth("pricing")`——默认公开,管理员可为 pricing 模块开启 requireAuth
|
||||
- **实测**:HTTP 200,含站点真实模型的服务质量数据
|
||||
- **本项目用途**:**高**——本工具独有视角。单个 sk-key 日志看不到全局服务质量;使用者反馈「慢/报错多」时,此接口能定位是模型问题还是自身问题。技术设计时可作为「模型健康度」功能的数据源
|
||||
|
||||
#### ③ `GET /api/rankings` — 全站模型用量排行
|
||||
|
||||
- **参数**:`period`(统计周期,默认 week)
|
||||
- **返回**:每个模型的 `rank`(名次)、`previous_rank`(上期名次)、`total_tokens`(总用量)、`share`(份额)、`growth_pct`(环比增长 %)
|
||||
- **实测**:HTTP 200,14KB 数据
|
||||
- **本项目用途**:中——项目作者选「主推模型」(便宜且稳定)的数据依据;配合②的成功率可识别衰退模型
|
||||
|
||||
#### ④ `GET /api/status` — 站点配置与计费参数
|
||||
|
||||
- **返回**:60+ 字段,与本项目相关的计费字段:`quota_per_unit`(**换算比率当前生效值,管理员可改**)、`quota_display_type`(USD/CNY/TOKENS,影响 billing 接口语义)、`usd_exchange_rate`、`price` 等
|
||||
- **本项目用途**:**关键**——换算比率的唯一权威来源。`QuotaPerUnit = 500000` 只是 NewAPI 默认值(源码 `common/constants.go:22`,`$0.002 / 1K tokens` 锚点),实现时必须从本接口实时读取、不得硬编码;同时感知 `quota_display_type` 切换(若切到 CNY/TOKENS,billing 接口数字语义会变)
|
||||
- **补充:`quota_per_unit` 的行为事实(2026-09-13 源码评估)**:它由全局变量承载,改设置是「先写库、再热更新内存」(`model/option.go` 的 `UpdateOption` → `updateOptionMap` 的 `case "QuotaPerUnit"`)——改后**立即全站生效、无需重启**。三条关键行为:
|
||||
- **无历史**:`options` 表一 key 一行、改即覆盖——任何接口都读不到过去的取值;管理审计只记"键名被改过"、**不记值**
|
||||
- **无编辑入口**:后台设置页该字段默认隐藏且只读(`web/src/features/system-settings/general/pricing-section.tsx`),修改只能走 API
|
||||
- **解读跟随当前值**:一切对外的金额换算(历史记录解读、billing 接口、界面显示)都在读取时实时使用当前值——改它 = 全站金额表达整体平移(数据库里的 quota 整数不受影响)
|
||||
|
||||
### 严格鉴权组(TokenAuth,sk-key)
|
||||
|
||||
#### ⑤ `GET /v1/dashboard/billing/subscription` — 总额度上限(美元)
|
||||
|
||||
- 返回 `hard_limit_usd` 等;无限额度 key 返回 `100000000`(一亿占位值,无参考意义)
|
||||
- 受 `DisplayTokenStatEnabled` 与 `quota_display_type` 影响;与⑥相减可得剩余额度
|
||||
- **本项目用途**:中——按 key 统计表的「累计剩余」列候选数据源
|
||||
|
||||
#### ⑥ `GET /v1/dashboard/billing/usage` — 累计已用(美分)
|
||||
|
||||
- 返回 `total_usage`(美分,÷100 = 美元);可与⑧的 `total_used ÷ quota_per_unit` 交叉对账(不一致 = 站点改过比率,见④)
|
||||
- **本项目用途**:中——同上,对账与校验用途
|
||||
|
||||
#### ⑦ `GET /v1/models` — 可用模型列表
|
||||
|
||||
- 数据源是数据库(渠道实际配置);与 `/v1/models/:model` 单查接口数据源不同
|
||||
- **本项目用途**:低——调试期验证渠道配置用
|
||||
|
||||
### 宽松鉴权组(TokenAuthReadOnly,sk-key)
|
||||
|
||||
#### ⑧ `GET /api/usage/token` — token 级用量汇总(原始 quota)
|
||||
|
||||
- 返回 `total_used`(累计,非当月)、`total_granted`、`total_available`、`unlimited_quota`
|
||||
- **本项目用途**:**高**——按 key 统计表「全周期累计(已用/剩余)」两列的直接数据源;key 耗尽后仍可查(用完也能对账)
|
||||
|
||||
#### ⑨ `GET /api/log/token` — 最近 1000 条调用日志
|
||||
|
||||
- **硬上限 1000 条**(`common.MaxRecentItems = 1000`)、不支持任何参数、最新在前;响应里的 `id` 是每次从 1 重编的展示序号,**跨请求无意义,去重必须用 `request_id`**
|
||||
- 大响应:pretty-print 后 2~4MB——本项目同步引擎需直面的单次响应体积
|
||||
- **本项目用途**:**核心**——增量同步引擎的唯一数据源;本地攒全量后,任意时间范围的用量/明细/模型统计都是本地聚合查询
|
||||
|
||||
## 3. 范围之外(sk-key 不可达)
|
||||
|
||||
1. **账号级统计接口**(如 `/api/log/self/stat`,支持时间范围 + quota 汇总):需账号登录态,sk-key 访问 401——本项目以「本地攒日志 + 聚合」替代
|
||||
2. **订阅信息**(订阅编号 / 生效期间 / 额度):**没有免 auth 的接口可直接查询**(作者实测,2026-09-13);登录态的**「钱包」页面**可见——显示订阅编号(形如「订阅 #N」)、状态(生效中 / 已过期 + 过期时间)、总额度与已用 / 剩余:
|
||||
- 订阅编号**推测**与日志 `other.subscription_id` 为同一体系(实现期以真实数据核对)
|
||||
- 对应设计:订阅信息由购买者手工录入(见 `docs/design/purchases表字段设计.md`)
|
||||
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@naku/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 契约常量 —— 与 `docs/design/API契约.md` 保持同步。
|
||||
*/
|
||||
|
||||
/** 明细分页:默认每页条数(契约 §2.1 请求参数) */
|
||||
export const LOGS_DEFAULT_LIMIT = 50
|
||||
|
||||
/** 明细分页:每页条数上限 */
|
||||
export const LOGS_MAX_LIMIT = 200
|
||||
|
||||
/** 每页条数选择器的可选值 */
|
||||
export const LOGS_LIMIT_OPTIONS = [20, 50, 100, 200] as const
|
||||
|
||||
/** 单次查询的 key 集合上限(初值,技术稿可调) */
|
||||
export const KEYS_MAX = 20
|
||||
|
||||
/** 日志类型(上游 model/log.go;常量注释明确「刻意不用 iota」,枚举值永不改变) */
|
||||
export const LOG_TYPE = {
|
||||
Unknown: 0,
|
||||
Topup: 1,
|
||||
Consume: 2,
|
||||
Manage: 3,
|
||||
System: 4,
|
||||
Error: 5,
|
||||
Refund: 6,
|
||||
Login: 7,
|
||||
} as const
|
||||
|
||||
/** 日志类型的中文标签(展示层用;未知值原样显示数字) */
|
||||
export const LOG_TYPE_LABEL: Record<number, string> = {
|
||||
[LOG_TYPE.Unknown]: '未知',
|
||||
[LOG_TYPE.Topup]: '充值',
|
||||
[LOG_TYPE.Consume]: '消费',
|
||||
[LOG_TYPE.Manage]: '管理',
|
||||
[LOG_TYPE.System]: '系统',
|
||||
[LOG_TYPE.Error]: '错误',
|
||||
[LOG_TYPE.Refund]: '退款',
|
||||
[LOG_TYPE.Login]: '登录',
|
||||
}
|
||||
|
||||
/** 额度美元换算的出厂默认(仅兜底展示;实际取值一律从 qpu_history 时间线来) */
|
||||
export const DEFAULT_QUOTA_PER_UNIT = 500000
|
||||
|
||||
/** 管理面请求头(契约 约定 6) */
|
||||
export const MANAGE_TOKEN_HEADER = 'x-manage-token'
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './types'
|
||||
export * from './constants'
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* API 契约类型 —— 与 `docs/design/API契约.md` 保持同步(全链路 snake_case,契约 约定 3)。
|
||||
* 字段集稳定:可空列返回 `null` 而非省略(约定「字段集稳定」)。
|
||||
*/
|
||||
|
||||
// ---------- 通用:错误与元信息 ----------
|
||||
|
||||
/** 错误码枚举(契约 约定 21) */
|
||||
export type ApiErrorCode =
|
||||
| 'invalid_params'
|
||||
| 'manage_unauthorized'
|
||||
| 'not_found'
|
||||
| 'internal'
|
||||
| 'key_rejected'
|
||||
| 'already_registered'
|
||||
| 'upstream_unavailable'
|
||||
|
||||
/** 失败响应体(约定 20:非 2xx + error 信封) */
|
||||
export interface ApiErrorBody {
|
||||
error: {
|
||||
code: ApiErrorCode | (string & {})
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看面查询响应统一携带的 meta(约定 22) */
|
||||
export interface QueryMetaKeyRegistered {
|
||||
key_id: number
|
||||
token_name: string
|
||||
registered: true
|
||||
status: 'active' | 'disabled'
|
||||
/** 该 key 自己的同步进度(新登记未首同步时为 null) */
|
||||
log_synced_at: string | null
|
||||
}
|
||||
|
||||
export interface QueryMetaKeyUnregistered {
|
||||
registered: false
|
||||
/** 即时生成、不落库:`sk-…a1b2` */
|
||||
key_hint: string
|
||||
}
|
||||
|
||||
export type QueryMetaKey = QueryMetaKeyRegistered | QueryMetaKeyUnregistered
|
||||
|
||||
export interface QueryMeta {
|
||||
/** 参与 key 中最慢的 log_synced_at(取非 null 的最慢值);全部未同步时为 null */
|
||||
data_until: string | null
|
||||
served_at: string
|
||||
keys: QueryMetaKey[]
|
||||
}
|
||||
|
||||
// ---------- /usage-log ----------
|
||||
|
||||
/** 日志类型(上游 model/log.go 的稳定枚举,照存不过滤) */
|
||||
export type LogType = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
/** 日志行的 `other`(实测 26 键中的常用项;完整键集合见 docs/upstream 实测记录) */
|
||||
export interface LogOther {
|
||||
billing_source?: 'subscription' | 'wallet'
|
||||
cache_tokens?: number
|
||||
model_ratio?: number
|
||||
request_path?: string
|
||||
subscription_id?: number
|
||||
subscription_plan_title?: string
|
||||
subscription_total?: number
|
||||
subscription_used?: number
|
||||
subscription_remain?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 明细行 = `usage_logs` 表 1:1(除内部列 `dedupe_key`)+ 计算字段 */
|
||||
export interface UsageLogRow {
|
||||
id: number
|
||||
key_id: number
|
||||
user_id: number
|
||||
username: string
|
||||
created_at: string
|
||||
type: LogType
|
||||
content: string
|
||||
/** 当前名(join api_keys),非日志快照名 */
|
||||
token_name: string
|
||||
model_name: string
|
||||
quota: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
use_time: number
|
||||
is_stream: boolean
|
||||
channel: number
|
||||
channel_name: string
|
||||
token_id: number
|
||||
group_name: string
|
||||
ip: string
|
||||
request_id: string
|
||||
upstream_request_id: string | null
|
||||
/** 该行折算实付人民币(错误行 quota = 0 → 0;匹配不到归属购买时为 null——未登记 / 充值来源) */
|
||||
amount_rmb: number | null
|
||||
other: LogOther
|
||||
}
|
||||
|
||||
export interface LogsQueryRequest {
|
||||
/** 必填;去重后最多 KEYS_MAX 个 */
|
||||
keys: string[]
|
||||
/** 必填;ISO 8601(输出 +08:00,输入宽容),左闭右开 [start, end) */
|
||||
start: string
|
||||
end: string
|
||||
/** 可选;缺省 = 全部类型;只作用于本端点 */
|
||||
type?: number[]
|
||||
/** 可选;默认 50,上限 200 */
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface LogsQueryResponse {
|
||||
items: UsageLogRow[]
|
||||
total: number
|
||||
meta: QueryMeta
|
||||
}
|
||||
|
||||
export interface SummaryRequest {
|
||||
keys: string[]
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export interface SummaryResponse {
|
||||
request_count: number
|
||||
consume_count: number
|
||||
error_count: number
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
cache_tokens: number
|
||||
/** quota 合计(原值,供核对) */
|
||||
quota: number
|
||||
/** 可折算部分的实付人民币合计(跨期按购买期间分段累加) */
|
||||
amount_rmb: number
|
||||
/** 无法折算的行数(`amount_rmb` 为 `null` 的行——未登记购买 / 充值来源) */
|
||||
unpriced_count: number
|
||||
meta: QueryMeta
|
||||
}
|
||||
|
||||
// ---------- /manage/api-keys ----------
|
||||
|
||||
/** 清单行 = `api_keys` 表 1:1(除 key_hash / key_ciphertext 两个永不出接口的敏感列) */
|
||||
export interface ApiKeyRow {
|
||||
id: number
|
||||
token_name: string
|
||||
status: 'active' | 'disabled'
|
||||
/** 停用来源(可辨「已失效」与「已停用」) */
|
||||
disabled_source: 'manual' | 'auto_401' | null
|
||||
consecutive_401s: number
|
||||
registered_at: string
|
||||
log_synced_at: string | null
|
||||
usage_synced_at: string | null
|
||||
total_granted: number
|
||||
total_used: number
|
||||
total_available: number
|
||||
unlimited_quota: boolean
|
||||
model_limits: Record<string, unknown>
|
||||
model_limits_enabled: boolean
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface RegisterApiKeyRequest {
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface PatchApiKeyRequest {
|
||||
status: 'active' | 'disabled'
|
||||
}
|
||||
|
||||
// ---------- /manage/purchases ----------
|
||||
|
||||
export type PurchaseKind = 'subscription' | 'topup'
|
||||
|
||||
/** 清单行 = `purchases` 表 1:1(9 列) */
|
||||
export interface PurchaseRow {
|
||||
id: number
|
||||
kind: PurchaseKind
|
||||
paid_at: string
|
||||
paid_amount: number
|
||||
note: string | null
|
||||
period_start: string | null
|
||||
period_end: string | null
|
||||
subscription_id: number | null
|
||||
quota_granted: number | null
|
||||
}
|
||||
|
||||
/** 登记请求(必填:kind / paid_at / paid_amount / quota_granted) */
|
||||
export interface PurchaseCreateRequest {
|
||||
kind: PurchaseKind
|
||||
paid_at: string
|
||||
paid_amount: number
|
||||
quota_granted: number
|
||||
note?: string | null
|
||||
period_start?: string | null
|
||||
period_end?: string | null
|
||||
subscription_id?: number | null
|
||||
}
|
||||
|
||||
/** 编辑请求:部分更新语义(提交哪些改哪些) */
|
||||
export type PurchasePatchRequest = Partial<PurchaseCreateRequest>
|
||||
|
||||
/** 观测项(核对提示的数据面) */
|
||||
export interface ObservationItem {
|
||||
subscription_id: number
|
||||
plan_title: string | null
|
||||
observed_quota_total: number | null
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
log_count: number
|
||||
/** null = 观测到但未登记(补录引导的目标) */
|
||||
matched_purchase_id: number | null
|
||||
}
|
||||
|
||||
export interface ObservationsResponse {
|
||||
items: ObservationItem[]
|
||||
}
|
||||
|
||||
// ---------- /manage/qpu ----------
|
||||
|
||||
/** 时间线行 = `qpu_history` 表 1:1(3 列,纯只读) */
|
||||
export interface QpuRow {
|
||||
id: number
|
||||
qpu: number
|
||||
effective_from: string
|
||||
}
|
||||
Reference in New Issue
Block a user