Compare commits

..
15 Commits
Author SHA1 Message Date
skycurtain 9ecf817df2 docs: 清理骨架搭建指南并校准 README 进度表述 2026-10-07 13:30:33 +08:00
skycurtain d47cb5543c feat(engine): 实现同步引擎与 engine_status 快照
apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样;
server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
2026-10-07 12:54:13 +08:00
skycurtain 3e12db1706 docs: 同步引擎设计定稿,API 时刻改为 Unix 秒
独立 apps/engine、setTimeout 轮次、engine_status 快照、不为 CFW 预留;
公共约定 §4 响应时刻统一 Unix 秒;契约 engine 增 sync_interval_ms。
2026-10-07 12:53:37 +08:00
skycurtain de48e5d39c perf: 去掉 handler 内层重复鉴权
鉴权只保留各路由 r.use 一次,受保护请求少一轮会话查库。
2026-10-06 13:19:53 +08:00
skycurtain 54ea686f44 fix: server 审查修复——member_id 校验、访问日志 finally、静默初始化并发
非法 member_id 不再以 NaN 入库;HTTP 访问日志覆盖异常路径;setup-state 静默建管理员遇唯一冲突视为已初始化。
2026-10-06 12:55:18 +08:00
skycurtain fa5b3a8942 fix: apps/server 继承根 tsconfig,消除 allowImportingTsExtensions 报错
包内相对导入均为 .ts 扩展名,包级配置须与根一致(extends + allowImportingTsExtensions)。
2026-10-06 12:14:38 +08:00
skycurtain 7ab9d32495 chore: 运行日志统一中文表述
访问日志改为自管中间件(弃 hono/logger 英文格式),启动与未捕获错误文案中文化。
2026-10-06 12:04:32 +08:00
skycurtain 867bb7d566 docs: 命令登记与 SK_KEY_ENCRYPTION_SECRET 命名对齐
AGENTS 命令表、会话留档约定;骨架搭建指南与设计文档键名对齐代码。
2026-10-06 01:30:52 +08:00
skycurtain a2fa755b62 feat(web): antdv-next §3.7 组件能力 spike
create-vue + antdv-next 覆盖侧边栏/展开行/对话框/范围选择/徽章等验收清单。
2026-10-06 01:30:52 +08:00
skycurtain 6454f4acfa feat(server): 管理面与成员视图 API + Scalar 在线文档
契约 19 路径全量:引导/会话/改密、engine 桩、keys、overview/logs、payments、
qpu、members、audit、成员视图。鉴权优先、422 信封、登录节流、上游验证接线。
2026-10-06 01:30:52 +08:00
skycurtain 6980d09750 feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。
db:八表 Drizzle schema + 仓储 + drizzle 配置。
2026-10-06 01:30:51 +08:00
skycurtain 4c55c3f15f chore: monorepo 工作区与开发环境
Bun workspaces + 根 tsconfig、开发用 PostgreSQL compose、.env.example(端口 18456、SK_KEY_ENCRYPTION_SECRET 等)。
2026-10-06 01:30:51 +08:00
skycurtain 4566ce8d58 docs: 技术选型与 API 契约定稿——四篇经作者逐篇审阅定稿,仓库转入实现主线 2026-10-03 19:35:02 +08:00
skycurtain db20dcb611 docs: 交互设计定稿——八篇按功能分篇,经作者逐篇审阅定稿
- 八篇:总览与共性 / 登录与会话 / 全员总览 / key 管理 / 支付记录 / 成员管理 / 审计日志 / 成员视图
- 关键定案:R12 携带形态(来源徽章 + 展开行,两种空值场景区分)、纯用量模式(部门实例——无支付记录的完整子集,共性 §3.8)、响应式只做布局响应、组件能力需求清单(选型验收标准)、审计日志保留操作者列(前向兼容多管理员)、Key 与成员管理分页 + 「+ 新建成员」缝合流程
- 审阅期修订全记录见需求清单 §9「交互设计八篇定稿」
2026-10-01 23:58:28 +08:00
skycurtain e4d25526f3 docs: 设计阶段定稿——需求清单(15 项全定案)与七篇表设计定稿
- 需求清单:独立计费平台定位,R1-R15 全部定案(12 项裁决 + R10 裁撤 + R3/R4 延后),裁决档案含访问主体模型(作者登录体系 + 成员长期令牌)、分摊哲学(平台不做价值判断只提供事实服务)、双形态交付(完整前后端先行,CFW 抽取版后续)
- 数据模型定稿(一表一文档):api_keys(16 列,401 状态机裁撤——引擎记录不决策)/ usage_logs(25 列,billing_source 与 subscription_id 提列,非消费行去重含 content 指纹)/ payments(币种无关支付记录,只存事实)/ qpu_history(按日志时刻取值)/ members(成员实体与长期令牌)/ users(空库双通道初始化与重置)/ sessions(服务端会话,否决 JWT)
- 接口参考:NewAPI-接口范围融合字段级实证,定位 docs/references/(计费平台可用接口参考,唯一上游参考)
- README / AGENTS:新版设计语境(项目沿革、重开设计动因、协作约定、文档自立原则)
2026-10-01 14:54:06 +08:00
90 changed files with 9166 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# ── new-api-key-usage · 本地环境变量示例 ──────────────────────────
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
# 本服务端口(避开 3000/3001 等常见占用)
PORT=18456
# PostgreSQL 连接串(开发 compose 默认如下)
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
# 生成示例(PowerShell):
# $r=[Security.Cryptography.RandomNumberGenerator]::Create();$b=New-Object byte[] 32;$r.GetBytes($b);[Convert]::ToBase64String($b)
# ⚠ 入库后不可更换;丢失 = 存量 key_ciphertext 无法解密。与库同等备份。
SK_KEY_ENCRYPTION_SECRET=
# 上游 NewAPI 实例根地址(不是本服务)
NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
COOKIE_SECURE=false
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME=
# ADMIN_INIT_PASSWORD=
+10
View File
@@ -13,3 +13,13 @@
.env .env
.env.* .env.*
!.env.example !.env.example
# 依赖与构建产物
node_modules/
dist/
build/
.turbo/
# 测试与工具
coverage/
*.tsbuildinfo
+40
View File
@@ -0,0 +1,40 @@
# AGENTS.md
给 AI 协作者的仓库指引。**开始任何工作前,先读 [`README.md`](README.md)**——它是全景入口:项目是什么、为什么长这样、走到哪了。
## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录,如 `v1-设计-*.md`)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关;`v1-设计-1.md` 为历史对话原档,允许保留旧术语,不作现行依据
## 项目速览(速记)
- **`design`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:四张基线表(`api_keys` · `usage_logs` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
## 命令
Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun` 需在 PowerShell 7(`pwsh`)里跑**——mise shim 由 pwsh profile 激活;Windows PowerShell 5.1 会报 command not found,改用 `pwsh -c "…"` 或绝对路径。
| 命令 | 用途 |
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `docker compose up -d` | 起开发用 PostgreSQL |
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
**默认端口 `18456`**(避开 3000/3001 等本机占用);`NEWAPI_BASE` 是上游 NewAPI 地址,与本服务端口无关。在线文档:`http://127.0.0.1:18456/api/docs`。
+92
View File
@@ -0,0 +1,92 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 当前状态 | **实现推进中**(`design` 分支 = 文档中心 + 实现主线)——设计文档全部定稿(含同步引擎);后端全量落地(core / db / server / engine + Scalar 在线文档),同步引擎已人工验收;**前端仅有 antdv-next 能力 spike,真实页面待开工** |
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 项目沿革
| 阶段 | 形态 | 结局 |
|------|------|------|
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 数据模型新增表:管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古)
## 这是什么项目
- 名字直述用途: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)
- 部署形态已裁决为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 核心需求(基线摘要;完整范围见需求清单)
| # | 需求 | 说明 |
|---|------|------|
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者的全员视角经管理面总览实现——「凭 sk-key 的开放查询」与「多 key 合并查询」机制已裁撤(见需求清单裁决记录) |
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) |
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
## 架构的根本动因:为什么必须攒数据
这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变):
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` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
## 关键事实清单(实测 / 源码确认,设计时不得违反)
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.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 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@new-api-key-usage/engine",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts"
},
"dependencies": {
"@new-api-key-usage/core": "workspace:*",
"@new-api-key-usage/db": "workspace:*"
}
}
+43
View File
@@ -0,0 +1,43 @@
export interface EngineEnv {
DATABASE_URL: string;
SK_KEY_ENCRYPTION_SECRET: string;
NEWAPI_BASE: string;
SYNC_INTERVAL_MS: number;
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
}
function parseLogLevel(
value: string | undefined,
): "debug" | "info" | "warning" | "error" | "fatal" {
const v = (value ?? "info").trim().toLowerCase();
if (
v === "debug" ||
v === "info" ||
v === "warning" ||
v === "error" ||
v === "fatal"
) {
return v;
}
throw new Error("LOG_LEVEL must be debug|info|warning|error|fatal");
}
export function loadEnv(source: Record<string, string | undefined>): EngineEnv {
const databaseUrl = source.DATABASE_URL;
const secret = source.SK_KEY_ENCRYPTION_SECRET;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
if (!secret) throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
const interval = Number(source.SYNC_INTERVAL_MS ?? 300_000);
if (!Number.isInteger(interval) || interval < 1000) {
throw new Error("SYNC_INTERVAL_MS must be an integer >= 1000");
}
return {
DATABASE_URL: databaseUrl,
SK_KEY_ENCRYPTION_SECRET: secret,
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
SYNC_INTERVAL_MS: interval,
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
};
}
+29
View File
@@ -0,0 +1,29 @@
import { appLogger, setupLogging } from "@new-api-key-usage/core";
import { createDb, createRepos } from "@new-api-key-usage/db";
import { loadEnv } from "./env.ts";
import { createSyncLoop } from "./loop.ts";
const env = loadEnv(process.env);
setupLogging(env.LOG_LEVEL);
const log = appLogger("engine");
const db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
repos,
newapiBase: env.NEWAPI_BASE,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
intervalMs: env.SYNC_INTERVAL_MS,
});
loop.start();
log.info`apps/engine 已就绪`;
process.on("SIGINT", () => {
void loop.stop().then(() => process.exit(0));
});
process.on("SIGTERM", () => {
void loop.stop().then(() => process.exit(0));
});
+75
View File
@@ -0,0 +1,75 @@
/**
* 调度循环(方案 A):一轮结束 → setTimeout 下一枪;inFlight 防重叠。
* 启停不得抹掉已有 last_round。
*/
import { appLogger } from "@new-api-key-usage/core";
import { syncRound, type SyncRoundDeps } from "./sync-round.ts";
const log = appLogger("engine");
export interface LoopOptions extends SyncRoundDeps {
intervalMs: number;
}
export function createSyncLoop(opts: LoopOptions) {
let inFlight = false;
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let last_round: unknown = null;
async function persist(running: boolean) {
await opts.repos.engine.upsertSnapshot({
running,
sync_interval_ms: opts.intervalMs,
last_round,
});
}
async function runOnce(): Promise<void> {
if (stopped) return;
if (inFlight) {
log.warn("上一轮尚未结束,跳过本枪");
schedule();
return;
}
inFlight = true;
try {
last_round = await syncRound(opts);
await persist(!stopped);
} catch (e) {
log.error(`同步轮次异常:${e}`);
await persist(!stopped).catch(() => {});
} finally {
inFlight = false;
schedule();
}
}
function schedule() {
if (stopped) return;
timer = setTimeout(() => {
void runOnce();
}, opts.intervalMs);
}
return {
start() {
log.info(`同步引擎启动:周期=${opts.intervalMs}ms`);
void (async () => {
const snap = await opts.repos.engine.getSnapshot();
last_round = snap?.last_round ?? null;
await persist(true).catch(() => {});
void runOnce();
})();
},
async stop() {
stopped = true;
if (timer) clearTimeout(timer);
// 等在跑的一轮结束,避免 stop 与 upsert 交错
for (let i = 0; i < 50 && inFlight; i++) {
await new Promise((r) => setTimeout(r, 100));
}
await persist(false).catch(() => {});
},
};
}
+174
View File
@@ -0,0 +1,174 @@
/**
* 同步轮次:按《同步引擎设计》§3 编排。
* 只经 packages/core(上游 + 去重)与 packages/db(仓储)写库。
*/
import {
appLogger,
buildDedupeKey,
createUpstreamClient,
KeyCrypto,
parseOtherJson,
toUnixSec,
} from "@new-api-key-usage/core";
import type { Repos } from "@new-api-key-usage/db";
const log = appLogger("engine");
export interface SyncRoundDeps {
repos: Repos;
newapiBase: string;
skKeyEncryptionSecret: string;
}
export interface LastRound {
started_at: number;
keys_total: number;
succeeded: number;
failed: number;
failed_api_key_ids: number[];
}
function parseOther(other: string | undefined): {
billing_source: string | null;
subscription_id: number | null;
raw: Record<string, unknown> | null;
} {
return parseOtherJson(other);
}
export async function syncRound(deps: SyncRoundDeps): Promise<LastRound> {
const started = Date.now();
const started_at = toUnixSec(new Date())!;
const client = createUpstreamClient({ baseUrl: deps.newapiBase });
const crypto = await KeyCrypto.fromEncryptionSecret(
deps.skKeyEncryptionSecret,
);
const keys = await deps.repos.keys.listActive();
const failed_api_key_ids: number[] = [];
let succeeded = 0;
let pulled = 0;
let inserted = 0;
let skipped = 0;
for (const key of keys) {
try {
const sk = await crypto.openKey(key.key_ciphertext);
const usage = await client.fetchTokenUsage(sk);
if (!usage.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=usage/${usage.kind}`);
continue;
}
await deps.repos.keys.update(key.id, {
token_name: usage.data.name ?? key.token_name,
total_granted: usage.data.total_granted,
total_used: usage.data.total_used,
total_available: usage.data.total_available,
unlimited_quota: usage.data.unlimited_quota,
model_limits: usage.data.model_limits,
model_limits_enabled: usage.data.model_limits_enabled,
expires_at: normalizeExpires(usage.data.expires_at),
usage_synced_at: new Date(),
});
const logs = await client.fetchTokenLogs(sk);
if (!logs.ok) {
failed_api_key_ids.push(key.id);
log.warn(`同步失败 key_id=${key.id} 原因=logs/${logs.kind}`);
continue;
}
const rows = [...(logs.data ?? [])].sort((a, b) => {
const ta = a.created_at ?? 0;
const tb = b.created_at ?? 0;
if (ta !== tb) return ta - tb;
return String(a.request_id ?? "").localeCompare(String(b.request_id ?? ""));
});
pulled += rows.length;
for (const row of rows) {
const other = parseOther(row.other);
const created_at = new Date(row.created_at * 1000);
const dedupe_key = await buildDedupeKey({
api_key_id: key.id,
request_id: row.request_id,
type: row.type ?? 2,
created_at: row.created_at,
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
content: row.content ?? "",
});
const ok = await deps.repos.keys.insertLog({
api_key_id: key.id,
dedupe_key,
billing_source: other.billing_source,
subscription_id: other.subscription_id,
user_id: row.user_id ?? 0,
created_at,
type: row.type ?? 2,
content: row.content ?? "",
username: row.username ?? "",
token_name: row.token_name ?? "",
model_name: row.model_name ?? "",
quota: row.quota ?? 0,
prompt_tokens: row.prompt_tokens ?? 0,
completion_tokens: row.completion_tokens ?? 0,
use_time: row.use_time ?? 0,
is_stream: !!row.is_stream,
channel: row.channel ?? 0,
channel_name: row.channel_name ?? "",
token_id: row.token_id ?? 0,
group_name: row.group ?? "",
ip: row.ip ?? "",
request_id: row.request_id ?? "",
upstream_request_id: row.upstream_request_id ?? null,
other: other.raw,
});
if (ok) inserted += 1;
else skipped += 1;
}
await deps.repos.keys.update(key.id, { log_synced_at: new Date() });
succeeded += 1;
} catch (e) {
failed_api_key_ids.push(key.id);
log.error(`同步异常 key_id=${key.id} ${e}`);
}
}
// QPU 采样:值变化才追加
const qpuRes = await client.fetchQuotaPerUnit();
if (qpuRes.ok) {
const latest = await deps.repos.qpu.latest();
const next = qpuRes.data.quota_per_unit;
if (!latest || latest.qpu !== next) {
await deps.repos.qpu.insertChange(next, new Date());
log.info(`QPU 变更 ${latest?.qpu ?? "-"} -> ${next}`);
}
} else {
log.warn(`QPU 采样失败 ${qpuRes.kind}`);
}
const last_round: LastRound = {
started_at,
keys_total: keys.length,
succeeded,
failed: failed_api_key_ids.length,
failed_api_key_ids,
};
log.info(
`同步轮次完成 key=${keys.length} 成功=${succeeded} 失败=${failed_api_key_ids.length} 拉取=${pulled} 新写=${inserted} 去重跳过=${skipped} 耗时=${Date.now() - started}ms`,
);
return last_round;
}
function normalizeExpires(unixSeconds: number | undefined | null): Date | null {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000);
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
# deps
node_modules/
+13
View File
@@ -0,0 +1,13 @@
# apps/server
母体后端(Hono + Bun)。
```sh
bun install
bun run dev
```
默认端口 **18456**(可用 `PORT` 覆盖):
- 在线文档:http://127.0.0.1:18456/api/docs
- OpenAPI:http://127.0.0.1:18456/api/openapi.json
+23
View File
@@ -0,0 +1,23 @@
{
"name": "@new-api-key-usage/server",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"@hono/zod-openapi": "^1.6.3",
"@new-api-key-usage/core": "workspace:*",
"@new-api-key-usage/db": "workspace:*",
"@scalar/hono-api-reference": "^0.12.9",
"hono": "^4.13.13",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "latest"
}
}
+86
View File
@@ -0,0 +1,86 @@
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
engineStatus,
errorEnvelope,
keyItem,
logItem,
logPage,
memberItem,
memberMe,
memberOverview,
memberWithToken,
patchKeyBody,
patchMemberBody,
paymentItem,
auditLogItem,
registerKeyBody,
setupBody,
setupState,
loginBody,
tokenOnly,
usernameBody,
} from "@new-api-key-usage/core";
/** OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 */
function nameSchema<T extends { openapi?: (n: string) => unknown }>(
schema: T,
name: string,
): T {
return typeof schema.openapi === "function"
? (schema.openapi(name) as T)
: schema;
}
export const errorSchema = nameSchema(errorEnvelope, "ErrorEnvelope");
export const setupStateSchema = nameSchema(setupState, "SetupState");
export const engineStatusSchema = nameSchema(engineStatus, "EngineStatus");
export const keyItemSchema = nameSchema(keyItem, "KeyItem");
export const memberItemSchema = nameSchema(memberItem, "MemberItem");
export const memberWithTokenSchema = nameSchema(
memberWithToken,
"MemberWithToken",
);
export const memberMeSchema = nameSchema(memberMe, "MemberMe");
export const memberOverviewSchema = nameSchema(
memberOverview,
"MemberOverview",
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema,
loginBody as loginBodySchema,
patchKeyBody as patchKeyBodySchema,
patchMemberBody as patchMemberBodySchema,
registerKeyBody as registerKeyBodySchema,
setupBody as setupBodySchema,
tokenOnly as tokenOnlySchema,
usernameBody as usernameSchema,
};
export const err401 = {
description: "凭证无效或过期",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err404 = {
description: "资源不存在",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err409 = {
description: "冲突",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err422 = {
description: "校验失败",
content: { "application/json": { schema: errorSchema } },
} as const;
+126
View File
@@ -0,0 +1,126 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { apiReference } from "@scalar/hono-api-reference";
import { ERR, appLogger } from "@new-api-key-usage/core";
import { createRepos, type Db } from "@new-api-key-usage/db";
import { jsonError, type AppVars } from "./middleware.ts";
import { adminSessionRoutes } from "./routes/admin-session.ts";
import { adminEngineRoutes } from "./routes/admin-engine.ts";
import { adminKeyRoutes } from "./routes/admin-keys.ts";
import { adminMemberRoutes } from "./routes/admin-members.ts";
import { adminUsageRoutes } from "./routes/admin-usage.ts";
import { adminPaymentRoutes } from "./routes/admin-payments.ts";
import { adminAuditRoutes } from "./routes/admin-audit.ts";
import { memberViewRoutes } from "./routes/member-view.ts";
export interface CreateAppOptions {
db: Db;
skKeyEncryptionSecret: string;
newapiBase: string;
cookieSecure?: boolean;
adminInitUsername?: string;
adminInitPassword?: string;
/** 可注入的上游验证(测试);默认走 real HTTP */
fetchTokenUsage?: AppVars["fetchTokenUsage"];
}
export function createApp(opts: CreateAppOptions) {
const app = new OpenAPIHono<{ Variables: AppVars }>({
defaultHook: (result, c) => {
if (!result.success) {
const fields: Record<string, string> = {};
for (const issue of result.error.issues) {
const key = issue.path.join(".") || "body";
fields[key] = issue.message;
}
return jsonError(c, 422, ERR.validation_failed(fields));
}
},
});
const repos = createRepos(opts.db);
app.use("*", async (c, next) => {
const start = Date.now();
try {
await next();
} finally {
appLogger("http").info(
`${c.req.method} ${c.req.path} -> ${c.res.status} | ${Date.now() - start}ms`,
);
}
});
app.use("*", async (c, next) => {
c.set("repos", repos);
c.set("skKeyEncryptionSecret", opts.skKeyEncryptionSecret);
c.set("newapiBase", opts.newapiBase);
c.set("cookieSecure", opts.cookieSecure ?? true);
if (opts.fetchTokenUsage) c.set("fetchTokenUsage", opts.fetchTokenUsage);
if (opts.adminInitUsername)
c.set("adminInitUsername", opts.adminInitUsername);
if (opts.adminInitPassword)
c.set("adminInitPassword", opts.adminInitPassword);
await next();
});
app.notFound((c) => {
if (c.req.path.startsWith("/api")) {
return jsonError(c, 404, ERR.not_found());
}
return c.text("Not Found", 404);
});
app.onError((err, c) => {
appLogger("server").error(`未捕获错误 ${c.req.method} ${c.req.path}:${err}`);
return jsonError(c, 500, ERR.internal());
});
app.openAPIRegistry.registerComponent("securitySchemes", "adminSession", {
type: "apiKey",
in: "cookie",
name: "admin_session",
description: "管理面会话 cookie(HttpOnly · Path=/api/admin)",
});
app.openAPIRegistry.registerComponent("securitySchemes", "memberToken", {
type: "http",
scheme: "bearer",
description: "成员视图 Bearer 令牌",
});
app.doc("/api/openapi.json", {
openapi: "3.1.0",
info: {
title: "new-api-key-usage",
version: "0.0.0",
description:
"NewAPI key 用量统计——管理面 `/api/admin/*`(会话 cookie)与成员视图 `/api/member/*`(Bearer)。成功响应无信封,错误见 ErrorEnvelope。",
},
tags: [
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
{ name: "成员视图", description: "令牌自助查询" },
],
});
app.get(
"/api/docs",
apiReference({
theme: "kepler",
spec: { url: "/api/openapi.json" },
}),
);
const admin = new OpenAPIHono<{ Variables: AppVars }>();
admin.route("/", adminSessionRoutes());
admin.route("/", adminEngineRoutes());
admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes());
admin.route("/", adminPaymentRoutes());
admin.route("/", adminAuditRoutes());
app.route("/api/admin", admin);
app.route("/api/member", memberViewRoutes());
return app;
}
+69
View File
@@ -0,0 +1,69 @@
export interface Env {
/** 本服务监听端口(避开 3000/3001 等占用) */
PORT: number;
/** PostgreSQL 连接串 */
DATABASE_URL: string;
/**
* 已登记 sk-key 的加密密钥(≥32 字节 base64url)。
* 经 HKDF 派生 hmac / aes 子密钥;**入库后不可更换**,否则存量 key_ciphertext 无法解密。
*/
SK_KEY_ENCRYPTION_SECRET: string;
/** 上游 NewAPI 实例根地址(非本服务) */
NEWAPI_BASE: string;
/** 会话 cookie 是否带 Secure;默认 true。本地 HTTP 调试可设 false */
COOKIE_SECURE: boolean;
/** 业务日志级别,默认 info */
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
/** 空库 + 二者均存在时静默落库管理员(与引导页双通道) */
ADMIN_INIT_USERNAME?: string;
ADMIN_INIT_PASSWORD?: string;
}
function parseBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined || value === "") return fallback;
const v = value.trim().toLowerCase();
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
throw new Error(`invalid boolean: ${value}`);
}
function parseLogLevel(
value: string | undefined,
): "debug" | "info" | "warning" | "error" | "fatal" {
const v = (value ?? "info").trim().toLowerCase();
if (
v === "debug" ||
v === "info" ||
v === "warning" ||
v === "error" ||
v === "fatal"
) {
return v;
}
throw new Error(`LOG_LEVEL must be debug|info|warning|error|fatal`);
}
export function loadEnv(source: Record<string, string | undefined>): Env {
const databaseUrl = source.DATABASE_URL;
const skKeyEncryptionSecret = source.SK_KEY_ENCRYPTION_SECRET;
if (!databaseUrl) throw new Error("DATABASE_URL is required");
if (!skKeyEncryptionSecret) {
throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
}
const port = Number(source.PORT ?? 18456);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("PORT must be an integer in 1..65535");
}
return {
PORT: port,
DATABASE_URL: databaseUrl,
SK_KEY_ENCRYPTION_SECRET: skKeyEncryptionSecret,
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
COOKIE_SECURE: parseBool(source.COOKIE_SECURE, true),
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
ADMIN_INIT_USERNAME: source.ADMIN_INIT_USERNAME,
ADMIN_INIT_PASSWORD: source.ADMIN_INIT_PASSWORD,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createApp } from "./app.ts";
import { loadEnv } from "./env.ts";
import { createDb } from "@new-api-key-usage/db";
import { appLogger, setupLogging } from "@new-api-key-usage/core";
const env = loadEnv(process.env);
setupLogging(env.LOG_LEVEL);
const db = createDb(env.DATABASE_URL);
const app = createApp({
db,
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
newapiBase: env.NEWAPI_BASE,
cookieSecure: env.COOKIE_SECURE,
adminInitUsername: env.ADMIN_INIT_USERNAME,
adminInitPassword: env.ADMIN_INIT_PASSWORD,
});
appLogger("server").info(
`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL})`,
);
export default {
port: env.PORT,
fetch: app.fetch,
};
+44
View File
@@ -0,0 +1,44 @@
/**
* 登录节流(契约:429,最小实现)
* 同一用户名失败计数 + 固定退避;进程内存即可(单机母体)。
*/
const WINDOW_MS = 5 * 60_000;
const MAX_FAILURES = 5;
const LOCK_MS = 60_000;
type Entry = { count: number; firstAt: number; lockedUntil: number };
const buckets = new Map<string, Entry>();
export function isLoginThrottled(username: string): boolean {
const e = buckets.get(username);
if (!e) return false;
if (e.lockedUntil > Date.now()) return true;
if (Date.now() - e.firstAt > WINDOW_MS) buckets.delete(username);
return false;
}
export function recordLoginFailure(username: string): void {
const now = Date.now();
const e = buckets.get(username);
if (!e || now - e.firstAt > WINDOW_MS) {
buckets.set(username, { count: 1, firstAt: now, lockedUntil: 0 });
return;
}
e.count += 1;
if (e.count >= MAX_FAILURES) {
e.lockedUntil = now + LOCK_MS;
e.count = 0;
e.firstAt = now;
}
}
export function recordLoginSuccess(username: string): void {
buckets.delete(username);
}
/** 测试用 */
export function resetLoginThrottle(): void {
buckets.clear();
}
+63
View File
@@ -0,0 +1,63 @@
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import { ERR, hashToken, type ErrorBody } from "@new-api-key-usage/core";
import type { Repos } from "@new-api-key-usage/db";
export const SESSION_COOKIE = "admin_session";
export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
export type AppVars = {
repos: Repos;
skKeyEncryptionSecret: string;
newapiBase: string;
cookieSecure: boolean;
/** 登记时调上游验证;测试可注入 */
fetchTokenUsage?: (skKey: string) => Promise<
| { ok: true; data: import("@new-api-key-usage/core").TokenUsage }
| { ok: false; kind: string }
>;
adminInitUsername?: string;
adminInitPassword?: string;
adminUsername?: string;
adminUserId?: number;
memberId?: number;
};
/** 错误信封统一出口;返回 any 以兼容 OpenAPIHono 严格响应联合 */
export function jsonError(
c: Context,
status: 400 | 401 | 404 | 409 | 422 | 429 | 500 | 502,
body: ErrorBody,
): any {
return c.json(body, status);
}
export async function requireAdminSession(
c: Context<{ Variables: AppVars }>,
next: Next,
) {
const token = getCookie(c, SESSION_COOKIE);
if (!token) return jsonError(c, 401, ERR.session_expired());
const tokenHash = await hashToken(token);
const row = await c.get("repos").sessions.findValid(tokenHash, new Date());
if (!row) return jsonError(c, 401, ERR.session_expired());
c.set("adminUserId", row.user_id);
c.set("adminUsername", row.username);
await next();
}
export async function requireMemberToken(
c: Context<{ Variables: AppVars }>,
next: Next,
) {
const header = c.req.header("Authorization") ?? "";
const m = /^Bearer\s+(.+)$/i.exec(header);
if (!m?.[1]) return jsonError(c, 401, ERR.session_expired());
const tokenHash = await hashToken(m[1]);
const row = await c.get("repos").members.findByTokenHash(tokenHash);
if (!row || row.status !== "active") {
return jsonError(c, 401, ERR.session_expired());
}
c.set("memberId", row.id);
await next();
}
+70
View File
@@ -0,0 +1,70 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { toUnixSec, type AuditLogItem } from "@new-api-key-usage/core";
import {
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { auditLogItemSchema, err401, err422 } from "../api-schemas.ts";
import { z } from "zod";
const pageSchema = z.object({
items: z.array(auditLogItemSchema),
total: z.number().int(),
});
export function adminAuditRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/audit-logs",
summary: "审计日志",
description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)",
tags: ["审计"],
security: [{ adminSession: [] }],
request: {
query: z.object({
page: z.string().optional(),
page_size: z.string().optional(),
domain: z
.enum(["key", "payment", "member", "account"])
.optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: pageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const page = Math.max(1, Number(q.page ?? 1) || 1);
const page_size = Math.min(
200,
Math.max(1, Number(q.page_size ?? 50) || 50),
);
const { rows, total } = await c.get("repos").audit.listPage({
domain: q.domain,
page,
page_size,
});
const items: AuditLogItem[] = rows.map((row) => ({
id: row.id,
created_at: toUnixSec(row.created_at)!,
operator: row.operator,
domain: row.domain as AuditLogItem["domain"],
action: row.action,
summary: row.summary,
}));
return c.json({ items, total }, 200);
},
);
return r;
}
+51
View File
@@ -0,0 +1,51 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import type { EngineStatus } from "@new-api-key-usage/core";
import { requireAdminSession, type AppVars } from "../middleware.ts";
import { engineStatusSchema, err401 } from "../api-schemas.ts";
export type EngineStatusView = EngineStatus & { sync_interval_ms: number };
export function toEngineStatusView(row: {
running: boolean;
sync_interval_ms: number;
last_round: unknown;
} | null): EngineStatusView {
if (!row) {
return { running: false, sync_interval_ms: 300_000, last_round: null };
}
return {
running: row.running,
sync_interval_ms: row.sync_interval_ms,
last_round: (row.last_round as EngineStatus["last_round"]) ?? null,
};
}
export function adminEngineRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/engine",
summary: "引擎状态",
description:
"同步引擎单行快照(engine_status);last_round 为 null 表示尚未跑过。sync_interval_ms 只读。",
tags: ["引擎"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "status",
content: { "application/json": { schema: engineStatusSchema } },
},
401: err401,
},
}),
async (c) => {
const row = await c.get("repos").engine.getSnapshot();
return c.json(toEngineStatusView(row), 200);
},
);
return r;
}
+325
View File
@@ -0,0 +1,325 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
KeyCrypto,
normalizeExpiresAt,
toUnixSec,
type KeyItem,
} from "@new-api-key-usage/core";
import type { ApiKeyRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
err401,
err404,
err409,
err422,
keyItemSchema,
patchKeyBodySchema,
registerKeyBodySchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
return {
id: row.id,
token_name: row.token_name,
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
total_used: row.total_used,
total_available: row.total_available,
total_granted: row.total_granted,
unlimited_quota: row.unlimited_quota,
expires_at: toUnixSec(row.expires_at),
};
}
export function adminKeyRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
// 鉴权优先于请求体校验(未登录恒 401)
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/keys",
summary: "key 清单",
description:
"全量直返(基数 10–25,不分页)。key_hash / key_ciphertext 永不出 API。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
responses: {
200: {
description: "key 数组",
content: {
"application/json": { schema: z.array(keyItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const q = c.req.query();
const filter: {
member_id?: number | "unassigned";
status?: string;
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
const failed = new Set(
((snap?.last_round as { failed_api_key_ids?: number[] } | null)
?.failed_api_key_ids) ?? [],
);
rows = rows.filter((r) => failed.has(r.id));
}
return c.json(rows.map(toKeyItem), 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/keys",
summary: "登记 key",
description:
"四态:201 成功 / 409 已登记(附 key_id)/ 422 上游拒绝 / 502 上游不可达。查库优先于上游验证。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: registerKeyBodySchema } },
required: true,
},
},
responses: {
201: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
409: err409,
422: err422,
502: {
description: "上游不可达",
content: {
"application/json": {
schema: err422.content["application/json"].schema,
},
},
},
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const sk_key = typeof body?.sk_key === "string" ? body.sk_key.trim() : "";
let member_id: number | null = null;
if (body?.member_id !== undefined && body?.member_id !== null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
member_id = n;
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "请粘贴有效的 sk- 开头的 key",
}),
);
}
const repos = c.get("repos");
const crypto = await KeyCrypto.fromEncryptionSecret(
c.get("skKeyEncryptionSecret"),
);
const key_hash = await crypto.hashKey(sk_key);
const existing = await repos.keys.findByHash(key_hash);
if (existing) {
return jsonError(c, 409, ERR.key_already_registered(existing.id));
}
if (member_id != null) {
const m = await repos.members.findById(member_id);
if (!m) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员不存在" }),
);
}
}
const key_ciphertext = await crypto.sealKey(sk_key);
// 上游验证(登记四态:422 拒绝 / 502 不可达)
const fetchUsage =
c.get("fetchTokenUsage") ??
(async (key: string) => {
const { createUpstreamClient } = await import(
"@new-api-key-usage/core"
);
const client = createUpstreamClient({
baseUrl: c.get("newapiBase"),
});
return client.fetchTokenUsage(key);
});
const upstream = await fetchUsage(sk_key);
if (!upstream.ok) {
if (upstream.kind === "rejected") {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "上游拒绝了这枚 key——可能不存在或已被禁用",
}),
);
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
const usage = upstream.data;
const row = await repos.keys.insert({
key_hash,
key_ciphertext,
member_id,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
if (row.id) {
await repos.keys.update(row.id, {
token_name: usage.name ?? "",
total_granted: usage.total_granted,
total_used: usage.total_used,
total_available: usage.total_available,
unlimited_quota: usage.unlimited_quota,
model_limits: usage.model_limits,
model_limits_enabled: usage.model_limits_enabled,
expires_at: normalizeExpiresAt(usage.expires_at),
usage_synced_at: new Date(),
});
}
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: "key.register",
summary: `登记 key #${row.id}${row.token_name ? ` ${row.token_name}` : ""}`,
});
const fresh = (await repos.keys.findById(row.id)) ?? row;
return c.json(toKeyItem({ ...fresh, member_name: null }), 201);
},
);
r.openapi(
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: patchKeyBodySchema } },
required: true,
},
},
responses: {
200: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const repos = c.get("repos");
const before = await repos.keys.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const patch: Partial<ApiKeyRow> = {};
const actions: Array<{ action: string; summary: string }> = [];
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
patch.status = body.status;
actions.push({
action: body.status === "active" ? "key.enable" : "key.disable",
summary: `key #${before.id} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (body && "member_id" in body) {
let nextMember: number | null = null;
if (body.member_id != null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
nextMember = n;
}
if (nextMember !== before.member_id) {
patch.member_id = nextMember;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 归属变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
const after = await repos.keys.update(id, patch);
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: a.action,
summary: a.summary,
});
}
return c.json(toKeyItem({ ...after, member_name: null }), 200);
},
);
return r;
}
+217
View File
@@ -0,0 +1,217 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { ERR, generateToken, toUnixSec } from "@new-api-key-usage/core";
import type { MemberRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createMemberBodySchema,
err401,
err404,
err409,
err422,
memberItemSchema,
memberWithTokenSchema,
patchMemberBodySchema,
tokenOnlySchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toMemberItem(row: MemberRow, key_count: number) {
return {
id: row.id,
name: row.name,
remark: row.remark,
status: row.status as "active" | "disabled",
token_issued_at: toUnixSec(row.token_issued_at),
created_at: toUnixSec(row.created_at)!,
key_count,
};
}
export function adminMemberRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/members",
summary: "成员清单",
tags: ["成员管理"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "成员数组",
content: {
"application/json": { schema: z.array(memberItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const rows = await repos.members.listAll();
const items = [];
for (const row of rows) {
items.push(toMemberItem(row, await repos.members.countKeys(row.id)));
}
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/members",
summary: "开户(自动签发令牌)",
description: "响应附明文 token——仅此一次;名称冲突 409",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createMemberBodySchema } },
required: true,
},
},
responses: {
201: {
description: "member + 明文 token",
content: { "application/json": { schema: memberWithTokenSchema } },
},
401: err401,
409: err409,
422: err422,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const name = typeof body?.name === "string" ? body.name.trim() : "";
const remark = typeof body?.remark === "string" ? body.remark : "";
if (!name) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "请填写成员名称" }),
);
}
const repos = c.get("repos");
if (await repos.members.findByName(name)) {
return jsonError(c, 409, ERR.member_name_taken());
}
const token = await generateToken();
const row = await repos.members.insert({
name,
remark,
token_hash: token.hash,
token_issued_at: new Date(),
});
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.create",
summary: `开户 ${name}`,
});
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.token_issue",
summary: `签发令牌 ${name}`,
});
return c.json(
{
member: toMemberItem(row, 0),
token: token.plaintext,
},
201,
);
},
);
r.openapi(
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: patchMemberBodySchema } },
required: true,
},
},
responses: {
200: {
description: "成员实体",
content: { "application/json": { schema: memberItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const status = body?.status;
if (status !== "active" && status !== "disabled") {
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
}
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
});
return c.json(toMemberItem(row, 0), 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/members/{id}/token",
summary: "令牌重发",
description: "旧令牌即刻失效;明文仅此响应一次",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
201: {
description: "明文 token",
content: { "application/json": { schema: tokenOnlySchema } },
},
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const token = await generateToken();
await repos.members.reissueToken(id, token.hash, new Date());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: "member.token_reissue",
summary: `重发令牌 ${before.name}`,
});
return c.json({ token: token.plaintext }, 201);
},
);
return r;
}
+287
View File
@@ -0,0 +1,287 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
import { z } from "zod";
const createBody = z.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string().optional().default(""),
period_start: z.string().nullable().optional(),
period_end: z.string().nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
});
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
};
}
function validatePaymentBody(
body: Record<string, unknown>,
):
| { fields: Record<string, string> }
| {
values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
};
} {
const fields: Record<string, string> = {};
const kind = body?.kind;
if (kind !== "subscription" && kind !== "topup") {
fields.kind = "请选择订阅或充值";
return { fields };
}
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
if (typeof body?.paid_amount !== "number") {
fields.paid_amount = "请填写支付金额";
}
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
if (kind === "subscription") {
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
if (body?.subscription_id == null) {
fields.subscription_id = "订阅必须填写订阅编号";
}
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
kind: kind as string,
paid_at: new Date(String(body.paid_at)),
paid_amount: Number(body.paid_amount),
remark: typeof body.remark === "string" ? body.remark : "",
period_start:
kind === "subscription" && body.period_start
? new Date(String(body.period_start))
: null,
period_end:
body.period_end != null && body.period_end !== ""
? new Date(String(body.period_end))
: null,
subscription_id:
kind === "subscription" && body.subscription_id != null
? Number(body.subscription_id)
: null,
quota_granted: Number(body.quota_granted),
},
};
}
export function adminPaymentRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/payments",
summary: "支付记录清单",
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "payment 数组",
content: {
"application/json": { schema: z.array(paymentItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.payments.listAll();
const items = rows.map((row) => {
const qpu = qpuAt(row.paid_at, timeline);
return toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
);
});
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/payments",
summary: "登记支付",
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
201: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.payments.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.create",
summary: `登记支付 #${row.id} ${row.kind}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/payments/{id}",
summary: "编辑支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
200: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.payments.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.payments.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.update",
summary: `编辑支付 #${row.id}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/payments/{id}",
summary: "删除支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "No Content" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.payments.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.payments.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.delete",
summary: `删除支付 #${id}`,
});
return c.body(null, 204);
},
);
return r;
}
+311
View File
@@ -0,0 +1,311 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { setCookie, deleteCookie, getCookie } from "hono/cookie";
import {
ERR,
generateToken,
hashPassword,
hashToken,
verifyPassword,
} from "@new-api-key-usage/core";
import {
jsonError,
requireAdminSession,
SESSION_COOKIE,
SESSION_TTL_SECONDS,
type AppVars,
} from "../middleware.ts";
import {
changePasswordBodySchema,
err401,
err409,
err422,
loginBodySchema,
setupBodySchema,
setupStateSchema,
usernameSchema,
} from "../api-schemas.ts";
import {
isLoginThrottled,
recordLoginFailure,
recordLoginSuccess,
} from "../login-throttle.ts";
function setSessionCookie(
c: Parameters<typeof setCookie>[0],
plaintext: string,
secure: boolean,
) {
setCookie(c, SESSION_COOKIE, plaintext, {
httpOnly: true,
secure,
sameSite: "Lax",
path: "/api/admin",
maxAge: SESSION_TTL_SECONDS,
});
}
/** 哑哈希:用户不存在时也跑一次 PBKDF2,抹平时序侧信道 */
const DUMMY_PASSWORD_HASH =
"pbkdf2$210000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
export function adminSessionRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.openapi(
createRoute({
method: "get",
path: "/setup-state",
summary: "入口三态判定",
description:
"users 非空 → true(登录页);空库 + env 凭证在 → 静默落库后 true;空库 + 无凭证 → false(引导页)",
tags: ["引导与会话"],
responses: {
200: {
description: "initialized",
content: { "application/json": { schema: setupStateSchema } },
},
},
}),
async (c) => {
const repos = c.get("repos");
if ((await repos.users.countAll()) > 0) {
return c.json({ initialized: true }, 200);
}
const initUser = c.get("adminInitUsername");
const initPass = c.get("adminInitPassword");
if (initUser && initPass) {
try {
const password_hash = await hashPassword(initPass);
await repos.users.insert(initUser, password_hash);
await repos.audit.insert({
operator: null,
domain: "account",
action: "account.init",
summary: "空库 env 静默重建管理员",
});
} catch {
// 并发/重复初始化:唯一约束冲突时视为已初始化
}
return c.json({ initialized: true }, 200);
}
return c.json({ initialized: false }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/setup",
summary: "引导页落库(一次性)",
description: "仅空库 + 无 env 凭证时有效;成功即建立会话",
tags: ["引导与会话"],
request: {
body: {
content: { "application/json": { schema: setupBodySchema } },
required: true,
},
},
responses: {
200: {
description: "username + Set-Cookie",
content: { "application/json": { schema: usernameSchema } },
},
409: err409,
422: err422,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username =
typeof body?.username === "string" ? body.username.trim() : "";
const password = typeof body?.password === "string" ? body.password : "";
const fields: Record<string, string> = {};
if (!username) fields.username = "请填写用户名";
if (password.length < 8) fields.password = "密码至少 8 位";
if (Object.keys(fields).length > 0) {
return jsonError(c, 422, ERR.validation_failed(fields));
}
const repos = c.get("repos");
if ((await repos.users.countAll()) > 0) {
return jsonError(c, 409, ERR.already_initialized());
}
const password_hash = await hashPassword(password);
const user = await repos.users.insert(username, password_hash);
const token = await generateToken();
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
await repos.sessions.insert(user.id, token.hash, expires_at);
await repos.audit.insert({
operator: username,
domain: "account",
action: "account.init",
summary: `引导页创建管理员 ${username}`,
});
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
return c.json({ username: user.username }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/session",
summary: "登录",
tags: ["引导与会话"],
request: {
body: {
content: { "application/json": { schema: loginBodySchema } },
required: true,
},
},
responses: {
200: {
description: "username + Set-Cookie",
content: { "application/json": { schema: usernameSchema } },
},
401: err401,
429: {
description: "登录过于频繁",
content: {
"application/json": {
schema: err422.content["application/json"].schema,
},
},
},
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username = typeof body?.username === "string" ? body.username : "";
const password = typeof body?.password === "string" ? body.password : "";
if (isLoginThrottled(username)) {
return jsonError(c, 429, ERR.too_many_requests());
}
const repos = c.get("repos");
const user = await repos.users.findByUsername(username);
const ok = await verifyPassword(
password,
user?.password_hash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !ok) {
recordLoginFailure(username || "_");
return jsonError(c, 401, ERR.invalid_credentials());
}
recordLoginSuccess(username);
const token = await generateToken();
const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000);
await repos.sessions.insert(user.id, token.hash, expires_at);
setSessionCookie(c, token.plaintext, c.get("cookieSecure"));
return c.json({ username: user.username }, 200);
},
);
r.openapi(
createRoute({
method: "get",
path: "/session",
summary: "当前会话",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "username",
content: { "application/json": { schema: usernameSchema } },
},
401: err401,
},
}),
async (c) => {
return c.json({ username: c.get("adminUsername") ?? "" }, 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/session",
summary: "登出",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
responses: {
204: { description: "No Content" },
401: err401,
},
}),
async (c) => {
const token = getCookie(c, SESSION_COOKIE);
if (token) {
await c.get("repos").sessions.deleteByTokenHash(await hashToken(token));
}
deleteCookie(c, SESSION_COOKIE, { path: "/api/admin" });
return c.body(null, 204);
},
);
r.openapi(
createRoute({
method: "put",
path: "/password",
summary: "改密",
description: "成功后删除其他会话、保留当前",
tags: ["引导与会话"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: changePasswordBodySchema } },
required: true,
},
},
responses: {
204: { description: "No Content" },
401: err401,
422: err422,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const old_password = String(body?.old_password ?? "");
const new_password = String(body?.new_password ?? "");
if (new_password.length < 8) {
return jsonError(
c,
422,
ERR.validation_failed({ new_password: "密码至少 8 位" }),
);
}
const repos = c.get("repos");
const userId = c.get("adminUserId")!;
const user = await repos.users.findById(userId);
if (!user) return jsonError(c, 401, ERR.session_expired());
const ok = await verifyPassword(old_password, user.password_hash);
if (!ok) {
return jsonError(
c,
422,
ERR.validation_failed({ old_password: "旧密码不正确" }),
);
}
await repos.users.updatePassword(
userId,
await hashPassword(new_password),
);
const token = getCookie(c, SESSION_COOKIE);
const currentHash = token ? await hashToken(token) : null;
const all = await repos.sessions.listByUser(userId);
for (const s of all) {
if (s.token_hash !== currentHash) {
await repos.sessions.deleteById(s.id);
}
}
await repos.audit.insert({
operator: user.username,
domain: "account",
action: "account.password_change",
summary: "修改密码,其他会话已失效",
});
return c.body(null, 204);
},
);
return r;
}
+350
View File
@@ -0,0 +1,350 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
resolveQpuTimeline,
sumUsage,
toUnixSec,
type LogItem,
} from "@new-api-key-usage/core";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
err401,
err422,
logPageSchema,
} from "../api-schemas.ts";
import { z } from "zod";
const usageTotals = z.object({
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
const overviewSchema = z.object({
data_cutoff: z.number().int().nullable(),
unassigned_subscription: usageTotals,
by_source: z.array(
z.object({
billing_source: z.string(),
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
}),
),
by_member: z.array(
z.object({
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
key_count: z.number().int(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
}),
),
by_key: z.array(
z.object({
key_id: z.number().int(),
token_name: z.string(),
member_id: z.number().int().nullable(),
status: z.string(),
data_cutoff: z.number().int().nullable(),
usage: usageTotals,
}),
),
});
const qpuLatestSchema = z.object({
qpu: z.number(),
effective_from: z.number().int(),
});
const timeRange = z.object({
from: z.string(),
to: z.string(),
});
function parseRange(
query: { from?: string; to?: string },
): { from: Date; to: Date } | { fields: Record<string, string> } {
const fields: Record<string, string> = {};
if (!query.from) fields.from = "缺少起始时间";
if (!query.to) fields.to = "缺少结束时间";
if (Object.keys(fields).length > 0) return { fields };
const from = new Date(query.from!);
const to = new Date(query.to!);
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
if (Object.keys(fields).length > 0) return { fields };
return { from, to };
}
export function adminUsageRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/qpu/latest",
summary: "当前换算率",
description: "支付表单美元 ↔ quota 双输入换算用;库空时回退 500000",
tags: ["用量"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "qpu",
content: { "application/json": { schema: qpuLatestSchema } },
},
401: err401,
},
}),
async (c) => {
const latest = await c.get("repos").qpu.latest();
if (!latest) {
return c.json(
{ qpu: 500_000, effective_from: 0 },
200,
);
}
return c.json(
{
qpu: latest.qpu,
effective_from: toUnixSec(latest.effective_from)!,
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/overview",
summary: "全员总览",
description:
"from/to 必填、左闭右开;聚合仅 type=2。unassigned_subscription 为健康指示器。",
tags: ["用量"],
security: [{ adminSession: [] }],
request: { query: timeRange },
responses: {
200: {
description: "overview",
content: { "application/json": { schema: overviewSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.logs.listForAggregate(range.from, range.to);
const data_cutoff = await repos.keysExtra.slowestLogSyncedAt();
const unassigned_rows = rows.filter(
(x) => x.billing_source === "subscription" && x.subscription_id == null,
);
const unassigned_subscription = sumUsage(unassigned_rows, timeline);
const bySourceMap = new Map<
string,
Array<{ quota: number; created_at: Date }>
>();
for (const x of rows) {
const key = x.billing_source ?? "unknown";
const list = bySourceMap.get(key) ?? [];
list.push({ quota: x.quota, created_at: x.created_at });
bySourceMap.set(key, list);
}
const by_source = [...bySourceMap.entries()].map(([billing_source, list]) => ({
billing_source,
...sumUsage(list, timeline),
}));
// 按 key / 成员(含未归属)
const keyMap = new Map<
number,
{
token_name: string;
member_id: number | null;
member_name: string | null;
status: string;
log_synced_at: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
const keyMeta = await repos.keysExtra.listIdsWithCutoff();
for (const k of keyMeta) {
keyMap.set(k.id, {
token_name: k.token_name,
member_id: k.member_id,
member_name: null,
status: k.status,
log_synced_at: k.log_synced_at,
rows: [],
});
}
for (const x of rows) {
const entry = keyMap.get(x.api_key_id);
if (entry) {
entry.rows.push({ quota: x.quota, created_at: x.created_at });
if (x.member_name) entry.member_name = x.member_name;
}
}
const by_key = [...keyMap.entries()]
.map(([key_id, v]) => ({
key_id,
token_name: v.token_name,
member_id: v.member_id,
status: v.status,
data_cutoff: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
}))
.sort((a, b) => b.usage.quota - a.usage.quota);
const memberBuckets = new Map<
string,
{
member_id: number | null;
member_name: string | null;
key_count: number;
cutoff: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
for (const [key_id, v] of keyMap) {
const mid = v.member_id == null ? "null" : String(v.member_id);
const b = memberBuckets.get(mid) ?? {
member_id: v.member_id,
member_name: v.member_name,
key_count: 0,
cutoff: null,
rows: [],
};
b.key_count += 1;
if (
v.log_synced_at &&
(b.cutoff === null || v.log_synced_at < b.cutoff)
) {
b.cutoff = v.log_synced_at;
}
b.rows.push(...v.rows);
if (!b.member_name && v.member_name) b.member_name = v.member_name;
memberBuckets.set(mid, b);
}
const by_member = [...memberBuckets.values()].map((b) => ({
member_id: b.member_id,
member_name: b.member_name,
key_count: b.key_count,
data_cutoff: toUnixSec(b.cutoff),
usage: sumUsage(b.rows, timeline),
}));
return c.json(
{
data_cutoff: toUnixSec(data_cutoff),
unassigned_subscription,
by_source,
by_member,
by_key,
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/logs",
summary: "日志明细(下钻)",
description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
tags: ["用量"],
security: [{ adminSession: [] }],
request: {
query: timeRange.extend({
page: z.string().optional(),
page_size: z.string().optional(),
api_key_id: z.string().optional(),
member_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const page = Math.max(1, Number(q.page ?? 1) || 1);
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({
from: range.from,
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id: q.member_id ? Number(q.member_id) : undefined,
billing_source: q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
type: q.type != null && q.type !== "" ? Number(q.type) : undefined,
});
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
timeline,
);
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name,
model_name: row.model_name,
type: row.type,
content: row.content,
quota: row.quota,
quota_usd,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
};
});
return c.json({ items, total }, 200);
},
);
return r;
}
+295
View File
@@ -0,0 +1,295 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
resolveQpuTimeline,
sumUsage,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
import {
jsonError,
requireMemberToken,
type AppVars,
} from "../middleware.ts";
import {
err401,
err422,
logPageSchema,
memberMeSchema,
memberOverviewSchema,
paymentItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function parseRange(
query: { from?: string; to?: string },
): { from: Date; to: Date } | { fields: Record<string, string> } {
const fields: Record<string, string> = {};
if (!query.from) fields.from = "缺少起始时间";
if (!query.to) fields.to = "缺少结束时间";
if (Object.keys(fields).length > 0) return { fields };
const from = new Date(query.from!);
const to = new Date(query.to!);
if (Number.isNaN(from.getTime())) fields.from = "时间格式无效";
if (Number.isNaN(to.getTime())) fields.to = "时间格式无效";
else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间";
if (Object.keys(fields).length > 0) return { fields };
return { from, to };
}
/** 成员视图(`/api/member/*`,Bearer)——与管理面物理隔离 */
export function memberViewRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireMemberToken);
r.openapi(
createRoute({
method: "get",
path: "/me",
summary: "令牌验证 + 页头",
description: "不建会话;令牌即长期凭证",
tags: ["成员视图"],
security: [{ memberToken: [] }],
responses: {
200: {
description: "member",
content: { "application/json": { schema: memberMeSchema } },
},
401: err401,
},
}),
async (c) => {
const row = await c.get("repos").members.findById(c.get("memberId")!);
if (!row) return jsonError(c, 401, ERR.session_expired());
return c.json({ id: row.id, name: row.name }, 200);
},
);
r.openapi(
createRoute({
method: "get",
path: "/overview",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
query: z.object({ from: z.string(), to: z.string() }),
},
responses: {
200: {
description: "overview",
content: { "application/json": { schema: memberOverviewSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const range = parseRange(c.req.query());
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const repos = c.get("repos");
const memberId = c.get("memberId")!;
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const member = await repos.members.findById(memberId);
const rows = await repos.logs.listMemberForAggregate(
memberId,
range.from,
range.to,
);
const usage = sumUsage(
rows.map((x) => ({ quota: x.quota, created_at: x.created_at })),
timeline,
);
const sourceMap = new Map<
string,
Array<{ quota: number; created_at: Date }>
>();
const keyMap = new Map<
number,
{
token_name: string;
log_synced_at: Date | null;
rows: Array<{ quota: number; created_at: Date }>;
}
>();
for (const x of rows) {
const skey = x.billing_source ?? "unknown";
const s = sourceMap.get(skey) ?? [];
s.push({ quota: x.quota, created_at: x.created_at });
sourceMap.set(skey, s);
const k = keyMap.get(x.api_key_id) ?? {
token_name: x.token_name,
log_synced_at: x.log_synced_at,
rows: [],
};
k.rows.push({ quota: x.quota, created_at: x.created_at });
keyMap.set(x.api_key_id, k);
}
// 名下 key 全量(即使时段无用量)
const allKeys = (await repos.keys.listAll()).filter(
(k) => k.member_id === memberId,
);
for (const k of allKeys) {
if (!keyMap.has(k.id)) {
keyMap.set(k.id, {
token_name: k.token_name,
log_synced_at: k.log_synced_at,
rows: [],
});
}
}
const by_source = [...sourceMap.entries()].map(([billing_source, list]) => ({
billing_source,
...sumUsage(list, timeline),
}));
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
key_id,
token_name: v.token_name,
log_synced_at: toUnixSec(v.log_synced_at),
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const paymentRows = await repos.payments.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const payments: PaymentItem[] = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const ongoing =
p.kind === "subscription" &&
(p.period_end == null ||
p.period_end > new Date() ||
(p.period_start != null &&
p.period_start <= range.to &&
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline);
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
p.quota_granted != null
? quotaToUsdStr(p.quota_granted, qpu)
: "0.00",
});
}
return c.json(
{
member: { id: memberId, name: member?.name ?? null },
usage,
by_source,
keys,
payments: payments.map((p) => ({
...p,
kind: p.kind as string,
period_start: p.period_start ?? null,
period_end: p.period_end ?? null,
subscription_id: p.subscription_id ?? null,
quota_granted: p.quota_granted ?? null,
})),
},
200,
);
},
);
r.openapi(
createRoute({
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;恒 type=2;DTO 与管理面同构",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
query: z.object({
from: z.string(),
to: z.string(),
page: z.string().optional(),
page_size: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const q = c.req.query();
const range = parseRange(q);
if ("fields" in range) {
return jsonError(c, 422, ERR.validation_failed(range.fields));
}
const page = Math.max(1, Number(q.page ?? 1) || 1);
const page_size = Math.min(
200,
Math.max(1, Number(q.page_size ?? 50) || 50),
);
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({
member_id: c.get("memberId")!,
from: range.from,
to: range.to,
page,
page_size,
});
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
timeline,
);
return {
id: row.id,
api_key_id: row.api_key_id,
created_at: toUnixSec(row.created_at)!,
token_name: row.token_name ?? "",
model_name: row.model_name,
type: row.type,
content: row.content,
quota: row.quota,
quota_usd,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
};
});
return c.json({ items, total }, 200);
},
);
return r;
}
+7
View File
@@ -0,0 +1,7 @@
/** 必须在 core 的 zod schema 被求值之前执行(api-schemas 首行 side-effect import) */
import { z } from "zod";
import { extendZodWithOpenApi } from "@hono/zod-openapi";
extendZodWithOpenApi(z);
export {};
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["bun"],
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+54
View File
@@ -0,0 +1,54 @@
# apps/web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
bun install
```
### Compile and Hot-Reload for Development
```sh
bun dev
```
### Type-Check, Compile and Minify for Production
```sh
bun run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
bun test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
bun lint
```
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+32
View File
@@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>antdv-next §3.7 能力 spike</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
{
"name": "@new-api-key-usage/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": {
"@new-api-key-usage/core": "workspace:*",
"antdv-next": "^1.6.0",
"pinia": "^4.0.3",
"vue": "^3.5.42",
"vue-router": "^5.3.1"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.5",
"@types/jsdom": "^30.0.0",
"@types/node": "^24.13.4",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vitest/eslint-plugin": "^1.6.27",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.5.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.82.0",
"eslint-plugin-vue": "~10.11.0",
"jiti": "^2.7.0",
"jsdom": "^30.0.1",
"npm-run-all2": "^9.0.3",
"oxlint": "~1.82.0",
"prettier": "3.9.6",
"typescript": "~6.0.0",
"vite": "^8.2.2",
"vite-plugin-vue-devtools": "^8.2.1",
"vitest": "^4.1.11",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+168
View File
@@ -0,0 +1,168 @@
<script setup lang="ts">
/**
* antdv-next §3.7 能力 spike——逐项覆盖《交互设计-总览与共性》清单。
* 任一项不覆盖即触发回退(Element Plus / ant-design-vue)并回报作者。
*/
import { h, ref } from "vue";
const collapsed = ref(false);
const drawerOpen = ref(false);
const modalOpen = ref(false);
const confirmLoading = ref(false);
const range = ref<[string, string] | null>(null);
const skKey = ref("");
const quotaMode = ref<"usd" | "quota">("usd");
const page = ref(1);
const dataSource = [
{
key: "1",
token_name: "小张-01",
billing_source: "subscription",
status: "active",
quota: 250000,
quota_usd: "0.50",
},
{
key: "2",
token_name: "小李-02",
billing_source: "wallet",
status: "disabled",
quota: 100000,
quota_usd: "0.20",
},
];
const columns = [
{ title: "名称", dataIndex: "token_name", key: "token_name" },
{
title: "来源",
key: "billing_source",
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
h(
"a-tag",
{ color: record.billing_source === "subscription" ? "blue" : "gold" },
() => (record.billing_source === "subscription" ? "订阅" : "充值"),
),
},
{
title: "状态",
key: "status",
customRender: ({ record }: { record: (typeof dataSource)[0] }) =>
h(
"a-badge",
{
status: record.status === "active" ? "success" : "default",
text: record.status === "active" ? "启用" : "停用",
},
),
},
{ title: "quota", dataIndex: "quota", key: "quota" },
{ title: "额度美元", dataIndex: "quota_usd", key: "quota_usd" },
];
function onCopy() {
// toast:复制反馈
(window as unknown as { antd?: { message?: { success: (m: string) => void } } })
.antd?.message?.success?.("已复制");
}
function onRangeChange(vals: [string, string] | null) {
range.value = vals;
}
</script>
<template>
<a-layout style="min-height: 100vh">
<a-layout-sider v-model:collapsed="collapsed" collapsible>
<div style="color: #fff; padding: 16px; font-weight: 600">
new-api-key-usage
</div>
<a-menu theme="dark" mode="inline" default-selected-keys="['spike']">
<a-menu-item key="spike">能力 Spike</a-menu-item>
<a-menu-item key="overview" disabled>全员总览</a-menu-item>
<a-menu-item key="keys" disabled>Key 管理</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout>
<a-layout-content style="margin: 16px; background: #fff; padding: 16px">
<a-alert
type="info"
show-icon
closable
message="antdv-next §3.7 能力 spike"
description="可折叠侧边栏 / 抽屉 · 密集表格与展开行 · 对话框与二次确认 · message / Alert · DatePicker 范围 · 大号粘贴 · 徽章 / Segmented / Skeleton / Pagination"
style="margin-bottom: 16px"
/>
<a-space direction="vertical" style="width: 100%" :size="16">
<a-space wrap>
<a-button type="primary" @click="drawerOpen = true">打开抽屉</a-button>
<a-button @click="modalOpen = true">打开对话框</a-button>
<a-button @click="onCopy">message.toast</a-button>
<a-popconfirm title="确认停用?" @confirm="onCopy">
<a-button danger>二次确认</a-button>
</a-popconfirm>
</a-space>
<a-space wrap>
<a-range-picker
style="width: 320px"
@change="(_: unknown, vals: [string, string]) => onRangeChange(vals)"
/>
<a-segmented
v-model:value="quotaMode"
:options="[
{ label: '美元', value: 'usd' },
{ label: 'quota', value: 'quota' },
]"
/>
</a-space>
<a-textarea
v-model:value="skKey"
:rows="4"
placeholder="大号粘贴输入框:sk-key 登记对话框用"
/>
<a-table
:columns="columns"
:data-source="dataSource"
:pagination="false"
size="small"
:scroll="{ x: 800 }"
:expand-column-width="48"
>
<template #expandedRowRender="{ record }">
<div style="padding: 8px">
来源徽章展开行:{{ record.billing_source }} ·
<a-tag color="green">订阅 #3</a-tag>
</div>
</template>
</a-table>
<a-pagination v-model:current="page" :total="50" show-size-changer />
<a-skeleton active :paragraph="{ rows: 2 }" />
</a-space>
<a-drawer
v-model:open="drawerOpen"
title="可折叠侧边栏 / 抽屉"
placement="right"
:width="320"
>
<p>抽屉能力:筛选面板 / 详情。</p>
</a-drawer>
<a-modal
v-model:open="modalOpen"
title="对话框"
:confirm-loading="confirmLoading"
@ok="modalOpen = false"
>
<p>对话框 + 二次确认能力。</p>
</a-modal>
</a-layout-content>
</a-layout>
</a-layout>
</template>
+8
View File
@@ -0,0 +1,8 @@
body {
margin: 0;
font-family:
-apple-system,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from "vue";
import Antd from "antdv-next";
import "antdv-next/dist/reset.css";
import App from "./App.vue";
import "./assets/main.css";
const app = createApp(App);
app.use(Antd);
app.mount("#app");
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+28
View File
@@ -0,0 +1,28 @@
// 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",
"allowImportingTsExtensions": true,
// 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"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})
+1346
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
services:
postgres:
image: postgres:17-alpine
container_name: new-api-key-usage-pg
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: new_api_key_usage
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"]
interval: 5s
timeout: 3s
retries: 10
volumes:
pgdata:
+92
View File
@@ -0,0 +1,92 @@
# API契约-公共约定
> 两个世界(管理面 / 成员视图)共用的 HTTP 层约定:路由隔离、鉴权两档、错误信封、时间 / 数值 / 分页 / DTO 命名。本篇是《API契约-管理面》《API契约-成员视图》的引用锚点——跨面共性在此定死,端点逐个定义归后两篇。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 七篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
---
## 1. 总则
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除)
## 2. 路由前缀与隔离机制
| 面 | 路由前缀 | 凭证 | 校验中间件 |
|----|---------|------|-----------|
| 管理面 | `/api/admin/*` | 会话 cookie | sessions 校验 |
| 成员视图 | `/api/member/*` | Bearer 令牌 | members 哈希比对 + `status` 检查 |
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
## 3. 错误信封
统一错误体(成功响应无信封包装,见 §6):
```json
{ "error": { "code": "invalid_credentials", "message": "用户名或密码错误" } }
```
- `message`:面向使用者的中文、说人话(共性 §3.6 文案基调);不携带堆栈与内部细节
- `code`:机器可读枚举(snake_case);表单校验失败可加 `fields`(字段名 → 中文说明);判别已足时允许端点附机器可读附注字段(如重复登记的 `409` 附 `key_id` 定位既有行)
| HTTP 状态 | 语义 | 本项目对应 |
|-----------|------|-----------|
| 200 / 201 | 成功 / 创建成功 | 创建类写操作返 201 |
| 400 | 请求格式错误 | 参数缺漏、JSON 解析失败 |
| 401 | 凭证无效或过期 | 会话过期、令牌停用 / 重发 |
| 404 | 资源不存在 | 非法 id |
| 409 | 冲突 | 重复登记(sk-key 已存在等) |
| 422 | 校验失败 | 表单字段校验(带 `fields`) |
| 429 | 请求过频 | 登录节流(管理面,最小实现) |
| 5xx | 服务端故障 | `message` 统一「服务暂时不可用,请稍后重试」,不泄内部细节 |
- **HTTP 状态码是成功/失败的唯一判别**——客户端永远先看状态码、再解析响应体,不从响应体形状推断成败;错误信封的解析集中在前端唯一的统一请求封装内,各端点代码只碰成功类型;中间人返回的非 JSON 错误体(代理 502 页等)回退为通用文案
- **业务错误即请求失败**——状态码表即业务错误分类表(409 重复登记、422 规则校验失败、404 资源不存在等),一律非 2xx + 错误信封;**不存在「请求成功但携带业务错误」的响应形态**。系统状态类数据例外:响应携带的「坏事」是事实数据而非请求错误——如总览健康带的「最近轮次失败 2」走 200,失败的轮次是数据、不是本次请求的错误
## 4. 时间口径
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
## 5. 数值口径(对齐共性 §3.2)
- **quota 双单位**:用量一律成对返回 `quota`(整数原值,口径内精确显示)+ `quota_usd`(**字符串,两位小数**,如 `"25.00"`)
- **换算责任在后端**:按日志时刻取 QPU(`qpu_history` 时间线)逐行换算——单一事实源,前端只呈现不换算;占比 / 分段条比例由前端现算(纯呈现职责)
- **取整规则**:换算内部以微分(1/100 分)整数运算,仅输出时四舍五入到分;聚合桶先微分求和再一次取整——小行贡献不丢失、单一舍入点
- **支付金额**:`paid_amount` 原样数字(币种无关,R9 裁决),无符号、无换算、无派生金额
- **次数**:`count`(日志行数)
## 6. 分页与响应形态
- **列表类端点**(日志明细、审计日志等):`page`(1 起)+ `page_size`(默认 50,上限 200)→ `{ "items": [...], "total": 123 }`
- **聚合类端点**(按成员 / key / 来源分组):不分页——分组基数有限(成员个位数、key 10–25),返回全量数组
- **成功响应无信封**:对象直返、列表 `items/total`、创建返创建后实体——信封只用于错误(§3);空响应体的动作类成功(如登出)返 `204 No Content` 无响应体
## 7. DTO 命名
- **snake_case、与表字段同名同构**——七篇表字段设计即 DTO 字段权威,无映射层
- 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰)
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
1. 时间传输格式 ISO 8601 UTC 字符串(§4);
2. 时间范围左闭右开 `[from, to)`(§4);
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
4. DTO 与表字段 snake_case 同构、无映射层(§7);
5. 分页 `page/page_size` + `items/total`,聚合类不分页(§6);
6. 错误信封单层 `{error: {code, message, fields?}}`,成功无信封(§3/§6);
7. 空响应体动作类成功返 `204`(§6);HTTP 状态码为成功/失败的唯一判别(§3);
8. 业务错误一律非 2xx——不存在 200 携业务错误的形态;系统状态类数据(健康带轮次失败数等)例外,是响应数据、不是请求错误(§3)。
+91
View File
@@ -0,0 +1,91 @@
# API契约-成员视图
> 成员视图(令牌档)全部端点:令牌验证与页头、页面聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§3)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》(草案——裁定沿用)·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `payments` 表字段设计(DTO 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
---
## 1. 端点总览
| # | 端点 | 方法 | 用途 |
|---|------|------|------|
| 1 | `/api/member/me` | GET | 令牌验证 + 页头(登录壳提交的落点) |
| 2 | `/api/member/overview` | GET | 单页聚合:总用量、按来源分解、我的 key、支付参数卡 |
| 3 | `/api/member/logs` | GET | 日志明细(名下 key,分页) |
## 2. 令牌验证与页头
### GET `/api/member/me` → 200
`{ "id": 1, "name": "小张" }`
- 登录壳提交 = 前端将令牌存 sessionStorage 后调本端点(`Authorization: Bearer`):验证通过(哈希命中 + `status = active`)→ 进入成员视图;`401` → 登录壳(前端固定文案「令牌无效或已停用」,不区分停用与无效——公共约定 §2)
- **不建会话**:令牌即长期凭证(`members` 表设计 3.2——无会话语义、无登出端点);旧令牌在作者重发后于下一请求自然 `401`
## 3. 单页聚合
### GET `/api/member/overview?from&to` → 200
`from/to` 必填(快捷项为前端预置);聚合仅名下 key(经 `api_key_id → api_keys.member_id` 当前归属联查)的 `type = 2` 日志。
```json
{
"member": { "id": 1, "name": "小张" },
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" },
"by_source": [
{ "billing_source": "subscription", "count": 280, "quota": 1400000, "quota_usd": "2.80" },
{ "billing_source": "wallet", "count": 20, "quota": 100000, "quota_usd": "0.20" }
],
"keys": [
{ "key_id": 5, "token_name": "小张-01", "log_synced_at": "2026-10-02T08:00:00Z",
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } }
],
"payments": [
{ "id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00" }
]
}
```
- `usage`:该时段总用量(聚合卡);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 key 窄卡(`log_synced_at` 支撑停滞标注)
- `payments`:**支付参数卡 + 徽章展开行 + 「本订阅期间」快捷项的数据源**,判定规则见 §5 裁定 2;对成员**全字段可见**(含 `remark`——完全公开透明裁决);`payments` 全空 = 纯用量模式中性空态(前端事)
- 徽章展开行仅限「订阅 #N」徽章:「充值」不展开(余额池扣款挂不到具体某笔,展开无答案——§5 裁定 4)、「未关联」(日志缺 `subscription_id`)不可展开;降级:徽章 `subscription_id` 不在 `payments` 列表 → 「该订阅无登记信息」——空值场景各自归位(《交互设计-成员视图》§1.6)
## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key、恒 `type = 2`(§5 裁定 1——成员世界是「我的用量」视角,错误行排查属管理面)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
```json
{
"id": 12345, "api_key_id": 5, "created_at": "2026-10-01T14:22:05Z",
"token_name": "小张-01", "model_name": "gpt-5", "type": 2, "content": "",
"quota": 250000, "quota_usd": "0.50",
"prompt_tokens": 1200, "completion_tokens": 800,
"use_time": 3, "is_stream": true,
"billing_source": "subscription", "subscription_id": 3
}
```
`token_name` 取当前名(管理面裁定 2 沿用)。
## 5. 本稿裁定项(请作者重点审)
1. **成员视图明细恒 `type = 2`**——对齐聚合口径;无 `type` 筛选参数(管理面明细的全类型排查能力不进成员世界);
2. **「该时段涉及」支付记录的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应记录 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项与「该订阅无登记信息」降级);充值 = `paid_at ∈ [from, to)`(充值消耗无逐笔关联可查,按支付时间锚定);
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
4. **「充值」徽章不展开**——余额池扣款与具体充值记录无逐笔关联,展开回答不了「这笔花在哪」(《交互设计-成员视图》§1.6 未定义处的落定:徽章展开行仅限订阅徽章,「充值」为纯标识)。
## 6. 遗留项
1. **订阅编号同一性核查**(日志 `subscription_id` ↔ 支付记录是否同一体系)——需求清单既有标注,实现期真实数据核查后再终验(徽章文案预留「未关联」正常位)。
+270
View File
@@ -0,0 +1,270 @@
# API契约-管理面
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、支付记录、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计(定稿)· `api_keys` / `usage_logs` / `payments` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
---
## 1. 端点总览
| # | 端点 | 方法 | 用途 |
|---|------|------|------|
| 1 | `/api/admin/setup-state` | GET | 入口三态判定(登录页 / 引导页分流) |
| 2 | `/api/admin/setup` | POST | 引导页落库(一次性) |
| 3 | `/api/admin/session` | POST / GET / DELETE | 登录 / 当前会话 / 登出 |
| 4 | `/api/admin/password` | PUT | 改密(删其他会话) |
| 5 | `/api/admin/engine` | GET | 引擎状态(最近轮次摘要) |
| 6 | `/api/admin/overview` | GET | 全员总览(健康带数据 + 三维聚合) |
| 7 | `/api/admin/keys` | GET / POST | key 清单 / 登记 |
| 8 | `/api/admin/keys/:id` | PATCH | 启停 / 归属变更 |
| 9 | `/api/admin/logs` | GET | 日志明细(下钻,分页) |
| 10 | `/api/admin/payments` | GET / POST | 支付记录清单 / 登记 |
| 11 | `/api/admin/payments/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 13 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 14 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
| 15 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 16 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
## 2. 引导与会话
### GET `/api/admin/setup-state` → 200
`{ "initialized": true }`——入口三态判定端点(幂等):
- `users` 非空 → `true`(登录页);空库 + env 凭证在 → **静默落库**后 `true`;空库 + 无凭证 → `false`(引导页)
- 「永不复现」保证:`initialized` 恒以库内事实为准
### POST `/api/admin/setup` → 200 `{username}` + Set-Cookie
引导页提交 `{username, password}`(确认密码为前端校验)——落库 + 直接建立会话(免二次登录)。仅空库 + 无 env 凭证时有效,否则 `409 already_initialized`(与三态判定构成双保险)。
### POST `/api/admin/session` → 200 `{username}` + Set-Cookie
登录 `{username, password}`。失败 `401 invalid_credentials`(通用文案,不区分用户名 / 密码错);连续失败触发 `429`(节流最小实现——失败延迟 / 计数,参数实现期定)。cookie 名 `admin_session`,形态见公共约定 §2。
### GET `/api/admin/session` → 200 `{username}`
当前会话(刷新后恢复侧边栏用户名);无有效会话 → `401`。
### DELETE `/api/admin/session` → 204
登出(删会话行)。
### PUT `/api/admin/password` → 204
`{old_password, new_password}`——成功后**删除其他会话、保留当前**。旧密码不正确 → `422`(`fields: {old_password: "旧密码不正确"}`)。
## 3. 引擎状态
### GET `/api/admin/engine` → 200
```json
{
"running": true,
"sync_interval_ms": 300000,
"last_round": {
"started_at": "2026-10-02T08:00:00Z",
"keys_total": 18, "succeeded": 16, "failed": 2,
"failed_api_key_ids": [3, 7]
}
}
```
`sync_interval_ms`:当前同步周期(毫秒),**只读观测**,经 env 配置、无修改接口。
`failed_api_key_ids` 支撑 Key 管理「最近轮次失败」筛选与健康带失败数跳转;`last_round` 为 `null`(引擎尚未跑过)合法。状态存储为 `engine_status` 单行快照(《同步引擎设计》§5)。
## 4. 全员总览
### GET `/api/admin/overview?from&to` → 200
`from/to` **必填**(杜绝无口径数字;快捷项为前端预置)。聚合仅 `type = 2`(消费行,§10 裁定 1)。
```json
{
"data_cutoff": "2026-10-02T08:00:00Z",
"unassigned_subscription": { "count": 0, "quota": 0, "quota_usd": "0.00" },
"by_source": [
{ "billing_source": "subscription", "count": 420, "quota": 2100000, "quota_usd": "4.20" },
{ "billing_source": "wallet", "count": 80, "quota": 400000, "quota_usd": "0.80" }
],
"by_member": [
{ "member_id": 1, "member_name": "小张", "key_count": 3,
"data_cutoff": "2026-10-02T08:00:00Z",
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } },
{ "member_id": null, "member_name": null, "key_count": 2,
"data_cutoff": "2026-10-01T06:00:00Z",
"usage": { "count": 200, "quota": 1000000, "quota_usd": "2.00" } }
],
"by_key": [
{ "key_id": 5, "token_name": "小张-01", "member_id": 1, "status": "active",
"data_cutoff": "2026-10-02T08:00:00Z",
"usage": { "count": 120, "quota": 600000, "quota_usd": "1.20" } }
]
}
```
- `data_cutoff`:全池 `log_synced_at` 最慢;`by_member` 行为名下最慢、`member_id: null` 行为「未归属(归作者)」固定卡
- `unassigned_subscription`:该时段 `billing_source = subscription` 且 `subscription_id` 缺失的量——正常恒为零(健康指示器,非零即异常信号);`billing_source = wallet` 的缺失属正常、不进此分组
- 占全员比例条由前端现算(公共约定 §5)
## 5. Key 管理
### GET `/api/admin/keys` → 200 `[key]`
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
```json
{
"id": 5, "token_name": "小张-01", "status": "active",
"member_id": 1, "member_name": "小张",
"registered_at": "2026-09-20T10:00:00Z",
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
"unlimited_quota": false, "expires_at": null
}
```
`key_hash` / `key_ciphertext` 永不出 API(凭证形态);`model_limits` / `model_limits_enabled` 已入库暂无 UI 消费方,不出(随表设计 3.4 待 UI 稳定回审)。
### POST `/api/admin/keys` → 201 `key`
`{ "sk_key": "sk-…", "member_id": null }`——登记四态:
| 结果 | 响应 |
|------|------|
| 成功 | `201` + key 实体(首轮日志同步异步补,不阻塞响应——表设计 §4) |
| 已登记(hash 命中,查库优先、先于上游验证) | `409 key_already_registered`,信封附注 `key_id` 定位既有行 |
| 上游拒绝(401) | `422`(`fields: {sk_key: "上游拒绝了这枚 key——可能不存在或已被禁用"}`) |
| 上游暂不可达 | `502 upstream_unreachable`(「上游站点暂时不可达,请稍后重试」) |
### PATCH `/api/admin/keys/:id` → 200 `key`
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
## 6. 日志明细(管理面下钻)
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
```json
{
"id": 12345, "api_key_id": 5, "created_at": "2026-10-01T14:22:05Z",
"token_name": "小张-01", "model_name": "gpt-5", "type": 2, "content": "",
"quota": 250000, "quota_usd": "0.50",
"prompt_tokens": 1200, "completion_tokens": 800,
"use_time": 3, "is_stream": true,
"billing_source": "subscription", "subscription_id": 3
}
```
`token_name` 取**当前名**(联查 `api_keys`,与「展示名以当前为准」对齐——§10 裁定 2);`username` / `channel` / `ip` / `token_id` / `request_id` / `other` 等其余表字段暂不出 API,界面需要时再扩。
> **界面落点待补**:总览「行点击 → Key 管理页该 key 详情」——交互设计无成篇的「key 详情」视图,本端点先行、界面归交互补篇或实现期定(§11 遗留 1)。
## 7. 支付记录
### GET `/api/admin/payments` → 200 `[payment]`
全量直返(年十位数,不分页),时间倒序(后端排序)。
```json
{
"id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00"
}
```
`quota_usd` 按支付时刻(`paid_at`)QPU 换算(§10 裁定 4);`kind = topup` 行 `period_*` / `subscription_id` 为 `null`。
### POST `/api/admin/payments` → 201 `payment`
`kind` 分形态必填校验(`422`):公共 `{kind, paid_at, paid_amount, remark?}`;`subscription` 追加必填 `period_start` / `subscription_id` / `quota_granted`(`period_end` 可空 = 进行中);`topup` 追加必填 `quota_granted`。表单美元双输入为前端换算(按 `qpu/latest` 预览),落库恒原始 quota(表设计 3.6)。
### PUT `/api/admin/payments/:id` → 200 `payment` / DELETE → 204
编辑全字段、删除有确认(前端);改错即修,均落审计。
### GET `/api/admin/qpu/latest` → 200
`{ "qpu": 500000, "effective_from": "2026-09-01T00:00:00Z" }`——当前换算率(支付表单美元 ↔ quota 双输入换算用)。
## 8. 成员管理
### GET `/api/admin/members` → 200 `[member]`
全量直返(个位数,不分页)。
```json
{
"id": 1, "name": "小张", "remark": "同事", "status": "active",
"token_issued_at": "2026-09-18T10:00:00Z", "created_at": "2026-09-18T10:00:00Z",
"key_count": 3
}
```
`key_count` 为派生聚合计数(当前归属名下 key 数);`token_hash` 永不出 API。名称唯一冲突 `409 member_name_taken`。
### POST `/api/admin/members` → 201
`{name, remark?}`——开户即自动签发令牌,**响应附明文(仅此一次)**:
```json
{ "member": { "…": "如上实体" }, "token": "(明文令牌——关闭后不可再得,库里只有哈希)" }
```
### PATCH `/api/admin/members/:id` → 200 `member`
`{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)。
### POST `/api/admin/members/:id/token` → 201
`{ "token": "…" }`——重发:旧令牌即刻失效(旧令牌的成员侧下一请求 `401`)。明文仅此响应一次。
## 9. 审计日志
### GET `/api/admin/audit-logs` → 200 `{items, total}`
分页;时间倒序;过滤 `domain`(`key` / `payment` / `member` / `account`)。
```json
{
"id": 99, "created_at": "2026-10-02T09:15:00Z",
"operator": "aaron", "domain": "key", "action": "key.disable",
"summary": "key 小张-01 · 停用"
}
```
`operator` 为用户名;**系统触发事件为 `null`**(如空库静默重建 `account.init`)。操作类型枚举(审计域全集):
| 域 | action 枚举 |
|----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| payment | `payment.create` / `payment.update` / `payment.delete` |
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` |
| account | `account.password_change` / `account.init`(系统,operator null) |
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
## 10. 本稿裁定项(表设计 / 公共约定的待定落定,请作者重点审)
1. **用量聚合口径仅 `type = 2`(消费行)**——usage_logs §5 待定「type 过滤策略」落定:聚合与统计只计消费行(错误行入聚合即失真);明细查询默认全类型、可按 `type` 筛(错误排查入口);
2. **日志明细 `token_name` 取当前名**(联查 `api_keys`;快照名存库不出 API)——表设计 2.3 待定「展示口径」落定;
3. **有限基数清单不分页**:`keys` / `members` / `payments` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **支付记录 `quota_usd` 按支付时刻(`paid_at`)QPU 换算**——对齐「按日志时刻取值」的历史正确性原则;
5. **公共约定随本稿与审阅增补四处**:状态表加 `429`(登录节流)、错误信封允许端点附机器可读附注字段(`409 key_already_registered` 附 `key_id`)、`/api` 命名空间专属接口(页面路由永不进入)、端点面向交互设计不与数据表对齐。
## 11. 遗留项(归后续立稿或作者裁决)
1. **管理面日志明细的界面落点**——**已裁决:实现期定**(先做「跳 Key 管理定位到该行」,详情视图待真实数据再定);
2. **审计日志表结构**——归技术稿,以本文 §9 DTO 字段为对齐锚点;
3. **`GET /api/admin/engine` 的状态存储**——已对齐《同步引擎设计》§5(`engine_status` 单行快照 + 只读 `sync_interval_ms`);
4. **令牌生成细节**(随机源 / 长度 / 前缀)——归技术稿。
+136
View File
@@ -0,0 +1,136 @@
# api_keys 表字段设计
> 数据模型第一张表:API key 登记表——每个被登记的 API key 一行,**由管理通道登记**。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/references/NewAPI-接口范围.md` §2.⑧(`/api/usage/token` 字段依据)· 同文 §1(鉴权三态)· `docs/design/members表字段设计.md`(`member_id` 指向) |
| 定稿范围 | 列结构 + 语义 + 写入时机;同步引擎细节不在此文;方言映射归数据访问层设计 |
| 字段数 | 16 = 8 本系统管理字段 + 8 上游接口字段 |
---
## 1. 表定位
- **一行 = 一个登记过的 API key**:在管理通道登记一次,这里多一行;之后所有轮询同步、查询都围绕它转
- 它是四个基本问题的答案所在地:
1. 「这个 key 登记过吗」→ `key_hash` 查询命中(支撑「本地查库、未命中即提示未收录」的判定流程)
2. 「该轮询哪些 key」→ `status = active` 的行
3. 「凭证安全存哪」→ `key_ciphertext`(加密存储)
4. 「这个 key 归属哪个成员」→ `member_id`(用量统计与成员视图的归属锚点;空 = 未归属,用量归作者)
- 与 `usage_logs` 一对多:每条日志经 `api_key_id` 指向本表;与 `members` 多对一:归属列在本表(见 3.9)
## 2. 字段清单
### 2.1 管理字段(8 个,本系统产生)
| 列 | 类型 | 值来源 | 消费方 / 用途 |
|----|------|--------|---------------|
| `id` | bigserial PK | 自增 | `usage_logs.api_key_id` 外键指向 |
| `key_hash` | text UNIQUE | 登记时 HMAC-SHA256(管理员粘贴输入) | 查询命中判定(「查库优先」);不可逆 |
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `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,可解密):唯一用途是调上游
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
> **注解:为什么是两个字段(给非密码学背景的读者)**
>
> 两列各管一件事——`key_hash` 管「找得到」,`key_ciphertext` 管「用得起来」:
>
> - `key_hash` 像**单向粉碎机**:同样的输入永远产出同样的指纹,指纹却反推不出原文。输入 key 时(登记或查询),服务端算出的指纹与库中比对,命中即「已登记」,零上游调用;即便整库泄露,外人拿到的也只是指纹。选用 HMAC(而非裸哈希)是多掺一味只有服务器持有的「调料」:没有它,就无法用「猜测输入 → 对照指纹」的方式离线验证。
> - `key_ciphertext` 像**保险箱**:用 `SK_KEY_ENCRYPTION_SECRET` 加密锁存,轮询调上游时开锁取明文;AES-GCM 自带防篡改,密文被动过则解密直接报错。
> - **两列并存的原因**:加密每次都掺随机数(同一 key 两次密文不同)→ 密文不能用于查找;哈希不可逆 → 指纹还原不出明文。「可查找」与「可还原」两个需求互斥,只能分存两份形态。
> - **防线边界**:能同时接触数据库与环境变量的人(服务器运营方)防不住——这是已接受的信任模型前提;本设计防的是「数据库单独落到第三方手里」的常见拖库场景(SQL 注入、备份泄露)。
> - 实现提示:两种算法均为标准件,Bun 内置 `crypto` 直接提供,无需第三方库。
### 3.2 命名:与上游对齐
- 上游字段直接采用原名(`total_granted` 等),对照 `docs/references/NewAPI-接口范围.md` §2.⑧ 看表即懂
- 例外:`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 生命周期:纯手动开关,无 401 自动摘除
- `active` / `disabled` 两值,**唯一写入者是管理面的启停操作**——引擎对 `status` 只读
- 停用:数据保留、移出轮询清单;启用:回清单,**下一轮同步即自然验证**(成败经数据截止与引擎日志立即显形,无需人工预验证环节)
- **引擎对失败(401 / 403 / 超时等)只记录、不决策**:失败不写行——`log_synced_at` 停滞即管理面可见信号,失败明细逐轮进引擎运行日志(母体 journald / CFW Workers Logs);持续 401 的处置(停用)是作者的管理动作
- **为什么不做自动摘除**:管理决策归人(平台呈现事实——与不做分摊计算同构);作者即运维者(自动化的受众唯一);对上游瞬时误报免疫(不杀就无误杀);代价是作者不看平台期间死 key 持续消耗轮询预算(CFW Free 档有感),可见且可控
### 3.7 表名:`api_keys`
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `payments` / `qpu_history`)风格一致
### 3.8 归属列 `member_id`:外键在本表,且可空
- **为什么外键在 key 侧**:成员与 key 是 1:N——外键由 N 侧持有是标准做法;成员表的行数不随 key 数量膨胀
- **为什么可空**:未归属的 key 是真实存在的状态(测试 key、暂未分配的 key)——空值即「用量归作者」的默认归属,无需哨兵行
- **归属变更即时生效**:`member_id` 是管理通道的 UPDATE,不影响日志行(历史日志按行级归属自然分组——见 `usage_logs表字段设计.md` 3.6)
## 4. 写入时机(两个写入者:管理通道 / 同步引擎)
| 时机 | 动作 |
|------|------|
| **登记**(管理通道,上游验证通过) | INSERT 一行:管理字段(HMAC / 加密 / registered_at / 归属 `member_id`)+ 上游字段全组(一次 `/api/usage/token` 拿全)+ 两个 synced_at;**首轮日志同步不阻塞登记响应**(由同步引擎立即异步补一轮) |
| **启停**(管理通道) | UPDATE `status`:启用 → `active`(回轮询清单,下一轮同步即自然验证)/ 停用 → `disabled`(移出轮询清单)——数据保留、不删除 |
| **归属变更**(管理通道) | UPDATE `member_id`——即时生效,不触碰日志行(统计按行级归属分组,见 3.8) |
| **每轮轮询** | 仅写成功结果:UPDATE 上游字段全组 + `usage_synced_at`;日志同步成功后写 `log_synced_at`;任何失败(401 / 403 / 超时等)**不写行**——`log_synced_at` 停滞即管理面可见信号,失败明细进引擎运行日志 |
- 两个上游调用各自独立:**各自的成功写各自的时间戳,互不阻塞、互不污染**
## 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. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 轮次摘要与失败明细的观测呈现(引擎日志格式) | 同步引擎设计(待立) |
| 多 key 时「数据截止」的聚合语义 | 已定:取最慢 |
| 「累计已用/剩余」展示格式与无限额度分支 | 交互细节层 |
| `usage_logs` 表字段清单 | 见 `docs/design/usage_logs表字段设计.md` |
+67
View File
@@ -0,0 +1,67 @@
# members 表字段设计
> 数据模型新增表之一:成员实体——「成员」维度的锚点。key 归属(经 `api_keys.member_id` 指向本表)、成员视图的身份、按成员统计的分组键。裁决背景见 `docs/design/需求清单.md` §9「访问主体模型」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 |
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
| 字段数 | 7 = 1 主键 + 6 业务字段 |
---
## 1. 表定位
- **一行 = 一个分摊成员**
- 三个角色:`api_keys.member_id` 的归属目标;成员视图的访问主体(令牌校验命中本表行 + `status = active`);按成员统计的分组键
- 数据规模:个位数行
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 |
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) |
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) |
| `created_at` | timestamptz | 开户时 | |
## 3. 决策记录(含理由)
### 3.1 令牌哈希存储
拖库不泄令牌(与 `api_keys.key_hash` 同思路);副作用是明文不可找回——丢了只能重发(`token_hash` + `token_issued_at` 一起更新),这正是「凭证事务经作者」的裁决语义。
### 3.2 令牌不过期(无 `token_expires_at`),且与 `sessions` 无关
- 令牌是**长期访问凭证**(access token,近 GitHub PAT 形态),不是「登录令牌」:没有会话语义——不过期、无登出、无「验证持久身份 → 换发短时会话」的过程,吊销与重发是唯一的生命周期操作,均经作者(管理面)
- **与 `sessions` 表零关系**:sessions 服务于管理面(作者)的登录体系(密码验证 → 短时会话);本表令牌是成员链路唯一的凭证,成员请求直接携带、服务端直查本表(`token_hash` + `status`)——两条链路各自独立、物理隔离
- 成员低频只读,过期只会制造无意义的重发摩擦
### 3.3 `name` UNIQUE
账目署名不可歧义。
### 3.4 软删除(`status`)而非物理删除
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。
## 4. 写入时机
| 时机 | 动作 |
|------|------|
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
| 停用 / 启用 | UPDATE `status` |
| 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) |
以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。
## 5. 待定事项
| 事项 | 归属 |
|------|------|
| 令牌生成的随机源与长度(256bit 起步) | 技术稿 |
| 审计日志表结构 | 技术稿 |
+98
View File
@@ -0,0 +1,98 @@
# payments 表字段设计
> 数据模型第三张表:支付记录——每笔支付事件(订阅购买 / 充值)一行。平台只存**事实**(支付金额、换得额度、来源参数),「支付金额与额度之间的比率」是查询时的派生视图,不存储、不做任何分摊计算。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/usage_logs表字段设计.md` 3.6(`subscription_id` 直连与本表)· `docs/design/qpu_history表字段设计.md`(quota → 额度美元的换算时间线)· `docs/references/NewAPI-接口范围.md` §2.⑨(`other.subscription_*` 字段依据) |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层设计 |
| 字段数 | 9 = 1 主键 + 8 人工录入(支付 4 · 期间 2 · 订阅 2) |
---
## 1. 表定位
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态(`kind` 列区分)
- **本表是「只有作者知道」的信息的唯一落点**:支付金额、额度、订阅 ID——上游没有免 auth 接口可查,全靠作者手工抄录
- **币种无关**:`paid_amount` 是支付金额数值,**系统不预设币种**——币种是使用者之间的约定;「金额 ↔ 额度」的比率同样是派生视图(查询时相除即得)
- **不做分摊计算**:平台不把支付金额按用量比例分摊到日志或成员——分账是社交层问题;本表与日志的交汇只发生在**事实关联**(`subscription_id` 直连)与**参数展示**(订阅信息对使用者完全公开透明)
- 数据规模:年个位数到十位数行——无需索引设计
## 2. 字段清单
### 2.1 主键与支付字段(5 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
| `paid_amount` | numeric | **人工必填** | **支付金额**(币种无关——数值本身,币种由使用者约定) |
| `remark` | text | 人工填写 | 备注 |
### 2.2 期间与订阅字段(4 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;与日志 `subscription_id` 直连(事实关联,不推算) |
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」/ 充值记录) | 本笔换得的额度——**单位钉死为原始 quota 整数**(如 $25 额度 = 12,500,000;见 3.6) |
## 3. 决策记录(含理由)
### 3.1 登记 = 手工抄录(来源只有两处)
支付记录全部由作者手工录入,来源仅两处:**支付凭证**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
### 3.2 只存事实,不存派生数
- `paid_amount` 与 `quota_granted` 是不可替代的事实;「金额 ↔ 额度」的比率是派生数——查询时相除即得,事后核对永远可用;只存比率会丢失核对依据
- **日志同样不加任何金额 / 比率列**(`usage_logs` 零分摊字段):把支付金额按用量分摊到日志行的任何计算都不做——分摊口径是社交层问题,账本上只有客观事实
- 关联维度(`billing_source` / `subscription_id`)已在日志侧提为独立列(见 `usage_logs表字段设计.md` 3.6),统计按事实关联
### 3.3 为什么额度也手工录入(不采用日志感知)
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对支付记录零回填、零推断**,观测仅用于核对(见 §4)。
### 3.4 币种无关(不预设人民币)
`paid_amount` 是纯数值。作者记账用什么货币是他的事,展示层的货币符号是界面约定——数据层与币种彻底解耦,更换支付渠道 / 币种时零 schema 变化。
### 3.5 命名与类型
- 表名 `payments`(支付记录):订阅与充值同构,`purchases`(购买)只覆盖前者,`payments` 是两者的上位词
- 金额列 `numeric`(金额不用浮点);时间列统一 `timestamptz`
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(直连即用此列)
### 3.6 `quota_granted` 的单位:原始 quota,不是美元
- **存真值,不存换算快照**:上游订阅套餐的额度本体是 quota 整数,「$25」只是界面显示形态(quota ÷ `quota_per_unit`,随 `quota_display_type` 而变)——本列存本体
- **域一致性**:日志的 `quota` 同为原始整数——`SUM(usage_logs.quota)` 与 `quota_granted` 同域直接可比,零换算;若存美元则每次比较都要过 ÷QPU,而 QPU 是时间线变量(`qpu_history`),凭空引入取值时刻问题
- **类型精确**:quota 恒为整数;美元额度可能有小数,`bigint` 装不下
- **录入体验归交互层**:钱包页面显示美元形态——管理面表单支持输入美元、按当时 QPU 换算落库(或展示两态),换算逻辑不进数据模型
- 美元形态随时可派生:`quota_granted ÷ QPU`(展示层)
- **与 qpu_history 无概念重复**:quota 是**量**,QPU 是**换算率**——记录量不等于记录单位定义(记「¥170」不涵盖「1 美元 = 6.8 人民币」)。真值域(quota)不携带任何换算,换算率只在一处(`qpu_history` 时间线),美元永远现算现用;反过来若直接存美元,每个值都是某次换算的快照、各自隐含一个换算时刻——那才是真正的冗余,且上游改 QPU 后与日志统计失去同域可比性
## 4. 写入时机与维护
| 时机 | 动作 |
|------|------|
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 金额)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何支付记录」——即「未关联」分组的健康信号),不参与回填 |
| 编辑 / 删除 | 数据为人工登记,改错即修 |
## 5. 展示层边界(界面设计的输入)
- **订阅与支付参数对使用者完全公开透明**(访问主体模型裁决):期间、额度、支付金额、订阅 ID——成员视图与管理面同源可见;不存在「管理面独占的金额信息」
- 「按订阅维度统计」(某笔订阅的消耗占比等)数据天然支持——按 `subscription_id` 直连聚合即可
- 成员视角的日志行携带所属订阅/充值信息的具体形态,交互设计期定(见 `docs/design/需求清单.md` R12)
## 6. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计(待立) |
| 充值(`topup`)的 `quota_granted` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
+75
View File
@@ -0,0 +1,75 @@
# qpu_history 表字段设计
> 数据模型第四张表:换算参数时间线(表名 `qpu_history`,QPU = QuotaPerUnit 缩写)——`quota_per_unit`(quota → 额度美元)的历史取值。历史值在 sk-key 视角无从观测,本表由同步引擎自动采样、只记变化点;**纯只读**(唯一写入者是同步引擎)。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/usage_logs表字段设计.md`(消费方:quota → 美元的展示换算)· `docs/references/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 取值语义:按日志自身时刻取值
- **一条日志的美元换算 = 该日志 `created_at` 时刻的当值 QPU**(「该时刻之前最近一条」)——这是纯粹的历史单位换算:这条日志发生时一单位 quota 值多少美元额度,就用当时的换算率
- **为什么按日志时刻**:换算的唯一目的是「这条日志当时值多少美元额度」——历史正确性要求用历史当值;不存在「一笔订阅一把尺」的分摊自洽诉求(分摊计算已整体裁撤)
- **边界**:日志时刻早于本表最早记录时(如补录历史数据),回退用「最早已知值」(近似规则,正常部署起点的日志不会触发)
### 3.4 `quota_per_unit` 的可变性说明(本表存在的理由)
- 它是「全站计价单位的定义」——改它 ≈ 重定义货币单位(全站一致重定标);管理员极少动(当前站点为出厂默认值)。本设计以「按时间线换算」从根上免疫它的任何变更
- **存在必要性审视(2026-09-29,作者裁决保留)**:改动概率极低(历史上零次)× 权限极高(Root,深思熟虑的定价动作而非误操作)× **不可逆**(一旦发生且未被捕捉,「当时的率」永久不存在)——风险结构与日志 1000 条窗口同构;备选方案「砍表 + 保留观测告警、事后人工重建」被否决——不可逆的历史由自动化保障,不依赖人工响应;全部代价 = 每轮一次 `/api/status` 采样 + 十行时点查询(`qpuAt(t)`,3 行内存缓存 + 区间判断)
### 3.5 为什么不做人工修正(只读设计)
- 人工介入的场景不成立——`quota_per_unit` 在上游**无编辑入口(前台默认隐藏且只读)、管理审计不记值、历史上从未变化**(见 `docs/references/NewAPI-接口范围.md`)
- 本表由此定型为**纯只读时间线**:唯一写入者是同步引擎(值变化才追加);管理通道仅提供只读查看
- 未来若真出现需要人工介入的场景(如运营方主动告知一次精确切换时刻),再评估启用
### 3.6 命名:表名 `qpu_history`、值列 `qpu`
- **表名**:`quota_per_unit_history` 过长、逐处引用啰嗦 → `qpu_history`——QPU 是 QuotaPerUnit 的通用缩写,短且醒目
- **值列**:`value` 过泛("什么的值"不自明)→ 改为 `qpu`,与表名一体、一眼即知
- 通用约定:**表名 / 列名用 QPU 缩写,引用上游字段 `quota_per_unit` 时保持全称**
## 4. 写入时机(唯一写入者:同步引擎)
| 时机 | 动作 |
|------|------|
| 部署(基线) | INSERT 基线一行(当时 `/api/status` 取值) |
| 每轮同步 | 读 `/api/status`:**值变化才追加一行** |
- 除追加外无 UPDATE / DELETE——数据不可变
## 5. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 采样调度(读取频率、失败重试) | 同步引擎设计(待立) |
+63
View File
@@ -0,0 +1,63 @@
# sessions 表字段设计
> 数据模型新增表之三:管理面会话——HttpOnly cookie 里的令牌与服务端状态的对应物。裁决背景见 `docs/design/需求清单.md` §9「访问主体模型」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/users表字段设计.md`(`user_id` 指向)· `docs/design/需求清单.md` §9 |
| 范围 | 列结构 + 语义 + 写入时机;cookie 参数(SameSite 等)归技术稿;方言映射归数据访问层设计 |
| 字段数 | 5 = 1 主键 + 4 业务字段 |
---
## 1. 表定位
- **一行 = 一次活跃登录**——登录成功 INSERT,登出 / 过期 DELETE
- 浏览器侧只持有令牌明文(HttpOnly cookie),服务端只存哈希
- **唯一服务对象是管理面(作者)登录**——`user_id` 指向单行的 `users` 表;成员链路**不经此表**(成员凭证是 `members.token_hash` 长期令牌,无会话语义,辨析见 `members表字段设计.md` 3.2)
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `user_id` | bigint FK | 登录成功时 | → `users.id` |
| `token_hash` | text UNIQUE | 登录成功时 | 会话令牌(随机 256bit)的哈希——cookie 存明文、库存哈希 |
| `expires_at` | timestamptz | 登录成功时 | 过期时间(如 7 天);过期即拒绝 |
| `created_at` | timestamptz | 登录成功时 | |
## 3. 决策记录(含理由)
### 3.1 服务端会话(查库校验),明确否决 JWT
- **JWT 的问题域我们没有**:JWT(无状态令牌)的收益在多服务共享认证、水平扩展免共享存储、海量会话——本项目是单后端、单实例、单管理员,收益一项不占,负担全额缴纳
- **不可吊销是致命伤**:JWT 签发后、过期前无法作废——登出仅是客户端删 cookie(令牌仍有效)、踢下线做不到、改密后旧会话无法失效;业界绕法「短过期 + refresh token」需要服务端存储 refresh token,绕回有状态,JWT 的唯一优势随之蒸发
- **多一个要管理的秘密**:签名密钥的保管、轮换、泄露处置,凭空多一项运维(CFW 形态下多一处 secret 配置)
- **本设计即业界最佳实践的翻译**:随机 256bit 令牌、哈希存储、`HttpOnly`/`Secure`/`SameSite`、服务端校验、过期拒绝、登出即吊销——对应 OWASP Session Management Cheat Sheet 逐条要求;「成熟」体现在模式本身。现成认证库的评估归实现期技术稿,模式不变
### 3.2 令牌哈希存储
拖库不泄会话令牌。
### 3.3 过期清理为惰性删除
校验命中过期行即删 + 拒绝;不建定时清理任务——过期行占用量级:个位数/月,无清理压力。
### 3.4 单设备多会话不互斥
同一账号可多处登录(各自行)——单人场景无需「顶号」语义。
## 4. 写入时机
| 时机 | 动作 |
|------|------|
| 登录成功 | INSERT(用户名 + PBKDF2 验证密码通过) |
| 登出 | DELETE 该行 |
| 校验命中过期行 | DELETE + 拒绝(惰性清理) |
## 5. 待定事项
| 事项 | 归属 |
|------|------|
| cookie 参数(`HttpOnly` / `Secure` / `SameSite=Lax` / Path 限管理面)、会话时长取值 | 技术稿 |
+138
View File
@@ -0,0 +1,138 @@
# usage_logs 表字段设计
> 数据模型第二张表:调用日志明细——增量同步的落点,本项目「任意时间范围用量」的唯一数据来源。字段清单依据 `/api/log/token` 实测响应(1000 条样本)与上游源码(`model/log.go`)双重校对;上游只有 1000 条滚动窗口,**错过即永久丢失**。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/references/NewAPI-接口范围.md` §2.⑨(`/api/log/token` 字段与限制依据)· `docs/design/api_keys表字段设计.md`(`api_key_id` 外键指向) |
| 定稿范围 | 列结构 + 去重与写入策略;同步引擎细节(调度、401 处理)不在此文;方言映射归数据访问层设计 |
| 字段数 | 25 = 3 系统字段 + 2 来源提列 + 20 上游字段(上游响应共 21 个字段,其中展示序号 `id` 不落表,见 3.2) |
---
## 1. 表定位
- **一行 = 一次上游调用记录**(消费 / 错误等类型全覆盖)
- 它是本项目最核心的数据资产:上游 `/api/log/token` 只保留最近 1000 条(实测当前速率下 ≈ 3 天),**错过即永久丢失**——本地全量累积是"任意时间范围用量"的唯一实现路径
- 与 `api_keys` 多对一:每条日志经 `api_key_id` 指向其所属 key
- 计费来源(`billing_source` / `subscription_id`)是**一等维度**:按来源统计、日志与支付记录的 `subscription_id` 直连(见 3.6)
- 数据规模:实测约 300+ 条/天,量级为数十万行/年——单表 + 时间索引完全够用,无需分区或预聚合
## 2. 字段清单
### 2.1 系统字段(3 个,本系统产生)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | 本地主键(与上游的展示序号 `id` 无关) |
| `api_key_id` | bigint FK | 同步引擎 | → `api_keys.id`;日志归属与索引锚点 |
| `dedupe_key` | text UNIQUE | 写入前生成 | **去重唯一键**,规则见 3.1 |
### 2.2 来源提列字段(2 个,解析自 `other`、提升为独立列)
| 列 | 类型 | 上游来源 | 说明 |
|----|------|---------|------|
| `billing_source` | text | `other.billing_source` | 计费来源:`subscription`(订阅)/ `wallet`(充值)——按来源统计的维度 |
| `subscription_id` | bigint NULL | `other.subscription_id` | 该次消耗所属的上游订阅实例 ID——与 `payments.subscription_id` 直连;缺失时 NULL(归「未关联」分组,见 3.6) |
### 2.3 上游字段(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` 的落点) |
| 普通 | `(api_key_id, created_at DESC)` | 明细页 + 按 key 聚合 |
| 普通 | `(created_at DESC)` | 跨 key 合计与统计 |
## 3. 决策记录(含理由)
### 3.1 去重:单列 `dedupe_key`,按类型分规则
- `request_id` 是去重依据(上游为每次请求生成的编号);但**错误行上不唯一**——实测样本中同一 `request_id` 可对应多条 `type=5` 错误记录(同一请求的多次失败 / 重试,`content` 的 `status_code` 可不同)。按 `request_id` 一律去重会把它们合并——**失败次数被少算,事实被丢**。规则按类型分:
- **消费行(`type = 2`)**:`dedupe_key = ${api_key_id}:${request_id}`(实测未观察到消费行 `request_id` 重复;重复拉取幂等合并)
- **非消费行(`type ≠ 2`,如实测的错误行)**:`dedupe_key = ${api_key_id}:${request_id}:${content指纹}`,指纹 = `sha256(api_key_id, created_at, type, model_name, quota, prompt_tokens, completion_tokens, use_time, content)`——把 `content` 纳入:同一请求多次失败、错误详情不同即指纹不同,**各自保留**;全部字段完全一致的两行在「无法区分」的意义上视作重复拉取,合并无害
- `request_id` 为空时:退化为纯内容指纹(同上字段集,消费行不含 `content`)
- **为什么不把 UNIQUE 直接挂在 `request_id` 列上**:空值语义两难——`NULL` 在唯一索引中互不冲突(空值行会重复入库);转空串则过度冲突(第二条空值记录被静默丢弃)。单列 `dedupe_key` + 指纹兜底从根上规避
- `api_key_id:` 前缀:跨 key 隔离的零成本保险
- `request_id` 原值仍单独成列——两者并行,互不替代
- **为什么它不做主键**:主键是「行身份」(短、稳定、永不变化),`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 为稳定枚举),展示层翻译(未知值原样显示数字)
### 3.6 来源提列:`billing_source` 与 `subscription_id` 升为独立列
- **为什么提列**:两者是按来源统计与日志-支付记录直连的核心维度——`other` jsonb 的键查询无法走索引(SQLite/D1 的 `json_extract` 同理),高频过滤维度应独立成列
- **`subscription_id` 直连语义**(事实关联,非推算):日志按 `subscription_id = payments.subscription_id` 关联支付记录;**不做期间推定**——订阅来源但该列缺失(NULL)的行归「未关联订阅」分组,统计中透明显示(正常应恒为零,非零即「缺登记」或「上游行为变化」的异常信号)
- `billing_source = wallet`(充值)的日志不挂订阅(余额扣款与订阅实例无关),`subscription_id` 为 NULL 是**正常状态**而非异常——按来源统计时它们在「充值」分组,不进「未关联订阅」分组
- **其他键留在 `other`**:提列只针对这两个一等维度;`cache_tokens`、倍率等仍留在 jsonb,出现真实分析需求再评估(见 §5)
### 3.7 行级归属
- key 的成员归属是**管理面可变的**(key 中途转给另一个成员),日志行不复制归属——统计时经 `api_key_id → api_keys.member_id` 联查取**当前归属**
- 归属变更不重写历史:变更后的统计自然反映新归属;「变更前谁用了多少」不是平台的账目职责(分账是社交层问题——平台只保证当前事实的正确)
## 4. 写入时机与策略
| 时机 | 动作 |
|------|------|
| 登记首轮同步 | 拉最近 1000 条 → 逐条入库 |
| 每轮轮询 | 同上;`INSERT ... ON CONFLICT (dedupe_key) DO NOTHING`——幂等,只落新行 |
- 写入者唯一:同步引擎(不存在其他写入路径)
- 日志不可变:已入库的行不做 UPDATE
- 与本表相关的读取链:从 `api_keys` 取密文 → 解密 → 调上游 → 新日志写本表
## 5. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 明细/统计的查询口径(`type` 过滤策略) | API 契约(待立) |
| `other` 内其余高频键是否提为独立列(如 `cache_tokens`) | 出现真实分析需求时再评估(`billing_source` / `subscription_id` 已提列,见 3.6) |
| 数据保留策略(是否清理旧数据) | 后议 |
+64
View File
@@ -0,0 +1,64 @@
# users 表字段设计
> 数据模型新增表之二:管理员——登录体系的身份落点,**实际只有一行**(作者,唯一管理员)。裁决背景见 `docs/design/需求清单.md` §9「访问主体模型」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/sessions表字段设计.md`(登录会话指向本表)· `docs/design/需求清单.md` §9 |
| 范围 | 列结构 + 语义 + 写入时机;登录流程细节(PBKDF2 参数、节流)归技术稿;方言映射归数据访问层设计 |
| 字段数 | 4 = 1 主键 + 3 业务字段 |
---
## 1. 表定位
- **一行 = 一个管理员账号**——本表设计为单行(作者),不存在注册路径
- 凭证经**部署期初始化**创建(env / wrangler secret 的引导凭证,空库幂等落库)——部署完成即就绪;空库且 env 无凭证时由**引导页**完成初始化(重置兜底通道,见 3.1)
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `username` | text UNIQUE | 部署期初始化 | 登录名 |
| `password_hash` | text | 部署期初始化 / 改密 | PBKDF2-SHA256 哈希(WebCrypto 原生,两形态统一——Workers 无 argon2/bcrypt) |
| `created_at` | timestamptz | 初始化时 | |
## 3. 决策记录(含理由)
### 3.1 单行表,不做注册;初始化与重置 = 空库双通道
管理员账号由空库初始化创建;改密走登录后的表单(旧密码验证 → 落库)。「单行」是**部署行为约定,不是 schema 约束**——表结构天然支持多行,未来若需要多管理员 + 角色,扩展为增量动作(加 `role` 列 + 中间件分支 + 管理页),无需推倒任何东西。
**管理面入口的幂等三态判断**:
- `users` 非空 → 登录页(如常)
- 空库 + env 凭证在 → **静默重建**(部署与重置共用此通道;密码即 env 凭证)
- 空库 + env 凭证无 → **引导页**(设置新用户名密码,直接进入登录态;`users` 非空后永不复现)
**忘记密码的重置**:删除 `users` 行(母体:请运营方执行一条 SQL;CFW:自助 `wrangler d1 execute`)→ 下一次请求触发三态判断 → 有 env 静默重建后改密,无 env 引导页当场设新密码。**env 凭证可留可删、无依赖**——留着享受静默重建,删了有页面兜底;抢注窗口仅存在于「删 env + 删行」的重置瞬间(作者主导、分钟级),部署路径(env 在)永远无窗口。
### 3.2 密码哈希选 PBKDF2-SHA256
WebCrypto 原生支持——母体与 CFW 抽取版同一实现(Workers runtime 无 argon2/bcrypt,选它们会造成两形态分叉);迭代参数归技术稿。
### 3.3 不建多用户 / 角色引擎
管理操作只有作者一人——RBAC 无用武之地;权限边界由访问面物理隔离(管理面 API 与成员 API 两套路由)。
## 4. 写入时机
| 时机 | 动作 |
|------|------|
| 部署期初始化(双通道) | 空库 + env 凭证在 → 静默 INSERT(幂等,已初始化则永远跳过);空库 + env 凭证无 → 引导页设新密码后 INSERT(重置兜底) |
| 改密(管理面表单) | UPDATE `password_hash` |
改密落**审计日志**(审计为独立需求,表结构归技术稿)。
## 5. 待定事项
| 事项 | 归属 |
|------|------|
| PBKDF2 迭代参数、登录失败节流策略 | 技术稿 |
| 审计日志表结构 | 技术稿 |
+55
View File
@@ -0,0 +1,55 @@
# 交互设计 · Key 管理
> key 的全生命周期操作面:登记(sk-key 明文在 web 界面的**唯一露面处**)、启停(纯手动,无自动摘除)、归属分配、死亡显形。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》· `api_keys表字段设计.md`(字段与 3.6 生命周期)· R2(已裁决:引擎记录不决策) |
| 数据 | `api_keys` 全表 · 引擎轮次摘要(失败明细) |
---
## 1. Key 清单(页面主体)
| 列 | 内容 | 备注 |
|----|------|------|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
| 登记时间 | `registered_at` | |
- 筛选:按成员、按状态、按「最近轮次失败」;排序默认按数据截止降序(异常在前)
- 页面顶部:健康摘要行(本页相关的失败 key 数)
## 2. 登记(sk-key 的唯一界面露面)
入口:「登记 Key」按钮 → 对话框:
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
3. 提交 → 服务端上游验证,四态反馈:
| 结果 | 反馈 |
|------|------|
| 成功 | 「已登记,首轮同步进行中(约 5 分钟内完成)」→ 清单出现新行,「数据截止」显示「同步中…」 |
| 已登记(hash 命中) | 「该 key 已在清单中」(定位到那行) |
| 上游拒绝(401) | 「上游拒绝了这枚 key——可能不存在或已被禁用」 |
| 上游暂不可用 | 「上游站点暂时不可达,请稍后重试」 |
## 3. 启停(纯手动,已裁决无自动摘除)
- **停用**:行操作 → 确认弹窗「停用后移出轮询清单(数据保留)。成员若仍在用它调用 API 不受影响——只有采集停止」→ `status = disabled`
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
## 4. 归属变更
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义)
## 5. 空态
- 无 key:引导文案「还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来」+ 登记按钮常驻
+55
View File
@@ -0,0 +1,55 @@
# 交互设计 · 全员总览
> 管理面首页(登录后的落地页):整个池子的健康与用量全景——你的「全员视角」的唯一居所(开放查看面裁撤后,R5 的全员入口在这里)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(时间范围选择器、数值呈现)· `需求清单.md` R5(全员视角归此页)· R9(多维统计) |
| 数据 | `usage_logs` 聚合(按成员 / key / 来源)· `api_keys.log_synced_at`(数据截止)· 引擎轮次摘要 |
---
## 1. 页面结构(自上而下)
### 1.1 健康带(页面顶部,常驻)
| 元素 | 内容 | 说明 |
|------|------|------|
| 引擎状态 | 「最近轮次:18 key,成功 16,失败 2」 | 失败数 > 0 时高亮并链到 Key 管理的失败筛选 |
| 全池数据截止 | 取全池 `log_synced_at` **最慢**(既定聚合语义) | 落后超过 2 小时 → 黄色预警「数据截止停滞——检查引擎日志」 |
| 未关联订阅分组 | 该时段内 `billing_source = subscription` 且 `subscription_id` 为空的量 | **正常恒为 0**;非零 → 红色提示(缺登记或上游行为变化)——健康指示器的可视化 |
> 健康带不主动轮询(打开页面刷新即可);告警推送能力属 R4(延后),此处只做「打开页面即可见」的显形。
### 1.2 时间范围选择器
- 共性篇 §3.1 的核心组件;**默认最近 30 天**,快捷项含「本订阅期间」
### 1.3 按成员汇总区(主区块)
卡片栅格,每成员一张卡:
- 成员名(+备注 hover)
- 名下 key 数、该时段用量(次数 / quota / 美元)、**占全员比例条**(该时段内的事实占比)
- 数据截止(名下最慢)
- 「未归属(归作者)」作为一张固定的卡——无归属 key 的用量去向(`member_id IS NULL` 的聚合)
### 1.4 按 key 明细区
- 折叠表(默认收起,与按成员卡互不抢屏):key 名、归属、状态、数据截止、该时段用量
- 行点击 → 跳转 Key 管理页该 key 详情
- 按「按来源」的分解以**分段条**呈现于表头上方:订阅消耗 / 充值消耗(该时段)
## 2. 交互流
| 动作 | 流程 |
|------|------|
| 切换时间范围 | 局部刷新全部区块(口径标注同步更新) |
| 点击健康带的失败数 | → Key 管理页,筛选「最近轮次失败」 |
| 点击成员卡 | → 该成员的 key 列表过滤视图(或成员管理页定位该成员) |
## 3. 空态
- 无任何 key:健康带显示「未登记任何 key」,主区块引导「去 Key 管理登记第一个」
- 有 key 无日志:各卡正常、用量为 0(空态与零值区分标注「该时段内无调用」)
+39
View File
@@ -0,0 +1,39 @@
# 交互设计 · 审计日志
> 管理操作留痕的查看面——审计是独立正交需求(与鉴权方案无关),本页是作者的自察工具,无对外语义。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `需求清单.md` §9(访问主体模型·审计日志条目)· `members/users表字段设计.md`(写入时机中「落审计」的动作全集) |
| 数据 | 审计日志表(表结构归技术稿——本文锁定信息结构与展示) |
---
## 1. 记录清单(页面主体)
时间线列表(时间倒序):
| 列 | 内容 |
|----|------|
| 时间 | 到秒(北京时间) |
| 操作者 | `user_id` → 用户名;**系统触发的事件(如空库重建)为 NULL**,天然区分系统操作与会话操作 |
| 操作类型 | 分类标签(见 §2) |
| 内容摘要 | 关键信息(「key 小张-01 · 停用」「成员 A · 令牌重发」「支付记录 #3 · 编辑」) |
## 2. 操作类型体系(登记面的完整枚举)
| 域 | 类型 |
|----|------|
| Key | 登记 / 启用 / 停用 / 归属变更 |
| 支付记录 | 登记 / 编辑 / 删除 |
| 成员 | 开户 / 停用 / 启用 / 令牌签发 / 令牌重发 |
| 账号 | 改密 /(空库重建为系统事件,记「初始化」) |
- 每类管理面写操作一行留痕——操作者列当前恒为作者(单管理员),但**审计是不可变历史,操作者归属不能事后补写**:保留该列是前向兼容(未来若扩展多管理员 / 权限引擎,历史记录自然区分),也是审计语义的自描述原则(who did what when 的 who 是本体字段)
- 筛选:按域(Key / 支付 / 成员 / 账号);分页(时间倒序 + 每页 50 条之类,细节随实现)
## 3. 定位边界
- 审计**只记录管理面写操作**:查询类动作(看总览、翻明细)不留痕;成员侧动作(看自己的视图)不属管理面、不在此列
- 本页无任何操作(纯只读)
+108
View File
@@ -0,0 +1,108 @@
# 交互设计 · 总览与共性
> 交互层的定位、访问拓扑与跨页共性约定——所有功能页设计篇(登录、总览、key 管理、支付记录、成员管理、审计、成员视图)共用本文的规则。**双形态通用**:前端与部署形态无关(母体与 CFW 抽取版共用同一交互层),形态差异只在部署与运行时通道。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `需求清单.md`(R 编号与裁决记录——本文全部设计的依据)· 七篇表字段设计(数据来源) |
| 产出 | 访问拓扑、导航结构、跨页组件、状态模式、数值与时间呈现 |
---
## 1. 访问拓扑:两个互不可见的世界
```svg
(拓扑见下文描述,实现时以此为准)
```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 单页;与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
## 2. 管理面导航:侧边栏
- 左侧固定侧边栏,结构(自上而下):
| 菜单 | 页面 | 职责一句话 |
|------|------|-----------|
| 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) |
| Key 管理 | key 清单 | 登记、启停、归属 |
| 支付记录 | 记录清单 | 订阅/充值登记、核对 |
| 成员管理 | 成员清单 | 开户、令牌、停用 |
| 审计日志 | 操作留痕 | 管理动作时间线 |
| 账号设置 | 改密 | 会话体系基本配套 |
- 侧边栏底部:当前用户名 + 登出(登出即删会话行 → 回登录页)
- 成员视图**无侧边栏**(单页,页头导航)
## 3. 跨页组件与呈现规则
### 3.1 时间范围选择器(核心组件)
- 一切统计的公共维度:**任意时段** + 快捷项
- 快捷项:最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
### 3.2 数值呈现三约定
| 值 | 呈现 | 依据 |
|----|------|------|
| 支付金额(`paid_amount`) | **纯数字,无货币符号**——如「170」;登记表单注明「金额数值,币种由你们约定,系统不预设」 | 币种无关裁决(R9) |
| 额度与用量 | **双单位**:原始 quota(如 12,500,000)+ 美元换算($25.00)——美元由 QPU 换算,带 `$` 前缀(换算目标客观明确) | quota 域同域可比 + 人眼直觉 |
| 美元换算的取值 | 按日志时刻取 QPU(`qpu_history`);聚合数字一律注明「按当时换算率」 | qpu_history 定稿 |
### 3.3 时间呈现
- 存储为 UTC,**展示一律北京时间**(应用层转换——表设计既有约定)
- 明细时间到秒(14:22:05),统计口径到日;「数据截止」到分钟
### 3.4 状态模式(全页面统一)
| 态 | 表现 |
|----|------|
| 加载 | 骨架屏(首屏)/ 行内 spinner(局部刷新),不整页白屏 |
| 空态 | 一句话说明 + 下一步动作引导(如「还没有登记任何 key——去 Key 管理登记第一个」) |
| 错误 | 顶部错误条(可重试操作提供按钮);401 走 §1 分流 |
| 同步中 | 数据截止旁的「同步中…」态(登记后首轮、或截止落后时) |
### 3.5 响应式原则
- **只做布局响应**:侧边栏窄屏折叠为抽屉、栅格与区块自适应换行、宽度弹性——纯 CSS 布局层的自适应,仅此而已
- **不做组件响应**:表格在窄屏保持表格形态(横向滚动即可),不为移动端做卡片化视图;**不做移动端 UI 重设计**——窄屏只是布局自然收窄,不识别设备、不切换界面
- 视觉稿、组件库、间距体系不在交互设计范围(作者即前端,实现期自定);本文锁定**信息结构、交互流、状态与文案**
### 3.6 文案基调
- 中文、直白、零营销腔;数字精确显示(用量不四舍五入口径内的原值);错误提示说人话(「上游站点暂时不可达,请稍后重试」而非「ERROR 502」)
### 3.7 组件能力需求(实现期选型参照)
八篇交互设计实际用到的组件能力——组件库与前端框架是实现期自由度,本清单是选型的验收标准而非锁定:
1. **布局**:可折叠侧边栏 / 抽屉、栅格系统(布局响应,见 §3.5)
2. **表格**:密集列、行内编辑(归属下拉)、**展开行**(来源徽章的展开层)、行级操作、窄屏横向滚动
3. **浮层**:对话框(含二次确认)、toast(复制反馈)、顶部提示条(可关闭)
4. **表单**:日期范围选择(时间选择器的快捷项 + 自定义起止)、大号粘贴输入框、双输入切换(支付记录的额度模式)
5. **展示**:徽章 / 标签(来源、状态)、分段条(来源分解)、骨架屏、分页
### 3.8 纯用量模式(无支付记录的完整子集)
平台支持**不登记任何支付记录**的部署形态(如部门实例:只看用量、不掌握订阅套餐信息)——用量统计链路(登记 → 采集 → 聚合)独立于金额层,**零改造支持**:
- 成员视图支付参数卡:中性空态「暂无支付记录信息」——**不设行动引导**(不登记是合法使用形态,非遗漏)
- 日志来源徽章的展开行:无对应登记时显示「该订阅无登记信息」
- 核对提示条:`payments` 全空时**自动静默**(作者显然选择了不登记,此时提示是噪音而非健康信号)
- 「本订阅期间」快捷项自动隐藏(无期间数据)
- 完全不受影响:健康带、按成员 / key 聚合、来源徽章与分解、全部管理操作、审计
## 4. 会话与令牌的生命周期呈现
| 事件 | 界面行为 |
|------|---------|
| 会话过期(7 天) | 下一请求 401 → 登录页 + 「会话已过期」提示 |
| 登出 | 删除会话行 → 登录页 |
| 改密成功 | 删除**其他**会话行(保留当前),提示「其他设备的登录已失效」 |
| 成员令牌重发 | 旧令牌即时失效(成员侧下次 401 回登录壳) |
| 成员停用 | 同上,登录壳提示「令牌无效或已停用」 |
+47
View File
@@ -0,0 +1,47 @@
# 交互设计 · 成员管理
> 成员实体的操作面:开户、令牌签发与重发(明文仅展示一次)、停用。访问主体模型中「凭证事务经作者」的落点。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、令牌不过期)· R12(成员视图的锚点) |
| 数据 | `members` 全表 · 名下 key 聚合 |
---
## 1. 成员清单(页面主体)
卡片栅格(成员量级:个位数):
| 元素 | 内容 |
|------|------|
| 名称 | `name`(+备注) |
| 状态 | active / disabled 徽章 |
| 令牌签发时间 | `token_issued_at`(提示「明文已不可找回,如需新令牌请重发」) |
| 名下 key 数 | 聚合计数(hover 列出 key 名) |
| 操作 | 重发令牌 / 停用(或启用) |
## 2. 开户
表单:名称(必填、唯一性校验)、备注(可空)→ 提交 → **自动生成令牌** → 明文展示对话框(见 §3)
## 3. 令牌签发与重发(敏感操作的标准交互)
**明文展示对话框**(开户自动触发,或重发触发):
- 令牌明文 + 复制按钮(点击即复制 + 「已复制」反馈)
- 醒目警示:「**关闭后将无法再次查看**——请立即复制并通过你信任的渠道发给成员」
- 关闭需二次确认(「我已保存并发送」按钮)
- **重发**的确认弹窗先行:「重发后旧令牌立即失效(该成员正在用的访问将中断)」→ 确认 → 新明文对话框
- 对话框关闭后,界面任何地方不再出现明文(库里只有哈希——members 3.1)
## 4. 停用 / 启用
- 停用:确认弹窗「停用后该成员的视图立即不可访问(令牌验证含状态检查);数据保留」——退拼 / 令牌疑似泄露的标准动作
- 启用:直接启用(下一请求即恢复)
- 软删除语义:**无删除按钮**——成员与其历史用量是账本事实,只有停用(members 3.4)
## 5. 空态
「还没有成员——为每位分摊朋友开户并发放访问令牌」
+76
View File
@@ -0,0 +1,76 @@
# 交互设计 · 成员视图
> 成员的单页世界——名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**:含「日志行携带所属订阅/充值信息」的形态定案。页面无任何「应付」概念(分摊哲学裁决)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `payments表字段设计.md`(参数卡数据源) |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `payments` 参数(完全可见) |
| 前置 | 经登录壳(令牌)进入;无侧边栏、单页 |
---
## 1. 单页结构(自上而下,滚动即全部)
### 1.1 页头
成员名 + 一句话定位「你在本池的名下用量」(「你」= 该成员;页面文案以成员视角书写)
### 1.2 时间范围选择器
共性组件;默认最近 30 天,快捷项含「本订阅期间」
### 1.3 聚合卡(该时段总览)
- 总用量:次数 / quota / 美元(标注「按当时换算率」)
- **按来源分解**:订阅消耗 / 充值消耗(分段条)——回答「我的用量花在哪个池子」
### 1.4 支付参数卡(完全公开透明的落点)
该时段涉及的订阅与充值,**参数原貌**逐笔列出:
- 订阅:期间、订阅 ID、额度(quota + $)、金额(纯数字)
- 充值:支付时间、额度、金额
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
> **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」——账本是事实,算账是社交层的事。
>
> 空态:无登记的部署形态(纯用量模式,《总览与共性》§3.8)下该卡显示中性文案「暂无支付记录信息」——不设行动引导(不登记是合法使用形态)。
### 1.5 我的 key 列表
每 key 一张窄卡:`token_name`、数据截止(停滞标注)、该时段用量(次数 / quota / $)。
### 1.6 日志明细表
| 列 | 内容 |
|----|------|
| 时间 | 到秒(北京时间) |
| key | `token_name` |
| 模型 | `model_name` |
| 来源 | **来源徽章**:`订阅 #N` / `充值` / `未关联`(灰)——携带形态的第一层(一眼识别) |
| 用量 | quota + 美元双单位 |
| tokens | prompt + completion |
**携带形态定案(R12 悬置项的本设计结论)**:两层实现——
1. **行内徽章**(常驻):来源类型 + 订阅编号,零噪声
2. **展开行**(点击徽章):该笔支付的完整参数(期间 / 额度 / 金额 / 备注)——完整透明的第二层,按需展开不打扰
> 展开行降级:徽章对应的订阅**无支付记录**时,展开显示「该订阅无登记信息」(纯用量模式的常态,见《总览与共性》§3.8);徽章显示「未关联」的行(日志缺 `subscription_id`)不可展开——两种空值场景各自归位,见 `usage_logs表字段设计.md` 3.6。
> 该形态随实现期真实数据核查(订阅编号同一性)后再终验;徽章文案预留「未关联」的正常位(健康指示语义)。
## 2. 状态与边界
| 场景 | 表现 |
|------|------|
| 该时段无日志 | 聚合区「该时段内无调用」+ 明细空态 |
| 名下无 key(刚开户) | 全页空态「作者尚未为你分配 key」 |
| 令牌失效/停用 | 任意请求 401 → 登录壳(「令牌无效或已停用」) |
| 旧令牌(作者重发后) | 同上——旧令牌即刻作废 |
## 3. 窄屏说明
成员可能用手机查看本页——但按《总览与共性》§3.5 的裁决:**不做移动端 UI 设计**,窄屏下依赖布局响应(区块纵向堆叠、宽度弹性)保证可读不崩;明细表保持表格形态,横向滚动即可。
+47
View File
@@ -0,0 +1,47 @@
# 交互设计 · 支付记录
> 订阅与充值的登记面(同构表单)+ 系统核对提示的呈现——「只有作者知道」的信息的唯一录入处(payments 定位)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现:金额无符号、额度双单位)· `payments表字段设计.md`(字段与「只存事实」)· R7(充值登记)· R8(`subscription_id` 直连) |
| 数据 | `payments` 全表 · 日志观测的订阅 ID(核对提示) |
---
## 1. 记录清单(页面主体)
| 列 | 内容 | 备注 |
|----|------|------|
| 类型 | `subscription` / `topup` 徽章(订阅 / 充值) | 同构表的两种形态 |
| 金额 | **纯数字**(如「170」) | 币种由你们约定——列头标注「金额(币种自定)」 |
| 时间 | 订阅:期间 `[start, end)`,未结束显示「进行中」;充值:`paid_at` | 期间左闭右开,展示为「09-01 ~ 10-01」 |
| 订阅 ID | `subscription_id`(充值留空) | 直连日志的分源键 |
| 额度 | 原始 quota + 美元换算双单位(如「12,500,000($25.00)」) | 美元按 QPU 换算,标注「按当时换算率」 |
| 备注 | `remark` | |
- 排序:时间倒序;编辑 / 删除(删除有确认弹窗——「改错即修,删除即消失」,人工登记数据)
## 2. 登记表单(订阅 / 充值同构,`kind` 切换)
**公共字段**:金额(必填,纯数字输入;脚注「金额数值——币种由你们约定,系统不预设」)、备注(可空)
**订阅形态追加**:
- 支付时间、期间起止(结束可空 = 进行中)
- 订阅 ID(抄自钱包页面「订阅 #N」)
- 额度——**双输入模式**:直接输原始 quota,或输美元由系统按当前 QPU 换算落库(表单内二选一切换,换算即时预览;录入体验归交互层、数据落原始 quota——payments 3.6)
**充值形态追加**:支付时间、额度(同上双输入)
## 3. 核对提示(系统观测 ↔ 人工登记的对照)
页面顶部提示区,由日志观测驱动:
| 提示 | 触发 | 文案 |
|------|------|------|
| 未对应登记 | 观测到某 `subscription_id` 有消耗、但 `payments` 无此订阅 | 「观测到订阅 #N 有消耗,但尚无对应登记——请核对补录」 |
| 正常 | 全部观测到的订阅均有登记 | 不显示(健康默认无声) |
- 观测**只提示、不回填**(payments 定稿语义);提示区与全员总览的「未关联订阅」分组互为印证(一个说「有 ID 没账」,一个说「有账路没 ID」)
- **静默规则**:`payments` 全空时本提示区自动静默——纯用量模式(《总览与共性》§3.8,如部门实例)下「无登记」是作者的有意选择,此时提示是噪音而非健康信号
@@ -0,0 +1,48 @@
# 交互设计 · 登录与会话
> 两个世界的入口设计:管理面登录页、空库三态(静默重建 / 引导页)、改密表单、成员登录壳。访问主体模型的交互落点(R11 裁决)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(导航与状态模式)· `users表字段设计.md` 3.1(空库双通道)· `members表字段设计.md`(令牌)· `sessions表字段设计.md` |
| 页面 | 管理面登录页、引导页、账号设置(改密)、成员登录壳 |
---
## 1. 管理面登录页
- **唯一元素**:用户名、密码、登录按钮——无「忘记密码」链接(重置路径是服务器侧删行,见 users 3.1,不设界面入口)
- 提交:失败 → 「用户名或密码错误」(通用提示);成功 → 建会话(HttpOnly cookie)→ 落地全员总览
- **空库三态在此路由内分流**(前端请求管理面入口时后端判定状态):
```
GET 管理面入口
├─ users 非空 ────────── 登录页(如常)
├─ 空库 + env 凭证在 ─── 后端静默重建 → 登录页(密码即 env 凭证,用户无感)
└─ 空库 + env 凭证无 ─── 引导页(见 §2)
```
## 2. 引导页(空库兜底,一次性)
- 出现条件:`users` 空且 env 无引导凭证——重置密码时的兜底通道
- 表单:用户名、密码、确认密码;提交 → 落库 → 直接进入登录态(免二次登录)
- 页头说明文案:「系统检测到管理员尚未初始化——请设置管理员账号」
- **永不复现**:`users` 非空后此页面不再可能出现(后端状态判定保证)
- 安全提示(页面脚注):「请尽快完成设置——初始化完成前,此页面对任何访问者可见」
## 3. 账号设置(改密)
- 侧边栏「账号设置」:旧密码、新密码、确认新密码
- 成功 → 提示「密码已更新;其他设备的登录已失效」(**删除其他会话行、保留当前**——sessions 可吊销能力的运用)
- 失败 → 「旧密码不正确」
## 4. 成员登录壳
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮
- 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(单页,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
- 页面氛围:与登录页同构但**视觉上明确这是「成员入口」**(页头「成员入口」标识),防止你自己走错门
- 无「找回令牌」链接:丢了找作者重发(裁决语义,界面不设自助入口)
- 关闭标签 → sessionStorage 清除 → 下次重输(一个月一次的成本,换来零凭证持久化)
+203
View File
@@ -0,0 +1,203 @@
# 同步引擎设计
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在七篇表设计;本文定「引擎怎么转」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-05) |
| 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` |
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
---
## 1. 进程形态(与后端分离)
同步引擎是**独立入口进程**(`apps/engine`),与 `apps/server`(Hono)**分进程、可同机部署**:
```text
apps/server ──► HTTP(/api/admin · /api/member)──► packages/db
apps/engine ──► 定时 syncRound() ──► packages/db
│
└────────► packages/core/upstream(NewAPI)
```
| 约定 | 说明 |
|------|------|
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) |
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
## 2. 调度(方案 A:手写 `setTimeout`)
不引入 `croner` / `node-cron` 等库;用标准定时器实现「一轮结束 → 约下一枪」。
```text
loop():
if inFlight: log warn; schedule(); return
inFlight = true
try:
await syncRound()
finally:
inFlight = false
schedule() // 本轮结束后再等 interval
schedule():
setTimeout(loop, SYNC_INTERVAL_MS)
```
| 项 | 约定 |
|----|------|
| 周期 | **`SYNC_INTERVAL_MS`**,默认 `300000`(5 分钟);由 env 配置,非法值启动失败 |
| 语义 | **结束后 + interval**(周期 = 间隔 + 上轮耗时);不追求整点 cron |
| 防堆积 | 上一轮未结束则跳过 / 不叠加(`inFlight`);**禁止**裸 `setInterval(async …)` |
| 启动 | `apps/engine` 进程起来后立即跑一轮,再进循环 |
| 停止 | 收到退出信号:不强制中断本轮(可等完);不保证优雅排空 |
| 错过 | 不追补「错过的时刻」;下一轮全量拉最近 1000 条即补偿(上游窗口语义) |
> 与交互文案「约 5 分钟 / ≤5 分钟」对齐:实际最坏接近 `interval + 单轮耗时`;若单轮通常远小于 interval,观感仍约为 5 分钟。需要「整点对齐」时再改为 `nextSlot = ceil(now / interval) * interval`(非本阶段)。
## 3. 一轮 `syncRound()` 做什么
```text
1. 选 key:api_keys.status = active 全量(id 升序,串行)
2. 对每个 key:
a. 解密 key_ciphertext → sk 明文
b. GET /api/usage/token
成功 → UPDATE 上游字段全组 + usage_synced_at
失败 → 记入本轮 failed,不写 api_keys
c. GET /api/log/token
成功 → 逐行 dedupe_key → INSERT ON CONFLICT DO NOTHING
→ UPDATE log_synced_at
失败 → 记入本轮 failed,不写 log_synced_at
d. (建议)每 key 之间 setImmediate/短 sleep 让出,降低单轮 CPU 连续占用
3. QPU 采样(每轮一次,与 key 无关):
GET /api/status → quota_per_unit
与 qpu_history 最新值相同 → 不写;变化 → INSERT(effective_from = 本轮时刻)
4. 收束 last_round(§5),打一行轮次日志
```
### 3.1 与表设计的对齐(勿另立标准)
| 行为 | 权威出处 |
|------|----------|
| 仅写成功结果;失败不写行 | 《api_keys表字段设计》§4 |
| `dedupe_key` 规则(type=2 / 非 2 / 空 request_id) | 《usage_logs表字段设计》§3.1 |
| `ON CONFLICT (dedupe_key) DO NOTHING` | 同文写入时机 |
| QPU **值变化才追加** | 《qpu_history表字段设计》§4 |
| 登记后**异步补一轮**,不阻塞 201 | 《api_keys表字段设计》§4 |
### 3.2 幂等与重试
- 重复拉取 → `dedupe_key` 合并,零副作用。
- 单 key 失败不影响其他 key;不写「半行」已成功的 key 会保留。
- 不做单 key 积极重试;**周期即重试**。
### 3.3 登记后补跑(已裁决:方案 A)
- Key 登记(`POST /api/admin/keys`)成功后,**不**在 HTTP 请求里同步拉日志,**也不**引入秒级唤醒/优先队列。
- **最迟一个 `SYNC_INTERVAL_MS` 内**由下一轮自然覆盖(独立进程下避免 IPC;与交互文案「约 5 分钟内完成」一致)。
- 与《api_keys表字段设计》§4「首轮日志同步不阻塞登记响应」不冲突:登记 201 立即返回;首轮日志异步、且上限为一个调度周期。
## 4. 失败语义(R2:记录、不决策)
| 情况 | 引擎行为 | 可见 |
|------|----------|------|
| 401 / 403 / 5xx / 超时 | 该 key 本轮失败;不写 api_keys / log_synced_at | `log_synced_at` 停滞;`last_round.failed_api_key_ids` |
| 登记阶段 422/502 | 不属引擎 | 登记接口已处理 |
**不做**:自动停用、自动摘除、自动告警(R4 延后)。
运行日志(stdout)建议**中文描述**(与平台文案基调一致),例如:
```text
同步轮次完成:key=18 成功=16 失败=2 耗时=812ms
同步失败:key_id=3 原因=401
```
访问日志、启动日志与错误日志同样使用中文表述(实现已统一)。
## 5. 引擎状态与 `/api/admin/engine` 衔接
契约响应形态不变。**存储:`engine_status` 单行快照表**(已裁决);engine 每轮写入,server 的 `/api/admin/engine` 只读该表(不 import engine 包、不走 IPC)。
```text
engine_status(单行)
running: boolean
sync_interval_ms: number // 只读观测:当前生效周期
last_round: { started_at, keys_total, succeeded, failed, failed_api_key_ids } | null
updated_at
```
- 与《API契约-管理面》§3 字段一一对应(`last_round` 为 `null` 合法);`sync_interval_ms` 为**只读观测**,便于界面展示,**不提供修改接口**。
- `GET /api/admin/keys?recent_failed=true` 读 `failed_api_key_ids`。
- **不做**完整 `engine_rounds` 历史表;表名/列名实现期可微调,语义不变。
## 6. 代码落点
| 逻辑 | 位置 | 约束 |
|------|------|------|
| 上游 HTTP | `packages/core/upstream.ts`(补 `fetchLogs`) | Web 标准 API |
| dedupe / 折算 | `packages/core` | 已有 |
| SQL | `packages/db`(engine 写入用仓储) | 方言不外泄 |
| `syncRound` + 定时循环 + 进程入口 | **`apps/engine`**(如 `src/loop.ts`、`src/sync-round.ts`、`src/index.ts`) | 独立 `package.json` |
| `/api/admin/engine` 读状态 | `apps/server` 读 `engine_status` | 不 import engine 包 |
Monorepo 增补:
```text
apps/
server/ # HTTP
engine/ # 同步循环(本设计)
web/
```
## 7. 配置(env,由 `apps/engine` 读取)
| 键 | 默认 | 含义 |
|----|------|------|
| `SYNC_INTERVAL_MS` | `300000` | 轮询间隔毫秒(**可配置**;默认 5 分钟)。**仅 env、仅启动时读**;管理面/API **不提供修改**(运维旋钮而非业务口径) |
| 复用 | `DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` | 与 server 相同语义 |
`server` **不读** `SYNC_*`。
## 8. 非目标
1. **CFW / Cron Triggers / D1**——不预留;
2. **cron 库 / cron 表达式**——本阶段方案 A,只用毫秒间隔;
3. **经 server / API / 管理面修改同步周期**——interval 只走 env;状态快照中的 `sync_interval_ms` 仅只读观测;
4. **R3 备份 / R4 告警**;
5. **多 key 并行、分布式锁、多 engine 实例**——单 engine 实例部署约定(文档写明即可);
6. **轮次历史表、调用自家 HTTP API、进程间 IPC**——协作只经 PostgreSQL。
## 9. 验收清单(实现后)
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
- [ ] 同一数据重复轮次不产生重复行
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
- [ ] QPU 不变无新行,变化 +1
- [ ] `GET /api/admin/engine` 与契约一致(跨进程读到 engine 写的状态)
- [ ] `SYNC_INTERVAL_MS` 可调;**不启动 `apps/engine` 即不轮询**(独立进程,无 `SYNC_ENABLED` 开关)
- [ ] 无定时器叠加(人为拉长一轮耗时,验证 `inFlight`)
## 10. 裁决记录
### 2026-10-05 · 进程与调度形态
- 独立 `apps/engine`,与 `apps/server` 分进程;不为 CFW 预留。
- 调度方案 A:手写 `setTimeout`,一轮结束再约下一枪;`SYNC_INTERVAL_MS` 可配置。
### 2026-10-05 · 补跑与状态(作者认可推荐项)
1. **登记后补跑 = 方案 A**:最迟一个 interval 内由下一轮覆盖;无秒级唤醒/优先队列。
2. **`engine_status` 单行快照表**:engine 写、server 读;表名实现期可微调;可含只读 `sync_interval_ms`。
3. 周期文案:默认 5 分钟、可配置(`SYNC_INTERVAL_MS`)。
### 2026-10-05 · 周期修改通道
**不通过 server / API 修改 interval**——仅 env、启动时读;管理面无改配。协作只经 PostgreSQL,无 IPC/内嵌 HTTP。
实现落点:`syncRound` + 调度循环在 `apps/engine`(见 §6)。
+102
View File
@@ -0,0 +1,102 @@
# 技术选型
> 母体形态(完整前后端 + PostgreSQL,R13 先行交付形态)的技术栈裁决:语言、逐轴选型、分层落地、验收标准与回退预案。**范围**:功能全集在母体上设计与实现;CFW 抽取版为 R13 后续阶段,待功能全集实现后启动——本稿不覆盖其形态面(Workers / D1 / Cron Triggers / Pages),只保留其中现阶段零成本的预留(§5)。选型总原则:**一项选择既要是母体上的好选择,也不给后续抽取挖坑。**
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | `需求清单.md`(R13/R14 · 裁决记录——PBKDF2 两形态统一实现)· `交互设计-总览与共性.md`(§3.7 组件能力清单——前端验收标准)· `README.md` |
| 取代 | 无(首篇选型文档) |
---
## 1. 结论总览
**全栈 TypeScript,一套语言贯穿前端与后端。**
| 层 | 选定 |
|----|------|
| 运行时与工具链 | **Bun**(runtime + 包管理 + 测试 + 打包一体化) |
| HTTP | **Hono** |
| 数据访问 | **Drizzle ORM** + postgres.js + **PostgreSQL** |
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
| 加密 | WebCrypto(Bun 原生) |
| 前端 | **Vue 3 + antdv-next + Vite** |
| 部署 | **Docker**(倾向,§6) |
## 2. 语言:TypeScript 全栈
1. 前端横竖是 JS 家族(Vue 3)——前后端同语言,类型系统、模块、异步语义一份学完,DTO 类型前后端共用一份契约;
2. 作者点名学习目标(Bun + Hono + Drizzle + PostgreSQL)即 TS 生态;
3. R13 后续的 CFW 形态原生运行时也只有 JS/TS——全栈 TS 顺带保留了那扇门(现阶段不作为选型依据,零成本预留见 §5)。
## 3. 逐轴选型与依据
| 轴 | 选定 | 依据 | 回退 |
|----|------|------|------|
| 运行时与工具链 | **Bun** | runtime + 包管理 + 测试 + 打包一体化,学习只认一个工具;作者点名学习目标,认可其生产可用性 | Node——业务代码不动(§7) |
| HTTP 框架 | **Hono** | TS 原生、极轻、中间件模型干净;Bun 原生适配 | `@hono/node-server`(Node) |
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§7.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
| 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) |
| 构建 / 测试 | **Vite**(web)+ **bun test**(core/db/server) | Bun 自带 test runner;Vite 为 Vue 生态默认 | 前端组件测试可加 vitest(实现期定) |
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
## 4. 分层落地:monorepo(Bun workspaces)
```text
(仓库根 · design 分支)
├─ apps/
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 进程内调度 · 环境变量与密钥注入
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite
├─ packages/
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储实现
```
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
**代码落点**:实现代码与设计文档同在 design 分支演进。
## 5. R14 预留:现阶段零成本,只为后续抽取不挖坑
R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留:
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/server` 与 `packages/db`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
## 6. 部署通道(倾向,细则归后续部署设计)
母体以 **Docker 镜像**交付(server 容器 + PostgreSQL,compose 编排):满足仓库「一条命令链可部署」约定;运营方服务器零环境依赖(Bun 运行时随镜像内置),升级 = 换镜像重启;作者不接触服务器,镜像化把运行时选择与运营方环境解耦。
## 7. 风险与回退
| 风险 | 评估 | 对策 |
|------|------|------|
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§7.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
| Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 |
| Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
### 7.1 antdv-next 核验记录(2026-10-02,一手源直查)
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
| 核验点 | 结果 |
|--------|------|
| 包与许可 | `antdv-next`@1.6.0(2026-10-01 发布);MIT;ESM + 类型完备(`dist/index.d.ts`) |
| 活跃度 | 仓库 2025-12 建立、最后推送 2026-10-01;近 15 版分布于 2026-05-17 ~ 10-01(约 1–2 周一版);951 星 / 115 fork / 开放 issue 仅 7 |
| 工程质量 | CI + codecov;单测覆盖工件随 release 发布;性能回归基线(每实例响应式计数上限)+ `pnpm bench` 基准;web-types 自 `global.d.ts` 生成、覆盖全部 128 个全局组件;发布说明双语、带性能数据与逐 PR 出处 |
| §3.7 覆盖 | 清单逐项有对应文档组件:Layout(Sider) / Drawer / Grid;Table(expandable 展开行、scroll 横向滚动);Modal / Popconfirm / Message / Alert(closable) / Notification;DatePicker(RangePicker) / Input(TextArea) / InputNumber;Badge / Tag / Segmented / Skeleton / Pagination |
| Agent 友好 | llms.txt / llms-full / llms-semantic(中英)+ For Agents / MCP Server / Skills 专用文档——超出「宣称」水平,利好本仓库 AI 协作流程 |
| 残留风险 | 项目约 10 个月、维护者 3 人(单点风险);minor 版本随依赖下限整体抬升(锁版本对冲);社区采用规模未核验;官网 llms.txt 一处条目标签笔误(Flex 误标 Empty——工程细节小瑕疵) |
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
## 8. 待裁决项
1. Docker 细则:基础镜像、compose 形态、密钥注入方式——部署设计时定;
2. 前端测试设施是否引入 vitest。
+165
View File
@@ -0,0 +1,165 @@
# 需求清单
> 平台的需求权威索引与裁决档案:以「**不依赖 NewAPI 平台管理员与用户体系的独立计费平台**」为定位,按功能域记录平台做什么、不做什么、为什么(裁决脉络见 §9)。后续专门设计文档(API 契约 / 交互 / 引擎)回答「怎么做」;R 编号体系是被引用的锚点。R3 / R4 延后项存档于 §9,核心功能实现后重启;新需求按 R 体系增补。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `README.md`(项目沿革与动因)· 七篇表字段设计(定稿)· `docs/references/NewAPI-接口范围.md`(可用接口参考) |
| 取代 | 《设计议题》(2026-09-29 由本清单取代;其中双形态裁决与 CFW 调研结论已并入本文 §5/§7) |
---
## 0. 平台定位与背景
**定位**:不依赖 NewAPI 平台管理员与用户体系的**独立计费平台**。上游只提供 sk-key 级接口——没有账单、没有分摊、没有历史回填,一切计费性质的能力(采集、归集、折算、分摊、账单)都必须由平台自身承载。上游接口的完整实证见 `docs/references/NewAPI-接口范围.md`。
**平台哲学(2026-09-29 裁决)**:**平台不做价值判断,只提供事实服务**——「谁该付谁多少钱」的分摊口径是人和人之间的事;平台提供分账所需的全部原材料(用量事实、来源标注、支付记录、多维统计),数字全部客观中立。
**背景**(三类问题促成当前设计阶段):
1. **迭代部署成本**:服务器部署形态下每次迭代都需实例运营方手工升级,测试阶段迭代频繁而运营方无暇跟随 → 双形态交付(R13,已裁决)
2. **初版设计刻意收缩的部分**:管理面固定口令不妥(R11)、充值折算(R7)、多订阅处理(R8)
3. **已知设计问题**:错误行 `request_id` 去重(R15 必修项)
## 1. 数据采集域
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R1 | sk-key 轮询采集与攒库 | 定时轮询 key 级接口、`request_id` 去重、全量日志入库——架构动因(上游无时间范围过滤接口)是物理约束,实现细节开放讨论 | **已裁决**(2026-10-01,随 R15 表设计审阅定稿) |
| R2 | key 登记与生命周期 | 管理通道登记、手动启停(`status` 纯手动开关,引擎只读);401 等持续失败经引擎日志与数据截止**显形**,停用由作者手动执行(**不做自动摘除**——引擎记录、不决策) | **已裁决**(2026-09-29,随 R15 审阅) |
## 2. 数据资产域(新增)
> 本地库是全量日志的**唯一副本**(上游不提供回填);上游窗口仅最近 1000 条(实测当前速率 ≈ 3 天),同步停摆超过窗口即**永久丢数据**。「不依赖上游」的直接推论:资产保护是平台自己的责任。
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R3 | 数据资产保护 | 备份 / 导出 / 恢复策略——平台核心资产不能只有单副本 | **延后**(2026-09-29 裁决:核心功能实现后再议;提案存档见裁决记录) |
| R4 | 采集健康与告警 | 同步停摆告警、数据连续性校验——3 天窗口的不可逆风险需要可观测兜底 | **延后**(2026-09-29 裁决:核心功能实现后再议;提案存档见裁决记录) |
## 3. 计费域
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R5 | 用量查询 | 时间范围 / 明细与汇总;查询入口已归位——成员视图(令牌)与管理面总览/下钻(口令),「凭 sk-key 的开放查询」已裁撤(见裁决记录 2026-09-29);统计维度含按 key / 成员 / 计费来源 / 模型(见 R9) | **已裁决**(2026-10-01,数据层随表设计定稿;查询口径细节归 API 契约) |
| R6 | 美元换算与支付记录 | **重定义**(2026-09-29):① quota → 额度美元换算(÷ `quota_per_unit`,`qpu_history` 时间线服务它)——纯客观单位换算,保留;② **裁撤**每行日志的实付分摊金额(`amount_rmb`,「quota 占比 × 实付」)——它是分摊口径的派生值,随购买记录漂移,违反事实服务原则;③ 支付记录登记:支付金额 ↔ 额度的**比率关系**(币种无关——系统不预设币种,见 R9) | **已裁决**(2026-09-29,重定义) |
| R7 | 充值登记 | **重定义**(2026-09-29):充值与订阅同构——支付记录(金额 + 额度 + 比率)+ 日志来源标注(`billing_source`)+ 按来源统计;**同样不做**充值用量的金额分摊(原「池子加权平均」方案裁撤) | **已裁决**(2026-09-29,重定义) |
| R8 | 多订阅并行处理 | **重定义**(2026-09-29):① 支付记录允许多订阅期间重叠(不设互斥约束)② 日志-支付记录按 `subscription_id` **直连**(上游标注的客观事实,不推算)——「整笔订阅一把尺」原则已随 `amount_rmb` 裁撤消亡,订阅维度无需任何「尺」③ 按订阅分组的统计为查询时聚合输出(**非物理表**——账本只存事实行,统计是随查随算的派生视图);「未关联订阅」为 LEFT JOIN 未匹配集的透明分组(无新字段,正常时恒为零——非零即异常信号:缺登记或上游行为变化),兼作数据健康指示器;扣费顺序(先旧后新等)是上游策略,日志忠实记录,平台不管 | **已裁决**(2026-09-29,重定义) |
| R9 | 分摊原材料与多维统计 | **重定义**(2026-09-29):平台**不做**应付款计算、**不做**分摊口径(闲置额度归谁之类是社交层问题)——只提供原材料:计费来源标注(`billing_source`:订阅 / 充值)、支付记录(**币种无关**:支付金额与额度之间的比率关系,不预设币种)、任意时段多维聚合(按 key / 成员 / 来源 / 模型) | **已裁决**(2026-09-29,重定义) |
| R10 | ~~计费周期与账单~~ | **已裁撤**(2026-09-29):周期 = 任意查询时段(订阅期间为预置选项);无平台侧账单(应付款不计算,期末快照无对象)。保留行以维持编号连续 | **已裁撤** |
## 4. 使用者域
> 平台自身的身份体系——实际使用者 ≈ 作者(管理)+ 若干分摊成员(查询)+ 运营方(信任模型内,本就能看到一切)。这不是多租户产品。
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) |
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);**完全公开透明**(订阅与支付参数对成员可见);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29) |
## 5. 交付域(已裁决)
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(后端单进程承载 API 路由与同步引擎),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29) |
| R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) |
## 6. 数据模型
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R15 | 数据模型修订 | 七篇表设计(`api_keys` · `usage_logs` · `payments` · `qpu_history` · `members` · `users` · `sessions`)经作者逐篇审阅**定稿**:四张基线表复审落点 + `payments` 重定义(订阅/充值同构、币种无关、只存事实)+ 访问主体三新表 + `usage_logs` 来源提列(`billing_source` / `subscription_id`)与错误行去重修订(非消费行 `dedupe_key` 追加含 `content` 的指纹)+ `api_keys.member_id` 归属列 + 401 自动摘除状态机裁撤(引擎记录不决策);方言映射归数据访问层设计 | **定稿**(2026-10-01) |
## 7. R13 的支撑材料:CFW 可行性调研(2026-09-29,developers.cloudflare.com 官方文档核实,五项全部无阻断)
| # | 调研点 | 结论(Workers Free / Paid) |
|---|--------|---------------------------|
| 1 | Cron 粒度与补偿 | `*/5 * * * *` 支持(最小每分钟);UTC 执行;**单次执行上限 15 分钟**;`controller.scheduledTime` 可检测延迟;失败重试机制存在(`noRetry()` 可关);Cron Events 留存最近 100 次调用(可观测) |
| 2 | D1 限制 | 单库 **500 MB / 10 GB**;每 Worker 调用查询数 **50 / 1000**;单条 SQL ≤ 100 KB、绑定参数 ≤ 100/语句;库为单线程(写入串行)——本项目低频批量写入无碍 |
| 3 | Workers 运行时 | CPU:**10 ms**(网络等待不计)/ 30 s(<1h 间隔 cron;≥1h 为 15 min);外网子请求 **50 / 1000** 每调用;出站并发 6;Cron Triggers 每账号 5 / 250 |
| 4 | 免费额度 | Workers Free:10 万请求/天;D1 Free:读 500 万行/天、写 **10 万行/天**、存储 5 GB;**静态资产请求免费无限**(前端托管零成本) |
| 5 | 加密体系 | Workers 完整实现 Web Crypto:**HKDF ✓ / AES-256-GCM ✓ / HMAC-SHA256 ✓**(原生加速)——`SK_KEY_ENCRYPTION_SECRET` 体系可平移 |
**负载画像对照**(key 数按 10–25 估):
- 每轮 cron:外网子请求 = 2 × key 数(Free 上限 50 → 最多 25 key);CPU 主要花在 1000 条 JSON 解析 + SHA-256 指纹 + AES-GCM 解密——**Free 10 ms 是唯一贴线风险点**(实现期实测,超限则升 Paid)
- D1 写入:5 分钟一批、日增千行级 ≪ 10 万行/天;存储日增 MB 级 → Free 500 MB 可撑约两年,Paid 10 GB 富余
- 查询面:个人低频聚合查询 ≪ 500 万行读/天(时间范围需走索引避免全表扫描)
- 同步引擎防重入:D1 单线程天然串行化写入,多 cron 并发触发无竞态风险;每轮幂等(`request_id` 去重),失败重试零副作用
## 8. 推进方式
**需求阶段收官(2026-10-01)**:15 项全部有归宿——R1 / R2 / R5 / R6 / R7 / R8 / R9 / R11 / R12 / R13 / R14 / R15 已裁决关闭,R10 裁撤,R3 / R4 延后(提案存档见 §9)。七篇表设计、八篇交互设计均经作者逐篇审阅**定稿**。**剩余专门设计文档:API 契约 → 同步引擎设计**(双形态架构下,逐篇立稿、逐篇审阅)。
## 9. 裁决记录
### 2026-09-29 · 访问主体模型(R11 · R12 · 关联 R5)
**访问主体 = 作者 + 成员,一个模型两种凭证**(运营方完全信任,不设身份;CFW 版可将来叠加 Cloudflare Access 作为前置纵深,不作鉴权主体)。
**成员侧(成员视图)**:
- **成员实体(新增)**:基本用户字段(`id` / `name` 显示名 / `remark` 备注 / `status` 启停 / `created_at`)+ 受管理凭证(`token_hash` 哈希存储 + `token_issued_at`;生成时明文展示一次,可吊销、可重发)+ key 归属(`api_keys.member_id`,**1:N**,允许空——未归属 key 存在)
- **一层成员模型**:key 创建权始终在作者(唯一 NewAPI 账号持有人);**归属登记即层级**——成员向下拼车 = 作者开新 key 挂其名下,平台不感知下级;算账口径 = 成员名下全部 key 的计费总和
- **成员视图**:登录壳(输入令牌 → sessionStorage,关标签失效)→ 自己关联的 key 列表 + 每 key 计费 + 聚合(应付部分随 R9 裁决展开)
- 成员侧无注册、无密码、无会话;令牌泄露处置 = 作者吊销重发(朋友信任关系下凭证事务经作者,路径短于自助改密)
**管理面侧(作者,唯一管理员)——最小登录体系**:
- **users 表**(单行:作者 `username` + `password_hash`)+ **sessions 表**(随机 256bit 令牌哈希存储、`expires_at` 如 7 天、登出即删,随时可踢)
- **密码哈希 = PBKDF2-SHA256**(WebCrypto 原生,高迭代)——Workers runtime 无 argon2/bcrypt,PBKDF2 是两形态统一实现(R14 的落地细节)
- **浏览器侧**:`HttpOnly + Secure + SameSite=Lax` cookie,路径限管理面——替代手动凭证管理
- **部署期初始化(空库双通道)**:初始用户名/密码经 env(母体)/ wrangler secret(CFW)传入;管理面入口幂等**三态判断**——非空 → 登录页;空库 + env 凭证在 → **静默落库**(部署完成即就绪,无抢注窗口;母体启动 / 首次请求检查,CFW 首次请求 / 首次 Cron 检查);空库 + env 凭证无 → **引导页**设新密码(重置兜底通道)
- **初始凭证为引导性质**:仅空库时生效,此后改 env 不改密;**改密走登录后的表单**(旧密码验证 → 落库,登录体系的基本配套);**env 凭证可留可删、无依赖**——留着:删 `users` 行即静默重建;删了:引导页当场设新密码(见 `users表字段设计.md` 3.1);抢注窗口仅存在于「删 env + 删行」的重置瞬间
- 无注册、无找回(忘记密码 = 服务器改库,管理员特权);登录节流为最小实现(失败延迟/计数)
- **基线三件套状态修正**:作者侧登录页与会话**解禁**(最小形态);admin 角色不做(单管理员无角色引擎用武之地);成员侧维持令牌无会话
**开放查看面裁撤**(同日裁决):「凭 sk-key 查」入口取消——sk-key 明文从 web 界面消失(仅登记时输入一次、随即加密);场景归位:**成员 → 令牌视图;作者 → 管理面**(key 清单下钻 + 全员总览面板,总览为新增设计项)
**审计日志(独立需求,与鉴权方案正交)**:管理面写操作(key 登记/启停、购买记录增删改、成员开户/停用、令牌签发/吊销、改密)逐条留痕:**操作类型、时间、操作者**(前向兼容多管理员扩展——审计是不可变历史,归属不能事后补写;系统触发事件如空库重建为 NULL)、关键内容摘要;属业务核心层,两形态共有。同理正交的还有同步引擎运行日志——记录语义两形态一致,落盘通道随形态(母体 stdout/journald,CFW Workers Logs)
### 2026-09-29 · 分摊哲学与原材料(R9 重定义 · R10 裁撤 · R6/R7 重定义 · R12 形态修正)
**平台不做价值判断,只提供事实服务**——「谁该付谁多少钱」是人和人之间的社交问题;平台提供分账所需的全部原材料,数字全部客观中立。
**裁掉**:
- **应付款计算与分摊口径**——「闲置额度归谁」这类没有客观答案的问题,平台不回答(「按占额度分摊」vs「按占总用量分摊」的口径之争随之消失)
- **期末账单快照与周期管理**(R10 裁撤)——周期 = 任意查询时段(订阅期间为预置选项),无平台侧账单
- **每行日志的实付分摊金额(`amount_rmb`)**——「quota 占比 × 实付」是分摊口径的派生值而非事实,且随购买记录修订漂移;明细行金额回归 **quota + 额度美元**(÷ `quota_per_unit`,`qpu_history` 时间线,纯客观单位换算);基线「查看面金额口径 = 折算实付人民币」的设计作废
**保留与新增**:
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、**完全公开透明**(订阅与支付参数对成员可见)、无「应付」概念
### 2026-10-01 · R15 审阅定稿
七篇表设计经作者逐篇审阅,全部**定稿**。审阅期实质修订:`key_id` → `api_key_id`、`note` → `remark`、**401 自动摘除状态机裁撤**(api_keys 3.6——引擎记录不决策,`status` 纯手动开关)、**管理员初始化与重置改为空库双通道**(users 3.1——env 静默重建 / 引导页兜底,env 凭证降级为可弃);另有多处语义辨析随文入档(错误行去重指纹、量与换算率、sessions 与 members 零关系、QPU 存在必要性裁决保留)。
### 2026-10-01 · 交互设计八篇定稿
八篇交互设计(共性 / 登录 / 总览 / key / 支付 / 成员 / 审计 / 成员视图)经作者逐篇审阅,全部**定稿**。审阅期修订:
- **纯用量模式**(共性 §3.8,部门实例场景触发):无支付记录 = 完整可用子集——参数卡中性空态、展开行降级、核对提示全空静默、订阅期间快捷项隐藏
- **响应式范围收缩**(作者裁决):只做布局响应,不做组件响应与移动端 UI 重设计
- **组件能力需求清单**(共性 §3.7):组件库与前端框架归实现期自由度,本清单为选型验收标准
- **审计日志保留操作者列**:前向兼容多管理员——审计是不可变历史,操作者归属不能事后补写;系统触发事件为 NULL
- 其余:成员视图「移动端优先级」随响应式裁决改为「窄屏说明」;Key 登记对话框补「+ 新建成员」快捷入口;统计容器表述「桶」统一为「分组」
### 2026-09-29 · 多订阅并行(R8 重定义)
- **「一把尺」原则已随 `amount_rmb` 裁撤消亡**——其唯一客户是分摊金额计算;quota → 美元走 QPU(站点参数),订阅维度无需任何「尺」
- **日志归属 = 上游事实,非推算**:日志 `other.subscription_id` 直接关联支付记录(实现期核查项:登录态钱包页的订阅编号与日志 `subscription_id` 是否同一体系——基线草案既有推测,与 R12 携带形态并作一批真实数据核查)
- **未关联分组 = 查询层产物**(LEFT JOIN 未匹配集,无新字段),正常时恒为零,非零即异常信号;两种未关联(日志缺 ID / 有 ID 无登记)的展示区分留设计期
- **统计 = 派生视图**:数据库只存事实行,一切分组/分解/汇总为查询时聚合,无预计算存储
### 2026-09-29 · 数据资产域延后(R3 / R4)
**裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点:
- **R4 采集健康与告警(三层提案)**:① 同步健康面板(数据截止 / 轮次成败 / 引擎状态——即管理面总览的一部分)② 缺口检测与留痕(停摆恢复轮检测时间缝隙,落「数据缺口事件」记录——丢了多少无法精确知道,但「丢过」可精确留痕)③ 主动告警(webhook 推送,作者自配钉钉/企微/TG bot;停摆判定 = 连续 N 轮失败或数据截止落后超阈值;不做邮件、不做分级路由)
- **R3 资产保护(导出 + 备份 + 异地性提案)**:① 管理面一键导出(全量日志 JSONL + 支付记录 + 归属)② 形态差异——母体 pg_dump 定时 + 作者定期拉取;CFW 版 D1 Time Travel(PITR:付费 30 天 / 免费 7 天)+ wrangler d1 export ③ 异地性三选(作者手动拉起步 / 推 R2 对象存储 / 只信 Time Travel),倾向手动拉起步、导出与推送逻辑分离留升级口子
+318
View File
@@ -0,0 +1,318 @@
# NewAPI 接口范围
> 计费平台在 NewAPI 中可用的接口参考:每个接口的鉴权档位、请求与返回结构、字段语义、实测行为与对平台的用途——**平台所有上游调用都出自这份文档**。
>
> 依据:NewAPI 源码考证 + 接口实测 · 实测环境:NewAPI 实例 · 上游仓库:https://github.com/QuantumNous/new-api
---
## 0. 通用说明
### 认证方式
所有需要凭证的接口均使用 API key 鉴权,在请求头中携带:
```
Authorization: Bearer sk-xxxxxxxxxxxx
```
**不需要**账号登录态(access token / session)——这是仅凭 sk-key 即可查询的关键前提。BaseURL 即 NewAPI 站点地址,路径直接拼接、无多余前缀。
### 关键开关:`DisplayTokenStatEnabled`
- **billing 接口(⑤⑥)受影响**:开启 → 返回 token 级额度;关闭 → 回退账号级额度(会暴露整个账号余额)
- `/api/usage/token`、`/api/log/token` **不受影响**,恒返回 token 级数据
- **实测已确认当前站点开关为开启状态**;开关位于后台设置 → 计费设置(Billing)→ 货币与显示(Currency & Display)→ `Display Token Statistics`,仅管理员可改
### 换算参数:`QuotaPerUnit`
- 默认 `500000`(源码 `common/constants.go`,锚点 `$0.002 / 1K tokens`),**管理员可改且改后立即全站生效**(详见 ④)
- 实现时必须从 `GET /api/status` 实时读取,**不得硬编码**
- 实测交叉验证:`/api/usage/token` 的 `total_used = 673,419,457` ÷ 500000 = **1346.84 美元**;`/v1/dashboard/billing/usage` 的 `total_usage = 134,683.8914` 美分 ÷ 100 = **1346.84 美元**——完全吻合
## 1. 鉴权三态总览
本项目对接的接口按鉴权中间件分三档:
| 鉴权档 | 中间件 | 语义 | 本项目的请求方式 |
|--------|--------|------|----------------|
| **公开**(none) | 无 / `HeaderNavModulePublicOrUserAuth` | 不持有任何凭证即可访问 | 直接请求,无需 key |
| **严格**(strict) | `TokenAuth` | 校验 key 有效、未过期、有剩余额度;无效直接被拒 | 携带 sk-key 请求 |
| **宽松**(lenient) | `TokenAuthReadOnly` | 只验 key 存在且用户未封禁;**过期/额度耗尽仍可查**,但**显式禁用(Disabled)会被 401 拒绝**(`middleware/auth.go` 注释原文:only explicitly disabled tokens are denied) | 携带 sk-key 请求;**本项目主要依赖此档** |
> 失败状态码全集(2026-09-13 源码复核):**401** = key 不存在(`MsgTokenInvalid`)或 token 被显式禁用(`MsgTokenStatusUnavailable`);**403** = 账号被封禁;**500** = 数据库错误。→ 摘除计数只认 401;403 / 500 视为其他故障,不计入。宽松档**允许** key 过期(Expired)与额度耗尽(Exhausted)状态查询。
## 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` 等
- **本项目用途**:**关键**——换算比率的唯一权威来源,实现时必须实时读取、不得硬编码;同时感知 `quota_display_type` 切换(若切到 CNY/TOKENS,billing 接口数字语义会变)。实测当前站点:`quota_per_unit = 500000`、`quota_display_type = USD`、`usd_exchange_rate = 6.8`
- **`quota_per_unit` 的行为事实(2026-09-13 源码评估)**:由全局变量承载,改设置是「先写库、再热更新内存」(`model/option.go` 的 `UpdateOption` → `updateOptionMap`)——改后**立即全站生效、无需重启**。三条关键行为:
- **无历史**:`options` 表一 key 一行、改即覆盖——任何接口都读不到过去的取值;管理审计只记"键名被改过"、**不记值**
- **无编辑入口**:后台设置页该字段默认隐藏;当显示类型为 TOKENS 或取值非默认(≠500000)时才显示,且显示时仍为只读(`disabled`)——源码 `web/src/features/system-settings/general/pricing-section.tsx`(2026-09-29 main 分支复核);修改只能走 API(`PUT /api/option/`,Root 权限,热更新内存立即生效)
- **解读跟随当前值**:一切对外的金额换算(历史记录解读、billing 接口、界面显示)都在读取时实时使用当前值——改它 = 全站金额表达整体平移(数据库里的 quota 整数不受影响)
### 严格鉴权组(TokenAuth,sk-key)
#### ⑤ `GET /v1/dashboard/billing/subscription` — 总额度上限(美元)
- **返回结构**(源码 `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`(总额度上限,美元;三个 limit 字段同值)、`access_until`(token 过期时间,Unix 秒,0 = 永不过期)
- **注意**:无限额度 key 返回 `100000000`(一亿占位值,**无参考意义**);受 `DisplayTokenStatEnabled` 与 `quota_display_type` 影响(USD 模式直接是美元,CNY 模式做货币转换,Tokens 模式是原始 token 数)
- **路径变体**:`/dashboard/billing/subscription`(不带 `/v1` 前缀)是同一接口,为兼容不同客户端写法——测一组即可
- **本项目用途**:中——按 key 统计的「累计剩余」列候选数据源;与⑥相减可得剩余额度
#### ⑥ `GET /v1/dashboard/billing/usage` — 累计已用(美分)
- **返回结构**(源码 `controller/billing.go:75` → `GetUsage`):
```json
{
"object": "list",
"total_usage": 134683.8914
}
```
- **字段**:`total_usage`(**累计已用,单位美分**,÷100 = 美元;从 key 创建至今累计,非当月)
- **注意**:受 `DisplayTokenStatEnabled` 与 `quota_display_type` 影响(USD 模式才是美元);`hard_limit_usd`(⑤)− `total_usage ÷ 100`(⑥)= 剩余额度(仅有限额 key 有意义);可与⑧的 `total_used ÷ quota_per_unit` 交叉对账(不一致 = 站点改过比率,见④)
- **路径变体**:`/dashboard/billing/usage` 同⑥
- **本项目用途**:中——对账与校验用途
#### ⑦ `GET /v1/models` — 可用模型列表
- **返回**:当前 key 可用的模型列表,OpenAI 兼容格式(`data[].id` 为模型名)
- **变体**(与用量统计无关,列出仅为完整覆盖):`/v1/models/:model` 单模型详情、`/v1beta/models`(Gemini 格式)、`/v1beta/openai/models`(OpenAI 格式,Gemini 路由下)
- **已知限制**:`/v1/models` 查数据库 `abilities` 表(渠道实际配置),而 `/v1/models/:model` 查代码内硬编码的静态模型列表(`openAIModelsMap`)——渠道配了新模型或自定义模型名但代码未收录时,会出现"列表里有但单查返回 `model_not_found`"的现象;不影响实际模型调用
- **本项目用途**:低——调试期验证渠道配置用
### 宽松鉴权组(TokenAuthReadOnly,sk-key)
#### ⑧ `GET /api/usage/token` — token 级用量汇总(原始 quota)
- **返回结构**(源码 `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 创建以来累计,非当月;换算美元 ÷ `quota_per_unit` |
| `total_available` | int | 剩余 = granted − used。无限额度时为负数,无实际意义 |
| `unlimited_quota` | bool | 是否无限额度。true 时 granted/available 无实际意义 |
| `model_limits` / `model_limits_enabled` | object / bool | 模型限制 |
| `expires_at` | int | 过期时间(Unix 秒),0 = 永不过期 |
- **注意**:额度单位是**原始 quota 整数**,需自行换算美元;禁用再解禁不会重置 `total_used`(源码验证:`Update()` 的 `.Select(...)` 列表不含 `used_quota`);key 耗尽后仍可查(用完也能对账)
- **本项目用途**:**高**——按 key 统计「全周期累计(已用/剩余)」的直接数据源
#### ⑨ `GET /api/log/token` — 最近 1000 条调用日志
- **返回结构**(源码 `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` / `token_name` | string | 账号用户名 / 令牌名 |
| `model_name` | string | 调用的模型名 |
| `quota` | int | 本次消耗的 quota(换算美元 ÷ `quota_per_unit`);错误记录为 0 |
| `prompt_tokens` / `completion_tokens` | int | 输入 / 输出 token 数;错误记录为 0 |
| `use_time` | int | 耗时(秒);错误记录为 0 |
| `is_stream` | bool | 是否流式 |
| `channel` / `channel_name` | int / string | 渠道 ID / 渠道名(普通用户视角后者为空串) |
| `token_id` | int | 上游 token ID(对账用) |
| `group` | string | 分组名(默认 `default`) |
| `ip` | string | 请求 IP(普通用户视角为空串) |
| `request_id` | string | 请求 ID(`yyyyMMddHHmmss` 前缀 + 随机串);源码写入有兜底保证非空——**去重唯一依据**。**补充实测(2026-09-15)**:错误场景下**不保证行级唯一**——1000 条实测样本中 8 个 `request_id` 各对应 3 条 `type=5` 错误记录(同一请求的多次失败 / 重试,`content` 的 `status_code` 可不同);按 `request_id` 去重会把它们合并为 1 条(消费类 `type=2` 样本中未观察到重复) |
| `upstream_request_id` | string | 上游请求 ID;JSON tag 为 `omitempty`——**空值时不出现、有值时才出现**,消费方需按「可选字段」处理 |
| `other` | string | JSON 字符串,扩展信息(结构见下) |
- **`type` 完整取值**(上游源码 `model/log.go`;常量注释明确「刻意不用 iota,以保证枚举值永不改变」):
| 值 | 常量 | 含义 | 可能出现在 token 日志? |
|----|------|------|------------------------|
| 0 | `LogTypeUnknown` | 未知 | 兜底值 |
| 1 | `LogTypeTopup` | 充值 | 否(账号级,无 token_id) |
| 2 | `LogTypeConsume` | 消费 | **是**(实测) |
| 3 | `LogTypeManage` | 管理操作 | 否(审计类) |
| 4 | `LogTypeSystem` | 系统 | 否 |
| 5 | `LogTypeError` | 错误 | **是**(实测) |
| 6 | `LogTypeRefund` | 退款 | 可能(任务计费链路带 token_id) |
| 7 | `LogTypeLogin` | 登录 | 否(账号级) |
> 本接口按 `token_id` 过滤、不按 `type` 过滤——凡是「带 token_id 写入」的类型都可能返回。
- **`other` 字段**(JSON 字符串,内容随 `type` 变化,实测 2026-09-11):
- 消费记录(`type=2`):`billing_source`、`cache_ratio`、`cache_tokens`、`completion_ratio`、`frt`、`group_ratio`、`model_price`、`model_ratio`、`reasoning_effort`、`request_conversion`、`request_path`、`stream_status`(对象)、`subscription_*`(订阅维度,共 8 个)、`user_group_ratio`、`wallet_quota_deducted`
- 错误记录(`type=5`):`error_code`、`error_type`、`request_path`、`status_code`
- 注:普通用户视角下,服务端会先对 `other` 做「可见性过滤」再返回——上表为过滤后实际可见的键
- **关键限制**:
- **硬上限 1000 条**:固定返回最近 1000 条(`Limit(common.MaxRecentItems)`,`MaxRecentItems = 1000`,`common/constants.go`);**不支持任何请求参数**(源码不读任何 `c.Query()`,无分页 / limit / 时间过滤);固定按 `id desc` 排序(最新 1000 条)
- **超过 1000 条的旧记录无法获取**:无法翻页、无法扩大 limit;实测当前站点日志速率下 1000 条 ≈ 3 天窗口
- **大响应**:pretty-print 后 2~4MB——同步引擎需直面的单次响应体积
- **本项目用途**:**核心**——增量同步引擎的唯一数据源;本地攒全量后,任意时间范围的用量/明细/模型统计都是本地聚合查询
## 3. 换算与 quota 计算
### 核心换算公式
```
原始 quota ÷ quota_per_unit(默认 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`
## 4. 范围之外(sk-key 不可达)
### 4.1 不存在的接口(404)
| 路径 | 状态 |
|------|------|
| `/v1/usage`、`/v1/quota`、`/api/token/self` | 不存在 |
### 4.2 存在但需要登录态的接口(sk-key 访问 401)
支持时间范围查询与过滤,但**必须用账号登录态**(access token + `New-Api-User` header):
| 路径 | 鉴权 | 能力 |
|------|------|------|
| `/api/log/self` | `UserAuth()` | 支持 `start_timestamp`、`end_timestamp`、`token_name` 过滤,返回日志明细 |
| `/api/log/self/stat` | `UserAuth()` | **支持时间范围 + token_name,返回 quota 汇总**(调 `SumUsedQuota`)——NewAPI 中最适合算"当月用量"的接口,是「按时间段统计用量」无法纯 sk-key 实现的**核心障碍** |
| `/api/data/self` | `UserAuth()` | 按日聚合的看板数据 |
| `/api/token/` (GET/POST/PUT/DELETE) | `UserAuth()` | token 的增删改查 |
| `/api/user/self` | `UserAuth()` | 用户自身信息 |
### 4.3 订阅信息
- **没有免 auth 的接口可直接查询**(作者实测,2026-09-13);登录态的「钱包」页面可见——显示订阅编号(形如「订阅 #N」)、状态(生效中 / 已过期 + 过期时间)、总额度与已用 / 剩余
- 订阅编号**推测**与日志 `other.subscription_id` 为同一体系(实现期以真实数据核对)
- 对应设计:订阅信息由购买者手工录入(见 `docs/design/payments表字段设计.md`)
### 4.4 参数被忽略的情况(实测)
| 请求 | 实际行为 |
|------|---------|
| `/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. 行为事实:写权限与 key 生命周期
- **sk-key 没有任何写接口**:没有 POST/PUT/DELETE 能改 token 自身;无法通过 sk-key 重置 `used_quota`(没有"重置用量"接口)
- **禁用 token 只改 `status` 字段**:不重置 `used_quota`、不清零 `remain_quota`、不丢历史日志;解禁后三者原封不动
- **唯一让 `used_quota` 归零的途径**:删除 token 后新建(产生全新 Id/Key,旧日志按旧 token_id 关联,新 token 查不到旧日志)
- **解禁保护**:额度耗尽(`Exhausted`)且 `RemainQuota ≤ 0` 且非无限额度的 token 不能直接解禁,需先充值;已过期的 token 也不能直接解禁,需改过期时间
## 6. 源码参考
| 关注点 | 文件路径 | 关键函数/位置 |
|--------|---------|--------------|
| 路由注册 | `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 / :75` | `GetSubscription` / `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` |
| 日志类型常量 | `model/log.go` | `LogTypeConsume = 2`、`LogTypeError = 5` 等(刻意不用 iota) |
+17
View File
@@ -0,0 +1,17 @@
{
"name": "new-api-key-usage",
"private": true,
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"test": "bun test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
"dev:engine": "bun --env-file=.env apps/engine/src/index.ts",
"dev:web": "bun --filter @new-api-key-usage/web dev"
},
"devDependencies": {
"@types/bun": "^1.4.2",
"typescript": "^7.0.2"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@new-api-key-usage/core",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@logtape/logtape": "^2.3.11",
"zod": "^4.6.5"
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* 加密体系(WebCrypto / Web 标准 API——Bun 纪律红线)
* SK_KEY_ENCRYPTION_SECRET 经 HKDF 派生 hmac / aes 子密钥(api_keys表字段设计 3.1)
* 密码哈希 = PBKDF2-SHA256(两形态统一)
*/
const enc = new TextEncoder();
const dec = new TextDecoder();
function b64url(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(s: string): Uint8Array<ArrayBuffer> {
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(pad + "===".slice((pad.length + 3) % 4));
const out = new Uint8Array(new ArrayBuffer(bin.length));
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hkdfDerive(
master: Uint8Array<ArrayBuffer>,
info: string,
length: number,
): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
"deriveBits",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode(info),
},
base,
{ name: "HMAC", hash: "SHA-256", length: length * 8 },
false,
["sign", "verify"],
);
}
async function hkdfAesKey(master: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode("aes-gcm"),
},
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export class KeyCrypto {
private constructor(
private readonly hmacKey: CryptoKey,
private readonly aesKey: CryptoKey,
) {}
static async fromEncryptionSecret(secretB64: string): Promise<KeyCrypto> {
const master = b64urlDecode(secretB64);
if (master.length < 32) {
throw new Error("SK_KEY_ENCRYPTION_SECRET too short");
}
const hmacKey = await hkdfDerive(master, "hmac-sha256", 32);
const aesKey = await hkdfAesKey(master);
return new KeyCrypto(hmacKey, aesKey);
}
/** 登记时:HMAC-SHA256(sk-key) → 不可逆指纹(查库优先命中) */
async hashKey(skKey: string): Promise<string> {
const sig = await crypto.subtle.sign("HMAC", this.hmacKey, enc.encode(skKey));
return b64url(new Uint8Array(sig as ArrayBuffer));
}
/** 登记时:AES-256-GCM,打包 nonce||ciphertext||tag */
async sealKey(skKey: string): Promise<Uint8Array> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
enc.encode(skKey),
);
const packed = new Uint8Array(new ArrayBuffer(nonce.length + ct.byteLength));
packed.set(nonce, 0);
packed.set(new Uint8Array(ct), nonce.length);
return packed;
}
/** 调上游时解密原文(唯一用途) */
async openKey(packed: Uint8Array): Promise<string> {
const nonce = packed.slice(0, 12);
const ct = packed.slice(12);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
ct,
);
return dec.decode(pt);
}
}
/** PBKDF2-SHA256,格式:pbkdf2$<iters>$<b64salt>$<b64hash> */
export const PBKDF2_ITERATIONS = 210_000;
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS },
key,
256,
);
return `pbkdf2$${PBKDF2_ITERATIONS}$${b64url(salt)}$${b64url(new Uint8Array(bits))}`;
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") return false;
const iters = Number(parts[1]);
const salt = b64urlDecode(parts[2]!);
const expect = b64urlDecode(parts[3]!);
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = new Uint8Array(
await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: iters },
key,
expect.length * 8,
),
);
if (bits.length !== expect.length) return false;
let diff = 0;
for (let i = 0; i < bits.length; i++) diff |= bits[i]! ^ expect[i]!;
return diff === 0;
}
/** 256bit 随机令牌 → b64url 明文;库存其 SHA-256 哈希 */
export async function generateToken(): Promise<{ plaintext: string; hash: string }> {
const raw = crypto.getRandomValues(new Uint8Array(32));
const plaintext = b64url(raw);
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return { plaintext, hash: b64url(new Uint8Array(digest)) };
}
export async function hashToken(plaintext: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return b64url(new Uint8Array(digest));
}
export function randomSkKeyEncryptionSecret(): string {
return b64url(crypto.getRandomValues(new Uint8Array(32)));
}
+65
View File
@@ -0,0 +1,65 @@
/**
* usage_logs.dedupe_key 生成(usage_logs表字段设计 §3.1)
* 消费行 type=2: `${api_key_id}:${request_id}`
* 非消费行: `${api_key_id}:${request_id}:${sha256指纹}`
* request_id 空:退化为纯内容指纹
*/
export interface DedupeInput {
api_key_id: number;
request_id: string | null | undefined;
type: number;
created_at: string | number | Date;
model_name: string;
quota: number;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
content: string;
}
const LOG_TYPE_CONSUME = 2;
function toUnix(created_at: string | number | Date): number {
if (typeof created_at === "number") return created_at;
if (created_at instanceof Date)
return Math.floor(created_at.getTime() / 1000);
return Math.floor(new Date(created_at).getTime() / 1000);
}
async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input),
);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** 内容指纹(错误行含 content;空 request_id 的消费行不含 content) */
export async function contentFingerprint(row: DedupeInput): Promise<string> {
const parts = [
String(row.api_key_id),
String(toUnix(row.created_at)),
String(row.type),
row.model_name,
String(row.quota),
String(row.prompt_tokens),
String(row.completion_tokens),
String(row.use_time),
];
if (row.type !== LOG_TYPE_CONSUME || !row.request_id) {
parts.push(row.content);
}
return sha256Hex(parts.join("\n"));
}
export async function buildDedupeKey(row: DedupeInput): Promise<string> {
const rid = row.request_id?.trim() ?? "";
if (row.type === LOG_TYPE_CONSUME && rid) {
return `${row.api_key_id}:${rid}`;
}
const fp = await contentFingerprint(row);
return rid ? `${row.api_key_id}:${rid}:${fp}` : `${row.api_key_id}:${fp}`;
}
+46
View File
@@ -0,0 +1,46 @@
export interface ErrorBody {
error: {
code: string;
message: string;
fields?: Record<string, string>;
key_id?: number;
};
}
export function errorBody(
code: string,
message: string,
extra?: { fields?: Record<string, string>; key_id?: number },
): ErrorBody {
return {
error: {
code,
message,
...(extra?.fields ? { fields: extra.fields } : {}),
...(extra?.key_id !== undefined ? { key_id: extra.key_id } : {}),
},
};
}
export const ERR = {
invalid_credentials: () =>
errorBody("invalid_credentials", "用户名或密码错误"),
session_expired: () =>
errorBody("session_expired", "会话已过期,请重新登录"),
already_initialized: () =>
errorBody("already_initialized", "系统已初始化,无法重复引导"),
key_already_registered: (key_id: number) =>
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"),
upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"),
bad_request: () => errorBody("bad_request", "请求格式错误"),
validation_failed: (fields: Record<string, string>) =>
errorBody("validation_failed", "请检查表单填写", { fields }),
internal: () =>
errorBody("internal", "服务暂时不可用,请稍后重试"),
too_many_requests: () =>
errorBody("too_many_requests", "操作过于频繁,请稍后再试"),
} as const;
+10
View File
@@ -0,0 +1,10 @@
export * from "./types.ts";
export * from "./errors.ts";
export * from "./time.ts";
export * from "./money.ts";
export * from "./crypto.ts";
export * from "./dedupe.ts";
export * from "./schemas.ts";
export * from "./log.ts";
export * from "./usage.ts";
export * from "./upstream.ts";
+81
View File
@@ -0,0 +1,81 @@
/**
* 业务日志(@logtape/logtape)——零依赖、Bun/edge 可跑。
* 访问日志另用 Hono `logger` 中间件;本模块只记启动、错误、引擎等业务事件。
*/
import {
configureSync,
getConsoleSink,
getLogger,
type Logger,
type LogLevel,
} from "@logtape/logtape";
export type AppLogLevel = Exclude<LogLevel, "trace">;
/** 防止密钥类串进日志正文(sk-key / Bearer / 机密 env) */
export function redactSecrets(message: string): string {
return message
.replace(/sk-[A-Za-z0-9_\-]{8,}/g, "sk-***")
.replace(/Bearer\s+\S+/gi, "Bearer ***")
.replace(
/SK_KEY_ENCRYPTION_SECRET[=:]\s*\S+/gi,
"SK_KEY_ENCRYPTION_SECRET=***",
);
}
/** 日志时间戳:本地时区、可读(终端运维友好) */
export function formatLogTime(d: Date = new Date()): string {
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const abs = Math.abs(offsetMin);
const off = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
`.${pad(d.getMilliseconds(), 3)}${off}`
);
}
let configured = false;
/**
* 进程内只允许配置一次(热更新时模块可能被重复求值)。
* 使用 configureSync,避免 configure() 异步窗口内的重入锁。
*/
export function setupLogging(level: AppLogLevel = "info"): void {
if (configured) return;
try {
configureSync({
reset: true,
sinks: {
console: getConsoleSink({
formatter: (record) => {
const ts = formatLogTime(new Date(record.timestamp));
const cat = record.category.join(".");
const msg = redactSecrets(String(record.message));
return `${ts} ${record.level.toUpperCase()} [${cat}] ${msg}`;
},
}),
},
// app 日志;meta 单独压到 warning,避免每次启动的配置提示
loggers: [
{ category: ["naku"], lowestLevel: level, sinks: ["console"] },
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["console"],
},
],
});
configured = true;
} catch {
// 并发/热更新竞态:已有配置在跑则忽略,绝不打崩业务进程
configured = true;
}
}
export function appLogger(...category: string[]): Logger {
if (!configured) setupLogging("info");
return getLogger(["naku", ...category]);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* quota → 额度美元换算(公共约定 §5)
* 内部以微分(1/100 美分 = 1e-6 美元)整数运算,仅输出时四舍五入到分。
*/
/** quota → 微分(1e-6 USD) */
export function quotaToMicroUsd(quota: number, qpu: number): number {
if (qpu <= 0) throw new Error("qpu must be positive");
// quota / qpu = USD;再 * 1_000_000 = micro-USD
return Math.round((quota * 1_000_000) / qpu);
}
export function microUsdToQuotaUsdStr(micro: number): string {
// 四舍五入到分(10000 micro = 1 cent)
const cents = Math.round(micro / 10_000);
const dollars = Math.floor(cents / 100);
const rem = cents % 100;
return `${dollars}.${String(rem).padStart(2, "0")}`;
}
export function quotaToUsdStr(quota: number, qpu: number): string {
return microUsdToQuotaUsdStr(quotaToMicroUsd(quota, qpu));
}
/** 聚合:先微分求和再一次取整(小行贡献不丢失) */
export function sumQuotaToUsdStr(quotas: readonly number[], qpu: number): string {
let micro = 0;
for (const q of quotas) micro += quotaToMicroUsd(q, qpu);
return microUsdToQuotaUsdStr(micro);
}
/** 由微分总和直接出字符串(仓储层先累加再调用) */
export function microSumToUsdStr(microSum: number): string {
return microUsdToQuotaUsdStr(microSum);
}
/**
* qpuAt(t):按时刻取 QPU 时间线
* timeline 按 effective_from 升序。
*/
export function qpuAt(
t: Date | number,
timeline: ReadonlyArray<{ qpu: number; effective_from: Date | number }>,
): number {
const tMs = t instanceof Date ? t.getTime() : t * 1000;
let picked: number | null = null;
for (const row of timeline) {
const fromMs =
row.effective_from instanceof Date
? row.effective_from.getTime()
: row.effective_from * 1000;
if (fromMs <= tMs) picked = row.qpu;
else break;
}
if (picked === null) {
// 时刻早于全部时间线:取最早一条
const first = timeline[0];
if (!first) throw new Error("qpu timeline is empty");
return first.qpu;
}
return picked;
}
+241
View File
@@ -0,0 +1,241 @@
import { z } from "zod";
/**
* 契约 zod schema(字段权威 = API契约三篇;DTO 类型见 types.ts)。
* OpenAPI 装饰(.openapi 组件名等)留在 apps/server,core 不依赖 hono。
*/
export const errorShape = z.object({
code: z.string(),
message: z.string(),
fields: z.record(z.string(), z.string()).optional(),
key_id: z.number().int().optional(),
});
export const errorEnvelope = z.object({ error: errorShape });
export type ErrorEnvelope = z.infer<typeof errorEnvelope>;
export const setupState = z.object({ initialized: z.boolean() });
export const usernameBody = z.object({ username: z.string() });
export const setupBody = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8).max(128),
});
export const loginBody = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export const changePasswordBody = z.object({
old_password: z.string().min(1),
new_password: z.string().min(8).max(128),
});
export const timeRangeQuery = z.object({
from: z.string().min(1),
to: z.string().min(1),
});
export const pageQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
page_size: z.coerce.number().int().min(1).max(200).default(50),
});
export const usageTotals = z.object({
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
export const logItem = z.object({
id: z.number().int(),
api_key_id: z.number().int(),
created_at: z.number().int(),
token_name: z.string(),
model_name: z.string(),
type: z.number().int(),
content: z.string(),
quota: z.number(),
quota_usd: z.string(),
prompt_tokens: z.number().int(),
completion_tokens: z.number().int(),
use_time: z.number().int(),
is_stream: z.boolean(),
billing_source: z.string().nullable(),
subscription_id: z.number().int().nullable(),
});
export const logPage = z.object({
items: z.array(logItem),
total: z.number().int(),
});
export const keyItem = z.object({
id: z.number().int(),
token_name: z.string(),
status: z.enum(["active", "disabled"]),
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
registered_at: z.number().int(),
log_synced_at: z.number().int().nullable(),
usage_synced_at: z.number().int().nullable(),
total_used: z.number(),
total_available: z.number(),
total_granted: z.number(),
unlimited_quota: z.boolean(),
expires_at: z.number().int().nullable(),
});
export const registerKeyBody = z.object({
sk_key: z.string().min(8),
member_id: z.number().int().positive().nullable().optional(),
});
export const patchKeyBody = z.object({
status: z.enum(["active", "disabled"]).optional(),
member_id: z.number().int().positive().nullable().optional(),
});
export const memberItem = z.object({
id: z.number().int(),
name: z.string(),
remark: z.string(),
status: z.enum(["active", "disabled"]),
token_issued_at: z.number().int().nullable(),
created_at: z.number().int(),
key_count: z.number().int(),
});
export const createMemberBody = z.object({
name: z.string().min(1).max(64),
remark: z.string().optional().default(""),
});
export const patchMemberBody = z.object({
status: z.enum(["active", "disabled"]),
});
export const memberWithToken = z.object({
member: memberItem,
token: z.string(),
});
export const tokenOnly = z.object({ token: z.string() });
export const memberMe = z.object({
id: z.number().int(),
name: z.string(),
});
export const paymentItem = z.object({
id: z.number().int(),
kind: z.enum(["subscription", "topup"]),
paid_at: z.number().int(),
paid_amount: z.number(),
remark: z.string(),
period_start: z.number().int().nullable(),
period_end: z.number().int().nullable(),
subscription_id: z.number().int().nullable(),
quota_granted: z.number().int().nullable(),
quota_usd: z.string(),
});
export const engineStatus = z.object({
running: z.boolean(),
sync_interval_ms: z.number().int().optional(),
last_round: z
.object({
started_at: z.number().int(),
keys_total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
failed_api_key_ids: z.array(z.number().int()),
})
.nullable(),
});
export const auditLogItem = z.object({
id: z.number().int(),
created_at: z.number().int(),
operator: z.string().nullable(),
domain: z.enum(["key", "payment", "member", "account"]),
action: z.string(),
summary: z.string(),
});
export const memberOverview = z.object({
member: z.object({
id: z.number().int().nullable(),
name: z.string().nullable(),
}),
usage: usageTotals,
by_source: z.array(
z.object({
billing_source: z.string(),
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
}),
),
keys: z.array(
z.object({
key_id: z.number().int(),
token_name: z.string(),
log_synced_at: z.number().int().nullable(),
usage: usageTotals,
}),
),
payments: z.array(z.looseObject({ id: z.number().int() })),
});
export const createPaymentBody = z
.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string().min(1),
paid_amount: z.number(),
remark: z.string().default(""),
period_start: z.string().min(1).nullable().optional(),
period_end: z.string().min(1).nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
})
.superRefine((val, ctx) => {
if (val.kind === "subscription") {
if (!val.period_start) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写期间起点",
path: ["period_start"],
});
}
if (val.subscription_id == null) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写订阅编号",
path: ["subscription_id"],
});
}
}
if (val.quota_granted == null) {
ctx.addIssue({
code: "custom",
message: "必须填写本笔额度",
path: ["quota_granted"],
});
}
});
export {
setupBody as setupBodySchema,
loginBody as loginBodySchema,
changePasswordBody as changePasswordBodySchema,
registerKeyBody as registerKeyBodySchema,
patchKeyBody as patchKeyBodySchema,
createMemberBody as createMemberBodySchema,
patchMemberBody as patchMemberBodySchema,
createPaymentBody as createPaymentBodySchema,
timeRangeQuery as timeRangeQuerySchema,
pageQuery as pageQuerySchema,
};
+51
View File
@@ -0,0 +1,51 @@
/**
* 时间口径(API 响应统一 Unix 秒整数;请求 from/to 仍收 ISO 8601 便于试调)
* 库内 timestamptz ↔ JS Date ↔ Unix 秒
*/
function toDate(value: number | string | Date | null | undefined): Date | null {
if (value === null || value === undefined) return null;
let d: Date;
if (value instanceof Date) {
d = value;
} else if (typeof value === "number") {
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
d = new Date(value);
}
return Number.isNaN(d.getTime()) ? null : d;
}
/** → Unix 秒(UTC);无效则 null */
export function toUnixSec(
value: number | string | Date | null | undefined,
): number | null {
const d = toDate(value);
if (!d) return null;
return Math.floor(d.getTime() / 1000);
}
/** 兼容旧名:ISO 串(仅本地/文档用;API 响应改用 toUnixSec) */
export function unixToIso(
value: number | string | Date | null | undefined,
): string | null {
const d = toDate(value);
if (!d) return null;
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function isoToUnix(iso: string): number {
return Math.floor(new Date(iso).getTime() / 1000);
}
export function requireIso(value: string | null | undefined, field: string): string {
if (!value) throw new Error(`missing time field: ${field}`);
return value;
}
/** 左闭右开 [from, to) 的毫秒边界(前端传 ISO,查询用) */
export function rangeToMs(fromIso: string, toIso: string): { fromMs: number; toMs: number } {
return {
fromMs: new Date(fromIso).getTime(),
toMs: new Date(toIso).getTime(),
};
}
+130
View File
@@ -0,0 +1,130 @@
/** Unix 秒(UTC 时刻)——API 响应时刻字段统一此类型 */
export type UnixSec = number;
/** 支付金额(币种无关数值) */
export type PaidAmount = number;
/** quota 原值整数 */
export type Quota = number;
/** 两位小数美元字符串,如 "25.00" */
export type QuotaUsd = string;
export type BillingSource = "subscription" | "wallet";
export type PaymentKind = "subscription" | "topup";
export type KeyStatus = "active" | "disabled";
export type MemberStatus = "active" | "disabled";
export interface UsageTotals {
count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
export interface LogItem {
id: number;
api_key_id: number;
created_at: UnixSec;
token_name: string;
model_name: string;
type: number;
content: string;
quota: Quota;
quota_usd: QuotaUsd;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
is_stream: boolean;
billing_source: BillingSource | null;
subscription_id: number | null;
}
export interface KeyItem {
id: number;
token_name: string;
status: KeyStatus;
member_id: number | null;
member_name: string | null;
registered_at: UnixSec;
log_synced_at: UnixSec | null;
usage_synced_at: UnixSec | null;
total_used: number;
total_available: number;
total_granted: number;
unlimited_quota: boolean;
expires_at: UnixSec | null;
}
export interface PaymentItem {
id: number;
kind: PaymentKind;
paid_at: UnixSec;
paid_amount: PaidAmount;
remark: string;
period_start: UnixSec | null;
period_end: UnixSec | null;
subscription_id: number | null;
quota_granted: number | null;
quota_usd: QuotaUsd;
}
export interface MemberItem {
id: number;
name: string;
remark: string;
status: MemberStatus;
token_issued_at: UnixSec | null;
created_at: UnixSec;
key_count: number;
}
export interface AuditLogItem {
id: number;
created_at: UnixSec;
operator: string | null;
domain: "key" | "payment" | "member" | "account";
action: string;
summary: string;
}
export interface Paginated<T> {
items: T[];
total: number;
}
export interface EngineLastRound {
started_at: UnixSec;
keys_total: number;
succeeded: number;
failed: number;
failed_api_key_ids: number[];
}
export interface EngineStatus {
running: boolean;
last_round: EngineLastRound | null;
}
export interface QpuLatest {
qpu: number;
effective_from: UnixSec;
}
export type AuditAction =
| "key.register"
| "key.enable"
| "key.disable"
| "key.reassign"
| "payment.create"
| "payment.update"
| "payment.delete"
| "member.create"
| "member.enable"
| "member.disable"
| "member.token_issue"
| "member.token_reissue"
| "account.password_change"
| "account.init";
/** 消费行(聚合口径仅此类型) */
export const LOG_TYPE_CONSUME = 2;
+140
View File
@@ -0,0 +1,140 @@
/**
* 上游 NewAPI 客户端(仅 Web 标准 API——Bun 纪律)
* 接口实证:docs/references/NewAPI-接口范围.md
*/
export interface TokenUsage {
name: string;
total_granted: number;
total_used: number;
total_available: number;
unlimited_quota: boolean;
model_limits: Record<string, unknown> | null;
model_limits_enabled: boolean;
/** Unix 秒,0 = 永不过期 */
expires_at: number;
}
export type UpstreamFailure =
/** 401:key 不存在或被显式禁用 */
| { ok: false; kind: "rejected" }
/** 网络失败 / 超时 */
| { ok: false; kind: "unreachable" }
/** 其他非预期 HTTP / 403 封禁 / 5xx */
| { ok: false; kind: "http"; status: number };
export type UpstreamResult<T> = { ok: true; data: T } | UpstreamFailure;
export interface UpstreamClientOptions {
baseUrl: string;
timeoutMs?: number;
}
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, "")}${path}`;
}
async function requestJson<T>(
url: string,
skKey: string,
timeoutMs: number,
): Promise<UpstreamResult<T>> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${skKey}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (res.status === 401) return { ok: false, kind: "rejected" };
if (!res.ok) return { ok: false, kind: "http", status: res.status };
const body = (await res.json()) as {
code?: boolean | number;
success?: boolean;
message?: string;
data?: T;
};
// NewAPI 包装:code:true / success:true + data
if (body.code === false || body.success === false) {
return { ok: false, kind: "rejected" };
}
if (body.data === undefined) {
return { ok: false, kind: "http", status: res.status };
}
return { ok: true, data: body.data };
} catch {
return { ok: false, kind: "unreachable" };
} finally {
clearTimeout(timer);
}
}
export interface TokenLogRow {
id?: number;
user_id?: number;
created_at: number;
type: number;
content?: string;
username?: string;
token_name?: string;
model_name?: string;
quota?: number;
prompt_tokens?: number;
completion_tokens?: number;
use_time?: number;
is_stream?: boolean;
channel?: number;
channel_name?: string;
token_id?: number;
group?: string;
ip?: string;
request_id?: string;
upstream_request_id?: string;
other?: string;
}
export function createUpstreamClient(opts: UpstreamClientOptions) {
const timeoutMs = opts.timeoutMs ?? 8_000;
return {
/** 登记验证 / 轮询额度:GET /api/usage/token */
async fetchTokenUsage(skKey: string): Promise<UpstreamResult<TokenUsage>> {
const r = await requestJson<TokenUsage>(
joinUrl(opts.baseUrl, "/api/usage/token"),
skKey,
timeoutMs,
);
return r;
},
/** 轮询日志:GET /api/log/token(最近最多 1000 条) */
async fetchTokenLogs(skKey: string): Promise<UpstreamResult<TokenLogRow[]>> {
return requestJson<TokenLogRow[]>(
joinUrl(opts.baseUrl, "/api/log/token"),
skKey,
timeoutMs,
);
},
/** 换算率权威来源:GET /api/status(取 quota_per_unit) */
async fetchQuotaPerUnit(): Promise<UpstreamResult<{ quota_per_unit: number }>> {
return requestJson<{ quota_per_unit: number }>(
joinUrl(opts.baseUrl, "/api/status"),
"sk-unused",
timeoutMs,
);
},
};
}
export type UpstreamClient = ReturnType<typeof createUpstreamClient>;
/** expires_at:上游 0 = 永不过期 → 我们存 null */
export function normalizeExpiresAt(unixSeconds: number): Date | null {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000);
}
+84
View File
@@ -0,0 +1,84 @@
/**
* 用量聚合辅助(公共约定 §5:后端换算、微分取整、聚合先求和再一次取整)
*/
import {
microSumToUsdStr,
qpuAt,
quotaToMicroUsd,
quotaToUsdStr,
} from "./money.ts";
/** 上游事实:QuotaPerUnit 默认 500000(qpu_history 未采样时的回退) */
export const DEFAULT_QPU = 500_000;
export type QpuPoint = { qpu: number; effective_from: Date | number };
export function resolveQpuTimeline(
rows: readonly QpuPoint[],
): QpuPoint[] {
if (rows.length > 0) return [...rows];
return [{ qpu: DEFAULT_QPU, effective_from: 0 }];
}
export interface UsageSum {
count: number;
quota: number;
quota_usd: string;
}
export function emptyUsage(): UsageSum {
return { count: 0, quota: 0, quota_usd: "0.00" };
}
/** 逐行微分累加(历史正确性:按日志时刻取 QPU) */
export function sumUsage(
rows: ReadonlyArray<{ quota: number; created_at: Date | number }>,
timeline: readonly QpuPoint[],
): UsageSum {
let quota = 0;
let micro = 0;
for (const r of rows) {
quota += r.quota;
const qpu = qpuAt(r.created_at, timeline);
micro += quotaToMicroUsd(r.quota, qpu);
}
return { count: rows.length, quota, quota_usd: microSumToUsdStr(micro) };
}
export function usageAt(
quota: number,
created_at: Date | number,
timeline: readonly QpuPoint[],
): UsageSum {
const qpu = qpuAt(created_at, timeline);
return { count: 1, quota, quota_usd: quotaToUsdStr(quota, qpu) };
}
export function addUsage(a: UsageSum, b: UsageSum): UsageSum {
// 合并已成形的两段时,quota_usd 需重算——调用方应尽量走 sumUsage
return {
count: a.count + b.count,
quota: a.quota + b.quota,
quota_usd: a.quota_usd,
};
}
/** 解析日志行 other JSON(billing_source / subscription_id) */
export function parseOtherJson(other: string | undefined | null): {
billing_source: string | null;
subscription_id: number | null;
raw: Record<string, unknown> | null;
} {
if (!other) return { billing_source: null, subscription_id: null, raw: null };
try {
const o = JSON.parse(other) as Record<string, unknown>;
return {
billing_source: typeof o.billing_source === "string" ? o.billing_source : null,
subscription_id:
typeof o.subscription_id === "number" ? o.subscription_id : null,
raw: o,
};
} catch {
return { billing_source: null, subscription_id: null, raw: null };
}
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage",
},
});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@new-api-key-usage/db",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"drizzle-orm": "^0.45.3",
"postgres": "^3.4.9"
},
"devDependencies": {
"@types/pg": "^8.23.1",
"drizzle-kit": "^0.31.11"
}
}
+13
View File
@@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema.ts";
export function createDb(connectionString: string) {
const client = postgres(connectionString, { max: 10 });
return drizzle(client, { schema });
}
export type Db = ReturnType<typeof createDb>;
export * from "./schema.ts";
export * from "./repos.ts";
+665
View File
@@ -0,0 +1,665 @@
import type { Db } from "./index.ts";
import {
apiKeys,
auditLogs,
engineStatus,
members,
payments,
qpuHistory,
sessions,
usageLogs,
users,
type ApiKeyRow,
type AuditLogRow,
type EngineStatusRow,
type MemberRow,
type PaymentRow,
type SessionRow,
type UsageLogRow,
type UserRow,
} from "./schema.ts";
import { and, asc, count, desc, eq, gt, gte, lt, or, sql } from "drizzle-orm";
export interface SessionUser {
user_id: number;
username: string;
expires_at: Date;
}
export interface KeyListRow extends ApiKeyRow {
member_name: string | null;
}
/**
* 仓储层(R14):数据访问只出现在 packages/db。
* apps/server 不 import drizzle-orm / postgres 驱动。
*/
export function createRepos(db: Db) {
return {
users: {
async countAll(): Promise<number> {
const rows = await db.select({ id: users.id }).from(users).limit(1);
return rows[0] ? 1 : 0;
},
async findByUsername(username: string): Promise<UserRow | null> {
const rows = await db
.select()
.from(users)
.where(eq(users.username, username))
.limit(1);
return rows[0] ?? null;
},
async findById(id: number): Promise<UserRow | null> {
const rows = await db.select().from(users).where(eq(users.id, id)).limit(1);
return rows[0] ?? null;
},
async insert(username: string, password_hash: string): Promise<UserRow> {
const [row] = await db
.insert(users)
.values({ username, password_hash })
.returning();
return row!;
},
async updatePassword(id: number, password_hash: string): Promise<void> {
await db.update(users).set({ password_hash }).where(eq(users.id, id));
},
},
sessions: {
async findValid(
token_hash: string,
now: Date,
): Promise<SessionUser | null> {
const rows = await db
.select({
user_id: sessions.user_id,
username: users.username,
expires_at: sessions.expires_at,
})
.from(sessions)
.innerJoin(users, eq(sessions.user_id, users.id))
.where(
and(eq(sessions.token_hash, token_hash), gt(sessions.expires_at, now)),
)
.limit(1);
return rows[0] ?? null;
},
async insert(
user_id: number,
token_hash: string,
expires_at: Date,
): Promise<SessionRow> {
const [row] = await db
.insert(sessions)
.values({ user_id, token_hash, expires_at })
.returning();
return row!;
},
async deleteByTokenHash(token_hash: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.token_hash, token_hash));
},
async listByUser(user_id: number) {
return db
.select({ id: sessions.id, token_hash: sessions.token_hash })
.from(sessions)
.where(eq(sessions.user_id, user_id));
},
async deleteById(id: number): Promise<void> {
await db.delete(sessions).where(eq(sessions.id, id));
},
},
keys: {
async listAll(filter?: {
member_id?: number | "unassigned";
status?: string;
}): Promise<KeyListRow[]> {
const conds = [];
if (filter?.member_id === "unassigned") {
conds.push(sql`${apiKeys.member_id} is null`);
} else if (typeof filter?.member_id === "number") {
conds.push(eq(apiKeys.member_id, filter.member_id));
}
if (filter?.status) {
conds.push(eq(apiKeys.status, filter.status));
}
const base = db
.select({
id: apiKeys.id,
key_hash: apiKeys.key_hash,
key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status,
member_id: apiKeys.member_id,
registered_at: apiKeys.registered_at,
log_synced_at: apiKeys.log_synced_at,
usage_synced_at: apiKeys.usage_synced_at,
token_name: apiKeys.token_name,
total_granted: apiKeys.total_granted,
total_used: apiKeys.total_used,
total_available: apiKeys.total_available,
unlimited_quota: apiKeys.unlimited_quota,
model_limits: apiKeys.model_limits,
model_limits_enabled: apiKeys.model_limits_enabled,
expires_at: apiKeys.expires_at,
member_name: members.name,
})
.from(apiKeys)
.leftJoin(members, eq(apiKeys.member_id, members.id));
if (conds.length > 0) {
return base.where(and(...conds));
}
return base;
},
async listActive() {
return db
.select({
id: apiKeys.id,
key_hash: apiKeys.key_hash,
key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status,
member_id: apiKeys.member_id,
token_name: apiKeys.token_name,
})
.from(apiKeys)
.where(eq(apiKeys.status, "active"))
.orderBy(asc(apiKeys.id));
},
async insertLog(values: {
api_key_id: number;
dedupe_key: string;
billing_source: string | null;
subscription_id: number | null;
user_id: number;
created_at: Date;
type: number;
content: string;
username: string;
token_name: string;
model_name: string;
quota: number;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
is_stream: boolean;
channel: number;
channel_name: string;
token_id: number;
group_name: string;
ip: string;
request_id: string;
upstream_request_id: string | null;
other: unknown;
}): Promise<boolean> {
const before = await db
.select({ n: count() })
.from(usageLogs)
.where(eq(usageLogs.dedupe_key, values.dedupe_key));
if ((before[0]?.n ?? 0) > 0) return false;
await db.insert(usageLogs).values(values).onConflictDoNothing();
return true;
},
async findById(id: number): Promise<ApiKeyRow | null> {
const rows = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.id, id))
.limit(1);
return rows[0] ?? null;
},
async findByHash(key_hash: string): Promise<{ id: number } | null> {
const rows = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(eq(apiKeys.key_hash, key_hash))
.limit(1);
return rows[0] ?? null;
},
async insert(values: {
key_hash: string;
key_ciphertext: Uint8Array;
member_id: number | null;
token_name?: string;
}): Promise<ApiKeyRow> {
const [row] = await db
.insert(apiKeys)
.values({
key_hash: values.key_hash,
key_ciphertext: values.key_ciphertext,
member_id: values.member_id,
token_name: values.token_name ?? "",
})
.returning();
return row!;
},
async update(
id: number,
patch: Partial<typeof apiKeys.$inferInsert>,
): Promise<ApiKeyRow> {
const [row] = await db
.update(apiKeys)
.set(patch)
.where(eq(apiKeys.id, id))
.returning();
return row!;
},
},
members: {
async listAll(): Promise<MemberRow[]> {
return db.select().from(members);
},
async findById(id: number): Promise<MemberRow | null> {
const rows = await db
.select()
.from(members)
.where(eq(members.id, id))
.limit(1);
return rows[0] ?? null;
},
async findByName(name: string): Promise<{ id: number } | null> {
const rows = await db
.select({ id: members.id })
.from(members)
.where(eq(members.name, name))
.limit(1);
return rows[0] ?? null;
},
async findByTokenHash(token_hash: string): Promise<{
id: number;
status: string;
} | null> {
const rows = await db
.select({ id: members.id, status: members.status })
.from(members)
.where(eq(members.token_hash, token_hash))
.limit(1);
return rows[0] ?? null;
},
async insert(values: {
name: string;
remark: string;
token_hash: string;
token_issued_at: Date;
}): Promise<MemberRow> {
const [row] = await db.insert(members).values(values).returning();
return row!;
},
async updateStatus(id: number, status: string): Promise<MemberRow> {
const [row] = await db
.update(members)
.set({ status })
.where(eq(members.id, id))
.returning();
return row!;
},
async reissueToken(
id: number,
token_hash: string,
token_issued_at: Date,
): Promise<void> {
await db
.update(members)
.set({ token_hash, token_issued_at })
.where(eq(members.id, id));
},
async countKeys(member_id: number): Promise<number> {
const rows = await db
.select({ n: count() })
.from(apiKeys)
.where(eq(apiKeys.member_id, member_id));
return rows[0]?.n ?? 0;
},
},
audit: {
async insert(values: {
operator: string | null;
domain: string;
action: string;
summary: string;
}): Promise<AuditLogRow> {
const [row] = await db.insert(auditLogs).values(values).returning();
return row!;
},
async listPage(params: {
domain?: string;
page: number;
page_size: number;
}): Promise<{ rows: AuditLogRow[]; total: number }> {
const conds = params.domain ? [eq(auditLogs.domain, params.domain)] : [];
const totalRows = await db
.select({ n: count() })
.from(auditLogs)
.where(conds.length ? and(...conds) : undefined);
const total = totalRows[0]?.n ?? 0;
const rows = await db
.select()
.from(auditLogs)
.where(conds.length ? and(...conds) : undefined)
.orderBy(desc(auditLogs.created_at), desc(auditLogs.id))
.limit(params.page_size)
.offset((params.page - 1) * params.page_size);
return { rows, total };
},
},
engine: {
async upsertSnapshot(values: {
running: boolean;
sync_interval_ms: number;
last_round: unknown;
}): Promise<EngineStatusRow> {
const [row] = await db
.insert(engineStatus)
.values({
id: 1,
running: values.running,
sync_interval_ms: values.sync_interval_ms,
last_round: values.last_round,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: engineStatus.id,
set: {
running: values.running,
sync_interval_ms: values.sync_interval_ms,
last_round: values.last_round,
updated_at: new Date(),
},
})
.returning();
return row!;
},
async getSnapshot(): Promise<EngineStatusRow | null> {
const rows = await db
.select()
.from(engineStatus)
.where(eq(engineStatus.id, 1))
.limit(1);
return rows[0] ?? null;
},
},
payments: {
async listAll(): Promise<PaymentRow[]> {
return db.select().from(payments).orderBy(desc(payments.paid_at));
},
async findById(id: number): Promise<PaymentRow | null> {
const rows = await db
.select()
.from(payments)
.where(eq(payments.id, id))
.limit(1);
return rows[0] ?? null;
},
async insert(values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
}): Promise<PaymentRow> {
const [row] = await db.insert(payments).values(values).returning();
return row!;
},
async update(id: number, patch: Partial<typeof payments.$inferInsert>) {
const [row] = await db
.update(payments)
.set(patch)
.where(eq(payments.id, id))
.returning();
return row!;
},
async remove(id: number): Promise<void> {
await db.delete(payments).where(eq(payments.id, id));
},
},
qpu: {
async listTimeline(): Promise<
Array<{ qpu: number; effective_from: Date }>
> {
return db
.select({
qpu: qpuHistory.qpu,
effective_from: qpuHistory.effective_from,
})
.from(qpuHistory)
.orderBy(asc(qpuHistory.effective_from));
},
async latest(): Promise<{ qpu: number; effective_from: Date } | null> {
const rows = await db
.select({
qpu: qpuHistory.qpu,
effective_from: qpuHistory.effective_from,
})
.from(qpuHistory)
.orderBy(desc(qpuHistory.effective_from))
.limit(1);
return rows[0] ?? null;
},
async insertChange(qpu: number, effective_from: Date): Promise<void> {
await db.insert(qpuHistory).values({ qpu, effective_from });
},
},
logs: {
/** 明细分页(时间范围左闭右开) */
async listPage(params: {
from: Date;
to: Date;
api_key_id?: number;
member_id?: number;
billing_source?: string;
subscription_id?: number;
type?: number;
page: number;
page_size: number;
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
const conds = [
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
if (params.type != null) {
conds.push(eq(usageLogs.type, params.type));
}
if (params.member_id != null) {
conds.push(eq(apiKeys.member_id, params.member_id));
}
const totalRows = await db
.select({ n: count() })
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds));
const total = totalRows[0]?.n ?? 0;
const offset = (params.page - 1) * params.page_size;
const rows = await db
.select({
id: usageLogs.id,
api_key_id: usageLogs.api_key_id,
dedupe_key: usageLogs.dedupe_key,
billing_source: usageLogs.billing_source,
subscription_id: usageLogs.subscription_id,
user_id: usageLogs.user_id,
created_at: usageLogs.created_at,
type: usageLogs.type,
content: usageLogs.content,
username: usageLogs.username,
token_name: apiKeys.token_name,
model_name: usageLogs.model_name,
quota: usageLogs.quota,
prompt_tokens: usageLogs.prompt_tokens,
completion_tokens: usageLogs.completion_tokens,
use_time: usageLogs.use_time,
is_stream: usageLogs.is_stream,
channel: usageLogs.channel,
channel_name: usageLogs.channel_name,
token_id: usageLogs.token_id,
group_name: usageLogs.group_name,
ip: usageLogs.ip,
request_id: usageLogs.request_id,
upstream_request_id: usageLogs.upstream_request_id,
other: usageLogs.other,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.orderBy(desc(usageLogs.created_at), desc(usageLogs.id))
.limit(params.page_size)
.offset(offset);
return {
rows: rows.map((r) => ({
...r,
token_name: r.token_name ?? "",
})) as Array<UsageLogRow & { token_name: string }>,
total,
};
},
/** 总览聚合用:type=2、按维字段 */
async listForAggregate(from: Date, to: Date, type = 2) {
return db
.select({
api_key_id: usageLogs.api_key_id,
member_id: apiKeys.member_id,
billing_source: usageLogs.billing_source,
subscription_id: usageLogs.subscription_id,
quota: usageLogs.quota,
created_at: usageLogs.created_at,
token_name: apiKeys.token_name,
status: apiKeys.status,
log_synced_at: apiKeys.log_synced_at,
member_name: members.name,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.leftJoin(members, eq(apiKeys.member_id, members.id))
.where(
and(
gte(usageLogs.created_at, from),
lt(usageLogs.created_at, to),
eq(usageLogs.type, type),
),
);
},
/** 成员视图聚合(名下 key、type=2) */
async listMemberForAggregate(
member_id: number,
from: Date,
to: Date,
type = 2,
) {
return db
.select({
api_key_id: usageLogs.api_key_id,
billing_source: usageLogs.billing_source,
subscription_id: usageLogs.subscription_id,
quota: usageLogs.quota,
created_at: usageLogs.created_at,
token_name: apiKeys.token_name,
log_synced_at: apiKeys.log_synced_at,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(
and(
eq(apiKeys.member_id, member_id),
gte(usageLogs.created_at, from),
lt(usageLogs.created_at, to),
eq(usageLogs.type, type),
),
);
},
/** 成员视图明细分页(恒 type=2) */
async listMemberPage(params: {
member_id: number;
from: Date;
to: Date;
page: number;
page_size: number;
}) {
const conds = [
eq(apiKeys.member_id, params.member_id),
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
eq(usageLogs.type, 2),
];
const totalRows = await db
.select({ n: count() })
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds));
const total = totalRows[0]?.n ?? 0;
const rows = await db
.select({
id: usageLogs.id,
api_key_id: usageLogs.api_key_id,
created_at: usageLogs.created_at,
token_name: apiKeys.token_name,
model_name: usageLogs.model_name,
type: usageLogs.type,
content: usageLogs.content,
quota: usageLogs.quota,
prompt_tokens: usageLogs.prompt_tokens,
completion_tokens: usageLogs.completion_tokens,
use_time: usageLogs.use_time,
is_stream: usageLogs.is_stream,
billing_source: usageLogs.billing_source,
subscription_id: usageLogs.subscription_id,
log_synced_at: apiKeys.log_synced_at,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.orderBy(desc(usageLogs.created_at), desc(usageLogs.id))
.limit(params.page_size)
.offset((params.page - 1) * params.page_size);
return { rows, total };
},
},
keysExtra: {
/** 全池数据截止 = 最慢 log_synced_at */
async slowestLogSyncedAt(): Promise<Date | null> {
const rows = await db
.select({ t: sql<string | null>`min(${apiKeys.log_synced_at})::text` })
.from(apiKeys)
.where(sql`${apiKeys.log_synced_at} is not null`);
const raw = rows[0]?.t;
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
},
async listIdsWithCutoff() {
return db
.select({
id: apiKeys.id,
member_id: apiKeys.member_id,
token_name: apiKeys.token_name,
status: apiKeys.status,
log_synced_at: apiKeys.log_synced_at,
})
.from(apiKeys);
},
},
};
}
export type Repos = ReturnType<typeof createRepos>;
+215
View File
@@ -0,0 +1,215 @@
import {
bigint,
boolean,
customType,
index,
integer,
jsonb,
numeric,
pgTable,
smallint,
text,
timestamp,
} from "drizzle-orm/pg-core";
/** bytea:AES-GCM 打包密文(nonce||ct||tag) */
const bytea = customType<{ data: Uint8Array; driverData: Buffer }>({
dataType: () => "bytea",
toDriver: (v) => Buffer.from(v),
fromDriver: (v) => new Uint8Array(v),
});
export const members = pgTable("members", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
name: text("name").notNull().unique(),
remark: text("remark").notNull().default(""),
status: text("status").notNull().default("active"),
token_hash: text("token_hash").notNull().unique(),
token_issued_at: timestamp("token_issued_at", {
withTimezone: true,
mode: "date",
}),
created_at: timestamp("created_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
});
export const apiKeys = pgTable(
"api_keys",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
key_hash: text("key_hash").notNull().unique(),
key_ciphertext: bytea("key_ciphertext").notNull(),
status: text("status").notNull().default("active"),
member_id: bigint("member_id", { mode: "number" }).references(
() => members.id,
),
registered_at: timestamp("registered_at", {
withTimezone: true,
mode: "date",
})
.notNull()
.defaultNow(),
log_synced_at: timestamp("log_synced_at", {
withTimezone: true,
mode: "date",
}),
usage_synced_at: timestamp("usage_synced_at", {
withTimezone: true,
mode: "date",
}),
token_name: text("token_name").notNull().default(""),
total_granted: bigint("total_granted", { mode: "number" })
.notNull()
.default(0),
total_used: bigint("total_used", { mode: "number" }).notNull().default(0),
total_available: bigint("total_available", { mode: "number" })
.notNull()
.default(0),
unlimited_quota: boolean("unlimited_quota").notNull().default(false),
model_limits: jsonb("model_limits"),
model_limits_enabled: boolean("model_limits_enabled")
.notNull()
.default(false),
expires_at: timestamp("expires_at", { withTimezone: true, mode: "date" }),
},
(t) => [index("api_keys_member_id_idx").on(t.member_id)],
);
export const usageLogs = pgTable(
"usage_logs",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
api_key_id: bigint("api_key_id", { mode: "number" })
.notNull()
.references(() => apiKeys.id),
dedupe_key: text("dedupe_key").notNull().unique(),
billing_source: text("billing_source"),
subscription_id: bigint("subscription_id", { mode: "number" }),
user_id: bigint("user_id", { mode: "number" }).notNull().default(0),
created_at: timestamp("created_at", {
withTimezone: true,
mode: "date",
}).notNull(),
type: smallint("type").notNull().default(2),
content: text("content").notNull().default(""),
username: text("username").notNull().default(""),
token_name: text("token_name").notNull().default(""),
model_name: text("model_name").notNull().default(""),
quota: bigint("quota", { mode: "number" }).notNull().default(0),
prompt_tokens: integer("prompt_tokens").notNull().default(0),
completion_tokens: integer("completion_tokens").notNull().default(0),
use_time: integer("use_time").notNull().default(0),
is_stream: boolean("is_stream").notNull().default(false),
channel: integer("channel").notNull().default(0),
channel_name: text("channel_name").notNull().default(""),
token_id: bigint("token_id", { mode: "number" }).notNull().default(0),
group_name: text("group_name").notNull().default(""),
ip: text("ip").notNull().default(""),
request_id: text("request_id").notNull().default(""),
upstream_request_id: text("upstream_request_id"),
other: jsonb("other"),
},
(t) => [
index("usage_logs_created_at_idx").on(t.created_at),
index("usage_logs_api_key_created_idx").on(t.api_key_id, t.created_at),
index("usage_logs_subscription_idx").on(t.subscription_id),
],
);
export const payments = pgTable("payments", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
kind: text("kind").notNull(),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
paid_amount: numeric("paid_amount", {
precision: 18,
scale: 2,
mode: "number",
}).notNull(),
remark: text("remark").notNull().default(""),
period_start: timestamp("period_start", { withTimezone: true, mode: "date" }),
period_end: timestamp("period_end", { withTimezone: true, mode: "date" }),
subscription_id: bigint("subscription_id", { mode: "number" }),
quota_granted: bigint("quota_granted", { mode: "number" }),
});
export const qpuHistory = pgTable("qpu_history", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
qpu: numeric("qpu", { precision: 18, scale: 6, mode: "number" }).notNull(),
effective_from: timestamp("effective_from", {
withTimezone: true,
mode: "date",
})
.notNull()
.defaultNow(),
});
export const users = pgTable("users", {
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
username: text("username").notNull().unique(),
password_hash: text("password_hash").notNull(),
created_at: timestamp("created_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
});
export const sessions = pgTable(
"sessions",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
user_id: bigint("user_id", { mode: "number" })
.notNull()
.references(() => users.id),
token_hash: text("token_hash").notNull().unique(),
expires_at: timestamp("expires_at", {
withTimezone: true,
mode: "date",
}).notNull(),
created_at: timestamp("created_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
},
(t) => [index("sessions_user_id_idx").on(t.user_id)],
);
/** 审计日志(管理面写操作留痕)——结构对齐 API契约-管理面 §9 */
export const auditLogs = pgTable(
"audit_logs",
{
id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
created_at: timestamp("created_at", {
withTimezone: true,
mode: "date",
})
.notNull()
.defaultNow(),
operator: text("operator"),
domain: text("domain").notNull(),
action: text("action").notNull(),
summary: text("summary").notNull().default(""),
},
(t) => [index("audit_logs_created_at_idx").on(t.created_at)],
);
/** 同步引擎状态单行快照(《同步引擎设计》§5) */
export const engineStatus = pgTable("engine_status", {
id: integer("id").primaryKey().default(1),
running: boolean("running").notNull().default(false),
sync_interval_ms: bigint("sync_interval_ms", { mode: "number" })
.notNull()
.default(300_000),
last_round: jsonb("last_round"),
updated_at: timestamp("updated_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
});
export type MemberRow = typeof members.$inferSelect;
export type ApiKeyRow = typeof apiKeys.$inferSelect;
export type UsageLogRow = typeof usageLogs.$inferSelect;
export type PaymentRow = typeof payments.$inferSelect;
export type QpuHistoryRow = typeof qpuHistory.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AuditLogRow = typeof auditLogs.$inferSelect;
export type EngineStatusRow = typeof engineStatus.$inferSelect;
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["bun"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@new-api-key-usage/core": ["./packages/core/src/index.ts"],
"@new-api-key-usage/db": ["./packages/db/src/index.ts"]
}
},
"include": [
"apps/server/src/**/*.ts",
"apps/server/test/**/*.ts",
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/*/drizzle.config.ts"
]
}