Compare commits
15
Commits
init
..
9ecf817df2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ecf817df2 | ||
|
|
d47cb5543c | ||
|
|
3e12db1706 | ||
|
|
de48e5d39c | ||
|
|
54ea686f44 | ||
|
|
fa5b3a8942 | ||
|
|
7ab9d32495 | ||
|
|
867bb7d566 | ||
|
|
a2fa755b62 | ||
|
|
6454f4acfa | ||
|
|
6980d09750 | ||
|
|
4c55c3f15f | ||
|
|
4566ce8d58 | ||
|
|
db20dcb611 | ||
|
|
e4d25526f3 |
@@ -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
@@ -13,3 +13,13 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# 依赖与构建产物
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.turbo/
|
||||||
|
|
||||||
|
# 测试与工具
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -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 级额度
|
||||||
@@ -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:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
@@ -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(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"],
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"],
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -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 |
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
-apple-system,
|
||||||
|
"PingFang SC",
|
||||||
|
"Microsoft YaHei",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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:
|
||||||
@@ -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)。
|
||||||
@@ -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` ↔ 支付记录是否同一体系)——需求清单既有标注,实现期真实数据核查后再终验(徽章文案预留「未关联」正常位)。
|
||||||
@@ -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. **令牌生成细节**(随机源 / 长度 / 前缀)——归技术稿。
|
||||||
@@ -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` |
|
||||||
@@ -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 起步) | 技术稿 |
|
||||||
|
| 审计日志表结构 | 技术稿 |
|
||||||
@@ -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` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
|
||||||
@@ -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. 待定事项(关联后续环节)
|
||||||
|
|
||||||
|
| 事项 | 归属 |
|
||||||
|
|------|------|
|
||||||
|
| 采样调度(读取频率、失败重试) | 同步引擎设计(待立) |
|
||||||
@@ -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 限管理面)、会话时长取值 | 技术稿 |
|
||||||
@@ -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) |
|
||||||
|
| 数据保留策略(是否清理旧数据) | 后议 |
|
||||||
@@ -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 迭代参数、登录失败节流策略 | 技术稿 |
|
||||||
|
| 审计日志表结构 | 技术稿 |
|
||||||
@@ -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 粘贴进来」+ 登记按钮常驻
|
||||||
@@ -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(空态与零值区分标注「该时段内无调用」)
|
||||||
@@ -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. 定位边界
|
||||||
|
|
||||||
|
- 审计**只记录管理面写操作**:查询类动作(看总览、翻明细)不留痕;成员侧动作(看自己的视图)不属管理面、不在此列
|
||||||
|
- 本页无任何操作(纯只读)
|
||||||
@@ -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 回登录壳) |
|
||||||
|
| 成员停用 | 同上,登录壳提示「令牌无效或已停用」 |
|
||||||
@@ -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. 空态
|
||||||
|
|
||||||
|
「还没有成员——为每位分摊朋友开户并发放访问令牌」
|
||||||
@@ -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 设计**,窄屏下依赖布局响应(区块纵向堆叠、宽度弹性)保证可读不崩;明细表保持表格形态,横向滚动即可。
|
||||||
@@ -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 清除 → 下次重输(一个月一次的成本,换来零凭证持久化)
|
||||||
@@ -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)。
|
||||||
@@ -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。
|
||||||
@@ -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),倾向手动拉起步、导出与推送逻辑分离留升级口子
|
||||||
@@ -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) |
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user