Compare commits
27
Commits
init
..
adbb6adb93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adbb6adb93 | ||
|
|
0adae25a8c | ||
|
|
311643065c | ||
|
|
2446d3bf2c | ||
|
|
3fb75ec699 | ||
|
|
bd5a9bf99f | ||
|
|
7ff87888a3 | ||
|
|
293141ea0a | ||
|
|
d6d7d9411f | ||
|
|
716ff96b07 | ||
|
|
129ce7e380 | ||
|
|
fbb989c3fb | ||
|
|
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,41 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
给 AI 协作者的仓库指引。**开始任何工作前,先读 [`README.md`](README.md)**——它是全景入口:项目是什么、为什么长这样、走到哪了。
|
||||||
|
|
||||||
|
## 协作约定(长期有效)
|
||||||
|
|
||||||
|
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
|
||||||
|
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
|
||||||
|
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
|
||||||
|
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||||
|
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
|
||||||
|
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
|
||||||
|
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录,如 `v1-设计-*.md`)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关;`v1-设计-1.md` 为历史对话原档,允许保留旧术语,不作现行依据
|
||||||
|
|
||||||
|
## 项目速览(速记)
|
||||||
|
|
||||||
|
- **`v1`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),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` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 当前状态 | **实现推进中**(`v1` 分支 = 文档中心 + 实现主线)——设计文档全部定稿;后端 + 同步引擎已落地;前端总览与 Key 管理已实现,其余页面进行中 |
|
||||||
|
| 技术栈 | 全栈 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,319 @@
|
|||||||
|
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 }>();
|
||||||
|
|
||||||
|
// 只保护需要会话的端点:GET/DELETE /session、PUT /password
|
||||||
|
// POST /session 是登录、setup* 是引导——不能套鉴权
|
||||||
|
r.use("/session", async (c, next) => {
|
||||||
|
if (c.req.method === "POST") return next();
|
||||||
|
return requireAdminSession(c, next);
|
||||||
|
});
|
||||||
|
r.use("/password", requireAdminSession);
|
||||||
|
|
||||||
|
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,356 @@
|
|||||||
|
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(),
|
||||||
|
member_name: z.string().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();
|
||||||
|
const memberNameById = new Map(
|
||||||
|
(await repos.members.listAll()).map((m) => [m.id, m.name]),
|
||||||
|
);
|
||||||
|
for (const k of keyMeta) {
|
||||||
|
keyMap.set(k.id, {
|
||||||
|
token_name: k.token_name,
|
||||||
|
member_id: k.member_id,
|
||||||
|
member_name:
|
||||||
|
k.member_id == null ? null : (memberNameById.get(k.member_id) ?? 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,
|
||||||
|
member_name: v.member_name,
|
||||||
|
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,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
<title>NAKU · NewAPI 用量统计</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"@antdv-next/icons": "^1.1.2",
|
||||||
|
"@new-api-key-usage/core": "workspace:*",
|
||||||
|
"@tanstack/vue-query": "^5.104.1",
|
||||||
|
"@vueuse/core": "15.0.0",
|
||||||
|
"antdv-next": "1.6.0",
|
||||||
|
"axios": "^1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
|
"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: 5.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 636 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#1677FF"/>
|
||||||
|
<text x="16" y="21.5" text-anchor="middle" dominant-baseline="middle"
|
||||||
|
font-family="Segoe UI, system-ui, sans-serif" font-size="18"
|
||||||
|
font-weight="600" fill="#FFFFFF">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 314 B |
@@ -0,0 +1,13 @@
|
|||||||
|
import type { AuditLogItem, Paginated } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchAuditLogs(params: {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
domain?: string;
|
||||||
|
}): Promise<Paginated<AuditLogItem>> {
|
||||||
|
const { data } = await adminHttp.get<Paginated<AuditLogItem>>("/audit-logs", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { EngineStatus, QpuLatest } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchEngine(): Promise<EngineStatus> {
|
||||||
|
const { data } = await adminHttp.get<EngineStatus>("/engine");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQpuLatest(): Promise<QpuLatest> {
|
||||||
|
const { data } = await adminHttp.get<QpuLatest>("/qpu/latest");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { KeyItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type KeyListFilters = {
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
recent_failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]> {
|
||||||
|
const { data } = await adminHttp.get<KeyItem[]>("/keys", { params: filters });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerKey(body: {
|
||||||
|
sk_key: string;
|
||||||
|
member_id?: number | null;
|
||||||
|
}): Promise<KeyItem> {
|
||||||
|
const { data } = await adminHttp.post<KeyItem>("/keys", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchKey(
|
||||||
|
id: number,
|
||||||
|
body: { status?: "active" | "disabled"; member_id?: number | null },
|
||||||
|
): Promise<KeyItem> {
|
||||||
|
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LogItem, Paginated } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchLogs(
|
||||||
|
range: { from: string; to: string; page: number; page_size: number },
|
||||||
|
): Promise<Paginated<LogItem>> {
|
||||||
|
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export async function fetchMembers(): Promise<MemberItem[]> {
|
||||||
|
const { data } = await adminHttp.get<MemberItem[]>("/members");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMember(body: {
|
||||||
|
name: string;
|
||||||
|
remark?: string;
|
||||||
|
}): Promise<MemberItem> {
|
||||||
|
const { data } = await adminHttp.post<MemberItem>("/members", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchMember(
|
||||||
|
id: number,
|
||||||
|
body: { status: "active" | "disabled" },
|
||||||
|
): Promise<MemberItem> {
|
||||||
|
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
||||||
|
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { UsageTotals, UnixSec } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type AdminOverview = {
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
unassigned_subscription: UsageTotals;
|
||||||
|
by_source: Array<{
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
}>;
|
||||||
|
by_member: Array<{
|
||||||
|
member_id: number | null;
|
||||||
|
member_name: string | null;
|
||||||
|
key_count: number;
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
by_key: Array<{
|
||||||
|
key_id: number;
|
||||||
|
token_name: string;
|
||||||
|
member_id: number | null;
|
||||||
|
member_name: string | null;
|
||||||
|
status: string;
|
||||||
|
data_cutoff: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchOverview(range: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<AdminOverview> {
|
||||||
|
const { data } = await adminHttp.get<AdminOverview>("/overview", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PaymentItem } from "@new-api-key-usage/core";
|
||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type PaymentBody = Record<string, unknown>;
|
||||||
|
|
||||||
|
export async function fetchPayments(): Promise<PaymentItem[]> {
|
||||||
|
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
|
||||||
|
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePayment(
|
||||||
|
id: number,
|
||||||
|
body: PaymentBody,
|
||||||
|
): Promise<PaymentItem> {
|
||||||
|
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePayment(id: number): Promise<void> {
|
||||||
|
await adminHttp.delete(`/payments/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type SetupState = { initialized: boolean };
|
||||||
|
export type UsernameBody = { username: string };
|
||||||
|
|
||||||
|
export async function fetchSetupState(): Promise<SetupState> {
|
||||||
|
const { data } = await adminHttp.get<SetupState>("/setup-state");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setup(body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.post<UsernameBody>("/setup", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(body: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.post<UsernameBody>("/session", body);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSession(): Promise<UsernameBody> {
|
||||||
|
const { data } = await adminHttp.get<UsernameBody>("/session");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await adminHttp.delete("/session");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changePassword(body: {
|
||||||
|
old_password: string;
|
||||||
|
new_password: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await adminHttp.put("/password", body);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 401 分流出口:由 main.ts 接到 router,避免 http ↔ router 循环依赖。
|
||||||
|
* 管理面任意 401 → /admin/login;成员视图任意 401 → /member/login(契约公共约定 §2)。
|
||||||
|
*/
|
||||||
|
export type AuthWorld = "admin" | "member";
|
||||||
|
|
||||||
|
type AuthRedirectHandler = (world: AuthWorld, reason: "expired" | "unauthorized") => void;
|
||||||
|
|
||||||
|
const fallback: AuthRedirectHandler = (world) => {
|
||||||
|
window.location.href = world === "admin" ? "/admin/login" : "/member/login";
|
||||||
|
};
|
||||||
|
|
||||||
|
let handler: AuthRedirectHandler = fallback;
|
||||||
|
|
||||||
|
export function setAuthRedirect(fn: AuthRedirectHandler): void {
|
||||||
|
handler = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redirectAuth(
|
||||||
|
world: AuthWorld,
|
||||||
|
reason: "expired" | "unauthorized" = "unauthorized",
|
||||||
|
): void {
|
||||||
|
handler(world, reason);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import axios, {
|
||||||
|
AxiosError,
|
||||||
|
type AxiosInstance,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import type { ErrorEnvelope } from "@new-api-key-usage/core";
|
||||||
|
import { redirectAuth, type AuthWorld } from "./auth-redirect.ts";
|
||||||
|
|
||||||
|
const MEMBER_TOKEN_KEY = "member_token";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
readonly fields: Record<string, string> | undefined;
|
||||||
|
readonly keyId: number | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
extra?: { fields?: Record<string, string>; key_id?: number },
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.fields = extra?.fields;
|
||||||
|
this.keyId = extra?.key_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorEnvelope(data: unknown): ErrorEnvelope | null {
|
||||||
|
if (!data || typeof data !== "object") return null;
|
||||||
|
const env = data as Partial<ErrorEnvelope>;
|
||||||
|
if (!env.error || typeof env.error !== "object") return null;
|
||||||
|
const { code, message } = env.error;
|
||||||
|
if (typeof code !== "string" || typeof message !== "string") return null;
|
||||||
|
return env as ErrorEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiError(error: AxiosError, world: AuthWorld): ApiError {
|
||||||
|
const envelope = parseErrorEnvelope(error.response?.data);
|
||||||
|
if (envelope) {
|
||||||
|
return new ApiError(
|
||||||
|
error.response?.status ?? 0,
|
||||||
|
envelope.error.code,
|
||||||
|
envelope.error.message,
|
||||||
|
{
|
||||||
|
fields: envelope.error.fields,
|
||||||
|
key_id: envelope.error.key_id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const status = error.response?.status ?? 0;
|
||||||
|
if (status === 401) {
|
||||||
|
return new ApiError(401, "unauthorized", "凭证无效或已过期");
|
||||||
|
}
|
||||||
|
if (status === 0 || !error.response) {
|
||||||
|
return new ApiError(0, "network", "网络异常,请稍后重试");
|
||||||
|
}
|
||||||
|
// 中间人非 JSON 错误体(代理 502 页等)回退通用文案
|
||||||
|
return new ApiError(status, "internal", "服务暂时不可用,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
|
||||||
|
if (world === "member") {
|
||||||
|
client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = sessionStorage.getItem(MEMBER_TOKEN_KEY);
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error: AxiosError) => {
|
||||||
|
const apiError = toApiError(error, world);
|
||||||
|
if (apiError.status === 401) {
|
||||||
|
redirectAuth(world, "expired");
|
||||||
|
}
|
||||||
|
return Promise.reject(apiError);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(world: AuthWorld, baseURL: string): AxiosInstance {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
timeout: 30_000,
|
||||||
|
// 管理面会话 cookie 需跨端口(Vite 5173 → API 18456)携带
|
||||||
|
withCredentials: true,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
attachInterceptors(client, world);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 管理面:`/api/admin/*`,会话 cookie */
|
||||||
|
export const adminHttp = createClient("admin", "/api/admin");
|
||||||
|
|
||||||
|
/** 成员视图:`/api/member/*`,Bearer 令牌 */
|
||||||
|
export const memberHttp = createClient("member", "/api/member");
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type {
|
||||||
|
LogItem,
|
||||||
|
Paginated,
|
||||||
|
PaymentItem,
|
||||||
|
UnixSec,
|
||||||
|
UsageTotals,
|
||||||
|
} from "@new-api-key-usage/core";
|
||||||
|
import { memberHttp } from "../http.ts";
|
||||||
|
|
||||||
|
export type MemberMe = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MemberOverview = {
|
||||||
|
member: { id: number | null; name: string | null };
|
||||||
|
usage: UsageTotals;
|
||||||
|
by_source: Array<{
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
}>;
|
||||||
|
keys: Array<{
|
||||||
|
key_id: number;
|
||||||
|
token_name: string;
|
||||||
|
log_synced_at: UnixSec | null;
|
||||||
|
usage: UsageTotals;
|
||||||
|
}>;
|
||||||
|
payments: PaymentItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchMe(): Promise<MemberMe> {
|
||||||
|
const { data } = await memberHttp.get<MemberMe>("/me");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOverview(range: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}): Promise<MemberOverview> {
|
||||||
|
const { data } = await memberHttp.get<MemberOverview>("/overview", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLogs(
|
||||||
|
range: { from: string; to: string; page: number; page_size: number },
|
||||||
|
): Promise<Paginated<LogItem>> {
|
||||||
|
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
||||||
|
params: range,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* vue-query key 工厂——失效与缓存的唯一命名源。
|
||||||
|
* 约定:`[面, 端点, …参数]`,参数保持可 JSON 序列化。
|
||||||
|
*/
|
||||||
|
export type KeyFilters = {
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
recent_failed?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageQuery = {
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeRange = {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const qk = {
|
||||||
|
admin: {
|
||||||
|
setupState: () => ["admin", "setup-state"] as const,
|
||||||
|
session: () => ["admin", "session"] as const,
|
||||||
|
engine: () => ["admin", "engine"] as const,
|
||||||
|
overview: (range: TimeRange) =>
|
||||||
|
["admin", "overview", range.from, range.to] as const,
|
||||||
|
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
|
||||||
|
logs: (range: TimeRange & PageQuery) =>
|
||||||
|
["admin", "logs", range.from, range.to, range.page, range.page_size] as const,
|
||||||
|
payments: () => ["admin", "payments"] as const,
|
||||||
|
qpuLatest: () => ["admin", "qpu-latest"] as const,
|
||||||
|
members: () => ["admin", "members"] as const,
|
||||||
|
auditLogs: (q: PageQuery & { domain?: string }) =>
|
||||||
|
["admin", "audit-logs", q.page, q.page_size, q.domain ?? ""] as const,
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
me: () => ["member", "me"] as const,
|
||||||
|
overview: (range: TimeRange) =>
|
||||||
|
["member", "overview", range.from, range.to] as const,
|
||||||
|
logs: (range: TimeRange & PageQuery) =>
|
||||||
|
["member", "logs", range.from, range.to, range.page, range.page_size] as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 路由出口 + 全局中文(DatePicker 等默认 en-US,须用 ConfigProvider)
|
||||||
|
import zhCN from "antdv-next/locale/zh_CN";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import "dayjs/locale/zh-cn";
|
||||||
|
|
||||||
|
dayjs.locale("zh-cn");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-config-provider :locale="zhCN">
|
||||||
|
<router-view />
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family:
|
||||||
|
-apple-system,
|
||||||
|
"PingFang SC",
|
||||||
|
"Microsoft YaHei",
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 健康带(交互-全员总览 §1.1):引擎轮次 · 全池数据截止 · 未关联订阅
|
||||||
|
* 文案偏完整说明,不追求极简电报体。
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import type { EngineStatus } from "@new-api-key-usage/core";
|
||||||
|
import type { AdminOverview } from "@/api/admin/overview.ts";
|
||||||
|
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
engine: EngineStatus | undefined;
|
||||||
|
overview: AdminOverview | undefined;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "go-failed-keys"): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const lastRound = computed(() => props.engine?.last_round ?? null);
|
||||||
|
const failedCount = computed(() => lastRound.value?.failed ?? 0);
|
||||||
|
const cutoff = computed(() => props.overview?.data_cutoff ?? null);
|
||||||
|
const cutoffHours = computed(() => hoursSince(cutoff.value));
|
||||||
|
const cutoffStale = computed(
|
||||||
|
() => cutoffHours.value != null && cutoffHours.value > 2,
|
||||||
|
);
|
||||||
|
const unassigned = computed(
|
||||||
|
() => props.overview?.unassigned_subscription ?? null,
|
||||||
|
);
|
||||||
|
const unassignedBad = computed(
|
||||||
|
() => (unassigned.value?.count ?? 0) > 0 || (unassigned.value?.quota ?? 0) > 0,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="health-band">
|
||||||
|
<a-skeleton v-if="loading" active :paragraph="{ rows: 1 }" :title="false" />
|
||||||
|
<template v-else>
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">同步引擎</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<template v-if="lastRound">
|
||||||
|
最近一次同步轮次共处理
|
||||||
|
<strong>{{ lastRound.keys_total }}</strong>
|
||||||
|
枚 key,其中成功
|
||||||
|
<strong>{{ lastRound.succeeded }}</strong>
|
||||||
|
枚。
|
||||||
|
<template v-if="failedCount > 0">
|
||||||
|
有
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="health-band__fail-btn"
|
||||||
|
@click="emit('go-failed-keys')"
|
||||||
|
>
|
||||||
|
{{ failedCount }} 枚拉取失败
|
||||||
|
</a-button>
|
||||||
|
,可到 Key 管理筛选「最近轮次失败」查看是哪几枚。
|
||||||
|
</template>
|
||||||
|
<template v-else>没有拉取失败。</template>
|
||||||
|
</template>
|
||||||
|
<span v-else class="health-band__muted">
|
||||||
|
引擎尚未完成过同步轮次,或引擎进程未启动——此时用量数据可能还不完整。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">数据截止</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<a-tooltip
|
||||||
|
title="取全部 key 的 log_synced_at 中最慢的那一个;某枚 key 同步失败会一直不写该字段,从而拖住全池截止"
|
||||||
|
>
|
||||||
|
<span>全池同步截止于 <strong>{{ formatMinute(cutoff) }}</strong>(北京时间)。</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<template v-if="cutoffStale">
|
||||||
|
<span v-if="failedCount > 0" class="health-band__warn">
|
||||||
|
截止时间落后超过 2 小时——通常是
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
class="health-band__fail-btn"
|
||||||
|
@click="emit('go-failed-keys')"
|
||||||
|
>
|
||||||
|
同步失败的 key
|
||||||
|
</a-button>
|
||||||
|
一直未成功、拖住了全池最慢值;请到 Key 管理处理(必要时停用)。
|
||||||
|
</span>
|
||||||
|
<span v-else class="health-band__warn">
|
||||||
|
截止时间落后超过 2 小时,而最近轮次没有失败 key——同步链路可能停滞,请检查引擎日志。
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else-if="cutoff != null" class="health-band__ok">
|
||||||
|
目前在正常滞后范围内。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="health-band__item">
|
||||||
|
<div class="health-band__title">未关联订阅</div>
|
||||||
|
<div class="health-band__body">
|
||||||
|
<a-tooltip
|
||||||
|
title="统计的是「来源为订阅、但日志上没有订阅编号」的用量;正常情况下应始终为 0"
|
||||||
|
>
|
||||||
|
<template v-if="unassignedBad">
|
||||||
|
<span class="health-band__danger">
|
||||||
|
本时段发现
|
||||||
|
{{ unassigned?.count }} 条 / ${{ unassigned?.quota_usd }}
|
||||||
|
订阅来源用量未关联到任何订阅编号
|
||||||
|
</span>
|
||||||
|
(说明支付记录缺登记,或上游计费行为发生了变化),需要人工核对。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
本时段没有出现「订阅来源却关联不到订阅编号」的用量,属正常状态。
|
||||||
|
</template>
|
||||||
|
</a-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.health-band {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 96px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__title {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__body {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__body strong {
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__muted {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__ok {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__warn {
|
||||||
|
color: #d48806;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__danger {
|
||||||
|
color: #cf1322;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-band__fail-btn {
|
||||||
|
padding: 0 4px;
|
||||||
|
height: auto;
|
||||||
|
line-height: inherit;
|
||||||
|
color: #cf1322;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 登记 Key:sk-key 明文在 web 的唯一露面处(交互-key管理 §2)
|
||||||
|
*/
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
|
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
members: MemberItem[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:open", v: boolean): void;
|
||||||
|
(e: "registered", keyId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const register = useRegisterKey();
|
||||||
|
const createMember = useCreateMember();
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
sk_key: "",
|
||||||
|
member_id: null as number | "unassigned" | null,
|
||||||
|
});
|
||||||
|
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const showNewMember = ref(false);
|
||||||
|
const newMemberName = ref("");
|
||||||
|
const newMemberRemark = ref("");
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (!v) {
|
||||||
|
form.sk_key = "";
|
||||||
|
form.member_id = null;
|
||||||
|
resultMsg.value = null;
|
||||||
|
showNewMember.value = false;
|
||||||
|
newMemberName.value = "";
|
||||||
|
newMemberRemark.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ value: "unassigned", label: "未归属" },
|
||||||
|
...props.members.map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
resultMsg.value = null;
|
||||||
|
if (!form.sk_key.trim()) {
|
||||||
|
resultMsg.value = { type: "warning", text: "请粘贴 sk-key" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const key = await register.mutateAsync({
|
||||||
|
sk_key: form.sk_key.trim(),
|
||||||
|
member_id:
|
||||||
|
form.member_id === "unassigned" || form.member_id == null
|
||||||
|
? null
|
||||||
|
: form.member_id,
|
||||||
|
});
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "success",
|
||||||
|
text: "已登记,首轮同步进行中(约 5 分钟内完成)",
|
||||||
|
};
|
||||||
|
emit("registered", key.id);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
if (err.status === 409) {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "warning",
|
||||||
|
text: "该 key 已在清单中",
|
||||||
|
};
|
||||||
|
if (err.keyId != null) emit("registered", err.keyId);
|
||||||
|
} else if (err.status === 422) {
|
||||||
|
// 上游拒绝 vs 本地表单校验(如 sk- 前缀)文案不同
|
||||||
|
const fieldMsg = err.fields?.sk_key;
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text:
|
||||||
|
fieldMsg ??
|
||||||
|
(err.code === "validation_failed"
|
||||||
|
? err.message || "请检查填写内容"
|
||||||
|
: "上游拒绝了这枚 key——可能不存在或已被禁用"),
|
||||||
|
};
|
||||||
|
} else if (err.status === 502 || err.status === 0) {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text: "上游站点暂时不可达,请稍后重试",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
resultMsg.value = {
|
||||||
|
type: "error",
|
||||||
|
text: err.message || "登记失败,请稍后重试",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateMember() {
|
||||||
|
const name = newMemberName.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
try {
|
||||||
|
const m = await createMember.mutateAsync({
|
||||||
|
name,
|
||||||
|
remark: newMemberRemark.value.trim(),
|
||||||
|
});
|
||||||
|
form.member_id = m.id;
|
||||||
|
showNewMember.value = false;
|
||||||
|
newMemberName.value = "";
|
||||||
|
newMemberRemark.value = "";
|
||||||
|
resultMsg.value = { type: "success", text: `已新建成员「${m.name}」并选为归属` };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
resultMsg.value = { type: "error", text: err.message || "新建成员失败" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
title="登记 Key"
|
||||||
|
:confirm-loading="register.isPending.value"
|
||||||
|
ok-text="登记"
|
||||||
|
cancel-text="取消"
|
||||||
|
@update:open="emit('update:open', $event)"
|
||||||
|
@ok="onSubmit"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="sk-key" required>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="form.sk_key"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="粘贴你在 NewAPI 创建的 sk-…"
|
||||||
|
/>
|
||||||
|
<div class="register-key__note">
|
||||||
|
粘贴后立即加密存储,界面不再回显明文
|
||||||
|
</div>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="归属成员">
|
||||||
|
<a-select
|
||||||
|
v-model:value="form.member_id"
|
||||||
|
:options="memberOptions"
|
||||||
|
placeholder="未归属"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
style="padding-left: 0"
|
||||||
|
@click="showNewMember = !showNewMember"
|
||||||
|
>
|
||||||
|
+ 新建成员
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item v-if="showNewMember" label="新成员名称">
|
||||||
|
<a-input
|
||||||
|
v-model:value="newMemberName"
|
||||||
|
placeholder="成员名称"
|
||||||
|
@press-enter="onCreateMember"
|
||||||
|
/>
|
||||||
|
<a-input
|
||||||
|
v-model:value="newMemberRemark"
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
/>
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
style="margin-top: 8px"
|
||||||
|
:loading="createMember.isPending.value"
|
||||||
|
@click="onCreateMember"
|
||||||
|
>
|
||||||
|
创建并选用
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="resultMsg"
|
||||||
|
:type="resultMsg.type"
|
||||||
|
show-icon
|
||||||
|
:message="resultMsg.text"
|
||||||
|
/>
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-key__note {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 页面标题区——对齐 design.md 排版:
|
||||||
|
* 标题 headline-sm(20/600/28)· 说明 body-sm 三级字(rgba 0.45)
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="page-header__text">
|
||||||
|
<h1 class="page-header__title">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="page-header__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__subtitle {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* 与标题块上缘对齐时,控件 32px 高与 label 竖向居中 */
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 额度双单位:原始 quota + 美元(契约公共约定 §5;前端只呈现不换算)
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
quota: number | null | undefined;
|
||||||
|
quotaUsd: string | null | undefined;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const quotaText = computed(() => formatInt(props.quota));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="quota-text">
|
||||||
|
<span class="quota-text__num">{{ quotaText }}</span>
|
||||||
|
<span class="quota-text__usd">(${{ quotaUsd ?? "—" }})</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quota-text {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-text__num {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quota-text__usd {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 来源分解分段条(交互共性 §3.7:订阅 / 充值)——前端现算比例(公共约定 §5)
|
||||||
|
*/
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
type SourceRow = {
|
||||||
|
billing_source: string;
|
||||||
|
count: number;
|
||||||
|
quota: number;
|
||||||
|
quota_usd: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
rows: SourceRow[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const segments = computed(() => {
|
||||||
|
const total = props.rows.reduce((s, r) => s + r.quota, 0);
|
||||||
|
return props.rows.map((r) => {
|
||||||
|
const pct = total > 0 ? (r.quota / total) * 100 : 0;
|
||||||
|
const isSub = r.billing_source === "subscription";
|
||||||
|
return {
|
||||||
|
key: r.billing_source,
|
||||||
|
label: isSub ? "订阅" : r.billing_source === "wallet" ? "充值" : r.billing_source,
|
||||||
|
pct,
|
||||||
|
count: r.count,
|
||||||
|
quota_usd: r.quota_usd,
|
||||||
|
color: isSub ? "#1677ff" : "#faad14",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasData = computed(() => segments.value.some((s) => s.pct > 0));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="source-bar">
|
||||||
|
<div v-if="!hasData" class="source-bar__empty">
|
||||||
|
本时段内没有任何消费调用,因此没有可分解的来源。
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="source-bar__track">
|
||||||
|
<div
|
||||||
|
v-for="seg in segments"
|
||||||
|
:key="seg.key"
|
||||||
|
class="source-bar__seg"
|
||||||
|
:style="{ width: `${seg.pct}%`, background: seg.color }"
|
||||||
|
:title="`${seg.label}来源:占额度 ${seg.pct.toFixed(1)}%,调用 ${seg.count} 次,$${seg.quota_usd}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="source-bar__legend">
|
||||||
|
<span v-for="seg in segments" :key="seg.key" class="source-bar__item">
|
||||||
|
<i class="source-bar__dot" :style="{ background: seg.color }" />
|
||||||
|
<span class="source-bar__label">{{ seg.label }}</span>
|
||||||
|
<span class="source-bar__meta">
|
||||||
|
调用 {{ seg.count }} 次,消耗 ${{ seg.quota_usd }}
|
||||||
|
(占 {{ seg.pct.toFixed(1) }}%)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.source-bar__track {
|
||||||
|
display: flex;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__seg {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__legend {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__label {
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 2.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__meta {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-bar__empty {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Dayjs } from "dayjs";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { recentDaysRange, type RangeValue } from "@/utils/time.ts";
|
||||||
|
import type { RangeKind } from "@/composables/use-live-time-range.ts";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: RangeValue;
|
||||||
|
/** 外部预置(如「本订阅期间」);空则只保留内建快捷项 */
|
||||||
|
extraPresets?: Array<{ label: string; from: string; to: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: RangeValue): void;
|
||||||
|
/** 相对区间可自动跟日历推进;fixed 为绝对窗口 */
|
||||||
|
(e: "change", value: RangeValue, kind: RangeKind): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
||||||
|
if (!props.modelValue.from || !props.modelValue.to) return null;
|
||||||
|
return [dayjs(props.modelValue.from), dayjs(props.modelValue.to)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const presets = computed(() => {
|
||||||
|
const builtIn = [
|
||||||
|
{
|
||||||
|
label: "最近 7 天",
|
||||||
|
value: rangeToDayjs(recentDaysRange(7)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "最近 30 天",
|
||||||
|
value: rangeToDayjs(recentDaysRange(30)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const extra = (props.extraPresets ?? []).map((p) => ({
|
||||||
|
label: p.label,
|
||||||
|
value: rangeToDayjs({ from: p.from, to: p.to }),
|
||||||
|
}));
|
||||||
|
return [...builtIn, ...extra];
|
||||||
|
});
|
||||||
|
|
||||||
|
function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
||||||
|
return [dayjs(range.from), dayjs(range.to)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectKind(value: RangeValue): RangeKind {
|
||||||
|
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
||||||
|
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
||||||
|
for (const days of [7, 30] as const) {
|
||||||
|
const r = recentDaysRange(days);
|
||||||
|
if (
|
||||||
|
dayjs(r.from).format("YYYY-MM-DD") === fromD &&
|
||||||
|
dayjs(r.to).format("YYYY-MM-DD") === toD
|
||||||
|
) {
|
||||||
|
return days === 7 ? "recent-7" : "recent-30";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "fixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(dates: Dayjs[] | null) {
|
||||||
|
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
|
||||||
|
const value = {
|
||||||
|
from: dates[0].toISOString(),
|
||||||
|
to: dates[1].toISOString(),
|
||||||
|
};
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
emit("change", value, detectKind(value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-range-picker
|
||||||
|
:value="pickerValue"
|
||||||
|
:presets="presets"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
:placeholder="['开始日期', '结束日期']"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { computed, onUnmounted, ref } from "vue";
|
||||||
|
import { recentDaysRange } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
export type RangeValue = { from: string; to: string };
|
||||||
|
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可跟随「今天」推进的时间范围:
|
||||||
|
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
||||||
|
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
||||||
|
*/
|
||||||
|
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
||||||
|
const kind = ref<RangeKind>(initial);
|
||||||
|
const fixed = ref<RangeValue>(recentDaysRange(30));
|
||||||
|
const nowTick = ref(Date.now());
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
nowTick.value = Date.now();
|
||||||
|
}, 60_000);
|
||||||
|
onUnmounted(() => clearInterval(timer));
|
||||||
|
|
||||||
|
const range = computed<RangeValue>(() => {
|
||||||
|
void nowTick.value;
|
||||||
|
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||||
|
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||||
|
return fixed.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function setRange(value: RangeValue, nextKind: RangeKind) {
|
||||||
|
kind.value = nextKind;
|
||||||
|
if (nextKind === "fixed") fixed.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { range, setRange, kind };
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MenuInfo, MenuItemType } from "antdv-next";
|
||||||
|
import { theme } from "antdv-next";
|
||||||
|
import {
|
||||||
|
AccountBookOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
} from "@antdv-next/icons";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
import { useLogout, useSession } from "@/queries/session.ts";
|
||||||
|
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const adminSession = useAdminSessionStore();
|
||||||
|
// 必须解构:未解构时经 reactive 访问会解包 Ref,再取 .value 会得到 undefined
|
||||||
|
const {
|
||||||
|
data: sessionData,
|
||||||
|
isPending: sessionPending,
|
||||||
|
isFetching: sessionFetching,
|
||||||
|
} = useSession();
|
||||||
|
const logout = useLogout();
|
||||||
|
|
||||||
|
const collapsed = ref(false);
|
||||||
|
/** 品牌区两行:主标题 + 一句描述(与日志前缀 naku 同源) */
|
||||||
|
const brandTitle = "NAKU";
|
||||||
|
const brandDescription = "NewAPI 用量统计";
|
||||||
|
const username = computed(
|
||||||
|
() => adminSession.username || sessionData.value?.username || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
|
||||||
|
const items: MenuItemType[] = [
|
||||||
|
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
|
||||||
|
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
|
||||||
|
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
|
||||||
|
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
|
||||||
|
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
|
||||||
|
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedKeys = ref<string[]>([route.path]);
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => {
|
||||||
|
selectedKeys.value = [path];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function onMenuClick({ key }: MenuInfo) {
|
||||||
|
void router.push(String(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenuItems: MenuItemType[] = [
|
||||||
|
{ key: "settings", icon: UserOutlined, label: "账号设置" },
|
||||||
|
{ type: "divider", key: "d1" },
|
||||||
|
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function onUserMenuClick({ key }: MenuInfo) {
|
||||||
|
if (key === "settings") {
|
||||||
|
void router.push({ name: "admin-settings" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "logout") {
|
||||||
|
await logout.mutateAsync().catch(() => undefined);
|
||||||
|
void router.push({ name: "admin-login" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-layout class="admin-layout">
|
||||||
|
<a-layout-sider
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
collapsible
|
||||||
|
:width="220"
|
||||||
|
:collapsed-width="80"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<MenuUnfoldOutlined v-if="collapsed" />
|
||||||
|
<MenuFoldOutlined v-else />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 品牌区:展开「徽标 + 标题/描述」两行;收起只留徽标 -->
|
||||||
|
<div
|
||||||
|
class="admin-layout__brand"
|
||||||
|
:class="{ 'is-collapsed': collapsed }"
|
||||||
|
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
|
||||||
|
>
|
||||||
|
<span class="admin-layout__mark" aria-hidden="true">N</span>
|
||||||
|
<div v-if="!collapsed" class="admin-layout__brand-text">
|
||||||
|
<div class="admin-layout__brand-title">{{ brandTitle }}</div>
|
||||||
|
<div class="admin-layout__brand-desc">{{ brandDescription }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
:inline-collapsed="collapsed"
|
||||||
|
:selected-keys="selectedKeys"
|
||||||
|
:items="items"
|
||||||
|
@click="onMenuClick"
|
||||||
|
/>
|
||||||
|
</a-layout-sider>
|
||||||
|
<a-layout class="admin-layout__main">
|
||||||
|
<a-layout-header class="admin-layout__header">
|
||||||
|
<a-dropdown
|
||||||
|
:menu="{ items: userMenuItems }"
|
||||||
|
placement="bottomRight"
|
||||||
|
trigger="click"
|
||||||
|
@menu-click="onUserMenuClick"
|
||||||
|
>
|
||||||
|
<a class="admin-layout__account">
|
||||||
|
<UserOutlined class="admin-layout__account-icon" />
|
||||||
|
<span v-if="username" class="admin-layout__username">{{ username }}</span>
|
||||||
|
<span v-else class="admin-layout__username is-muted">
|
||||||
|
{{ sessionPending || sessionFetching ? "…" : "未登录" }}
|
||||||
|
</span>
|
||||||
|
<DownOutlined class="admin-layout__account-caret" />
|
||||||
|
</a>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content class="admin-layout__content">
|
||||||
|
<div
|
||||||
|
class="admin-layout__surface"
|
||||||
|
:style="{
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: `${token.borderRadiusLG}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 固定外壳:侧栏 + 顶栏不动,仅内容区滚动 */
|
||||||
|
.admin-layout {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__main {
|
||||||
|
height: 100vh;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 品牌区:恒高 64,不随收起跳动。
|
||||||
|
* 展开:徽标 + 两行文字(标题 title-md 级 14/600/22 · 描述 body-sm 12/400/20)——22+20=42,竖向居中。
|
||||||
|
* 收起:只居中徽标;悬停 title 为「标题 · 描述」。
|
||||||
|
*/
|
||||||
|
.admin-layout__brand {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand.is-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__mark {
|
||||||
|
flex: 0 0 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1677ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 28px;
|
||||||
|
text-align: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand-text {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand-title {
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__brand-desc {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__header {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 40px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__account-caret {
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__username {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__username.is-muted {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-layout__surface {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
import { useMemberMe } from "@/queries/member.ts";
|
||||||
|
|
||||||
|
const tokenStore = useMemberTokenStore();
|
||||||
|
const meQuery = useMemberMe();
|
||||||
|
|
||||||
|
const memberName = computed(
|
||||||
|
() => tokenStore.memberName || meQuery.data.value?.name || "",
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-layout">
|
||||||
|
<header class="member-layout__header">
|
||||||
|
<div class="member-layout__brand">成员入口</div>
|
||||||
|
<div class="member-layout__name">{{ memberName }}</div>
|
||||||
|
</header>
|
||||||
|
<main class="member-layout__main">
|
||||||
|
<div class="member-layout__surface">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
|
||||||
|
.member-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__header {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__name {
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__main {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-layout__surface {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
|
||||||
|
import Antd from "antdv-next";
|
||||||
|
import "antdv-next/dist/reset.css";
|
||||||
|
import App from "./app.vue";
|
||||||
|
import { bindAuthRedirectToRouter, router } from "./router/index.ts";
|
||||||
|
import "./assets/main.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
bindAuthRedirectToRouter();
|
||||||
|
app.use(router);
|
||||||
|
app.use(VueQueryPlugin, { queryClient });
|
||||||
|
app.use(Antd);
|
||||||
|
app.mount("#app");
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
fetchKeys,
|
||||||
|
patchKey,
|
||||||
|
registerKey,
|
||||||
|
type KeyListFilters,
|
||||||
|
} from "@/api/admin/keys.ts";
|
||||||
|
import { fetchOverview } from "@/api/admin/overview.ts";
|
||||||
|
import { fetchEngine } from "@/api/admin/engine.ts";
|
||||||
|
import {
|
||||||
|
createPayment,
|
||||||
|
deletePayment,
|
||||||
|
fetchPayments,
|
||||||
|
updatePayment,
|
||||||
|
} from "@/api/admin/payments.ts";
|
||||||
|
import {
|
||||||
|
createMember,
|
||||||
|
fetchMembers,
|
||||||
|
issueMemberToken,
|
||||||
|
patchMember,
|
||||||
|
} from "@/api/admin/members.ts";
|
||||||
|
import { fetchAuditLogs } from "@/api/admin/audit.ts";
|
||||||
|
import { fetchLogs } from "@/api/admin/logs.ts";
|
||||||
|
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||||
|
|
||||||
|
export function useAdminOverview(range: () => TimeRange) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.overview(range()),
|
||||||
|
queryFn: () => fetchOverview(range()),
|
||||||
|
// 页面可长时间挂起:定期拉取引擎新写入的数据
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEngine() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.engine(),
|
||||||
|
queryFn: fetchEngine,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKeys(filters: () => KeyListFilters) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.keys(filters()),
|
||||||
|
queryFn: () => fetchKeys(filters()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegisterKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: registerKey,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchKey() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { status?: "active" | "disabled"; member_id?: number | null };
|
||||||
|
}) => patchKey(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminLogs(params: () => TimeRange & PageQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.logs(params()),
|
||||||
|
queryFn: () => fetchLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePayments() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.payments(),
|
||||||
|
queryFn: fetchPayments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createPayment,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
|
||||||
|
updatePayment(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeletePayment() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deletePayment,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMembers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.members(),
|
||||||
|
queryFn: fetchMembers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createMember,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
id: number;
|
||||||
|
body: { status: "active" | "disabled" };
|
||||||
|
}) => patchMember(id, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIssueMemberToken() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: issueMemberToken,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditLogs(
|
||||||
|
params: () => PageQuery & { domain?: string },
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.admin.auditLogs(params()),
|
||||||
|
queryFn: () => fetchAuditLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
fetchLogs,
|
||||||
|
fetchMe,
|
||||||
|
fetchOverview,
|
||||||
|
} from "@/api/member/me.ts";
|
||||||
|
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
|
||||||
|
|
||||||
|
export function useMemberMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.member.me(),
|
||||||
|
queryFn: fetchMe,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMemberOverview(range: () => TimeRange) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.member.overview(range()),
|
||||||
|
queryFn: () => fetchOverview(range()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMemberLogs(params: () => TimeRange & PageQuery) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: () => qk.member.logs(params()),
|
||||||
|
queryFn: () => fetchLogs(params()),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import {
|
||||||
|
changePassword,
|
||||||
|
fetchSession,
|
||||||
|
fetchSetupState,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
setup,
|
||||||
|
} from "@/api/admin/session.ts";
|
||||||
|
import { qk } from "@/api/query-keys.ts";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
|
||||||
|
export function useSetupState() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.setupState(),
|
||||||
|
queryFn: fetchSetupState,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.admin.session(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await fetchSession();
|
||||||
|
store.signIn(data.username);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
staleTime: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: login,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
store.signIn(data.username);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.session() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetup() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: setup,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
store.signIn(data.username);
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.setupState() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const store = useAdminSessionStore();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: logout,
|
||||||
|
onSuccess: () => {
|
||||||
|
store.signOut();
|
||||||
|
qc.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChangePassword() {
|
||||||
|
return useMutation({ mutationFn: changePassword });
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import AdminLayout from "@/layouts/admin-layout.vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理面路由 `/admin/*`(契约:页面路由永不进入 `/api`)。
|
||||||
|
* 侧边栏六项 + 账号设置(交互共性 §2)。
|
||||||
|
*/
|
||||||
|
export const adminRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/admin/login",
|
||||||
|
name: "admin-login",
|
||||||
|
component: () => import("@/views/admin/login-view.vue"),
|
||||||
|
meta: { public: true, world: "admin" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/admin",
|
||||||
|
component: AdminLayout,
|
||||||
|
meta: { requiresAdmin: true, world: "admin" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: { name: "admin-overview" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "overview",
|
||||||
|
name: "admin-overview",
|
||||||
|
component: () => import("@/views/admin/overview-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "keys",
|
||||||
|
name: "admin-keys",
|
||||||
|
component: () => import("@/views/admin/keys-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "payments",
|
||||||
|
name: "admin-payments",
|
||||||
|
component: () => import("@/views/admin/payments-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "members",
|
||||||
|
name: "admin-members",
|
||||||
|
component: () => import("@/views/admin/members-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "audit",
|
||||||
|
name: "admin-audit",
|
||||||
|
component: () => import("@/views/admin/audit-view.vue"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "settings",
|
||||||
|
name: "admin-settings",
|
||||||
|
component: () => import("@/views/admin/settings-view.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { createRouter, createWebHistory, type Router } from "vue-router";
|
||||||
|
import { adminRoutes } from "./admin.ts";
|
||||||
|
import { memberRoutes } from "./member.ts";
|
||||||
|
import { setAuthRedirect } from "@/api/auth-redirect.ts";
|
||||||
|
import { useAdminSessionStore } from "@/stores/admin-session.ts";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
|
||||||
|
declare module "vue-router" {
|
||||||
|
interface RouteMeta {
|
||||||
|
public?: boolean;
|
||||||
|
requiresAdmin?: boolean;
|
||||||
|
requiresMember?: boolean;
|
||||||
|
world?: "admin" | "member";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const router: Router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/", redirect: "/admin/overview" },
|
||||||
|
...adminRoutes,
|
||||||
|
...memberRoutes,
|
||||||
|
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta.public) return true;
|
||||||
|
|
||||||
|
const memberToken = useMemberTokenStore();
|
||||||
|
|
||||||
|
// 管理面:硬刷新后 Pinia 为空但会话 cookie 仍在——交给 GET /session 与 401 分流裁决
|
||||||
|
if (to.meta.requiresMember && !memberToken.hasToken) {
|
||||||
|
return { name: "member-login" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 401 分流接入 router(由 main.ts 调用一次) */
|
||||||
|
export function bindAuthRedirectToRouter(): void {
|
||||||
|
setAuthRedirect((world, reason) => {
|
||||||
|
const adminSession = useAdminSessionStore();
|
||||||
|
const memberToken = useMemberTokenStore();
|
||||||
|
if (world === "admin") {
|
||||||
|
adminSession.signOut();
|
||||||
|
void router.push({
|
||||||
|
name: "admin-login",
|
||||||
|
query: reason === "expired" ? { reason: "expired" } : {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
memberToken.clearToken();
|
||||||
|
void router.push({
|
||||||
|
name: "member-login",
|
||||||
|
query: reason === "expired" ? { reason: "expired" } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import MemberLayout from "@/layouts/member-layout.vue";
|
||||||
|
|
||||||
|
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
|
||||||
|
export const memberRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/member/login",
|
||||||
|
name: "member-login",
|
||||||
|
component: () => import("@/views/member/login-view.vue"),
|
||||||
|
meta: { public: true, world: "member" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/member",
|
||||||
|
component: MemberLayout,
|
||||||
|
meta: { requiresMember: true, world: "member" },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "member-home",
|
||||||
|
component: () => import("@/views/member/home-view.vue"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理面登录态(客户端镜像)。
|
||||||
|
* 服务端事实以 GET /api/admin/session 为准——刷新后由 queries/session 恢复。
|
||||||
|
*/
|
||||||
|
export const useAdminSessionStore = defineStore("admin-session", {
|
||||||
|
state: () => ({
|
||||||
|
username: "" as string,
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isLoggedIn: (s) => s.username.length > 0,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
signIn(username: string) {
|
||||||
|
this.username = username;
|
||||||
|
},
|
||||||
|
signOut() {
|
||||||
|
this.username = "";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "member_token";
|
||||||
|
|
||||||
|
function readToken(): string {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(STORAGE_KEY) ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToken(token: string): void {
|
||||||
|
try {
|
||||||
|
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
||||||
|
else sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// 隐私模式等场景忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员明文令牌——仅存在于登录壳输入与 sessionStorage(交互登录篇 §4)。
|
||||||
|
* 无登出端点:关标签即失效;作者重发令牌后旧令牌下一请求自然 401。
|
||||||
|
*/
|
||||||
|
export const useMemberTokenStore = defineStore("member-token", {
|
||||||
|
state: () => ({
|
||||||
|
token: readToken(),
|
||||||
|
memberName: "",
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
hasToken: (s) => s.token.length > 0,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setToken(token: string) {
|
||||||
|
this.token = token;
|
||||||
|
writeToken(token);
|
||||||
|
},
|
||||||
|
setMemberName(name: string) {
|
||||||
|
this.memberName = name;
|
||||||
|
},
|
||||||
|
clearToken() {
|
||||||
|
this.token = "";
|
||||||
|
this.memberName = "";
|
||||||
|
writeToken("");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** 呈现层格式化(不做业务换算——换算责任在后端,契约公共约定 §5) */
|
||||||
|
|
||||||
|
/** 千分位整数;口径内精确显示,不四舍五入 */
|
||||||
|
export function formatInt(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return value.toLocaleString("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** quota 原值 + 美元双单位展示片段 */
|
||||||
|
export function formatQuotaDual(
|
||||||
|
quota: number | null | undefined,
|
||||||
|
quotaUsd: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (quota == null) return "—";
|
||||||
|
const usd = quotaUsd ?? "—";
|
||||||
|
return `${formatInt(quota)}($${usd})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 支付金额:纯数字、无货币符号(R9 币种无关) */
|
||||||
|
export function formatPaidAmount(value: number | null | undefined): string {
|
||||||
|
if (value == null) return "—";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* 时间展示:API 响应时刻为 Unix 秒(UTC);界面一律北京时间(交互共性 §3.3)。
|
||||||
|
*/
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc";
|
||||||
|
import timezone from "dayjs/plugin/timezone";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
/** 北京时区展示(固定 +08:00,不随主机时区变) */
|
||||||
|
const DISPLAY_TZ = "Asia/Shanghai";
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
|
||||||
|
export function formatDateTime(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「YYYY-MM-DD」(统计口径到日) */
|
||||||
|
export function formatDate(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unix 秒 → 北京时间「HH:mm:ss」(明细时刻) */
|
||||||
|
export function formatTimeOfDay(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间日初 → 请求用 ISO 8601 UTC(契约 §4:from/to 收 ISO) */
|
||||||
|
export function beijingDayStartToIso(date: string): string {
|
||||||
|
return dayjs.tz(date, DISPLAY_TZ).startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO UTC */
|
||||||
|
export function beijingDayEndToIso(date: string): string {
|
||||||
|
return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RangeValue = { from: string; to: string };
|
||||||
|
|
||||||
|
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */
|
||||||
|
export function recentDaysRange(
|
||||||
|
days: number,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): { from: string; to: string } {
|
||||||
|
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
|
||||||
|
const from = to.subtract(days, "day");
|
||||||
|
return { from: from.toISOString(), to: to.toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||||
|
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
||||||
|
if (!fromIso || !toIso) return "—";
|
||||||
|
const from = dayjs(fromIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
const to = dayjs(toIso).tz(DISPLAY_TZ).format("YYYY-MM-DD");
|
||||||
|
return `${from} ~ ${to}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「数据截止」到分钟(交互共性 §3.3) */
|
||||||
|
export function formatMinute(unixSec: number | null | undefined): string {
|
||||||
|
if (unixSec == null) return "—";
|
||||||
|
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 距今小时数(用于数据截止停滞判定) */
|
||||||
|
export function hoursSince(unixSec: number | null | undefined): number | null {
|
||||||
|
if (unixSec == null) return null;
|
||||||
|
return (Date.now() / 1000 - unixSec) / 3600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="审计日志"
|
||||||
|
subtitle="管理操作留痕(信息结构见交互设计-审计日志)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Key 管理(交互设计-key管理):清单表 · 行内归属/启停 · 登记 · 停滞显形
|
||||||
|
*/
|
||||||
|
import { computed, h, ref, watch } from "vue";
|
||||||
|
import { Tooltip, message } from "antdv-next";
|
||||||
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import type { KeyItem } from "@new-api-key-usage/core";
|
||||||
|
import type { ApiError } from "@/api/http.ts";
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
||||||
|
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const registerOpen = ref(false);
|
||||||
|
const highlightKeyId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const filterMember = ref<number | "unassigned" | null>(null);
|
||||||
|
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||||
|
const filterRecentFailed = ref(false);
|
||||||
|
|
||||||
|
/** URL → 筛选(总览跳入) */
|
||||||
|
function syncFromRoute() {
|
||||||
|
const q = route.query;
|
||||||
|
const m = q.member_id;
|
||||||
|
if (m === "unassigned") filterMember.value = "unassigned";
|
||||||
|
else if (typeof m === "string" && m) filterMember.value = Number(m);
|
||||||
|
else filterMember.value = null;
|
||||||
|
|
||||||
|
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
|
||||||
|
else filterStatus.value = null;
|
||||||
|
|
||||||
|
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
|
||||||
|
|
||||||
|
const kid = q.key_id;
|
||||||
|
highlightKeyId.value = typeof kid === "string" && kid ? Number(kid) : null;
|
||||||
|
}
|
||||||
|
syncFromRoute();
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => syncFromRoute(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function pushQuery() {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (filterMember.value === "unassigned") query.member_id = "unassigned";
|
||||||
|
else if (filterMember.value != null) query.member_id = String(filterMember.value);
|
||||||
|
if (filterStatus.value) query.status = filterStatus.value;
|
||||||
|
if (filterRecentFailed.value) query.recent_failed = "1";
|
||||||
|
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
|
||||||
|
void router.replace({ path: "/admin/keys", query });
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterMember, filterStatus, filterRecentFailed], () => {
|
||||||
|
pushQuery();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filters = computed(() => ({
|
||||||
|
member_id: filterMember.value ?? undefined,
|
||||||
|
status: filterStatus.value ?? undefined,
|
||||||
|
recent_failed: filterRecentFailed.value ? true : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const keysQuery = useKeys(() => filters.value);
|
||||||
|
const engineQuery = useEngine();
|
||||||
|
const membersQuery = useMembers();
|
||||||
|
const patchKey = usePatchKey();
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const list = [...(keysQuery.data.value ?? [])];
|
||||||
|
// 异常在前:无截止/停滞 → 旧截止 → 新截止(交互:停滞预警)
|
||||||
|
list.sort((a, b) => {
|
||||||
|
const ta = a.log_synced_at ?? 0;
|
||||||
|
const tb = b.log_synced_at ?? 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const failedCount = computed(
|
||||||
|
() => engineQuery.data.value?.last_round?.failed ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasActiveFilter = computed(
|
||||||
|
() =>
|
||||||
|
filterMember.value != null ||
|
||||||
|
filterStatus.value != null ||
|
||||||
|
filterRecentFailed.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memberOptions = computed(() => [
|
||||||
|
{ value: "unassigned", label: "未归属" },
|
||||||
|
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isStalled(logSyncedAt: number | null | undefined): boolean {
|
||||||
|
const h = hoursSince(logSyncedAt);
|
||||||
|
return h != null && h > 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tipTitle(label: string, tip: string) {
|
||||||
|
return h("span", { class: "overview-col-title" }, [
|
||||||
|
label,
|
||||||
|
h(
|
||||||
|
Tooltip,
|
||||||
|
{ title: tip },
|
||||||
|
() =>
|
||||||
|
h(QuestionCircleOutlined, {
|
||||||
|
class: "overview-col-title__icon",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "名称", key: "token_name", width: 160 },
|
||||||
|
{ title: "归属", key: "member_name", width: 140 },
|
||||||
|
{ title: "状态", key: "status", width: 100 },
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"数据截止",
|
||||||
|
"该 key 最近一次日志同步成功的时刻;落后超过 2 小时标为停滞。检查引擎日志;若持续 401 请停用",
|
||||||
|
),
|
||||||
|
key: "data_cutoff",
|
||||||
|
width: 170,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("累计已用", "上游累计已消耗额度(quota)"),
|
||||||
|
key: "total_used",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("剩余", "上游剩余可用额度;不限额度显示「不限」"),
|
||||||
|
key: "total_available",
|
||||||
|
},
|
||||||
|
{ title: "登记时间", key: "registered_at", width: 160 },
|
||||||
|
{ title: "操作", key: "actions", width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function onMemberChange(
|
||||||
|
record: KeyItem,
|
||||||
|
memberId: number | "unassigned" | null,
|
||||||
|
) {
|
||||||
|
const next = memberId === "unassigned" || memberId == null ? null : memberId;
|
||||||
|
try {
|
||||||
|
await patchKey.mutateAsync({ id: record.id, body: { member_id: next } });
|
||||||
|
message.success("归属已更新");
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "归属更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggleStatus(record: KeyItem) {
|
||||||
|
const next = record.status === "active" ? "disabled" : "active";
|
||||||
|
try {
|
||||||
|
await patchKey.mutateAsync({ id: record.id, body: { status: next } });
|
||||||
|
if (next === "disabled") {
|
||||||
|
message.success("已停用——移出轮询清单,数据保留");
|
||||||
|
} else {
|
||||||
|
message.success("已启用——下一轮同步(≤5 分钟)将自动验证");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "状态更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRegistered(keyId: number) {
|
||||||
|
highlightKeyId.value = keyId;
|
||||||
|
pushQuery();
|
||||||
|
void keysQuery.refetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFailedClick() {
|
||||||
|
filterRecentFailed.value = !filterRecentFailed.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClass(record: KeyItem) {
|
||||||
|
return record.id === highlightKeyId.value ? "key-row--highlight" : "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader title="Key 管理" subtitle="登记、启停、归属与同步显形">
|
||||||
|
<template #actions>
|
||||||
|
<a-button type="primary" @click="registerOpen = true">登记 Key</a-button>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="keys-toolbar">
|
||||||
|
<span class="keys-toolbar__summary">
|
||||||
|
<template v-if="failedCount > 0">
|
||||||
|
最近轮次失败
|
||||||
|
<a-button type="link" size="small" @click="onFailedClick">
|
||||||
|
{{ failedCount }} 枚
|
||||||
|
</a-button>
|
||||||
|
(点击筛选)
|
||||||
|
</template>
|
||||||
|
<template v-else>最近轮次无失败 key</template>
|
||||||
|
</span>
|
||||||
|
<a-space wrap>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterMember"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部成员' },
|
||||||
|
{ value: 'unassigned', label: '未归属' },
|
||||||
|
...(membersQuery.data.value?.map((m) => ({ value: m.id, label: m.name })) ?? []),
|
||||||
|
]"
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="成员"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterStatus"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用中' },
|
||||||
|
{ value: 'disabled', label: '已停用' },
|
||||||
|
]"
|
||||||
|
style="width: 120px"
|
||||||
|
placeholder="状态"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!keysQuery.isPending.value && rows.length === 0"
|
||||||
|
:description="
|
||||||
|
hasActiveFilter
|
||||||
|
? '当前筛选条件下没有 key——可清除筛选,或登记新的 key'
|
||||||
|
: '还没有登记任何 key——把你在 NewAPI 创建的 sk-key 粘贴进来'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<a-button v-if="!hasActiveFilter" type="primary" @click="registerOpen = true">
|
||||||
|
登记 Key
|
||||||
|
</a-button>
|
||||||
|
</a-empty>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="rows"
|
||||||
|
:row-key="(r: KeyItem) => r.id"
|
||||||
|
:loading="keysQuery.isPending.value || keysQuery.isFetching.value"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1100 }"
|
||||||
|
:row-class-name="onRowClass"
|
||||||
|
size="middle"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'token_name'">
|
||||||
|
{{ record.token_name }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'member_name'">
|
||||||
|
<a-select
|
||||||
|
:value="record.member_id == null ? 'unassigned' : record.member_id"
|
||||||
|
:options="memberOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
<a-tooltip
|
||||||
|
v-if="isStalled(record.log_synced_at)"
|
||||||
|
title="检查引擎日志;若持续 401 请停用"
|
||||||
|
>
|
||||||
|
<span class="keys-stall">{{ formatMinute(record.log_synced_at) }} 停滞</span>
|
||||||
|
</a-tooltip>
|
||||||
|
<span v-else-if="record.log_synced_at == null" class="keys-syncing">
|
||||||
|
同步中…
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ formatMinute(record.log_synced_at) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'total_used'">
|
||||||
|
<span class="keys-num">{{ formatInt(record.total_used) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'total_available'">
|
||||||
|
<span class="keys-num">
|
||||||
|
{{ record.unlimited_quota ? "不限" : formatInt(record.total_available) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'registered_at'">
|
||||||
|
{{ formatMinute(record.registered_at) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'actions'">
|
||||||
|
<a-popconfirm
|
||||||
|
v-if="record.status === 'active'"
|
||||||
|
title="停用后移出轮询清单(数据保留)"
|
||||||
|
description="成员若仍在用它调用 API 不受影响——只有采集停止"
|
||||||
|
ok-text="停用"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="() => onToggleStatus(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>停用</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
<a-button v-else size="small" @click="() => onToggleStatus(record)">
|
||||||
|
启用
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
|
||||||
|
<RegisterKeyModal
|
||||||
|
v-model:open="registerOpen"
|
||||||
|
:members="membersQuery.data.value ?? []"
|
||||||
|
@registered="onRegistered"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.keys-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-toolbar__summary {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-stall {
|
||||||
|
color: #d48806;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-syncing {
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.key-row--highlight) {
|
||||||
|
background: #e6f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 登录 + 空库三态分流(交互登录篇) */
|
||||||
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { computed, reactive, ref, shallowRef } from "vue";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
import { useLogin, useSetup, useSetupState } from "@/queries/session.ts";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const setupState = useSetupState();
|
||||||
|
const login = useLogin();
|
||||||
|
const setup = useSetup();
|
||||||
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
|
||||||
|
const model = reactive({
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
passwordConfirm: "",
|
||||||
|
});
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
const isSetup = computed(() => setupState.data.value?.initialized === false);
|
||||||
|
const loading = computed(() => login.isPending.value || setup.isPending.value);
|
||||||
|
|
||||||
|
async function onFinish() {
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
if (isSetup.value) {
|
||||||
|
if (model.password !== model.passwordConfirm) {
|
||||||
|
error.value = "两次输入的密码不一致";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setup.mutateAsync({
|
||||||
|
username: model.username,
|
||||||
|
password: model.password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await login.mutateAsync({
|
||||||
|
username: model.username,
|
||||||
|
password: model.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const redirect = route.query.redirect;
|
||||||
|
void router.replace(typeof redirect === "string" ? redirect : "/admin/overview");
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "登录失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-shell">
|
||||||
|
<a-card class="login-shell__card" :title="isSetup ? '初始化管理员' : '管理面登录'">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
layout="vertical"
|
||||||
|
auto-complete="off"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
name="username"
|
||||||
|
label="用户名"
|
||||||
|
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||||
|
>
|
||||||
|
<a-input v-model:value="model.username" autocomplete="username" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
name="password"
|
||||||
|
label="密码"
|
||||||
|
:rules="[{ required: true, message: '请输入密码' }]"
|
||||||
|
>
|
||||||
|
<a-input-password
|
||||||
|
v-model:value="model.password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item
|
||||||
|
v-if="isSetup"
|
||||||
|
name="passwordConfirm"
|
||||||
|
label="确认密码"
|
||||||
|
:rules="[{ required: true, message: '请再次输入密码' }]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="model.passwordConfirm" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:message="error"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" html-type="submit" block :loading="loading">
|
||||||
|
{{ isSetup ? "完成设置" : "登录" }}
|
||||||
|
</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shell__card {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="成员管理"
|
||||||
|
subtitle="开户、令牌、停用(信息结构见交互设计-成员管理)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
|
||||||
|
*/
|
||||||
|
import { computed, h, ref } from "vue";
|
||||||
|
import { Tooltip } from "antdv-next";
|
||||||
|
import { QuestionCircleOutlined } from "@antdv-next/icons";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||||
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
|
import SourceBar from "@/components/common/source-bar.vue";
|
||||||
|
import HealthBand from "@/components/admin/health-band.vue";
|
||||||
|
import {
|
||||||
|
useLiveTimeRange,
|
||||||
|
type RangeKind,
|
||||||
|
type RangeValue,
|
||||||
|
} from "@/composables/use-live-time-range.ts";
|
||||||
|
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
|
||||||
|
import { formatMinute } from "@/utils/time.ts";
|
||||||
|
import { formatInt } from "@/utils/format.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { range, setRange } = useLiveTimeRange("recent-30");
|
||||||
|
|
||||||
|
const overviewQuery = useAdminOverview(() => range.value);
|
||||||
|
const engineQuery = useEngine();
|
||||||
|
const paymentsQuery = usePayments();
|
||||||
|
|
||||||
|
const overview = computed(() => overviewQuery.data.value);
|
||||||
|
const engine = computed(() => engineQuery.data.value);
|
||||||
|
const loading = computed(
|
||||||
|
() => overviewQuery.isPending.value || engineQuery.isPending.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 「本订阅期间」等预置:有 period 的订阅记录才出现(纯用量模式自动隐藏) */
|
||||||
|
const extraPresets = computed(() => {
|
||||||
|
const list = paymentsQuery.data.value ?? [];
|
||||||
|
return list
|
||||||
|
.filter((p) => p.kind === "subscription" && p.period_start != null)
|
||||||
|
.map((p) => ({
|
||||||
|
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
|
||||||
|
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
|
||||||
|
to: new Date(
|
||||||
|
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
|
||||||
|
).toISOString(),
|
||||||
|
}))
|
||||||
|
.slice(0, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
const byMember = computed(() => overview.value?.by_member ?? []);
|
||||||
|
const byKey = computed(() => overview.value?.by_key ?? []);
|
||||||
|
const bySource = computed(() => overview.value?.by_source ?? []);
|
||||||
|
|
||||||
|
const totalUsage = computed(() => {
|
||||||
|
const rows = byMember.value;
|
||||||
|
const count = rows.reduce((s, r) => s + r.usage.count, 0);
|
||||||
|
const quota = rows.reduce((s, r) => s + r.usage.quota, 0);
|
||||||
|
// 比例展示用 quota;美元仅示意合计(后端已逐行换算,这里加总展示仅供参考)
|
||||||
|
return { count, quota };
|
||||||
|
});
|
||||||
|
|
||||||
|
function memberPercent(quota: number): number {
|
||||||
|
const total = totalUsage.value.quota;
|
||||||
|
return total > 0 ? Math.round((quota / total) * 1000) / 10 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRangeChange(value: RangeValue, kind: RangeKind) {
|
||||||
|
setRange(value, kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMemberClick(memberId: number | null) {
|
||||||
|
void router.push({
|
||||||
|
path: "/admin/keys",
|
||||||
|
query: memberId == null ? { member_id: "unassigned" } : { member_id: String(memberId) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyClick(keyId: number) {
|
||||||
|
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goFailedKeys() {
|
||||||
|
void router.push({ path: "/admin/keys", query: { recent_failed: "1" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表头:标题 + 问号说明 */
|
||||||
|
function tipTitle(label: string, tip: string) {
|
||||||
|
return h("span", { class: "overview-col-title" }, [
|
||||||
|
label,
|
||||||
|
h(
|
||||||
|
Tooltip,
|
||||||
|
{ title: tip },
|
||||||
|
() =>
|
||||||
|
h(QuestionCircleOutlined, {
|
||||||
|
class: "overview-col-title__icon",
|
||||||
|
"aria-hidden": "true",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberColumns = [
|
||||||
|
{ title: "成员", key: "member_name" },
|
||||||
|
{ title: "名下 Key 数", key: "key_count" },
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"调用次数",
|
||||||
|
"本时段内该成员名下的消费日志条数(type=2,一次调用一行)",
|
||||||
|
),
|
||||||
|
key: "count",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"额度消耗",
|
||||||
|
"本时段消耗的 quota 原值,括号内为按当时换算率折算的美元",
|
||||||
|
),
|
||||||
|
key: "usage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"用量占比",
|
||||||
|
"该成员本时段 quota ÷ 全员同期 quota。只是用量事实占比,不是费用分摊",
|
||||||
|
),
|
||||||
|
key: "share",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle(
|
||||||
|
"同步截止",
|
||||||
|
"该成员名下各 key 的 log_synced_at 里最慢的一刻(北京时间)",
|
||||||
|
),
|
||||||
|
key: "data_cutoff",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const keyColumns = [
|
||||||
|
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
|
||||||
|
{ title: "归属成员", key: "member_name" },
|
||||||
|
{
|
||||||
|
title: tipTitle("当前状态", "启用中才会进入同步轮询;已停用仍可查历史"),
|
||||||
|
key: "status",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("同步截止", "该枚 key 最近一次成功写入日志的同步时刻"),
|
||||||
|
key: "data_cutoff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("调用次数", "本时段消费日志条数(type=2)"),
|
||||||
|
key: "count",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: tipTitle("额度消耗", "本时段 quota 原值与按当时换算率折算的美元"),
|
||||||
|
key: "usage",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="全员总览"
|
||||||
|
subtitle="健康与用量全景 · 仅统计消费行(type=2)"
|
||||||
|
class="overview-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<span class="overview-range-label">时间范围</span>
|
||||||
|
<TimeRangePicker
|
||||||
|
:model-value="range"
|
||||||
|
:extra-presets="extraPresets"
|
||||||
|
@change="onRangeChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<HealthBand
|
||||||
|
:engine="engine"
|
||||||
|
:overview="overview"
|
||||||
|
:loading="loading"
|
||||||
|
@go-failed-keys="goFailedKeys"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
v-if="overviewQuery.isError.value"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
message="总览数据加载失败"
|
||||||
|
:description="overviewQuery.error.value?.message"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
|
||||||
|
</template>
|
||||||
|
</a-alert>
|
||||||
|
|
||||||
|
<!-- 来源分解 -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按来源分解</h2>
|
||||||
|
<SourceBar :rows="bySource" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 按成员 -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按成员汇总</h2>
|
||||||
|
<a-empty
|
||||||
|
v-if="!loading && byMember.length === 0"
|
||||||
|
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
|
||||||
|
/>
|
||||||
|
<a-table
|
||||||
|
v-else
|
||||||
|
:columns="memberColumns"
|
||||||
|
:data-source="byMember"
|
||||||
|
:row-key="(r: { member_id: number | null }) => r.member_id ?? 'unassigned'"
|
||||||
|
size="middle"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
:loading="overviewQuery.isFetching.value"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'member_name'">
|
||||||
|
<a @click="onMemberClick(record.member_id)">
|
||||||
|
{{ record.member_name ?? "未归属" }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'key_count'">
|
||||||
|
{{ record.key_count }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'count'">
|
||||||
|
{{ formatInt(record.usage.count) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'usage'">
|
||||||
|
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'share'">
|
||||||
|
<span class="overview-share-cell__num">
|
||||||
|
{{ memberPercent(record.usage.quota) }}%
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
{{ formatMinute(record.data_cutoff) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 按 key -->
|
||||||
|
<section class="overview-block">
|
||||||
|
<h2 class="overview-block__title">按 key 明细</h2>
|
||||||
|
<a-table
|
||||||
|
:columns="keyColumns"
|
||||||
|
:data-source="byKey"
|
||||||
|
:row-key="(r: { key_id: number }) => r.key_id"
|
||||||
|
size="middle"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
:loading="overviewQuery.isFetching.value"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'member_name'">
|
||||||
|
{{
|
||||||
|
record.member_name ??
|
||||||
|
(record.member_id == null
|
||||||
|
? "未归属"
|
||||||
|
: `成员 #${record.member_id}`)
|
||||||
|
}}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'status'">
|
||||||
|
<a-badge
|
||||||
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
:text="record.status === 'active' ? '启用中' : '已停用'"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'data_cutoff'">
|
||||||
|
{{ formatMinute(record.data_cutoff) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'count'">
|
||||||
|
{{ formatInt(record.usage.count) }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'usage'">
|
||||||
|
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'token_name'">
|
||||||
|
<a @click="onKeyClick(record.key_id)">{{ record.token_name }}</a>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-range-label {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 32px;
|
||||||
|
color: rgba(0, 0, 0, 0.65);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-block__title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-col-title__icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overview-share-cell__num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="支付记录"
|
||||||
|
subtitle="订阅 / 充值登记与核对(信息结构见交互设计-支付记录)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader title="账号设置" subtitle="修改密码" />
|
||||||
|
<a-skeleton active :paragraph="{ rows: 3 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<PageHeader
|
||||||
|
title="我的用量"
|
||||||
|
subtitle="聚合 · 来源徽章 · 明细(信息结构见交互设计-成员视图)"
|
||||||
|
/>
|
||||||
|
<a-skeleton active :paragraph="{ rows: 4 }" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/** 成员令牌登录壳——明文仅存 sessionStorage(交互登录篇 §4) */
|
||||||
|
import type { FormInstance } from "antdv-next";
|
||||||
|
import { reactive, ref, shallowRef } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { useMemberTokenStore } from "@/stores/member-token.ts";
|
||||||
|
import { fetchMe } from "@/api/member/me.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const tokenStore = useMemberTokenStore();
|
||||||
|
const formRef = shallowRef<FormInstance>();
|
||||||
|
const model = reactive({ token: "" });
|
||||||
|
const error = ref("");
|
||||||
|
const pending = ref(false);
|
||||||
|
|
||||||
|
async function onFinish() {
|
||||||
|
error.value = "";
|
||||||
|
pending.value = true;
|
||||||
|
try {
|
||||||
|
tokenStore.setToken(model.token.trim());
|
||||||
|
const me = await fetchMe();
|
||||||
|
tokenStore.setMemberName(me.name);
|
||||||
|
void router.replace({ name: "member-home" });
|
||||||
|
} catch {
|
||||||
|
tokenStore.clearToken();
|
||||||
|
error.value = "令牌无效或已停用";
|
||||||
|
} finally {
|
||||||
|
pending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="member-login-shell">
|
||||||
|
<a-card class="member-login-shell__card" title="成员入口">
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="model"
|
||||||
|
layout="vertical"
|
||||||
|
auto-complete="off"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<a-form-item
|
||||||
|
name="token"
|
||||||
|
label="令牌"
|
||||||
|
:rules="[{ required: true, message: '请输入成员令牌' }]"
|
||||||
|
>
|
||||||
|
<a-input-password v-model:value="model.token" placeholder="粘贴成员令牌" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:message="error"
|
||||||
|
style="margin-bottom: 16px"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" html-type="submit" block :loading="pending">
|
||||||
|
进入
|
||||||
|
</a-button>
|
||||||
|
</a-form>
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.member-login-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f2f5;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-login-shell__card {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,28 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
/** 后端母体端口(与根 .env 的 PORT / AGENTS 约定一致) */
|
||||||
|
const API_TARGET = process.env.VITE_API_PROXY_TARGET ?? "http://127.0.0.1:18456";
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), vueJsx(), vueDevTools()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
|
||||||
|
"/api": {
|
||||||
|
target: API_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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 限管理面)、会话时长取值 | 技术稿 |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user