Compare commits

...
19 Commits
Author SHA1 Message Date
skycurtain 29019fc5d5 refactor(cli): naku 与 pack:release 合并为 pack CLI——release/build 子命令,--yes 非交互 2026-10-29 15:36:06 +08:00
skycurtain 8d4a5c4f94 docs: README 润色收尾——定位中性化、补适用场景与 AI 协作物料说明 2026-10-29 00:45:34 +08:00
skycurtain ddb1b56459 chore: MIT 许可证落地——LICENSE 全文、package.json license 字段与 README 边界声明 2026-10-28 23:14:41 +08:00
skycurtain fdc5942a72 feat(web): 管理端日志来源徽章支付弹窗与支付记录筛选——订阅 ID / 交易号可筛,弹窗可跳转核对补录 2026-10-18 12:22:02 +08:00
skycurtain 199972a2dc feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量 2026-10-14 08:01:58 +08:00
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00
skycurtain 843ab4433a fix(web): 复制令牌兼容 HTTP 非 Secure Context 2026-10-13 12:42:11 +08:00
skycurtain 75a567611b fix(deploy): COPY --chmod=755 根治单文件二进制丢失可执行位
Windows 打包与跨机传输会使 naku-* 丢掉 +x,容器内 exec 以 126
(Permission denied)退出,migrate 失败后 server/engine 不会启动。
镜像内用 COPY --chmod=755 固定可执行位,并同步部署方案说明。
2026-10-13 11:03:35 +08:00
skycurtain c0051cff03 feat(deploy): Caddy 对外端口支持 HTTP_PORT / HTTPS_PORT 配置 2026-10-13 10:39:12 +08:00
skycurtain 2dbd625e87 docs: 对齐实现状态——进程拆分、部署与支付拆表相关稿定稿 2026-10-13 01:11:13 +08:00
skycurtain 905d4ce77e feat(deploy): 产物包部署链路与 naku 打包 CLI
Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩;
naku pack/build(citty + clack);部署方案文档入档。
2026-10-13 00:56:43 +08:00
skycurtain 86782c63ad chore(deploy): 移除 Docker 配置与文档中的部署方案,待从代码重议 2026-10-12 19:02:38 +08:00
skycurtain 9966f03347 feat(web): 时间选择器增加「今天」快捷项
北京日界左闭右开;相对窗口可跨日自动重算。
2026-10-12 15:00:29 +08:00
skycurtain d0bab545b1 chore: 服务启动不再自动迁移数据库
schema 变更改为显式 db:push / 手动应用后再启动。
2026-10-12 13:57:25 +08:00
skycurtain f4f5ed0b0f feat(member): 档案修改与空壳删除
可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。
2026-10-12 13:57:19 +08:00
skycurtain e293a8e5a9 docs: R16 成员档案修改与空壳删除裁决入档
成员操作面扩为可改 name/remark、key_count=0 时硬删空壳;软删除仍是账本实体默认语义。
2026-10-11 22:41:43 +08:00
skycurtain 34a7823e1a feat(web): 根路径与未知路径默认进入成员端
管理端入口形态待定,当前经地址栏 /admin 进入;交互设计访问拓扑同步。
2026-10-10 16:47:01 +08:00
skycurtain ea4efbe1d7 feat(deploy): 一条命令链部署——双单文件 + Caddy + DATABASE_URL 单源
多阶段镜像编出 server/engine 单文件与前端静态;compose 起 pg/server/engine/caddy;
启动时自动迁移;仅用 DATABASE_URL,postgres 建库参数由 entrypoint 拆解。
2026-10-10 16:46:54 +08:00
skycurtain 5f76bfc9d6 docs: 清除版本叙事并按开源文档体系整理
去掉初版/新版与分支指称,重写 README/AGENTS,删子包 README,对齐文档交叉引用与已废止测试脚本。
2026-10-10 07:56:07 +08:00
79 changed files with 3895 additions and 409 deletions
+19
View File
@@ -0,0 +1,19 @@
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
.git
.env
.env.*
!.env.example
node_modules
**/node_modules
apps
packages
scripts
docs
*.log
api*.json
session-archive
coverage
*.tsbuildinfo
.claude
.agents
agent
+17 -3
View File
@@ -1,10 +1,15 @@
# ── new-api-key-usage · 本地环境变量示例 ────────────────────────── # ── new-api-key-usage · 环境变量示例 ──────────────────────────
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交) # 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
# 本服务端口(避开 3000/3001 等常见占用) # 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
PORT=18456 PORT=18456
# PostgreSQL 连接串(开发 compose 默认如下) # PostgreSQL 连接串——数据库配置的唯一来源
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
# 密码含 @ : / 等字符时请 URL 编码
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。 # 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
@@ -19,11 +24,20 @@ NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。 # 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true; # 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。 # 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
# HTTPS + 域名部署务必 true。
COOKIE_SECURE=false COOKIE_SECURE=false
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
# 仅影响宿主机映射;容器内仍是 80/443
HTTP_PORT=80
HTTPS_PORT=443
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出) # 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info LOG_LEVEL=info
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
# SYNC_INTERVAL_MS=300000
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库; # 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。 # 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME= # ADMIN_INIT_USERNAME=
+3
View File
@@ -17,8 +17,11 @@
# 依赖与构建产物 # 依赖与构建产物
node_modules/ node_modules/
dist/ dist/
bin/
build/ build/
.turbo/ .turbo/
release/
.bun-cache/
# 测试与工具 # 测试与工具
coverage/ coverage/
+11 -10
View File
@@ -5,18 +5,17 @@
## 协作约定(长期有效) ## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送 - **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建) - **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase - **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古) - **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13) - **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容 - **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
- **本地不入库也不 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 - **本仓库 = 设计文档中心 + 实现主线**:实现代码与文档同仓演进,monorepo 结构(`apps/server` · `apps/engine` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——`payments` 单表已按方案 B 拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变 - 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——支付登记已拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行) - 需求总集与裁决档案见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」) - 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
## 命令 ## 命令
@@ -27,14 +26,16 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|------|------| |------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) | | `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) | | `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期) | | `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
| `bun run db:studio` | Drizzle Studio 看库 | | `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) | | `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) | | `bun run dev:server` | 开发跑后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) | | `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite | | `bun run dev:web` | 开发跑前端 Vite |
| `bun run build:web` | 前端类型检查 + 产物构建 | | `bun run build:web` | 前端类型检查 + 产物构建 |
| `docker compose up -d` | 起开发用 PostgreSQL | | `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
| `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。 环境变量见 `.env.example`(复制为 `.env` 后填写)。
+17
View File
@@ -0,0 +1,17 @@
# 站点地址按部署环境改:
# 本地/内网 HTTP → :80
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
:80 {
root * /srv
# 接口反代到 server 容器
handle /api/* {
reverse_proxy server:18456
}
# SPA:非 /api 的路径回退 index.html
handle {
try_files {path} /index.html
file_server
}
}
+21
View File
@@ -0,0 +1,21 @@
# 语法:docker/dockerfile:1
# 只装配 release/ 产物,构建期不编译业务代码
# ---------- 应用运行时 ----------
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
FROM debian:bookworm-slim AS app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
COPY release/drizzle /app/drizzle
ENV MIGRATIONS_FOLDER=/app/drizzle
CMD ["/app/naku-server"]
# ---------- 网关(Caddy + 前端静态) ----------
FROM caddy:2-alpine AS proxy
COPY Caddyfile /etc/caddy/Caddyfile
COPY release/dist /srv
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 skycurtain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+139 -62
View File
@@ -1,95 +1,172 @@
# new-api-key-usage # new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具 > NewAPI 网关下的 key 用量统计工具(产品名 **NAKU**)
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。 定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 当前状态 | **实现推进中**(`v1` 分支 = 文档中心 + 实现主线)——设计文档全部定稿;后端 + 同步引擎已落地;前端总览与 Key 管理已实现,其余页面进行中 | | 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next | | 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 项目沿革 ## 这是什么
| 阶段 | 形态 | 结局 | 面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
|------|------|------|
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。 **适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
## 文档地图 - 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
- 仓库名 / 包名统一小写 kebab-case
| 文档 | 什么时候读 | 状态 | ## 背景与约束
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案;2026-10-09 增补支付拆表裁决 | 定稿(2026-10-09) |
| [`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) | 支付建模复盘:订阅权益 vs 充值入账两分;**方案 B 拆表已采纳**(2026-10-09) | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 待审阅(2026-10-09) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(2026-10-09) |
| [`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/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 管理面调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 草案(待审阅) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录页(两 Tab):订阅权益 / 充值入账登记、核对提示 | 待审阅(2026-10-09 拆表重写) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员世界——管理端同构壳三页(总览 / 调用日志 / 支付参数);R12:订阅徽章弹窗 + 行展开调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点:引导与会话、引擎、总览、key、日志、**订阅/充值拆资源**、成员、审计 | 待审阅(2026-10-09 §7 拆表修订) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview`(`subscriptions`+`topups`)· `/logs` | 待审阅(2026-10-09 拆表修订) |
| [`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/`(平台可用接口参考——实证事实,不做设计决策) - 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古) - 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 这是什么项目 **双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
- 名字直述用途: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 合并查询」机制已裁撤(见需求清单裁决记录) | | 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) | | 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。 完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
## 架构的根本动因:为什么必须攒数据 ## 架构的根本动因:为什么必须攒数据
这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变): 这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变):
1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤 1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤
2. 真正适合算“当月用量”的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是“按时间段统计用量”无法纯客户端实现的**核心障碍** 2. 真正适合算「当月用量」的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是「按时间段统计用量」无法纯客户端实现的**核心障碍**
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略) 3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。 → 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变,攒库架构不变。** 上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
## 技术栈与结构
全栈 TypeScript,monorepo(Bun workspaces):
```text
apps/
server/ # 后端:Hono + Drizzle/pg + Scalar 在线文档
engine/ # 同步引擎:独立进程,轮询去重入库
web/ # 前端:Vue 3 + antdv-next + Vite
packages/
core/ # 业务核心:去重、折算、加密、上游客户端、DTO
db/ # 数据访问层:Drizzle schema + 仓储
```
数据模型:`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + `members` · `users` · `sessions`。
## 快速开始
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
```bash
bun install
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
bun run db:push # 开发期推送 schema(启动不自动迁)
bun run dev:server # 后端(默认 18456)
bun run dev:engine # 同步引擎(另开终端)
bun run dev:web # 前端 Vite(5175)
```
在线 API 文档:`http://127.0.0.1:18456/api/docs`。
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
## 部署
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
### 本地打包
```bash
bun run pack release
# 交互选:目标平台 · 打包内容 · 是否 tar.gz
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
# 非交互(CI / 一键):
bun run pack release --yes
# 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
```
### 服务器
首次安装:把 `docker-compose.yml` · `Dockerfile` · `.dockerignore` · `Caddyfile` · `entrypoint-*.sh` 放到约定目录(如 `/opt/naku/`),再放 `release/`,填写 `.env`(`chmod 600`)。日常更新只覆盖 `release/`。
```bash
cd /opt/naku
docker compose up -d --build
docker compose ps # migrate 应为 exited(0),其余 running/healthy
```
- 页面:`http://<服务器>/`(若改了 `HTTP_PORT` 则加端口);接口文档:`http://<服务器>/api/docs`
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
- 端口:默认 80/443;宿主机被占用时在 `.env` 设 `HTTP_PORT` / `HTTPS_PORT`,再执行固定命令
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
### 注意
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
| [`docs/design/部署方案.md`](docs/design/部署方案.md) | 母体交付与更新:产物包、服务器目录、容器编排、迁移时机、安全约定 | 已实现(定稿 2026-10-12) |
| [`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) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 定稿(2026-10-12) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 定稿(2026-10-12) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
| [`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/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、来源徽章支付弹窗、明细表与跳转链 | 定稿(2026-10-08;模型筛选与汇总条、徽章弹窗修订) |
| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示、ID/交易号筛选 | 定稿(2026-10-12;补筛选) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 定稿(2026-10-12) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 定稿(2026-10-12) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反) ## 关键事实清单(实测 / 源码确认,设计时不得违反)
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。 > 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**:折算到实付人民币还需乘「购买汇率」(由运营方定价、可变,历史上调整过一次) 1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**;折算到实付金额还需乘「购买汇率」(由运营方定价、可变)
2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重 2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重
3. **`created_at` 是 UTC Unix 秒**(三字段交叉实测),北京时间输入需转换 3. **`created_at` 是 UTC Unix 秒**,本地时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——实测当前站点的日志速率下约等于 3 天;5 分钟级同步余量充分,但同步停摆超过约 3 天会永久丢数据 4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询(用完也能对账);**显式禁用会被 401 拒绝** 5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度 6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
## License
[MIT](LICENSE) © 2026 skycurtain
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
+1
View File
@@ -8,6 +8,7 @@ setupLogging(env.LOG_LEVEL);
const log = appLogger("engine"); const log = appLogger("engine");
const db = createDb(env.DATABASE_URL); const db = createDb(env.DATABASE_URL);
const repos = createRepos(db); const repos = createRepos(db);
const loop = createSyncLoop({ const loop = createSyncLoop({
-13
View File
@@ -1,13 +0,0 @@
# 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
+1 -1
View File
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" }, { name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" }, { name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" }, { name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" }, { name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
{ name: "成员视图", description: "令牌自助查询" }, { name: "成员视图", description: "令牌自助查询" },
], ],
}); });
+24 -1
View File
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled", status: row.status as "active" | "disabled",
member_id: row.member_id, member_id: row.member_id,
member_name: row.member_name ?? null, member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!, registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at), log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at), usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({ query: z.object({
member_id: z.string().optional(), member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(), recent_failed: z.enum(["true", "false"]).optional(),
}), }),
}, },
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: { const filter: {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: string; status?: string;
billing_domain?: string | "none";
} = {}; } = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned"; if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id); else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") { if (q.status === "active" || q.status === "disabled") {
filter.status = q.status; filter.status = q.status;
} }
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter); let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") { if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot(); const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
} }
member_id = n; member_id = n;
} }
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) { if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError( return jsonError(
c, c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash, key_hash,
key_ciphertext, key_ciphertext,
member_id, member_id,
billing_domain,
token_name: usage.name ?? "", token_name: usage.name ?? "",
}); });
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补) // 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/keys/{id}", path: "/keys/{id}",
summary: "启停 / 归属变更", summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行", description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"], tags: ["Key 管理"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
}); });
} }
} }
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
return jsonError( return jsonError(
c, c,
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/members/{id}", path: "/members/{id}",
summary: "停用 / 启用", summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"], tags: ["成员管理"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: { request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
}, },
401: err401, 401: err401,
404: err404, 404: err404,
409: err409,
422: err422, 422: err422,
}, },
}), }),
async (c) => { async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null); 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 repos = c.get("repos");
const before = await repos.members.findById(id); const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found()); if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
const profile: { name?: string; remark?: string } = {};
const actions: Array<{ action: string; summary: string }> = [];
if (typeof body?.name === "string") {
const name = body.name.trim();
if (!name || name.length > 64) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "名称无效" }),
);
}
if (name !== before.name) {
const taken = await repos.members.findByName(name);
if (taken && taken.id !== id) {
return jsonError(c, 409, ERR.member_name_taken());
}
profile.name = name;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 改名为 ${name}`,
});
}
}
if (typeof body?.remark === "string") {
if (body.remark !== before.remark) {
profile.remark = body.remark;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 修改备注`,
});
}
}
let nextStatus: "active" | "disabled" | null = null;
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
nextStatus = body.status;
actions.push({
action: body.status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (Object.keys(profile).length === 0 && !nextStatus) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
let row = before;
if (Object.keys(profile).length > 0) {
row = await repos.members.updateProfile(id, profile);
}
if (nextStatus) {
row = await repos.members.updateStatus(id, nextStatus);
}
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: a.action,
summary: a.summary,
});
}
return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/members/{id}",
summary: "空壳删除",
description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "已删除" },
401: err401,
404: err404,
409: err409,
},
}),
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 keyCount = await repos.members.countKeys(id);
if (keyCount > 0) {
return jsonError(c, 409, ERR.member_has_keys(keyCount));
}
await repos.members.deleteById(id);
await repos.audit.insert({ await repos.audit.insert({
operator: c.get("adminUsername") ?? null, operator: c.get("adminUsername") ?? null,
domain: "member", domain: "member",
action: status === "active" ? "member.enable" : "member.disable", action: "member.delete",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`, summary: `删除成员 ${before.name}`,
}); });
return c.json(toMemberItem(row, 0), 200); return c.body(null, 204);
}, },
); );
+19 -1
View File
@@ -37,6 +37,7 @@ function toSubscriptionItem(
quota_usd, quota_usd,
paid_amount: row.paid_amount, paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at), paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark, remark: row.remark,
created_at: toUnixSec(row.created_at)!, created_at: toUnixSec(row.created_at)!,
}; };
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: number; quota_granted: number;
paid_amount: number | null; paid_amount: number | null;
paid_at: Date | null; paid_at: Date | null;
billing_domain: string | null;
remark: string; remark: string;
}; };
} { } {
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: body.quota_granted, quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null, paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null, paid_at: body.paid_at ? new Date(body.paid_at) : null,
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "", remark: body.remark ?? "",
}, },
}; };
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
summary: "订阅权益清单", summary: "订阅权益清单",
tags: ["支付记录"], tags: ["支付记录"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: { responses: {
200: { 200: {
description: "subscription 数组", description: "subscription 数组",
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
async (c) => { async (c) => {
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.subscriptions.listAll(); const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.subscriptions.listAll(filter);
return c.json( return c.json(
rows.map((row) => rows.map((row) =>
toSubscriptionItem( toSubscriptionItem(
+19 -1
View File
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
paid_at: toUnixSec(row.paid_at)!, paid_at: toUnixSec(row.paid_at)!,
quota_derived, quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu), quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark, remark: row.remark,
created_at: toUnixSec(row.created_at)!, created_at: toUnixSec(row.created_at)!,
}; };
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: string | null; payment_provider: string | null;
status: string | null; status: string | null;
paid_at: Date; paid_at: Date;
billing_domain: string | null;
remark: string; remark: string;
}; };
} { } {
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: body.payment_provider ?? null, payment_provider: body.payment_provider ?? null,
status: body.status ?? "success", status: body.status ?? "success",
paid_at: new Date(body.paid_at), paid_at: new Date(body.paid_at),
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "", remark: body.remark ?? "",
}, },
}; };
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
summary: "充值入账清单", summary: "充值入账清单",
tags: ["支付记录"], tags: ["支付记录"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: { responses: {
200: { 200: {
description: "topup 数组", description: "topup 数组",
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
async (c) => { async (c) => {
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.topups.listAll(); const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.topups.listAll(filter);
return c.json( return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))), rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200, 200,
+30 -8
View File
@@ -279,7 +279,7 @@ export function adminUsageRoutes() {
path: "/logs", path: "/logs",
summary: "日志明细(下钻)", summary: "日志明细(下钻)",
description: description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名", "分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
tags: ["用量"], tags: ["用量"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: { request: {
@@ -290,12 +290,13 @@ export function adminUsageRoutes() {
member_id: z.string().optional(), member_id: z.string().optional(),
billing_source: z.string().optional(), billing_source: z.string().optional(),
subscription_id: z.string().optional(), subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(), type: z.string().optional(),
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "items/total", description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } }, content: { "application/json": { schema: logPageSchema } },
}, },
401: err401, 401: err401,
@@ -312,15 +313,13 @@ export function adminUsageRoutes() {
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50)); const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({ const filters = {
from: range.from, from: range.from,
to: range.to, to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id: member_id:
q.member_id === "unassigned" q.member_id === "unassigned"
? "unassigned" ? ("unassigned" as const)
: q.member_id : q.member_id
? Number(q.member_id) ? Number(q.member_id)
: undefined, : undefined,
@@ -329,13 +328,22 @@ export function adminUsageRoutes() {
subscription_id: q.subscription_id subscription_id: q.subscription_id
? Number(q.subscription_id) ? Number(q.subscription_id)
: undefined, : undefined,
model_name: q.model_name || undefined,
type: type:
q.type === "error" q.type === "error"
? "error" ? ("error" as const)
: q.type != null && q.type !== "" : q.type != null && q.type !== ""
? Number(q.type) ? Number(q.type)
: undefined, : undefined,
};
const { rows, total } = await repos.logs.listPage({
...filters,
page,
page_size,
}); });
const sum = await repos.logs.summarize(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listModels(filters);
const items: LogItem[] = rows.map((row) => { const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise( const { quota_usd } = usageRowPrecise(
@@ -389,7 +397,21 @@ export function adminUsageRoutes() {
}; };
}); });
return c.json({ items, total }, 200); return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
}, },
); );
+47 -12
View File
@@ -71,7 +71,7 @@ export function memberViewRoutes() {
path: "/overview", path: "/overview",
summary: "单页聚合", summary: "单页聚合",
description: description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)", "名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"], tags: ["成员视图"],
security: [{ memberToken: [] }], security: [{ memberToken: [] }],
request: { request: {
@@ -159,8 +159,13 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline), usage: sumUsage(v.rows, timeline),
})); }));
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) // 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core"); const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const myDomains = new Set<string>();
for (const k of allKeys) {
if (k.billing_domain) myDomains.add(k.billing_domain);
}
const subRows = await repos.subscriptions.listAll(); const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set( const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null), rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
@@ -172,8 +177,11 @@ export function memberViewRoutes() {
s.period_end > new Date() || s.period_end > new Date() ||
(s.period_start <= range.to && (s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from); (s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id) || ongoing; const involved = usedSubIds.has(s.subscription_id);
if (!involved) continue; const inMyDomain =
s.billing_domain != null && myDomains.has(s.billing_domain);
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
if (!involved && !(ongoing && inMyDomain)) continue;
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline); const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({ subscriptionsOut.push({
id: s.id, id: s.id,
@@ -184,6 +192,7 @@ export function memberViewRoutes() {
quota_usd: quotaToUsdStr(s.quota_granted, qpu), quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount, paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at), paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark, remark: s.remark,
created_at: toUnixSec(s.created_at)!, created_at: toUnixSec(s.created_at)!,
}); });
@@ -191,6 +200,7 @@ export function memberViewRoutes() {
const topupRows = await repos.topups.listAll(); const topupRows = await repos.topups.listAll();
const topupsOut = []; const topupsOut = [];
for (const t of topupRows) { for (const t of topupRows) {
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue; if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline); const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu); const quota_derived = Math.round(t.amount * qpu);
@@ -204,6 +214,7 @@ export function memberViewRoutes() {
payment_provider: t.payment_provider, payment_provider: t.payment_provider,
status: t.status, status: t.status,
paid_at: toUnixSec(t.paid_at)!, paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived, quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu), quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark, remark: t.remark,
@@ -230,7 +241,8 @@ export function memberViewRoutes() {
method: "get", method: "get",
path: "/logs", path: "/logs",
summary: "日志明细(名下 key)", summary: "日志明细(名下 key)",
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构", description:
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
tags: ["成员视图"], tags: ["成员视图"],
security: [{ memberToken: [] }], security: [{ memberToken: [] }],
request: { request: {
@@ -242,12 +254,13 @@ export function memberViewRoutes() {
api_key_id: z.string().optional(), api_key_id: z.string().optional(),
billing_source: z.string().optional(), billing_source: z.string().optional(),
subscription_id: z.string().optional(), subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(), type: z.string().optional(),
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "items/total", description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } }, content: { "application/json": { schema: logPageSchema } },
}, },
401: err401, 401: err401,
@@ -267,25 +280,33 @@ export function memberViewRoutes() {
); );
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({ const member_id = c.get("memberId")!;
member_id: c.get("memberId")!, const filters = {
member_id,
from: range.from, from: range.from,
to: range.to, to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source: billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined, q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id subscription_id: q.subscription_id
? Number(q.subscription_id) ? Number(q.subscription_id)
: undefined, : undefined,
model_name: q.model_name || undefined,
type: type:
q.type === "error" q.type === "error"
? "error" ? ("error" as const)
: q.type != null && q.type !== "" : q.type != null && q.type !== ""
? Number(q.type) ? Number(q.type)
: undefined, : undefined,
};
const { rows, total } = await repos.logs.listMemberPage({
...filters,
page,
page_size,
}); });
const sum = await repos.logs.summarizeMember(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listMemberModels(filters);
const items: LogItem[] = rows.map((row) => { const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise( const { quota_usd } = usageRowPrecise(
row.quota, row.quota,
@@ -337,7 +358,21 @@ export function memberViewRoutes() {
}, },
}; };
}); });
return c.json({ items, total }, 200); return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
}, },
); );
-54
View File
@@ -1,54 +0,0 @@
# 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
```
+7 -1
View File
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
export type KeyListFilters = { export type KeyListFilters = {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: "active" | "disabled"; status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean; recent_failed?: boolean;
}; };
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
export async function registerKey(body: { export async function registerKey(body: {
sk_key: string; sk_key: string;
member_id?: number | null; member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> { }): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body); const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data; return data;
@@ -22,7 +24,11 @@ export async function registerKey(body: {
export async function patchKey( export async function patchKey(
id: number, id: number,
body: { status?: "active" | "disabled"; member_id?: number | null }, body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> { ): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body); const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data; return data;
+4 -5
View File
@@ -1,4 +1,4 @@
import type { LogItem, Paginated } from "@new-api-key-usage/core"; import type { LogPage } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts"; import { adminHttp } from "../http.ts";
export type AdminLogFilters = { export type AdminLogFilters = {
@@ -10,13 +10,12 @@ export type AdminLogFilters = {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
billing_source?: string; billing_source?: string;
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
}; };
export async function fetchLogs( export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
params: AdminLogFilters, const { data } = await adminHttp.get<LogPage>("/logs", {
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
params, params,
}); });
return data; return data;
+10 -1
View File
@@ -22,12 +22,21 @@ export async function createMember(body: {
export async function patchMember( export async function patchMember(
id: number, id: number,
body: { status: "active" | "disabled" }, body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> { ): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body); const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data; return data;
} }
/** 空壳删除:仅 key_count = 0 */
export async function deleteMember(id: number): Promise<void> {
await adminHttp.delete(`/members/${id}`);
}
/** 重发:旧令牌即刻失效,明文仅此一次 */ /** 重发:旧令牌即刻失效,明文仅此一次 */
export async function issueMemberToken(id: number): Promise<{ token: string }> { export async function issueMemberToken(id: number): Promise<{ token: string }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`); const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
+7 -2
View File
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
quota_granted: number; quota_granted: number;
paid_amount?: number | null; paid_amount?: number | null;
paid_at?: string | null; paid_at?: string | null;
billing_domain?: string | null;
remark?: string; remark?: string;
}; };
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> { export async function fetchSubscriptions(filter?: {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions"); billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data; return data;
} }
+7 -2
View File
@@ -10,11 +10,16 @@ export type TopupBody = {
payment_provider?: string | null; payment_provider?: string | null;
status?: string | null; status?: string | null;
paid_at: string; paid_at: string;
billing_domain?: string | null;
remark?: string; remark?: string;
}; };
export async function fetchTopups(): Promise<TopupItem[]> { export async function fetchTopups(filter?: {
const { data } = await adminHttp.get<TopupItem[]>("/topups"); billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data; return data;
} }
+4 -4
View File
@@ -1,6 +1,5 @@
import type { import type {
LogItem, LogPage,
Paginated,
SubscriptionItem, SubscriptionItem,
TopupItem, TopupItem,
UnixSec, UnixSec,
@@ -55,11 +54,12 @@ export type MemberLogQuery = {
api_key_id?: number; api_key_id?: number;
billing_source?: string; billing_source?: string;
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
}; };
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> { export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", { const { data } = await memberHttp.get<LogPage>("/logs", {
params: query, params: query,
}); });
return data; return data;
+2
View File
@@ -36,6 +36,7 @@ export const qk = {
range.page_size, range.page_size,
range.api_key_id ?? "", range.api_key_id ?? "",
range.member_id ?? "", range.member_id ?? "",
range.model_name ?? "",
range.billing_source ?? "", range.billing_source ?? "",
range.subscription_id ?? "", range.subscription_id ?? "",
range.type ?? "", range.type ?? "",
@@ -60,6 +61,7 @@ export const qk = {
range.page, range.page,
range.page_size, range.page_size,
range.api_key_id ?? "", range.api_key_id ?? "",
range.model_name ?? "",
range.billing_source ?? "", range.billing_source ?? "",
range.subscription_id ?? "", range.subscription_id ?? "",
range.type ?? "", range.type ?? "",
@@ -5,6 +5,7 @@
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { message } from "antdv-next"; import { message } from "antdv-next";
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons"; import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
import { useClipboard } from "@vueuse/core";
const props = defineProps<{ const props = defineProps<{
open: boolean; open: boolean;
@@ -27,9 +28,12 @@ watch(
}, },
); );
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
const { copy } = useClipboard({ legacy: true });
async function onCopy() { async function onCopy() {
try { try {
await navigator.clipboard.writeText(props.token); await copy(props.token);
message.success("已复制"); message.success("已复制");
} catch { } catch {
message.error("复制失败,请手动选中复制"); message.error("复制失败,请手动选中复制");
@@ -16,6 +16,7 @@ import type {
PlatformTopup, PlatformTopup,
} from "@/api/admin/platform.ts"; } from "@/api/admin/platform.ts";
import { import {
useBillingDomainOptions,
useCreateSubscription, useCreateSubscription,
useCreateTopup, useCreateTopup,
useQpuLatest, useQpuLatest,
@@ -78,6 +79,8 @@ const registeredTopIds = computed(
.filter((v): v is number => v != null), .filter((v): v is number => v != null),
); );
const domainOptions = useBillingDomainOptions();
const subForm = reactive({ const subForm = reactive({
subscription_id: null as number | null, subscription_id: null as number | null,
period_start: null as Dayjs | null, period_start: null as Dayjs | null,
@@ -87,6 +90,7 @@ const subForm = reactive({
quota_usd: null as number | null, quota_usd: null as number | null,
paid_amount: null as number | null, paid_amount: null as number | null,
paid_at: null as Dayjs | null, paid_at: null as Dayjs | null,
billing_domain: "",
remark: "", remark: "",
}); });
@@ -98,6 +102,7 @@ const topForm = reactive({
payment_method: "", payment_method: "",
payment_provider: "", payment_provider: "",
paid_at: null as Dayjs | null, paid_at: null as Dayjs | null,
billing_domain: "",
remark: "", remark: "",
}); });
@@ -111,6 +116,7 @@ function resetSub() {
quota_usd: null, quota_usd: null,
paid_amount: null, paid_amount: null,
paid_at: null, paid_at: null,
billing_domain: "",
remark: "", remark: "",
}); });
} }
@@ -124,6 +130,7 @@ function resetTop() {
payment_method: "", payment_method: "",
payment_provider: "", payment_provider: "",
paid_at: null, paid_at: null,
billing_domain: "",
remark: "", remark: "",
}); });
} }
@@ -147,6 +154,7 @@ watch(
subForm.paid_amount = s.paid_amount; subForm.paid_amount = s.paid_amount;
subForm.paid_at = subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null; s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.billing_domain = s.billing_domain ?? "";
subForm.remark = s.remark; subForm.remark = s.remark;
} else { } else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天 // 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
@@ -162,6 +170,7 @@ watch(
topForm.payment_method = t.payment_method ?? ""; topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? ""; topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at); topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.billing_domain = t.billing_domain ?? "";
topForm.remark = t.remark; topForm.remark = t.remark;
} else { } else {
resetTop(); resetTop();
@@ -239,6 +248,7 @@ async function onSubmit() {
quota_granted: quota, quota_granted: quota,
paid_amount: subForm.paid_amount, paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at), paid_at: iso(subForm.paid_at),
billing_domain: subForm.billing_domain.trim() || null,
remark: subForm.remark, remark: subForm.remark,
}; };
if (props.editingSub) { if (props.editingSub) {
@@ -268,6 +278,7 @@ async function onSubmit() {
payment_provider: topForm.payment_provider || null, payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success", status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!, paid_at: iso(topForm.paid_at)!,
billing_domain: topForm.billing_domain.trim() || null,
remark: topForm.remark, remark: topForm.remark,
}; };
if (props.editingTop) { if (props.editingTop) {
@@ -381,6 +392,13 @@ async function onSubmit() {
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记 社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div> </div>
</a-form-item> </a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="subForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注"> <a-form-item label="备注">
<a-input v-model:value="subForm.remark" /> <a-input v-model:value="subForm.remark" />
</a-form-item> </a-form-item>
@@ -432,6 +450,13 @@ async function onSubmit() {
style="width: 120px; margin-left: 8px" style="width: 120px; margin-left: 8px"
/> />
</a-form-item> </a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="topForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注"> <a-form-item label="备注">
<a-input v-model:value="topForm.remark" /> <a-input v-model:value="topForm.remark" />
</a-form-item> </a-form-item>
@@ -5,7 +5,7 @@
import { computed, reactive, ref, watch } from "vue"; import { computed, reactive, ref, watch } from "vue";
import type { MemberItem } from "@new-api-key-usage/core"; import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts"; import type { ApiError } from "@/api/http.ts";
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts"; import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
import MemberTokenModal from "@/components/admin/member-token-modal.vue"; import MemberTokenModal from "@/components/admin/member-token-modal.vue";
const props = defineProps<{ const props = defineProps<{
@@ -20,10 +20,12 @@ const emit = defineEmits<{
const register = useRegisterKey(); const register = useRegisterKey();
const createMember = useCreateMember(); const createMember = useCreateMember();
const domainOptions = useBillingDomainOptions();
const form = reactive({ const form = reactive({
sk_key: "", sk_key: "",
member_id: null as number | "unassigned" | null, member_id: null as number | "unassigned" | null,
billing_domain: "",
}); });
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>( const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
null, null,
@@ -41,6 +43,7 @@ watch(
if (!v) { if (!v) {
form.sk_key = ""; form.sk_key = "";
form.member_id = null; form.member_id = null;
form.billing_domain = "";
resultMsg.value = null; resultMsg.value = null;
showNewMember.value = false; showNewMember.value = false;
newMemberName.value = ""; newMemberName.value = "";
@@ -67,6 +70,7 @@ async function onSubmit() {
form.member_id === "unassigned" || form.member_id == null form.member_id === "unassigned" || form.member_id == null
? null ? null
: form.member_id, : form.member_id,
billing_domain: form.billing_domain.trim() || null,
}); });
resultMsg.value = { resultMsg.value = {
type: "success", type: "success",
@@ -170,6 +174,13 @@ async function onCreateMember() {
+ 新建成员 + 新建成员
</a-button> </a-button>
</a-form-item> </a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="form.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item v-if="showNewMember" label="新成员名称"> <a-form-item v-if="showNewMember" label="新成员名称">
<a-input <a-input
v-model:value="newMemberName" v-model:value="newMemberName"
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 调用日志汇总条(交互设计-调用日志 §2.5):
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
*/
import QuotaText from "./quota-text.vue";
defineProps<{
hitCount: number;
usageCount: number;
errorCount: number;
quota: number;
quotaUsd: string;
loading?: boolean;
}>();
</script>
<template>
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">请求次数</span>
<span class="logs-summary-bar__value">
<template v-if="loading">—</template>
<template v-else>
{{ hitCount }} 条<template v-if="errorCount > 0">
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
</template>
</span>
</div>
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">消费用量</span>
<span class="logs-summary-bar__value">
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
<template v-else>—</template>
</span>
</div>
</div>
</template>
<style scoped>
.logs-summary-bar {
display: flex;
align-items: center;
gap: 24px;
min-height: 36px;
padding: 0 12px;
margin-bottom: 12px;
border: 1px solid #e8ecf4;
border-radius: 6px;
background: #f7f9fc;
}
.logs-summary-bar.is-loading {
opacity: 0.55;
}
.logs-summary-bar__seg {
display: inline-flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.logs-summary-bar__label {
font-size: 12px;
color: #8a8f99;
}
.logs-summary-bar__value {
font-size: 13px;
font-weight: 500;
color: #1f2329;
}
</style>
@@ -4,6 +4,7 @@ import dayjs from "dayjs";
import { computed } from "vue"; import { computed } from "vue";
import { import {
recentDaysRange, recentDaysRange,
todayRange,
wallAsBeijingIso, wallAsBeijingIso,
type RangeValue, type RangeValue,
} from "@/utils/time.ts"; } from "@/utils/time.ts";
@@ -28,6 +29,10 @@ const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
const presets = computed(() => { const presets = computed(() => {
const builtIn = [ const builtIn = [
{
label: "今天",
value: rangeToDayjs(todayRange()),
},
{ {
label: "最近 7 天", label: "最近 7 天",
value: rangeToDayjs(recentDaysRange(7)), value: rangeToDayjs(recentDaysRange(7)),
@@ -51,6 +56,13 @@ function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
function detectKind(value: RangeValue): RangeKind { function detectKind(value: RangeValue): RangeKind {
const fromD = dayjs(value.from).format("YYYY-MM-DD"); const fromD = dayjs(value.from).format("YYYY-MM-DD");
const toD = dayjs(value.to).format("YYYY-MM-DD"); const toD = dayjs(value.to).format("YYYY-MM-DD");
const today = todayRange();
if (
dayjs(today.from).format("YYYY-MM-DD") === fromD &&
dayjs(today.to).format("YYYY-MM-DD") === toD
) {
return "today";
}
for (const days of [7, 30] as const) { for (const days of [7, 30] as const) {
const r = recentDaysRange(days); const r = recentDaysRange(days);
if ( if (
@@ -1,12 +1,12 @@
import { computed, onUnmounted, ref } from "vue"; import { computed, onUnmounted, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts"; import { recentDaysRange, todayRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string }; export type RangeValue = { from: string; to: string };
export type RangeKind = "recent-7" | "recent-30" | "fixed"; export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/** /**
* 可跟随「今天」推进的时间范围: * 可跟随「今天」推进的时间范围:
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧) * - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - fixed:自定义/订阅期间等绝对区间,窗口不挪 * - fixed:自定义/订阅期间等绝对区间,窗口不挪
*/ */
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") { export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
@@ -21,6 +21,7 @@ export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-
const range = computed<RangeValue>(() => { const range = computed<RangeValue>(() => {
void nowTick.value; void nowTick.value;
if (kind.value === "today") return todayRange();
if (kind.value === "recent-7") return recentDaysRange(7); if (kind.value === "recent-7") return recentDaysRange(7);
if (kind.value === "recent-30") return recentDaysRange(30); if (kind.value === "recent-30") return recentDaysRange(30);
return fixed.value; return fixed.value;
+48 -4
View File
@@ -1,3 +1,4 @@
import { computed, type ComputedRef } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { import {
fetchKeys, fetchKeys,
@@ -23,6 +24,7 @@ import {
} from "@/api/admin/topups.ts"; } from "@/api/admin/topups.ts";
import { import {
createMember, createMember,
deleteMember,
fetchMembers, fetchMembers,
issueMemberToken, issueMemberToken,
patchMember, patchMember,
@@ -83,7 +85,11 @@ export function usePatchKey() {
body, body,
}: { }: {
id: number; id: number;
body: { status?: "active" | "disabled"; member_id?: number | null }; body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
};
}) => patchKey(id, body), }) => patchKey(id, body),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] }); void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
@@ -105,7 +111,7 @@ export function useAdminLogs(
export function useSubscriptions() { export function useSubscriptions() {
return useQuery({ return useQuery({
queryKey: qk.admin.subscriptions(), queryKey: qk.admin.subscriptions(),
queryFn: fetchSubscriptions, queryFn: () => fetchSubscriptions(),
staleTime: 0, staleTime: 0,
}); });
} }
@@ -144,7 +150,7 @@ export function useDeleteSubscription() {
export function useTopups() { export function useTopups() {
return useQuery({ return useQuery({
queryKey: qk.admin.topups(), queryKey: qk.admin.topups(),
queryFn: fetchTopups, queryFn: () => fetchTopups(),
staleTime: 0, staleTime: 0,
}); });
} }
@@ -207,10 +213,26 @@ export function usePatchMember() {
body, body,
}: { }: {
id: number; id: number;
body: { status: "active" | "disabled" }; body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body), }) => patchMember(id, body),
onSuccess: () => { onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() }); void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function useDeleteMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteMember,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
}, },
}); });
} }
@@ -233,3 +255,25 @@ export function useAuditLogs(
queryFn: () => fetchAuditLogs(params()), queryFn: () => fetchAuditLogs(params()),
}); });
} }
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
export function useBillingDomainOptions(): ComputedRef<
Array<{ value: string }>
> {
const keysQuery = useKeys(() => ({}));
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
return computed(() => {
const set = new Set<string>();
for (const k of keysQuery.data.value ?? []) {
if (k.billing_domain) set.add(k.billing_domain);
}
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((value) => ({ value }));
});
}
+2 -2
View File
@@ -17,10 +17,10 @@ declare module "vue-router" {
export const router: Router = createRouter({ export const router: Router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: "/", redirect: "/admin/overview" }, { path: "/", redirect: "/member" },
...adminRoutes, ...adminRoutes,
...memberRoutes, ...memberRoutes,
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" }, { path: "/:pathMatch(.*)*", redirect: "/member" },
], ],
}); });
+1 -1
View File
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts"; import { recentDaysRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string }; export type RangeValue = { from: string; to: string };
export type RangeKind = "recent-7" | "recent-30" | "fixed"; export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/** /**
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。 * 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
+8
View File
@@ -83,6 +83,14 @@ export function recentDaysRange(
return { from: shanghaiIso(from), to: shanghaiIso(to) }; return { from: shanghaiIso(from), to: shanghaiIso(to) };
} }
/** 快捷项:今天(北京时间日界,左闭右开) */
export function todayRange(nowMs: number = Date.now()): {
from: string;
to: string;
} {
return recentDaysRange(1, nowMs);
}
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */ /** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
export function formatRangeCaption(fromIso: string, toIso: string): string { export function formatRangeCaption(fromIso: string, toIso: string): string {
if (!fromIso || !toIso) return "—"; if (!fromIso || !toIso) return "—";
+67 -2
View File
@@ -11,7 +11,7 @@ import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue"; import TableRefresh from "@/components/common/table-refresh.vue";
import RegisterKeyModal from "@/components/admin/register-key-modal.vue"; import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts"; import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt } from "@/utils/format.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts"; import { formatMinute, hoursSince } from "@/utils/time.ts";
@@ -23,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | null>(null); const filterMember = ref<number | "unassigned" | null>(null);
const filterStatus = ref<"active" | "disabled" | null>(null); const filterStatus = ref<"active" | "disabled" | null>(null);
const filterDomain = ref<string | "none" | null>(null);
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
const filterRecentFailed = ref(false); const filterRecentFailed = ref(false);
/** URL → 筛选(总览跳入) */ /** URL → 筛选(总览跳入) */
@@ -35,6 +37,10 @@ function syncFromRoute() {
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status; if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
else filterStatus.value = null; else filterStatus.value = null;
if (q.billing_domain === "none") filterDomain.value = "none";
else if (typeof q.billing_domain === "string" && q.billing_domain) {
filterDomain.value = q.billing_domain;
} else filterDomain.value = null;
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true"; filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
@@ -52,18 +58,21 @@ function pushQuery() {
if (filterMember.value === "unassigned") query.member_id = "unassigned"; if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value); else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value; if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1"; if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value); if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query }); void router.replace({ path: "/admin/keys", query });
} }
watch([filterMember, filterStatus, filterRecentFailed], () => { watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery(); pushQuery();
}); });
const filters = computed(() => ({ const filters = computed(() => ({
member_id: filterMember.value ?? undefined, member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined, status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined, recent_failed: filterRecentFailed.value ? true : undefined,
})); }));
@@ -91,6 +100,7 @@ const hasActiveFilter = computed(
() => () =>
filterMember.value != null || filterMember.value != null ||
filterStatus.value != null || filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value, filterRecentFailed.value,
); );
@@ -99,6 +109,8 @@ const memberOptions = computed(() => [
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })), ...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]); ]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean { function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt); const h = hoursSince(logSyncedAt);
return h != null && h > 2; return h != null && h > 2;
@@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) {
const columns = [ const columns = [
{ title: "名称", key: "token_name", width: 160 }, { title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 }, { title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 }, { title: "状态", key: "status", width: 100 },
{ {
title: tipTitle( title: tipTitle(
@@ -156,6 +169,36 @@ async function onMemberChange(
} }
} }
async function onDomainChange(record: KeyItem, value: string) {
const next = value.trim() || null;
if ((record.billing_domain ?? null) === next) return;
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
const id = record.id;
domainSaveTimer = setTimeout(() => {
domainSaveTimer = null;
void saveDomain(id, next);
}, 400);
}
async function onDomainBlur(record: KeyItem, value: string) {
const next = value.trim() || null;
if (domainSaveTimer != null) {
clearTimeout(domainSaveTimer);
domainSaveTimer = null;
}
if ((record.billing_domain ?? null) === next) return;
await saveDomain(record.id, next);
}
async function saveDomain(id: number, billing_domain: string | null) {
try {
await patchKey.mutateAsync({ id, body: { billing_domain } });
message.success("账本域已更新");
} catch (e) {
message.error((e as ApiError).message || "账本域更新失败");
}
}
async function onToggleStatus(record: KeyItem) { async function onToggleStatus(record: KeyItem) {
const next = record.status === "active" ? "disabled" : "active"; const next = record.status === "active" ? "disabled" : "active";
try { try {
@@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) {
placeholder="状态" placeholder="状态"
allow-clear allow-clear
/> />
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 140px"
placeholder="账本域"
allow-clear
/>
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox> <a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space> </a-space>
</div> </div>
@@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) {
@change="(v: number | 'unassigned') => onMemberChange(record, v)" @change="(v: number | 'unassigned') => onMemberChange(record, v)"
/> />
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
<a-auto-complete
:value="record.billing_domain ?? ''"
:options="domainOptions"
size="small"
style="width: 120px"
allow-clear
@change="(v: string) => onDomainChange(record, v)"
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
/>
</template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-badge <a-badge
:status="record.status === 'active' ? 'success' : 'default'" :status="record.status === 'active' ? 'success' : 'default'"
+148 -10
View File
@@ -3,16 +3,20 @@
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转 * 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/ */
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem } from "@new-api-key-usage/core"; import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue"; import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue"; import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue"; import QuotaText from "@/components/common/quota-text.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts"; import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts"; import {
useLiveTimeRange,
type RangeKind,
} from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
import { formatDateTime } from "@/utils/time.ts"; import { formatDateTime } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -20,6 +24,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
const memberId = ref<number | "unassigned" | null>(null); const memberId = ref<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null); const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null); const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null); const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null); const typeFilter = ref<number | "error" | null>(null);
@@ -47,6 +52,7 @@ const params = computed(() => ({
page_size: pageSize.value, page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined, api_key_id: apiKeyId.value ?? undefined,
member_id: memberId.value ?? undefined, member_id: memberId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined, billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined, subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined, type: typeFilter.value ?? undefined,
@@ -55,6 +61,11 @@ const params = computed(() => ({
const logsQuery = useAdminLogs(() => params.value); const logsQuery = useAdminLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []); const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0); const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const memberOptions = computed(() => [ const memberOptions = computed(() => [
{ value: null, label: "全部成员" }, { value: null, label: "全部成员" },
@@ -102,6 +113,8 @@ function syncFromRoute() {
else if (typeof m === "string" && m) memberId.value = Number(m); else if (typeof m === "string" && m) memberId.value = Number(m);
const k = q.api_key_id ?? q.key_id; const k = q.api_key_id ?? q.key_id;
if (typeof k === "string" && k) apiKeyId.value = Number(k); if (typeof k === "string" && k) apiKeyId.value = Number(k);
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
if (typeof q.billing_source === "string" && q.billing_source) { if (typeof q.billing_source === "string" && q.billing_source) {
billingSource.value = q.billing_source; billingSource.value = q.billing_source;
} }
@@ -128,6 +141,7 @@ function pushQuery() {
if (memberId.value === "unassigned") query.member_id = "unassigned"; if (memberId.value === "unassigned") query.member_id = "unassigned";
else if (memberId.value != null) query.member_id = String(memberId.value); else if (memberId.value != null) query.member_id = String(memberId.value);
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value); if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value; if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) { if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value); query.subscription_id = String(subscriptionId.value);
@@ -143,7 +157,7 @@ watch(
() => syncFromRoute(), () => syncFromRoute(),
); );
watch( watch(
[memberId, apiKeyId, billingSource, subscriptionId, typeFilter], [memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
() => { () => {
page.value = 1; page.value = 1;
pushQuery(); pushQuery();
@@ -153,12 +167,54 @@ watch(page, () => pushQuery());
function sourceBadge(record: LogItem) { function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) { if (record.billing_source === "subscription" && record.subscription_id != null) {
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const }; return {
text: `订阅 #${record.subscription_id}`,
color: "blue" as const,
kind: "sub" as const,
id: record.subscription_id,
};
} }
if (record.billing_source === "wallet") { if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const }; return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
} }
return { text: "未关联", color: undefined }; return { text: "未关联", color: undefined, kind: "none" as const, id: null };
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const subsQuery = useSubscriptions();
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of subsQuery.data.value ?? []) {
m.set(p.subscription_id, p);
}
return m;
});
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function goPayments() {
if (payModalSubId.value == null) return;
payModalOpen.value = false;
void router.push({
path: "/admin/payments",
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
});
} }
function goKey(keyId: number) { function goKey(keyId: number) {
@@ -177,7 +233,7 @@ let ro: ResizeObserver | null = null;
function onRangeChange( function onRangeChange(
v: { from: string; to: string }, v: { from: string; to: string },
k: "recent-7" | "recent-30" | "fixed", k: RangeKind,
) { ) {
setRange(v, k); setRange(v, k);
page.value = 1; page.value = 1;
@@ -242,6 +298,15 @@ onUnmounted(() => {
show-search show-search
option-filter-prop="label" option-filter-prop="label"
/> />
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select <a-select
v-model:value="billingSource" v-model:value="billingSource"
:options="[ :options="[
@@ -273,6 +338,15 @@ onUnmounted(() => {
/> />
</div> </div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh <TableRefresh
:loading="logsQuery.isFetching.value" :loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()" @refresh="() => logsQuery.refetch()"
@@ -322,7 +396,19 @@ onUnmounted(() => {
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'source'"> <template v-else-if="column.key === 'source'">
<a-tag :color="sourceBadge(record).color"> <a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="call-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else :color="sourceBadge(record).color">
{{ sourceBadge(record).text }} {{ sourceBadge(record).text }}
</a-tag> </a-tag>
</template> </template>
@@ -400,6 +486,41 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</a-modal> </a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="call-logs__pay">
<div class="call-logs__kv">
<span class="call-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">金额</span>
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="call-logs__kv">
<span class="call-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
</div>
<div v-else class="call-logs__note">该订阅无登记信息</div>
<div class="call-logs__foot">
<a @click="goPayments">在支付记录中查看</a>
</div>
</a-modal>
</section> </section>
</template> </template>
@@ -476,4 +597,21 @@ onUnmounted(() => {
line-height: 20px; line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
} }
.call-logs__badge {
cursor: pointer;
}
.call-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__foot {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style> </style>
+82 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停 * 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/ */
import { computed, h, reactive, ref, watch } from "vue"; import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next"; import { Tooltip, message } from "antdv-next";
@@ -16,6 +16,7 @@ import TableRefresh from "@/components/common/table-refresh.vue";
import MemberTokenModal from "@/components/admin/member-token-modal.vue"; import MemberTokenModal from "@/components/admin/member-token-modal.vue";
import { import {
useCreateMember, useCreateMember,
useDeleteMember,
useIssueMemberToken, useIssueMemberToken,
useMembers, useMembers,
usePatchMember, usePatchMember,
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers(); const membersQuery = useMembers();
const createMember = useCreateMember(); const createMember = useCreateMember();
const patchMember = usePatchMember(); const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken(); const issueToken = useIssueMemberToken();
const createOpen = ref(false); const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false); const tokenOpen = ref(false);
const plainToken = ref(""); const plainToken = ref("");
const tokenMemberName = ref(""); const tokenMemberName = ref("");
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" }); const createForm = reactive({ name: "", remark: "" });
const createError = ref(""); const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref(""); const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null); const filterStatus = ref<"active" | "disabled" | null>(null);
@@ -83,7 +89,7 @@ const columns = [
width: 170, width: 170,
}, },
{ title: "名下 Key 数", key: "key_count", width: 110 }, { title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 200 }, { title: "操作", key: "actions", width: 280 },
]; ];
const rows = computed(() => { const rows = computed(() => {
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
message.error((e as ApiError).message || "状态更新失败"); message.error((e as ApiError).message || "状态更新失败");
} }
} }
function openEdit(record: MemberItem) {
editForm.id = record.id;
editForm.name = record.name;
editForm.remark = record.remark ?? "";
editError.value = "";
editOpen.value = true;
}
async function onEdit() {
editError.value = "";
const name = editForm.name.trim();
if (!name) {
editError.value = "请填写成员名称";
return;
}
try {
await patchMember.mutateAsync({
id: editForm.id,
body: { name, remark: editForm.remark },
});
editOpen.value = false;
message.success("已保存");
} catch (e) {
const err = e as ApiError;
editError.value = err.message || "保存失败";
}
}
async function onDelete(record: MemberItem) {
try {
await deleteMember.mutateAsync(record.id);
message.success("已删除——仅清理未挂 key 的空壳");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
</script> </script>
<template> <template>
<section> <section>
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停"> <PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions> <template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button> <a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template> </template>
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
> >
查调用 查调用
</a-button> </a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm <a-popconfirm
title="重发后旧令牌立即失效" title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断" description="该成员正在用的访问将中断"
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
<a-button v-else size="small" @click="() => onToggle(record)"> <a-button v-else size="small" @click="() => onToggle(record)">
启用 启用
</a-button> </a-button>
<a-tooltip
v-if="record.key_count > 0"
title="名下仍有 key,只能停用"
>
<a-button size="small" danger disabled>删除</a-button>
</a-tooltip>
<a-popconfirm
v-else
title="删除后不可恢复(名称与令牌一并作废)"
description="仅清理未挂 key 的空壳"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space> </a-space>
</template> </template>
</template> </template>
</a-table> </a-table>
<a-modal
v-model:open="editOpen"
title="编辑成员"
:confirm-loading="patchMember.isPending.value"
ok-text="保存"
cancel-text="取消"
@ok="onEdit"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="editForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="editError" type="error" show-icon :message="editError" />
</a-form>
</a-modal>
<a-modal <a-modal
v-model:open="createOpen" v-model:open="createOpen"
title="新建成员" title="新建成员"
+100 -8
View File
@@ -25,6 +25,49 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const subsQuery = useSubscriptions(); const subsQuery = useSubscriptions();
const topupsQuery = useTopups(); const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((v) => ({ value: v, label: v }));
});
const filteredSubs = computed(() => {
let list = subsQuery.data.value ?? [];
if (filterSubId.value != null) {
list = list.filter((r) => r.subscription_id === filterSubId.value);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const filteredTops = computed(() => {
let list = topupsQuery.data.value ?? [];
if (filterTradeNo.value) {
const q = filterTradeNo.value.trim().toLowerCase();
list = list.filter(
(r) =>
(r.trade_no ?? "").toLowerCase().includes(q) ||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const deleteSub = useDeleteSubscription(); const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup(); const deleteTop = useDeleteTopup();
@@ -37,22 +80,27 @@ const checking = ref(false);
function syncFromRoute() { function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs"; tab.value = route.query.tab === "topups" ? "topups" : "subs";
const sid = route.query.subscription_id;
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
const tn = route.query.trade_no;
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
} }
function pushTab() { function pushQuery() {
void router.replace({ const query: Record<string, string> = {};
path: "/admin/payments", if (tab.value === "topups") query.tab = "topups";
query: tab.value === "topups" ? { tab: "topups" } : {}, if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
}); if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
void router.replace({ path: "/admin/payments", query });
} }
syncFromRoute(); syncFromRoute();
watch(() => route.query, () => syncFromRoute()); watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab()); watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...(subsQuery.data.value ?? [])]); const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => { const topRows = computed(() => {
const list = [...(topupsQuery.data.value ?? [])]; const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0)); list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list; return list;
}); });
@@ -62,6 +110,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 }, { title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 }, { title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 }, { title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 }, { title: "操作", key: "actions", width: 120 },
@@ -78,6 +127,7 @@ const topColumns = [
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 }, { title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 }, { title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 }, { title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 }, { title: "操作", key: "actions", width: 120 },
]; ];
@@ -198,6 +248,38 @@ watch(
<a-tab-pane key="topups" tab="充值" /> <a-tab-pane key="topups" tab="充值" />
</a-tabs> </a-tabs>
<div class="payments-toolbar">
<template v-if="tab === 'subs'">
<a-input-number
v-model:value="filterSubId"
:min="1"
:precision="0"
style="width: 140px"
placeholder="订阅 ID"
allow-clear
/>
</template>
<template v-else>
<a-input
v-model:value="filterTradeNo"
style="width: 180px"
placeholder="交易号 / 上游 ID"
allow-clear
/>
</template>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 160px"
placeholder="账本域"
allow-clear
/>
</div>
<a-alert <a-alert
v-if="tab === 'subs' && missingSubs.length" v-if="tab === 'subs' && missingSubs.length"
type="warning" type="warning"
@@ -253,6 +335,9 @@ watch(
: "—" : "—"
}} }}
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
@@ -316,6 +401,9 @@ watch(
<template v-else-if="column.key === 'quota'"> <template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" /> <QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
@@ -346,6 +434,10 @@ watch(
</template> </template>
<style scoped> <style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period { .pay-period {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+33 -3
View File
@@ -12,7 +12,9 @@ import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue"; import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue"; import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue"; import QuotaText from "@/components/common/quota-text.vue";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import { useMemberRange } from "@/composables/use-member-range.ts"; import { useMemberRange } from "@/composables/use-member-range.ts";
import type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts"; import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts"; import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts"; import { formatInt, formatPaidAmount } from "@/utils/format.ts";
@@ -22,6 +24,7 @@ const router = useRouter();
const { range, setRange } = useMemberRange(); const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null); const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null); const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null); const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null); const typeFilter = ref<number | "error" | null>(null);
@@ -39,6 +42,7 @@ const params = computed(() => ({
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined, api_key_id: apiKeyId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined, billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined, subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined, type: typeFilter.value ?? undefined,
@@ -47,6 +51,11 @@ const params = computed(() => ({
const logsQuery = useMemberLogs(() => params.value); const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []); const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0); const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const keyOptions = computed(() => [ const keyOptions = computed(() => [
{ value: null, label: "全部 Key" }, { value: null, label: "全部 Key" },
@@ -136,6 +145,8 @@ function syncFromRoute() {
const q = route.query; const q = route.query;
const k = q.api_key_id ?? q.key_id; const k = q.api_key_id ?? q.key_id;
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null; apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
billingSource.value = billingSource.value =
typeof q.billing_source === "string" && q.billing_source typeof q.billing_source === "string" && q.billing_source
? q.billing_source ? q.billing_source
@@ -159,6 +170,7 @@ function pushQuery() {
to: range.value.to, to: range.value.to,
}; };
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value); if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value; if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) { if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value); query.subscription_id = String(subscriptionId.value);
@@ -170,14 +182,14 @@ function pushQuery() {
syncFromRoute(); syncFromRoute();
watch(() => route.query, () => syncFromRoute()); watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => { watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return; if (applyingRoute.value) return;
page.value = 1; page.value = 1;
pushQuery(); pushQuery();
}); });
watch(page, () => pushQuery()); watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: "recent-7" | "recent-30" | "fixed") { function onRangeChange(v: { from: string; to: string }, k: RangeKind) {
setRange(v, k); setRange(v, k);
page.value = 1; page.value = 1;
pushQuery(); pushQuery();
@@ -245,6 +257,15 @@ watch(
show-search show-search
option-filter-prop="label" option-filter-prop="label"
/> />
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select <a-select
v-model:value="billingSource" v-model:value="billingSource"
:options="[ :options="[
@@ -276,6 +297,15 @@ watch(
/> />
</div> </div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh <TableRefresh
:loading="logsQuery.isFetching.value" :loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()" @refresh="() => logsQuery.refetch()"
@@ -438,7 +468,7 @@ watch(
<span>{{ payModalPayment.remark }}</span> <span>{{ payModalPayment.remark }}</span>
</div> </div>
<div class="member-logs__foot"> <div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数在你所属账本域内公开;币种由成员与作者约定
</div> </div>
</div> </div>
<div v-else class="member-logs__note">该订阅无登记信息</div> <div v-else class="member-logs__note">该订阅无登记信息</div>
+4 -2
View File
@@ -49,6 +49,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 }, { title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 }, { title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true }, { title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
]; ];
@@ -68,6 +69,7 @@ const topColumns = [
}, },
{ title: "交易号 / 渠道", key: "trade", width: 180 }, { title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 }, { title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true }, { title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
]; ];
</script> </script>
@@ -76,7 +78,7 @@ const topColumns = [
<section> <section>
<PageHeader <PageHeader
title="支付参数" title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开" subtitle="订阅与充值的登记参数 · 同账本域内可见"
> >
<template #actions> <template #actions>
<span class="mv-range-label">时间范围</span> <span class="mv-range-label">时间范围</span>
@@ -186,7 +188,7 @@ const topColumns = [
</a-table> </a-table>
<div class="mv-foot"> <div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数在你所属账本域内公开;币种由成员与作者约定
</div> </div>
</template> </template>
</section> </section>
+16
View File
@@ -5,7 +5,9 @@
"": { "": {
"name": "new-api-key-usage", "name": "new-api-key-usage",
"devDependencies": { "devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2", "@types/bun": "^1.4.2",
"citty": "^0.2.2",
"typescript": "^7.0.2", "typescript": "^7.0.2",
}, },
}, },
@@ -180,6 +182,10 @@
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="], "@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="], "@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
@@ -714,6 +720,8 @@
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -822,6 +830,12 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="], "fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -1120,6 +1134,8 @@
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
+86 -8
View File
@@ -1,20 +1,98 @@
# 一键部署:docker compose up -d --build
# 验收:docker compose ps(migrate 应为 exited 0)
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
services: services:
postgres: postgres:
container_name: naku-postgres
image: postgres:17-alpine image: postgres:17-alpine
container_name: new-api-key-usage-pg entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
environment: environment:
POSTGRES_USER: postgres DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
POSTGRES_PASSWORD: postgres
POSTGRES_DB: new_api_key_usage
ports:
- "5432:5432"
volumes: volumes:
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"] test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 retries: 12
restart: unless-stopped
migrate:
container_name: naku-migrate
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-migrate"]
environment:
DATABASE_URL: ${DATABASE_URL}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
postgres:
condition: service_healthy
restart: "no"
server:
container_name: naku-server
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-server"]
environment:
PORT: "18456"
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env}
NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
LOG_LEVEL: ${LOG_LEVEL:-info}
ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
engine:
container_name: naku-engine
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-engine"]
environment:
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET}
NEWAPI_BASE: ${NEWAPI_BASE}
SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000}
LOG_LEVEL: ${LOG_LEVEL:-info}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
caddy:
container_name: naku-caddy
build:
context: .
target: proxy
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
depends_on:
- server
restart: unless-stopped
volumes: volumes:
pgdata: pgdata:
+5 -5
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-02) | | 状态 | 定稿(2026-10-02;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 七篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) | | 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 各篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 | | 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
--- ---
@@ -15,7 +15,7 @@
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json` - JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图) - 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则) - DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除) - **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除为主——**空壳例外可硬删**:无 key 归属的成员行不承载账本事实,见 R16)
## 2. 路由前缀与隔离机制 ## 2. 路由前缀与隔离机制
@@ -27,7 +27,7 @@
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行 - **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4) - **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1) - 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计) - **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
## 3. 错误信封 ## 3. 错误信封
@@ -77,7 +77,7 @@
## 7. DTO 命名 ## 7. DTO 命名
- **snake_case、与表字段同名同构**——七篇表字段设计即 DTO 字段权威,无映射层 - **snake_case、与表字段同名同构**——各篇表字段设计即 DTO 字段权威,无映射层
- 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰) - 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰)
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审) ## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
+9 -7
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 待审阅(2026-10-09 支付拆表——overview 分 `subscriptions`/`topups`) | | 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) | | 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) | | 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
@@ -48,13 +48,13 @@
"subscriptions": [ "subscriptions": [
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z", { "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00", "period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "remark": "九月拼车", "paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "billing_domain": "carpool", "remark": "九月拼车",
"created_at": "2026-09-02T12:00:00Z" } "created_at": "2026-09-02T12:00:00Z" }
], ],
"topups": [ "topups": [
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04, { "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay", "trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
"status": "success", "paid_at": "…", "status": "success", "paid_at": "…", "billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00", "quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "…" } "remark": "", "created_at": "…" }
] ]
@@ -62,16 +62,18 @@
``` ```
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注) - `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态 - `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」 - 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
## 4. 日志明细 ## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}` ### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。 分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。 可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用): 明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
@@ -92,7 +94,7 @@
## 5. 本稿裁定项(请作者重点审) ## 5. 本稿裁定项(请作者重点审)
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`; 1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
2. **「该时段涉及」登记的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项);充值 = `paid_at ∈ [from, to)`(入账事件按付费时刻锚定);补录审计看各表 `created_at`(不进本判定); 2. **「该时段涉及」登记的判定**(账本域隔离):成员的**我的域** = 名下 key 的 `billing_domain` 非空并集。订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行(**事实直连**,含未标域) **∪**(进行中 **∧** `billing_domain ∈ 我的域`)——未标域进行中订阅不放行;充值 = **`billing_domain ∈ 我的域`** ∧ `paid_at ∈ [from, to)`——未标域充值不放行。管理面不受此限;
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤; 3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。 4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
+45 -12
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 待审阅(2026-10-09 支付拆表修订——§7) | | 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) | | 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) | | 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
--- ---
@@ -29,7 +29,7 @@
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 | | 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) | | 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) | | 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 16 | `/api/admin/members/:id` | PATCH | 停用 / 启用 | | 16 | `/api/admin/members/:id` | PATCH / DELETE | 档案修改(name/remark)与启停 / 空壳删除(仅 `key_count = 0`) |
| 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) | | 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) | | 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
@@ -119,12 +119,13 @@
### GET `/api/admin/keys` → 200 `[key]` ### GET `/api/admin/keys` → 200 `[key]`
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。 全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`billing_domain`(值或 `none` = 未标注)、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
```json ```json
{ {
"id": 5, "token_name": "小张-01", "status": "active", "id": 5, "token_name": "小张-01", "status": "active",
"member_id": 1, "member_name": "小张", "member_id": 1, "member_name": "小张",
"billing_domain": "carpool",
"registered_at": "2026-09-20T10:00:00Z", "registered_at": "2026-09-20T10:00:00Z",
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08: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, "total_used": 600000, "total_available": 940000, "total_granted": 1540000,
@@ -136,7 +137,7 @@
### POST `/api/admin/keys` → 201 `key` ### POST `/api/admin/keys` → 201 `key`
`{ "sk_key": "sk-…", "member_id": null }`——登记四态: `{ "sk_key": "sk-…", "member_id": null, "billing_domain": null }`——登记四态:
| 结果 | 响应 | | 结果 | 响应 |
|------|------| |------|------|
@@ -147,13 +148,34 @@
### PATCH `/api/admin/keys/:id` → 200 `key` ### PATCH `/api/admin/keys/:id` → 200 `key`
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。 `{ "status": "disabled" }`(启用 / 停用)· `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)或 `{ "billing_domain": "office" }`(账本域变更;`null` = 未标域)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
## 6. 日志明细(管理面下钻) ## 6. 日志明细(管理面下钻)
### GET `/api/admin/logs?from&to` → 200 `{items, total}` ### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
分页(`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 的另一半)。 分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
```json
{
"summary": {
"hit_count": 131, "usage_count": 128, "error_count": 3,
"quota": 1250000, "quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 口径 |
|------|------|
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
```json ```json
{ {
@@ -184,6 +206,7 @@
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z", "period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"quota_granted": 12500000, "quota_usd": "25.00", "quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
"billing_domain": "carpool",
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z" "remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
} }
``` ```
@@ -193,7 +216,7 @@
### POST `/api/admin/subscriptions` → 201 `subscription` ### POST `/api/admin/subscriptions` → 201 `subscription`
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `remark`。美元双输入为前端换算,落库恒原始 quota。 必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `billing_domain` · `remark`。美元双输入为前端换算,落库恒原始 quota。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/subscriptions/:id` / DELETE → 204 ### PUT `/api/admin/subscriptions/:id` / DELETE → 204
@@ -209,6 +232,7 @@
"payment_method": "alipay", "payment_provider": "epay", "payment_method": "alipay", "payment_provider": "epay",
"status": "success", "status": "success",
"paid_at": "2026-10-09T12:22:56Z", "paid_at": "2026-10-09T12:22:56Z",
"billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00", "quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "2026-10-09T13:00:00Z" "remark": "", "created_at": "2026-10-09T13:00:00Z"
} }
@@ -218,7 +242,7 @@
### POST `/api/admin/topups` → 201 `topup` ### POST `/api/admin/topups` → 201 `topup`
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `remark`。 必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/topups/:id` / DELETE → 204 ### PUT `/api/admin/topups/:id` / DELETE → 204
@@ -259,7 +283,16 @@
### PATCH `/api/admin/members/:id` → 200 `member` ### PATCH `/api/admin/members/:id` → 200 `member`
`{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)。 部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9):
- `{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)
- `{ "name": "小张" }` / `{ "remark": "同事" }`(R16 档案修改)——`name` 冲突 `409 member_name_taken`;不接受 `token_hash` / `created_at` / `id`
非法值 `422`;成员不存在 `404`。
### DELETE `/api/admin/members/:id` → 204
空壳删除(R16):**仅当该成员当前 `key_count = 0`**(无任何 `api_keys.member_id` 指向)才物理删除行;否则 `409 member_has_keys`(`fields` 可附 `key_count`)。删除后名称 UNIQUE 释放、令牌一并作废。名下有 key 的清理路径 = 停用(软删除语义不变)。均落审计。
### POST `/api/admin/members/:id/token` → 201 ### POST `/api/admin/members/:id/token` → 201
@@ -285,7 +318,7 @@
|----|------------| |----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) | | key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) | | payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) |
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` | | member | `member.create` / `member.update`(档案改 name/remark)/ `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` / `member.delete`(空壳硬删) |
| account | `account.password_change` / `account.init`(系统,operator null) | | account | `account.password_change` / `account.init`(系统,operator null) |
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。 每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
+2 -1
View File
@@ -32,6 +32,7 @@
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) | | `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 | | `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 | | `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 | | `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) | | `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 | | `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
@@ -55,7 +56,7 @@
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证 - `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游 - `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档) - 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**
> **注解:为什么是两个字段(给非密码学背景的读者)** > **注解:为什么是两个字段(给非密码学背景的读者)**
> >
+22 -11
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-01) | | 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 | | 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9(访问主体模型)· R16(档案可改、空壳删除) |
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 | | 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
| 字段数 | 7 = 1 主键 + 6 业务字段 | | 字段数 | 7 = 1 主键 + 6 业务字段 |
@@ -21,13 +21,13 @@
| 列 | 类型 | 值来源 | 说明 | | 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------| |----|------|--------|------|
| `id` | bigserial PK | 自增 | | | `id` | bigserial PK | 自增 | 不可改 |
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 | | `name` | text UNIQUE | 管理面录入(可改) | 显示名——账目与视图中的署名;开户后可改(R16) |
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) | | `remark` | text | 管理面录入(可改) | 备注(联系方式、是谁等;可空);开户后可改(R16) |
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) | | `status` | text | 管理面启停 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断);**不并入档案编辑** |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) | | `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次);仅随重发更新 |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) | | `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索);仅随重发更新 |
| `created_at` | timestamptz | 开户时 | | | `created_at` | timestamptz | 开户时 | 不可改 |
## 3. 决策记录(含理由) ## 3. 决策记录(含理由)
@@ -45,17 +45,28 @@
账目署名不可歧义。 账目署名不可歧义。
### 3.4 软删除(`status`)而非物理删除 ### 3.4 软删除(`status`)为主,空壳例外硬删(R16)
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。 成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。**默认无物理删除**。
**空壳例外(R16)**:当前 `key_count = 0`(无任何 `api_keys.member_id` 指向本行)时允许硬删——该行不承载账本事实,删了只清掉误建/未使用的开户壳,并释放 UNIQUE 名称。名下有 key 一律拒绝删除(只能停用)。硬删与软删除不冲突:账本实体的默认语义仍是软删除,空壳删是「无账本可保」时的清理通道。
### 3.5 档案字段可改(`name` / `remark`)(R16)
- **动机**:开户后挂 key 前发现名称不对(误建、占位名)——已发出令牌时「改名」优于「删了重建」(重建会作废旧令牌)
- **可改字段收窄**:仅 `name` / `remark`;`status` / 令牌 / `id` / `created_at` 各走各的生命周期,不并入档案编辑
- **`name` 仍 UNIQUE**(3.3 不变);冲突 `409 member_name_taken`
- **展示口径 = 当前名**:日志 / 总览 / 成员视图联查实时取 `members.name`(与 `api_keys.token_name` 的「当前名为准」同则)——历史署名随改名漂移,可接受
## 4. 写入时机 ## 4. 写入时机
| 时机 | 动作 | | 时机 | 动作 |
|------|------| |------|------|
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) | | 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
| 档案修改(R16) | UPDATE `name` 和/或 `remark` |
| 停用 / 启用 | UPDATE `status` | | 停用 / 启用 | UPDATE `status` |
| 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) | | 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) |
| 空壳删除(R16) | DELETE(仅 `key_count = 0`;名下有 key 拒绝) |
以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。 以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。
+3 -2
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 待审阅(2026-10-09 拆出自原 `payments` 单表) | | 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 | | 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 | | 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 | | 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
@@ -29,6 +29,7 @@
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) | | `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 | | `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 | | `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
| `remark` | text | 人工填写 | 备注 | | `remark` | text | 人工填写 | 备注 |
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) | | `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
@@ -60,5 +61,5 @@
## 6. 对外边界 ## 6. 对外边界
- 参数对成员**完全公开透明**(含 `remark` / 金额)——访问主体模型裁决 - 参数对成员**同账本域内可见**(含 `remark` / 金额)——同域透明、跨域隔离;未标域默认对成员隐藏,例外:该订阅 `subscription_id` 出现在本人名下 key 的 `type = 2` 日志中(事实直连)
- 无「应付」、无分摊字段 - 无「应付」、无分摊字段
+3 -2
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 待审阅(2026-10-09 拆表 + 充值接口实测;事件时刻单列) | | 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` | | 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
| 范围 | 列结构 + 语义 + 写入时机 | | 范围 | 列结构 + 语义 + 写入时机 |
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 | | 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
@@ -30,6 +30,7 @@
| `payment_provider` | text? | `payment_provider` | 如 `epay` | | `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 | | `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 | | `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
| `remark` | text | — | 人工备注 | | `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) | | `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
@@ -68,7 +69,7 @@
## 6. 对外边界 ## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线 - 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
- 纯用量模式可不登记 - 纯用量模式可不登记
## 7. 迁移说明 ## 7. 迁移说明
+6 -3
View File
@@ -16,6 +16,7 @@
|----|------|------| |----|------|------|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) | | 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) | | 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 | | 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 | | 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 | | 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
@@ -30,6 +31,7 @@
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」 1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》) 2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
3. 提交 → 服务端上游验证,四态反馈: 3. 提交 → 服务端上游验证,四态反馈:
| 结果 | 反馈 | | 结果 | 反馈 |
@@ -45,10 +47,11 @@
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器) - **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里) - **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
## 4. 归属变更 ## 4. 归属 / 账本域变更
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆) - 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义) - 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
## 5. 空态 ## 5. 空态
+2 -2
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-01) | | 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `需求清单.md` §9(访问主体模型·审计日志条目)· `members/users表字段设计.md`(写入时机中「落审计」的动作全集) | | 关联 | `需求清单.md` §9(访问主体模型·审计日志条目)· `members/users表字段设计.md`(写入时机中「落审计」的动作全集) |
| 数据 | 审计日志表(表结构归技术稿——本文锁定信息结构与展示) | | 数据 | 审计日志表(表结构归技术稿——本文锁定信息结构与展示) |
@@ -27,7 +27,7 @@
|----|------| |----|------|
| Key | 登记 / 启用 / 停用 / 归属变更 | | Key | 登记 / 启用 / 停用 / 归属变更 |
| 支付记录 | 订阅 / 充值登记、编辑、删除 | | 支付记录 | 订阅 / 充值登记、编辑、删除 |
| 成员 | 开户 / 停用 / 启用 / 令牌签发 / 令牌重发 | | 成员 | 开户 / 档案修改(名称/备注)/ 停用 / 启用 / 令牌签发 / 令牌重发 / 空壳删除 |
| 账号 | 改密 /(空库重建为系统事件,记「初始化」) | | 账号 | 改密 /(空库重建为系统事件,记「初始化」) |
- 每类管理面写操作一行留痕——操作者列当前恒为作者(单管理员),但**审计是不可变历史,操作者归属不能事后补写**:保留该列是前向兼容(未来若扩展多管理员 / 权限引擎,历史记录自然区分),也是审计语义的自描述原则(who did what when 的 who 是本体字段) - 每类管理面写操作一行留痕——操作者列当前恒为作者(单管理员),但**审计是不可变历史,操作者归属不能事后补写**:保留该列是前向兼容(未来若扩展多管理员 / 权限引擎,历史记录自然区分),也是审计语义的自描述原则(who did what when 的 who 是本体字段)
+6 -6
View File
@@ -5,7 +5,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-01) | | 状态 | 定稿(2026-10-01) |
| 关联 | `需求清单.md`(R 编号与裁决记录——本文全部设计的依据)· 七篇表字段设计(数据来源) | | 关联 | `需求清单.md`(R 编号与裁决记录——本文全部设计的依据)· 各篇表字段设计(数据来源) |
| 产出 | 访问拓扑、导航结构、跨页组件、状态模式、数值与时间呈现 | | 产出 | 访问拓扑、导航结构、跨页组件、状态模式、数值与时间呈现 |
--- ---
@@ -16,9 +16,9 @@
(拓扑见下文描述,实现时以此为准) (拓扑见下文描述,实现时以此为准)
``` ```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》) - **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》);地址栏访问 `/admin`(根路径不指向此处)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图) - **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图);**根路径 `/` 默认进入本世界**(未知路径同样回退到此)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一 - **没有第三种入口**:无开放查询页、无游客态、无分发页;根地址即成员入口。管理面的入口形态待定(当前经地址栏 `/admin` 进入)
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉 - 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
## 2. 管理面导航:侧边栏 ## 2. 管理面导航:侧边栏
@@ -28,7 +28,7 @@
| 菜单 | 页面 | 职责一句话 | | 菜单 | 页面 | 职责一句话 |
|------|------|-----------| |------|------|-----------|
| 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) | | 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) |
| 成员管理 | 成员清单 | 开户、令牌、停用 | | 成员管理 | 成员清单 | 开户、档案修改、令牌、启停、空壳删除 |
| Key 管理 | key 清单 | 登记、启停、归属 | | Key 管理 | key 清单 | 登记、启停、归属 |
| 支付记录 | 记录清单 | 订阅/充值登记、核对 | | 支付记录 | 记录清单 | 订阅/充值登记、核对 |
| 调用日志 | 请求明细 | 全池模型请求查询与筛选 | | 调用日志 | 请求明细 | 全池模型请求查询与筛选 |
@@ -45,7 +45,7 @@
### 3.1 时间范围选择器(核心组件) ### 3.1 时间范围选择器(核心组件)
- 一切统计的公共维度:**任意时段** + 快捷项 - 一切统计的公共维度:**任意时段** + 快捷项
- 快捷项:最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止 - 快捷项:**今天** / 最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字 - 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
### 3.2 数值呈现三约定 ### 3.2 数值呈现三约定
+24 -8
View File
@@ -1,11 +1,11 @@
# 交互设计 · 成员管理 # 交互设计 · 成员管理
> 成员实体的操作面:开户、令牌签发与重发(明文仅展示一次)、停用。访问主体模型中「凭证事务经作者」的落点。 > 成员实体的操作面:开户、档案修改(名称/备注)、令牌签发与重发(明文仅展示一次)、停用/启用、空壳删除。访问主体模型中「凭证事务经作者」的落点。
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-01) | | 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、令牌不过期)· R12(成员视图的锚点) | | 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、档案可改、空壳删除)· R12(成员视图的锚点)· R16 |
| 数据 | `members` 全表 · 名下 key 聚合 | | 数据 | `members` 全表 · 名下 key 聚合 |
--- ---
@@ -22,13 +22,21 @@
| 状态 | active / disabled 徽章 | | 状态 | active / disabled 徽章 |
| 令牌签发时间 | `token_issued_at`(提示「明文已不可找回,如需新令牌请重发」) | | 令牌签发时间 | `token_issued_at`(提示「明文已不可找回,如需新令牌请重发」) |
| 名下 key 数 | 聚合计数(hover 列出 key 名) | | 名下 key 数 | 聚合计数(hover 列出 key 名) |
| 操作 | 重发令牌 / 停用(或启用) | | 操作 | 编辑 / 重发令牌 / 停用(或启用)/ 删除(见 §5 条件) |
## 2. 开户 ## 2. 开户
表单:名称(必填、唯一性校验)、备注(可空)→ 提交 → **自动生成令牌** → 明文展示对话框(见 §3) 表单:名称(必填、唯一性校验)、备注(可空)→ 提交 → **自动生成令牌** → 明文展示对话框(见 §3)
## 3. 令牌签发与重发(敏感操作的标准交互) ## 3. 档案修改(名称 / 备注)(R16)
- **入口**:卡片操作「编辑」→ 小对话框(或行内编辑,实现期定)
- **可改字段**:名称(必填、唯一性校验)、备注(可空)——**仅此两项**
- 不含启停、不含令牌(各自走 §4 / §5 / 既有重发);`created_at` 等只读展示
- 名称冲突提示与开户一致(「名称已被占用」)
- 典型场景:误建/占位名 → 挂 key 前改名;已发出令牌时改名优于删除重建(重建会作废旧令牌)
## 4. 令牌签发与重发(敏感操作的标准交互)
**明文展示对话框**(开户自动触发,或重发触发): **明文展示对话框**(开户自动触发,或重发触发):
@@ -38,12 +46,20 @@
- **重发**的确认弹窗先行:「重发后旧令牌立即失效(该成员正在用的访问将中断)」→ 确认 → 新明文对话框 - **重发**的确认弹窗先行:「重发后旧令牌立即失效(该成员正在用的访问将中断)」→ 确认 → 新明文对话框
- 对话框关闭后,界面任何地方不再出现明文(库里只有哈希——members 3.1) - 对话框关闭后,界面任何地方不再出现明文(库里只有哈希——members 3.1)
## 4. 停用 / 启用 ## 5. 停用 / 启用 · 空壳删除
**停用 / 启用**:
- 停用:确认弹窗「停用后该成员的视图立即不可访问(令牌验证含状态检查);数据保留」——退拼 / 令牌疑似泄露的标准动作 - 停用:确认弹窗「停用后该成员的视图立即不可访问(令牌验证含状态检查);数据保留」——退拼 / 令牌疑似泄露的标准动作
- 启用:直接启用(下一请求即恢复) - 启用:直接启用(下一请求即恢复)
- 软删除语义:**无删除按钮**——成员与其历史用量是账本事实,只有停用(members 3.4) - 软删除语义仍是账本实体默认:名下有 key 的成员**只有停用**(members 3.4)
## 5. 空态 **空壳删除(R16)**:
- **条件**:仅 `key_count = 0` 时露出「删除」;名下有 key 则隐藏或禁用并提示「名下仍有 key,只能停用」
- 确认弹窗:「删除后不可恢复(名称与令牌一并作废);仅清理未挂 key 的空壳」→ 删除
- 典型场景:误建、从未使用的开户行——清壳,避免停用行越积越多
## 6. 空态
「还没有成员——为每位分摊朋友开户并发放访问令牌」 「还没有成员——为每位分摊朋友开户并发放访问令牌」
+10 -9
View File
@@ -1,12 +1,12 @@
# 交互设计 · 成员视图 # 交互设计 · 成员视图
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。 > 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、同账本域内可见的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-09,作者认可布局重写) | | 状态 | 定稿(2026-10-09,作者认可布局重写) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 | | 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(完全可见) | | 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(同账本域内可见) |
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) | | 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
--- ---
@@ -32,11 +32,11 @@
|------|------|-----------|-----------| |------|------|-----------|-----------|
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** | | 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | | | 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(完全公开) | | | 支付参数 | 支付参数 | 订阅 / 充值登记参数(同账本域内可见) | |
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。 顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅看期间重叠 ∪ 进行中,充值看 `paid_at`。 **时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅 = 涉及我日志的 `subscription_id` ∪ 我域内进行中;充值 = 我域内且 `paid_at` 落在窗口内。
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。 **数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
@@ -83,8 +83,9 @@
### 4.1 页头与筛选 ### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上) - `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空) - 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链) - 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
### 4.2 明细表(页面主体,表体滚动) ### 4.2 明细表(页面主体,表体滚动)
@@ -113,11 +114,11 @@
## 5. 支付参数页 ## 5. 支付参数页
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。 同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
### 5.1 页头 ### 5.1 页头
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。 `PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
### 5.2 订阅表 ### 5.2 订阅表
@@ -139,7 +140,7 @@
| 派生额度 | `amount × qpu` + $(展示) | | 派生额度 | `amount × qpu` + $(展示) |
| 备注 | `remark` | | 备注 | `remark` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明 - 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」 - **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导** - 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
+9 -7
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 待审阅(2026-10-09 随拆表重写) | | 状态 | 定稿(2026-10-12,作者认可实现;补订阅 ID / 交易号筛选) |
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 | | 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) | | 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
@@ -19,14 +19,15 @@
| **订阅** | 订阅权益表 | 登记订阅 | | **订阅** | 订阅权益表 | 登记订阅 |
| **充值** | 充值入账表 | 登记充值 | | **充值** | 充值入账表 | 登记充值 |
筛选随 Tab: 筛选随 Tab(均为前端过滤——清单全量直返):
| Tab | 筛选 | URL | | Tab | 筛选 | URL |
|-----|------|-----| |-----|------|-----|
| 订阅 | 订阅 ID / 期间与所选窗口重叠(拆表后) | `tab=subs` | | 订阅 | 订阅 ID(精确,可空)· 账本域(含「未标注」) | `tab=subs` · `subscription_id` |
| 充值 | **仅类型**(付费时间筛选已撤——`paid_at` 语义待拆表钉死) | `tab=topups` · `kind` | | 充值 | 交易号(子串,可空)· 账本域 | `tab=topups` · `trade_no` |
当前实现(拆表前):仅「类型」筛选;补录审计可看「登记」列(`created_at`)。 - 切换 Tab 保留另一 Tab 已填筛选;URL 同步便于从调用日志来源徽章深链(`subscription_id`)
- 补录审计可看「登记」列(`created_at`)
## 2. 订阅清单列 ## 2. 订阅清单列
@@ -50,13 +51,14 @@
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` | | 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
| 派生额度 | `amount × qpu` + $(展示,不入库) | | 派生额度 | `amount × qpu` + $(展示,不入库) |
| 备注 / 登记 | `remark` · `created_at` | | 备注 / 登记 | `remark` · `created_at` |
| 账本域 | `billing_domain`(可空;列表可按域筛,含「未标注」) |
| 操作 | 编辑 / 删除 | | 操作 | 编辑 / 删除 |
## 4. 登记表单(分 Tab,不再 `kind` 切换) ## 4. 登记表单(分 Tab,不再 `kind` 切换)
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表) **订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生) **充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
### 4.1 选用弹层(订阅 / 充值同规则) ### 4.1 选用弹层(订阅 / 充值同规则)
+1 -1
View File
@@ -39,7 +39,7 @@ GET 管理面入口
## 4. 成员登录壳 ## 4. 成员登录壳
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮 - **唯一入口**:令牌输入框(单字段)+ 进入按钮;**根路径 `/` 默认落到本页**(已持令牌则进成员总览);管理面入口形态(是否在界面露出)待定,当前经地址栏 `/admin` 进入
- 提交 → 服务端哈希比对 + `status` 检查: - 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》) - 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者) - 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
+21 -5
View File
@@ -4,9 +4,9 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-08) | | 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` | | 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) | | 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
--- ---
@@ -23,12 +23,26 @@
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) | | 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` | | 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` | | Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` | | 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` | | 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) | | 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 1 页 - 切换任一筛选 → 回第 1 页
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链 - URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`model_name`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
## 2.5 汇总条(筛选条与表格之间)
低高度**单行**读数条(约 32–36px),只回答「这批条件加起来多少」:
| 段 | 内容 | 口径 |
|----|------|------|
| 请求次数 | 当前筛选条数;错误数 > 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) |
| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 |
- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」)
- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零
- 成员端同构(见《交互设计-成员视图》§4)
## 3. 日志表(页面主体) ## 3. 日志表(页面主体)
@@ -45,7 +59,8 @@
| 操作 | 「详情」按钮 | 开详情弹窗 | | 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表) - 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选 - **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
- 空态:「该时段内无调用日志」 - 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5) - 窄屏:表格横向滚动(共性 §3.5)
@@ -58,6 +73,7 @@
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` | | 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) | | 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) | | 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 | | 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界 ## 5. 状态与边界
+4 -4
View File
@@ -1,10 +1,10 @@
# 同步引擎设计 # 同步引擎设计
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在七篇表设计;本文定「引擎怎么转」。 > 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在各篇表字段设计;本文定「引擎怎么转」。
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-05) | | 状态 | 已实现(定稿 2026-10-05) |
| 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` | | 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` |
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 | | 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
@@ -26,7 +26,7 @@ apps/engine ──► 定时 syncRound() ──► packages/db
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** | | 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** | | 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` | | 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) | | 运行形态 | 开发:两个命令(`dev:server` / `dev:engine`);生产两个进程(均连同一 PostgreSQL) |
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。 **为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
@@ -174,7 +174,7 @@ apps/
## 9. 验收清单(实现后) ## 9. 验收清单(实现后)
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进 - [ ] 只启 `dev:engine`(或只跑 engine + db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志 - [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
- [ ] 同一数据重复轮次不产生重复行 - [ ] 同一数据重复轮次不产生重复行
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之 - [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
@@ -0,0 +1,260 @@
# 实现方案 · 日志模型筛选与汇总条
> 调用日志页(管理端 + 成员端)的两项增强:**按模型名筛选**、**当前筛选条件下的用量汇总条**。交互方向已在讨论中议定——汇总条只做「条数 + 花费」、高度压低;本篇落 API / 数据层 / 前端改造清单与验收口径。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-10-13) |
| 关联 | 《交互设计-调用日志》·《交互设计-成员视图》§4 · 《API契约-管理面》§6 · 《API契约-成员视图》· 《API契约-公共约定》(分页、quota 双单位、聚合口径)· `usage_logs表字段设计.md` · 需求清单 R5 / R9(多维统计含模型) |
| 数据 | `usage_logs.model_name`(已入库)· 明细分页筛选与消费汇总(派生查询) |
---
## 1. 问题与目标
日志表已展示 `model_name`,筛选区却没有模型维度;总览有三维聚合,日志页却回答不了「这批条件加起来花了多少」。本方案补上:
1. **模型筛选**——筛选条第六格,与时间 / 成员 / Key / 来源 / 订阅 / 类型并列
2. **汇总条**——筛选条与表格之间一条**低高度**读数条,只展示**请求次数** + **消费用量(quota + $)**
范围仅两端调用日志页及其数据链路;**不**改总览 by_member / by_key / by_source,**不**加 by_model 分组卡(R5/R9 的「按模型」分组统计另议)。
---
## 2. 交互(已议定,实施以此为准)
### 2.1 页面骨架(两端同构)
```
PageHeader(标题 + 时间范围)
筛选条(成员/Key | 模型 | 计费来源 | 订阅 ID | 类型) ← 模型新增
汇总条(请求次数 · 消费用量) ← 新增,低高度单行
日志表(既有列不变)
分页
```
成员端无「成员」筛选,其余同构。
### 2.2 模型筛选
| 项 | 约定 |
|----|------|
| 位置 | 筛选条内,Key 与计费来源之间(谁 → 调了什么 → 记在哪) |
| 形态 | Select,`showSearch` + `allowClear`;空值 = 全部模型 |
| 选项 | 当前 `from/to` 与主体筛选(成员/Key)下、时间窗内去重的 `model_name`;**不含**模型自身与 type 筛选,避免「选了只剩自己」 |
| 变更 | 回第 1 页;URL 同步 `model`(与 `member_id` 等同级) |
| 宽度 | 与相邻筛选格一致(约 120–140px),可搜避免长模型名截断误选 |
### 2.3 汇总条
| 项 | 约定 |
|----|------|
| 位置 | 筛选条下方、表格上方——改条件 → 扫总量 → 下钻明细 |
| 高度 | **单行**,约 32–36px;浅底或细分隔,不做成大卡片 |
| 内容 | 两段:**请求次数**(当前筛选下总条数)· **消费用量**(quota + $,`QuotaText`) |
| 口径 | **请求次数**跟全部筛选(含 type);**消费用量**恒 `type = 2` + 其余筛选(既有裁决:聚合仅消费行) |
| 类型筛「错误」时 | 请求次数 = 错误条数;消费用量仍显示同期同条件的消费口径,标签保持「消费用量」不叫「花费」,避免误读为错误行开销 |
| 加载 | 查询中数字区骨架/淡化,不闪上一轮数字 |
| 空结果 | 汇总条保留,数字归零/`—`;表格空态不变 |
文案示例:
- 类型全部:`请求次数 131 条(消费 128 / 错误 3)` + `消费用量 1,250,000 quota ≈ $2.50`
- 类型=错误:`请求次数 3 条` + `消费用量 …`(消费口径不变)
- 零结果:`请求次数 0 条` + `消费用量 0 quota ≈ $0.00`
「消费 128 / 错误 3」在错误数为 0 时可只显示 `请求次数 128 条`,保持条更短。
---
## 3. API 契约变更
两端 `GET /api/admin/logs` / `GET /api/member/logs` **同名同义**扩展;DTO 继续一份复用。
### 3.1 请求
新增可选查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `model_name` | string | 精确匹配 `usage_logs.model_name`;空/缺省 = 不筛 |
其余参数不变。`from/to` 仍必填。
### 3.2 响应
在现有 `{ items, total }` 上增加 `summary` 与 `models`:
```json
{
"items": [ /* 不变 */ ],
"total": 131,
"summary": {
"hit_count": 131,
"usage_count": 128,
"error_count": 3,
"quota": 1250000,
"quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 语义 |
|------|------|
| `total` | 既有:当前筛选下命中总数(分页用) |
| `summary.hit_count` | 与 `total` 同值(汇总条「请求次数」;保留字段便于前端不依赖分页语义) |
| `summary.usage_count` | 当前筛选下 **type=2** 条数 |
| `summary.error_count` | 当前筛选下 **type≠2** 条数(`hit_count = usage_count + error_count`) |
| `summary.quota` / `quota_usd` | 当前筛选下 **type=2** 行的 quota 合计与美元(公共约定 §5:微分累加、一次取整) |
| `models` | 时间窗 + 主体筛选下去重的 `model_name` 升序列表(选项源;**不含** `model_name` / `type` 筛选) |
口径备注(写入契约裁定区):
1. `summary.usage_*` / `quota*` **不含** type 筛选与 `model_name` 筛选以外的 type 限制——即 **model_name 与主体/时间/来源/订阅参与,type 不参与用量字段**;`hit_count` / `error_count` / `usage_count` 的**条数**则跟全部筛选。
2. 更精确说:
- **条数字段**(`hit_count` / `usage_count` / `error_count`)→ 全部筛选(含 `type`、`model_name`)
- **用量字段**(`quota` / `quota_usd`)→ 同等筛选但 **固定 type=2**(即使请求 `type=error`)
3. `quota_usd` 换算沿「按日志时刻取 QPU」——汇总不得用单一当前汇率乘总 quota。
`models` 轻量查询:`DISTINCT model_name`,条件 = 时间窗 + 主体/来源/订阅(与列表一致),**刻意不套** `model_name` 与 `type`。办公室规模(模型十来个)直接全量返回,不分页。
### 3.3 契约文档落点
- 《API契约-管理面》§6:过滤表加 `model_name`;响应从 `{items,total}` 扩为含 `summary`/`models`
- 《API契约-成员视图》日志节:同名同义同步
- 《API契约-公共约定》:若需,补一句「列表响应可附带与筛选同源的汇总块」——或在各自契约内写死,避免总纲膨胀
---
## 4. 数据层(`packages/db`)
### 4.1 筛选
`repos.logs.listPage` / `listMemberPage` 参数增加 `model_name?: string`,条件 `eq(usageLogs.model_name, model_name)`(非空时)。
### 4.2 汇总
新增 `repos.logs.summarize` / `summarizeMember`(或 list 方法返回体扩展,推荐**独立方法**以免分页热路径多跑聚合):
- 条数:SQL `count()`,条件与列表筛选一致(含 type)
- 用量:`count() filter (type=2)` + `sum(quota) filter (type=2)`;`quota_usd` 需按行 QPU——**取 type=2 行的 `(quota, created_at)` 交给 `sumUsage`**(与总览聚合同一套微分规则)
- 选项:`select distinct model_name`,条件同列表但去掉 `model_name` 与 `type`
办公室查询频率低、命中行数量级可控;**不做**预计算表(需求清单「统计 = 派生视图」)。
时间范围仍走 `created_at` 索引;`model_name` 无索引,精确等值 + 时间窗过滤后残余行少,**暂不**加索引(表设计不为此改;若日后慢查询再评估)。
### 4.3 类型共享
`packages/core`:
- `logPage` schema:增加 `summary`(见下)与 `models: z.array(z.string())`
- 新导出 `LogPageSummary`(或复用/扩展 `UsageTotals`——字段更多,独立类型更清晰):
```ts
interface LogPageSummary {
hit_count: number;
usage_count: number;
error_count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
```
---
## 5. 服务端路由
| 文件 | 改动 |
|------|------|
| `apps/server/src/routes/admin-usage.ts` | `GET /logs` query 增 `model_name`;调用 list + summarize + distinct models;组 `summary`(`sumUsage` → quota_usd);响应扩字段 |
| `apps/server/src/routes/member-view.ts` | 同上(`listMemberPage` / member 侧汇总与选项) |
| `apps/server/src/api-schemas.ts` | `logPageSchema` 随 core 更新 |
OpenAPI 描述字符串同步(summary 文案提到模型筛选与汇总块)。
---
## 6. 前端(`apps/web`)
### 6.1 API 层
- `api/admin/logs.ts`:`AdminLogFilters` 加 `model_name?: string`;返回类型改为带 `summary` / `models` 的分页结构
- `api/member/me.ts`:`MemberLogQuery` 同上
### 6.2 汇总条组件
新建共享组件(两端复用,避免双份样式漂移):
- 路径建议:`apps/web/src/components/common/logs-summary-bar.vue`
- Props:`hitCount` · `usageCount` · `errorCount` · `quota` · `quotaUsd` · `loading`
- 布局:单行 flex,左「请求次数…」右「消费用量…」;`loading` 时数字占位
- 花费数字复用 `QuotaText`
- 样式对齐共性:弱标签 + 主数字,高度 32–36px,间距遵循页面既有 `gap`
### 6.3 两端 logs-view
| 项 | 改动 |
|----|------|
| 状态 | `modelFilter: ref<string \| null>` |
| params | 带上 `model_name` |
| 模型选项 | 读响应 `models`(或独立 query 缓存按 `from/to+主体` 键) |
| 模板 | 筛选条插入模型 Select;筛选条与 `a-table` 之间插入 `LogsSummaryBar` |
| URL | `syncFromRoute` / 写回增加 `model` |
| 筛选变更 | 既有「回第 1 页」逻辑覆盖 model |
列结构、详情弹窗、跳转链路**不动**。
### 6.4 样式
筛选条若因第六格换行,允许两行(共性已允许一行或两行);汇总条始终单行不换行,过窄时隐藏次要括注(如「/ 错误 3」)。
---
## 7. 设计文档同步
实现时同步修订(不另开版本叙事):
| 文档 | 修订点 |
|------|--------|
| 《交互设计-调用日志》 | §2 筛选表加「模型」;§3 前插入汇总条小节(位置、两段、口径);URL 同步参数补 `model` |
| 《交互设计-成员视图》§4 | 同构引用或摘要 + 成员端差异 |
| 《API契约-管理面》§6 | 过滤 + 响应扩展 + 口径裁定 |
| 《API契约-成员视图》 | 同名同义 |
| README 文档地图 | 本篇条目 |
状态流转:本篇「待审阅」→ 实现完成后「已实现(日期)」。
---
## 8. 实现顺序
1. ✅ `packages/core`:`LogPageSummary` + `LogPage` + `logPage` schema 扩展
2. ✅ `packages/db`:`buildLogConds`、`model_name` 筛选、`summarize` / `summarizeMember`、`listModels` / `listMemberModels`
3. ✅ `apps/server`:两端 `/logs` 接线(summary + models)
4. ✅ `apps/web`:API 类型 → `logs-summary-bar` → admin / member logs-view
5. ✅ 设计文档修订 + README 文档地图
6. ✅ `bun run typecheck` + `bun run build:web` 通过
---
## 9. 验收口径
- [x] 管理端 / 成员端均可按模型筛选,选项来自时间窗内真实数据,可搜可清空
- [x] 筛选变更回第 1 页;URL 带 `model_name`,深链可还原
- [x] 汇总条位于筛选条与表格之间,**单行低高度**,仅「条数 + 消费用量」
- [x] 消费用量恒 type=2 + 其余筛选;类型筛错误时命中=错误数、用量仍为消费口径
- [x] `quota_usd` 按行 QPU 微分汇总(`sumUsage`)
- [x] 切换筛选时汇总加载态不闪旧数字(`is-loading` 淡化 + 占位)
- [x] typecheck / build 通过;无新依赖
---
## 10. 明确不做
- 总览页 by_model 分组卡(R5/R9 多维统计的另一落点,单议)
- 模型多选、模糊匹配(先精确单选;不够再扩)
- `model_name` 索引 / 预计算汇总表
- Tokens 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+14 -18
View File
@@ -22,7 +22,6 @@
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) | | 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
| 加密 | WebCrypto(Bun 原生) | | 加密 | WebCrypto(Bun 原生) |
| 前端 | **Vue 3 + antdv-next + Vite** | | 前端 | **Vue 3 + antdv-next + Vite** |
| 部署 | **Docker**(倾向,§6) |
## 2. 语言:TypeScript 全栈 ## 2. 语言:TypeScript 全栈
@@ -39,49 +38,47 @@
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 | | 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres | | pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — | | 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§7.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) | | 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§6.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
| 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) | | 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) |
| 构建 / 测试 | **Vite**(web)+ **bun test**(core/db/server) | Bun 自带 test runner;Vite 为 Vue 生态默认 | 前端组件测试可加 vitest(实现期定) | | 构建 / 测试 | **Vite**(web);质量门 = `typecheck` + `build` + 人工验收 | 前端构建走 Vite 生态默认;自动化测试不在当前范围 | 需要时再引入测试框架 |
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) | | API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
## 4. 分层落地:monorepo(Bun workspaces) ## 4. 分层落地:monorepo(Bun workspaces)
```text ```text
(仓库根 · v1 分支) (仓库根)
├─ apps/ ├─ apps/
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 进程内调度 · 环境变量与密钥注入 │ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 环境变量与密钥注入
│ ├─ engine/ # 同步引擎:独立进程(《同步引擎设计》)
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite │ └─ web/ # 前端:Vue 3 + antdv-next + Vite
├─ packages/ ├─ packages/
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型 │ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储实现 │ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI
├─ scripts/ # 打包/构建 CLI(pack release / build)
``` ```
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。 **依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
**代码落点**:实现代码与设计文档同在 v1 分支演进。 **代码落点**:实现代码与设计文档同仓演进。
## 5. R14 预留:现阶段零成本,只为后续抽取不挖坑 ## 5. R14 预留:现阶段零成本,只为后续抽取不挖坑
R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留: R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留:
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/server` 与 `packages/db`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运; 1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/*` 装配层、`packages/db` 与 `scripts/`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。 2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。 Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
## 6. 部署通道(倾向,细则归后续部署设计) ## 6. 风险与回退
母体以 **Docker 镜像**交付(server 容器 + PostgreSQL,compose 编排):满足仓库「一条命令链可部署」约定;运营方服务器零环境依赖(Bun 运行时随镜像内置),升级 = 换镜像重启;作者不接触服务器,镜像化把运行时选择与运营方环境解耦。
## 7. 风险与回退
| 风险 | 评估 | 对策 | | 风险 | 评估 | 对策 |
|------|------|------| |------|------|------|
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§7.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) | | antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§6.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
| Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 | | Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 |
| Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 | | Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
### 7.1 antdv-next 核验记录(2026-10-02,一手源直查) ### 6.1 antdv-next 核验记录(2026-10-02,一手源直查)
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。 口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
@@ -96,7 +93,6 @@ Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。 结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
## 8. 待裁决项 ## 7. 待裁决项
1. Docker 细则:基础镜像、compose 形态、密钥注入方式——部署设计时定; 1. 前端测试设施是否引入 vitest。
2. 前端测试设施是否引入 vitest。
+330
View File
@@ -0,0 +1,330 @@
# 部署方案
> 母体(完整前后端 + PostgreSQL)的交付与更新方案:产物包形态、服务器目录约定、容器编排、迁移时机、安全边界与固定操作流程。**范围**:只定「东西怎么装到运营方服务器、怎么更新」;不改业务语义,不覆盖 R13 的 CFW 抽取版部署面。设计对齐作者现场运维心智——**本地产物 → 传到服务器约定目录 → 一条固定命令 → 看容器状态**。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(定稿 2026-10-12) |
| 关联 | `技术选型.md`(Bun / Hono / Drizzle / Vue)· `同步引擎设计.md`(独立 `apps/engine` 进程)· `需求清单.md` R13/R14 · `users表字段设计.md`(部署期初始化)· `README.md` |
| 产出 | 产物包布局 · 服务器目录 · Dockerfile / Compose / Caddy 要点 · 打包与更新流程 · 迁移与安全约定 |
---
## 1. 设计约束(来自现场经验与项目事实)
1. **操作者心智**(作者工作流,优先对齐):
- 前端:本地打包 `dist/` → 放到服务器指定目录 → 执行固定命令 → `docker ps` 看容器全绿;
- 后端:拿到 `jar` 类产物 → 上传指定目录 → 同一固定命令 → 看容器。
2. **仓库事实**:`apps/server` 与 `apps/engine` 两个独立进程;前端为静态 `dist`(server 不托管静态);数据在 PostgreSQL;配置全走 env;`packages/db` 已有按 journal 的幂等 `runMigrations`。
3. **能力边界**:作者是前端,Docker 不娴熟;服务器侧只需 Docker,不要求 Bun / Node / 源码构建能力。
4. **协作约定**:交付物保证「一条命令链」可部署(AGENTS);密钥 `SK_KEY_ENCRYPTION_SECRET` 丢失 = 已登记 key 全部无法解密。
由此收敛出的形态一句话:**常驻 Docker 编排 + 每次更新只替换产物包 + 一条 compose 命令完成重建/重启/迁表。**
---
## 2. 总体结构
```text
本地(打包) 服务器(运营方)
────────── ────────────────
bun run pack release --yes /opt/naku/ ← 约定目录
├─ 前端 dist/ ├─ docker-compose.yml
├─ naku-server(linux 单文件) ├─ Dockerfile
├─ naku-engine(linux 单文件) ├─ .dockerignore
├─ naku-migrate(linux 单文件) ├─ Caddyfile
└─ drizzle/(迁移 SQL + journal) ├─ entrypoint-pg.sh
│ ├─ entrypoint-app.sh
▼ ├─ .env ← 密钥在此,不进包
release/ ──── 上传覆盖 ──────────► └─ release/ ← 每次更新整目录覆盖
固定命令: docker compose up -d --build
验收: docker compose ps
```
| 角色 | 职责 | 谁改 |
|------|------|------|
| 常驻编排(compose / Dockerfile / Caddyfile / entrypoint-*.sh / .env) | 描述系统怎么跑 | 装机时放好;改架构时才动 |
| 产物包 `release/` | 当前要跑的版本 | 每次发版整目录替换 |
首次安装与日常更新**同一套命令**;差别只是首次多一步「把常驻编排放到服务器」。
---
## 3. 产物包 `release/`
本地 `bun run pack release --yes` 产出,内容固定为:
```text
release/
dist/ # apps/web 构建产物(index.html · assets/…)
naku-server # Bun --compile,目标平台 linux-x64
naku-engine # 同上
naku-migrate # 同上;只跑迁移后退出
drizzle/ # packages/db/drizzle/ 的 SQL + meta/_journal.json
```
### 3.1 打包约定
| 项 | 约定 |
|----|------|
| 命令入口 | 优先 `bun run pack release`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack release --yes` |
| 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑) |
| 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) |
| 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz |
| 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 |
| 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 |
| 本地试跑 | `pack build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 |
### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」
- 对齐 `jar` 心智:产物即交付物,服务器零构建依赖;
- 更新体积小、失败面小(不跑 `bun install` / `vite build`);
- 运行时镜像只需 Debian slim + `ca-certificates` + `libstdc++6`,不需要 Bun。
代价:目标平台写在打包脚本里;换 CPU 架构需重新打包(在脚本参数上留出口即可)。
---
## 4. 服务器目录(`/opt/naku/`)
路径可改,下文以 `/opt/naku` 指代「约定目录」。
```text
/opt/naku/
docker-compose.yml
Dockerfile
.dockerignore
Caddyfile
entrypoint-pg.sh
entrypoint-app.sh
.env
release/ ← 上传覆盖,不入库
```
---
## 5. 配置文件设计
### 5.1 `Dockerfile`(两阶段,只吃 `release/`)
```dockerfile
# ---------- 应用运行时 ----------
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
FROM debian:bookworm-slim AS app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
COPY release/drizzle /app/drizzle
ENV MIGRATIONS_FOLDER=/app/drizzle
CMD ["/app/naku-server"]
# ---------- 网关(Caddy + 前端静态) ----------
FROM caddy:2-alpine AS proxy
COPY Caddyfile /etc/caddy/Caddyfile
COPY release/dist /srv
```
要点:
- **构建期不编译业务代码**,只装配产物;`docker compose up -d --build` 的语义是「用当前 `release/` 重建镜像并重启」;
- 单文件二进制用 **`COPY --chmod=755`**:Windows 打包 / 跨机传输常丢可执行位,缺 `x` 时容器内 `exec` 以 **exit 126**(Permission denied)退出,迁移与应用都不会启动;
- `app` 阶段一个镜像供 `server` / `engine` / `migrate` 三用,靠 compose 的 `command` 区分;
- 应用运行时选 **`debian:bookworm-slim`**(glibc,对齐 Bun `--compile` 目标);`ca-certificates`(HTTPS 证书校验)与 `libstdc++6`(单文件 C++ 运行时)显式安装——两个都不指望镜像自带;
- 网关仍用官方 `caddy:2-alpine`(与应用基底无关,体积友好);
- PostgreSQL 使用官方镜像,不进本 Dockerfile。
### 5.2 `docker-compose.yml`(五服务)
| 服务 | 来源 | command | 说明 |
|------|------|---------|------|
| `postgres` | `postgres:17-alpine` | 官方入口 | 卷 `pgdata`;`healthcheck` 用 `pg_isready` |
| `migrate` | `target: app` | `/app/naku-migrate` | **一次性**:`restart: "no"`;迁完退出 |
| `server` | `target: app` | `/app/naku-server` | 管理面 / 成员视图 API |
| `engine` | `target: app` | `/app/naku-engine` | 轮询同步 |
| `caddy` | `target: proxy` | 官方入口 | **唯一对外**:`80` / `443` |
依赖关系:
```text
postgres (healthy) ──► migrate (completed successfully) ──► server ──► caddy
└──────► engine
```
要点:
1. **`migrate` 独立服务**,而不是塞进 server 启动:语义是「部署时迁表」,避免 server/engine 双进程抢迁;`depends_on: service_completed_successfully` 保证先迁后跑。
2. **环境变量**:`DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` · `COOKIE_SECURE` · `LOG_LEVEL` · `SYNC_INTERVAL_MS` · 可选 `ADMIN_INIT_*`——均从 `.env` 注入 `server` / `engine`(`migrate` 只需要 `DATABASE_URL`)。
3. **`DATABASE_URL` 单源**:用户 / 密码 / 库名都写在这一串里;`entrypoint-pg.sh` 解析出 `POSTGRES_USER/PASSWORD/DB` 喂给官方库镜像,`entrypoint-app.sh` 把主机改写为 compose 服务名 `postgres`。开发与部署共用同一份 `.env` 语义。
4. 端口:仅 `caddy` 映射到宿主机;默认 `80`/`443`,可用 `.env` 的 `HTTP_PORT` / `HTTPS_PORT` 覆盖(容器内仍为 80/443)。`server` / `engine` / `postgres` 均不对外。
5. **容器名固定**(`container_name`):`naku-postgres` / `naku-migrate` / `naku-server` / `naku-engine` / `naku-caddy`——单副本约定下使用;服务间 DNS 仍用服务名(如 `server:18456`)。
### 5.3 `Caddyfile`
```caddyfile
# 站点地址按部署环境改:
# 本地/内网 HTTP → :80
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
:80 {
root * /srv
handle /api/* {
reverse_proxy server:18456
}
handle {
try_files {path} /index.html
file_server
}
}
```
与 `API契约-公共约定` 的命名空间约定一致:`/api/*` 归接口,其余归 SPA(非 `/api` 回退 `index.html`)。
### 5.4 `entrypoint-pg.sh` / `entrypoint-app.sh`
| 脚本 | 职责 |
|------|------|
| `entrypoint-pg.sh` | 从 `DATABASE_URL` 拆出 `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`,`exec` 官方 `docker-entrypoint.sh postgres` |
| `entrypoint-app.sh` | 将 `DATABASE_URL` 的主机段改写为 `postgres`(保留端口与路径),再 `exec "$@"` 启动传入的二进制 |
二者属**常驻编排**,打进镜像或只读挂载皆可(建议只读挂载,改脚本不用重建镜像)。
### 5.5 `packages/db/src/migrate-cli.ts`(新增入口)
```text
loadEnv → createDb(DATABASE_URL) → runMigrations(db, MIGRATIONS_FOLDER) → 退出码
```
- 复用已有 `runMigrations`(Drizzle migrator,**按 journal 幂等**:已应用的迁移号跳过);
- 失败打日志并以非 0 退出,使 `migrate` 服务失败 → `server`/`engine` 不会启动(`service_completed_successfully` 门闩)。
---
## 6. 操作流程
### 6.1 首次安装
```bash
# 本地
bun run pack release --yes
# 服务器
# 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/
# 将 release/ 上传到 /opt/naku/release/
cp .env.example /opt/naku/.env # 再编辑填写
chmod 600 /opt/naku/.env
cd /opt/naku
docker compose up -d --build
docker compose ps
```
### 6.2 日常更新(与首次同一命令)
```bash
# 本地
bun run pack release --yes
# 上传,整目录覆盖 /opt/naku/release/
# 服务器(固定命令)
cd /opt/naku
docker compose up -d --build
docker compose ps
```
- 有表变更时,新 `release/drizzle/` 自动带新 SQL,`migrate` 启动时补跑——无需手工;
- 观察构建/迁移日志:`docker compose logs migrate`;同步:`docker compose logs -f engine`。
### 6.3 HTTPS(可选)
1. `Caddyfile` 站点行 `:80` → 域名;
2. `.env` 中 `COOKIE_SECURE=true`;
3. 重新执行固定命令。
### 6.4 验收标准
- `docker compose ps`:`postgres` healthy,`server` / `engine` / `caddy` running,`migrate` exited(0);
- 浏览器打开站点可见页面;`/api/docs` 可访问;
- `docker compose logs engine` 出现就绪/轮次日志(已配置 key 时)。
---
## 7. 迁移策略(裁决)
| 问题 | 裁决 |
|------|------|
| 会不会「多灌几次」? | **不会。** 使用 Drizzle `migrate()`(journal 表),重复执行只应用未跑过的迁移号 |
| 谁来跑? | compose 一次性服务 `migrate`(`naku-migrate` 单文件),不随 server/engine 长驻 |
| 何时跑? | 每次固定命令(含首次)启动/更新时 |
| 不用什么? | **不用 `db:push` 进部署链**——它是 schema diff 硬推,不适合生产更新命令 |
| 失败怎么办? | `migrate` 非 0 退出 → `server`/`engine` 不启动;修复 SQL 或数据后重跑固定命令 |
| `migrate` exit 126? | 二进制缺可执行位(Windows 打包 / 传输丢 `x`)。镜像内已 `COPY --chmod=755` 根治;宿主机侧应急 `chmod +x release/naku-*` 后 rebuild |
---
## 8. 安全约定
| 点 | 约定 |
|----|------|
| `.env` 位置 | 只在服务器约定目录;**不进产物包、不进 git** |
| 权限 | `chmod 600 .env`,仅部署用户可读 |
| `SK_KEY_ENCRYPTION_SECRET` | 生成一次、入库后不可更换;**与数据库同等备份**(`.env` 必须备份) |
| 对外暴露 | 仅 Caddy 的 80/443;库与应用进程不映射公网端口 |
| `COOKIE_SECURE` | 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」;HTTPS/域名用 `true` |
| 日志 | 不打印密钥与完整 `DATABASE_URL`(含密码) |
| 范围说明 | 单运营方内部服务器场景;不上 vault/KMS。若未来有多租户或公有多环境,再升档 |
---
## 9. 与开发方式的关系
| | 开发 | 部署 |
|--|------|------|
| 进程 | `bun run dev:server` / `dev:engine` / `dev:web` | compose:`server` / `engine` / `caddy` |
| 库 | 任意 PostgreSQL,`DATABASE_URL` 指向即可 | compose 内 `postgres`,同一 `DATABASE_URL` 语义(主机由 entrypoint 改写) |
| 建表 | `bun run db:push`(开发期) | `migrate` 服务(生产幂等迁移) |
| 前端 | Vite dev server | `release/dist` 由 Caddy 托管 |
| 配置 | `.env` | 服务器 `.env` |
开发**不依赖**本方案的 Docker 配置;部署也**不依赖**本机 Bun。
---
## 10. 文件改动清单
| 文件 | 动作 |
|------|------|
| `scripts/cli.ts` · `scripts/pack-core.ts` | 打包/构建 CLI 与核心逻辑 |
| `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 |
| `package.json` | `pack` · `build:server` · `build:engine` |
| `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) |
| `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 |
| `README.md` | 「部署」章与文档地图 |
| `AGENTS.md` | 命令表(pack / build) |
| `.env.example` | 部署语义注释 |
---
## 11. 已知取舍(否决时改这里)
1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。
2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。
3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。
4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack release --yes`。
---
## 12. 验收清单(实现后)
- [ ] `bun run pack release --yes` 在 Windows 产出完整 `release/`(五条目齐全)
- [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4
- [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建)
- [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证)
- [ ] 故意放一份坏 SQL:`migrate` 失败,`server`/`engine` 未启动
- [ ] `COOKIE_SECURE` 与 HTTP/HTTPS 交叉场景符合 §8
- [ ] 产物包内无 `.env`、无源码、无 `node_modules`
+26 -12
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-01) | | 状态 | 定稿(2026-10-01;R16 定稿 2026-10-10) |
| 关联 | `README.md`(项目沿革与动因)· 七篇表字段设计(定稿)· `docs/references/NewAPI-接口范围.md`(可用接口参考) | | 关联 | `README.md`(背景、动因与架构约束)· 各篇表字段设计 · `docs/references/NewAPI-接口范围.md`(可用接口参考) |
| 取代 | 《设计议题》(2026-09-29 由本清单取代;其中双形态裁决与 CFW 调研结论已并入本文 §5/§7) | | 取代 | 《设计议题》(2026-09-29 由本清单取代;其中双形态裁决与 CFW 调研结论已并入本文 §5/§7) |
--- ---
@@ -19,7 +19,7 @@
**背景**(三类问题促成当前设计阶段): **背景**(三类问题促成当前设计阶段):
1. **迭代部署成本**:服务器部署形态下每次迭代都需实例运营方手工升级,测试阶段迭代频繁而运营方无暇跟随 → 双形态交付(R13,已裁决) 1. **迭代部署成本**:服务器部署形态下每次迭代都需实例运营方手工升级,测试阶段迭代频繁而运营方无暇跟随 → 双形态交付(R13,已裁决)
2. **初版设计刻意收缩的部分**:管理面固定口令不妥(R11)、充值折算(R7)、多订阅处理(R8) 2. **刻意收缩后重新纳入的部分**:管理面固定口令不妥(R11)、充值折算(R7)、多订阅处理(R8)
3. **已知设计问题**:错误行 `request_id` 去重(R15 必修项) 3. **已知设计问题**:错误行 `request_id` 去重(R15 必修项)
## 1. 数据采集域 ## 1. 数据采集域
@@ -56,20 +56,21 @@
| # | 需求 | 说明 | 状态 | | # | 需求 | 说明 | 状态 |
|---|------|------|------| |---|------|------|------|
| R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) | | R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) |
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);**完全公开透明**(订阅与支付参数对成员可见);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29) | | R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);支付参数**同账本域内可见**(同域透明、跨域隔离;未标域默认藏,详见各表 `billing_domain` 与《API契约-成员视图》§5);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29;2026-10-13 补账本域可见性) |
| R16 | 成员档案维护与空壳删除 | **修改**:开户后可改 `name` / `remark`(误建、挂 key 前改名等真实场景);`status` 走启停、令牌走重发,不并入档案编辑。**删除**:仅当**当前无任何 key 归属**(`key_count = 0`)才允许硬删——清理空壳,不触碰账本事实;名下有 key 只能停用(软删除语义不变) | **已裁决**(2026-10-10) |
## 5. 交付域(已裁决) ## 5. 交付域(已裁决)
| # | 需求 | 说明 | 状态 | | # | 需求 | 说明 | 状态 |
|---|------|------|------| |---|------|------|------|
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(后端单进程承载 API 路由与同步引擎),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29) | | R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(`apps/server` 承载 API 路由,`apps/engine` 独立进程同步——见《同步引擎设计》2026-10-05),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29;进程拆分修订 2026-10-05) |
| R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) | | R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) |
## 6. 数据模型 ## 6. 数据模型
| # | 需求 | 说明 | 状态 | | # | 需求 | 说明 | 状态 |
|---|------|------|------| |---|------|------|------|
| R15 | 数据模型修订 | 七篇表设计经作者逐篇审阅**定稿**(2026-10-01);其后 `payments` 按 2026-10-09 裁决拆为 `subscriptions` + `topups`(见裁决记录)——基线域现为 `api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + 访问主体三表 | **已裁决**(2026-10-01;2026-10-09 拆表修订) | | R15 | 数据模型修订 | 表字段设计经作者逐篇审阅**定稿**(2026-10-01);其后 `payments` 按 2026-10-09 裁决拆为 `subscriptions` + `topups`(见裁决记录)——基线域现为 `api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + 访问主体三表 | **已裁决**(2026-10-01;2026-10-09 拆表修订) |
## 7. R13 的支撑材料:CFW 可行性调研(2026-09-29,developers.cloudflare.com 官方文档核实,五项全部无阻断) ## 7. R13 的支撑材料:CFW 可行性调研(2026-09-29,developers.cloudflare.com 官方文档核实,五项全部无阻断)
@@ -90,7 +91,9 @@
## 8. 推进方式 ## 8. 推进方式
**需求阶段收官(2026-10-01)**:15 项全部有归宿——R1 / R2 / R5 / R6 / R7 / R8 / R9 / R11 / R12 / R13 / R14 / R15 已裁决关闭,R10 裁撤,R3 / R4 延后(提案存档见 §9)。七篇表设计、八篇交互设计均经作者逐篇审阅**定稿**。**剩余专门设计文档:API 契约 → 同步引擎设计**(双形态架构下,逐篇立稿、逐篇审阅)。 **需求阶段收官(2026-10-01)**:15 项全部有归宿——R1 / R2 / R5 / R6 / R7 / R8 / R9 / R11 / R12 / R13 / R14 / R15 已裁决关闭,R10 裁撤,R3 / R4 延后(提案存档见 §9)。表字段设计与交互设计均经作者逐篇审阅**定稿**;其后 API 契约与同步引擎设计亦已立稿。
**增补(2026-10-10)**:R16(成员档案维护与空壳删除)已裁决——成员操作面由「开户 / 启停 / 重发」扩为含档案修改与条件硬删;随 members / API / 交互三线落地。
## 9. 裁决记录 ## 9. 裁决记录
@@ -134,15 +137,15 @@
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15) - **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合 - **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤) - **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、**完全公开透明**(订阅与支付参数对成员可见)、无「应付」概念 - **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、支付参数**同账本域内可见**、无「应付」概念
### 2026-10-01 · R15 审阅定稿 ### 2026-10-01 · R15 审阅定稿
七篇表设计经作者逐篇审阅,全部**定稿**。审阅期实质修订:`key_id` → `api_key_id`、`note` → `remark`、**401 自动摘除状态机裁撤**(api_keys 3.6——引擎记录不决策,`status` 纯手动开关)、**管理员初始化与重置改为空库双通道**(users 3.1——env 静默重建 / 引导页兜底,env 凭证降级为可弃);另有多处语义辨析随文入档(错误行去重指纹、量与换算率、sessions 与 members 零关系、QPU 存在必要性裁决保留)。 表字段设计经作者逐篇审阅,全部**定稿**。审阅期实质修订:`key_id` → `api_key_id`、`note` → `remark`、**401 自动摘除状态机裁撤**(api_keys 3.6——引擎记录不决策,`status` 纯手动开关)、**管理员初始化与重置改为空库双通道**(users 3.1——env 静默重建 / 引导页兜底,env 凭证降级为可弃);另有多处语义辨析随文入档(错误行去重指纹、量与换算率、sessions 与 members 零关系、QPU 存在必要性裁决保留)。
### 2026-10-01 · 交互设计八篇定稿 ### 2026-10-01 · 交互设计定稿
八篇交互设计(共性 / 登录 / 总览 / key / 支付 / 成员 / 审计 / 成员视图)经作者逐篇审阅,全部**定稿**。审阅期修订: 交互设计八篇(共性 / 登录 / 总览 / key / 支付 / 成员 / 审计 / 成员视图)经作者逐篇审阅,全部**定稿**。审阅期修订:
- **纯用量模式**(共性 §3.8,部门实例场景触发):无支付记录 = 完整可用子集——参数卡中性空态、展开行降级、核对提示全空静默、订阅期间快捷项隐藏 - **纯用量模式**(共性 §3.8,部门实例场景触发):无支付记录 = 完整可用子集——参数卡中性空态、展开行降级、核对提示全空静默、订阅期间快捷项隐藏
- **响应式范围收缩**(作者裁决):只做布局响应,不做组件响应与移动端 UI 重设计 - **响应式范围收缩**(作者裁决):只做布局响应,不做组件响应与移动端 UI 重设计
@@ -157,13 +160,24 @@
- **未关联分组 = 查询层产物**(LEFT JOIN 未匹配集,无新字段),正常时恒为零,非零即异常信号;两种未关联(日志缺 ID / 有 ID 无登记)的展示区分留设计期 - **未关联分组 = 查询层产物**(LEFT JOIN 未匹配集,无新字段),正常时恒为零,非零即异常信号;两种未关联(日志缺 ID / 有 ID 无登记)的展示区分留设计期
- **统计 = 派生视图**:数据库只存事实行,一切分组/分解/汇总为查询时聚合,无预计算存储 - **统计 = 派生视图**:数据库只存事实行,一切分组/分解/汇总为查询时聚合,无预计算存储
### 2026-10-09 · 支付建模拆表(方案 B) ### 2026-10-09 · 支付建模拆表
- **裁决:`payments` 单表拆为 `subscriptions`(订阅权益)+ `topups`(充值入账事件)**——`paid_at` 在订阅行上语义污染(更像登记时刻),后补录无法按付款时间对齐用量窗口;对齐 NewAPI「权益 vs 入账」两分(评估见 `payments建模评估.md`) - **裁决:`payments` 单表拆为 `subscriptions`(订阅权益)+ `topups`(充值入账事件)**——`paid_at` 在订阅行上语义污染(更像登记时刻),后补录无法按付款时间对齐用量窗口;对齐 NewAPI「权益 vs 入账」两分(评估见 `payments建模评估.md`)
- **三时刻钉死**:`created_at`(登记)· `paid_at`(钱出手,订阅可空)· `period_*`(用量覆盖期);筛选:订阅看期间重叠、充值看 `paid_at`、补录看 `created_at` - **三时刻钉死**:`created_at`(登记)· `paid_at`(钱出手,订阅可空)· `period_*`(用量覆盖期);筛选:订阅看期间重叠、充值看 `paid_at`、补录看 `created_at`
- **展示仍称「支付记录」**:管理面一菜单两 Tab;成员「支付参数」两组清单;R7/R8/R9 哲学不变(只存事实、`subscription_id` 直连、币种无关) - **展示仍称「支付记录」**:管理面一菜单两 Tab;成员「支付参数」两组清单;R7/R8/R9 哲学不变(只存事实、`subscription_id` 直连、币种无关)
- **原 `payments表字段设计.md` 删除**,由两篇表设计替代;契约与交互随拆表修订 - **原 `payments表字段设计.md` 删除**,由两篇表设计替代;契约与交互随拆表修订
### 2026-10-10 · 成员档案维护与空壳删除(R16)
**动机**:开户后挂 key 前发现名称不对(误建、占位名)——改比重发/重建好(令牌已发出时重建会作废旧令牌);另有从未使用的开户空壳需要清理,停用行会越积越多。
**裁决**:
- **修改成员档案**:允许改 `name` / `remark` 两字段。`status` 与令牌不并入档案编辑(仍走启停 / 重发,生命周期语义不变);`id` / `created_at` / `token_hash` / `token_issued_at` 不可直接改。`name` 仍 UNIQUE(账目署名不可歧义,members 3.3);冲突 `409 member_name_taken`。**展示口径 = 当前名**(日志 / 总览 / 成员视图联查实时取 `members.name`,与 key 的 `token_name` 同则——历史账目署名随改名漂移可接受)
- **删除成员(条件硬删)**:**仅当 `key_count = 0`(当前无任何 `api_keys.member_id` 指向)** 才允许物理删除。理由:用量事实经 key 归属联查,空壳行不承载任何账本事实;硬删释放 UNIQUE 名称,便于「删了重建」。名下有 key → 拒绝删除(`409`),只允许停用——**软删除仍是账本实体的默认语义**,硬删只是空壳例外
- **与支付删除的分界**:支付记录是人工抄录、改错即修(全 CRUD);成员是账本锚点,默认软删除——空壳硬删不突破该哲学(无账本可保)
- **审计**:`member.update`(改名/改备注,按实际变更字段落痕)· `member.delete`(摘要含删除时名称);均属管理面写操作
### 2026-09-29 · 数据资产域延后(R3 / R4) ### 2026-09-29 · 数据资产域延后(R3 / R4)
**裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点: **裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点:
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# 把 DATABASE_URL 的主机改写为 compose 服务名 postgres,再启动应用
set -e
if [ -z "$DATABASE_URL" ]; then
echo "DATABASE_URL is required" >&2
exit 1
fi
export DATABASE_URL="$(echo "$DATABASE_URL" | sed -E 's#(@)[^:/]+(:[0-9]+)?(/)#\1postgres\2\3#')"
exec "$@"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# 从 DATABASE_URL 拆出官方镜像建库所需的 POSTGRES_*,再交回原入口
set -e
if [ -z "$DATABASE_URL" ]; then
echo "DATABASE_URL is required" >&2
exit 1
fi
u="${DATABASE_URL#*://}"
auth="${u%%@*}"
rest="${u#*@}"
export POSTGRES_USER="${auth%%:*}"
export POSTGRES_PASSWORD="${auth#*:}"
export POSTGRES_DB="${rest#*/}"
export POSTGRES_DB="${POSTGRES_DB%%\?*}"
exec docker-entrypoint.sh postgres
+13 -4
View File
@@ -1,10 +1,13 @@
{ {
"name": "new-api-key-usage", "name": "new-api-key-usage",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"workspaces": ["apps/*", "packages/*"], "workspaces": [
"apps/*",
"packages/*"
],
"scripts": { "scripts": {
"test": "bun test",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts", "dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
"dev:engine": "bun --env-file=.env apps/engine/src/index.ts", "dev:engine": "bun --env-file=.env apps/engine/src/index.ts",
@@ -12,10 +15,16 @@
"db:generate": "bun run --cwd packages/db db:generate", "db:generate": "bun run --cwd packages/db db:generate",
"db:push": "bun run --cwd packages/db db:push", "db:push": "bun run --cwd packages/db db:push",
"db:studio": "bun run --cwd packages/db db:studio", "db:studio": "bun run --cwd packages/db db:studio",
"build:web": "bun run --cwd apps/web build" "build:web": "bun run --cwd apps/web build",
"build:server": "bun build --compile --outfile=bin/naku-server apps/server/src/index.ts",
"build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
"pack": "bun run scripts/cli.ts"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2", "@types/bun": "^1.4.2",
"citty": "^0.2.2",
"typescript": "^7.0.2" "typescript": "^7.0.2"
} },
"packageManager": "bun@1.4.2"
} }
+4
View File
@@ -36,6 +36,10 @@ export const ERR = {
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }), errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () => member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"), errorBody("member_name_taken", "成员名称已被占用"),
member_has_keys: (key_count: number) =>
errorBody("member_has_keys", `名下仍有 ${key_count} 枚 key,只能停用`, {
fields: { key_count: String(key_count) },
}),
upstream_unreachable: () => upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"), errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"), not_found: () => errorBody("not_found", "请求的资源不存在"),
+20 -1
View File
@@ -68,9 +68,19 @@ export const logItem = z.object({
raw: z.record(z.string(), z.unknown()).optional(), raw: z.record(z.string(), z.unknown()).optional(),
}); });
export const logPageSummary = z.object({
hit_count: z.number().int(),
usage_count: z.number().int(),
error_count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
export const logPage = z.object({ export const logPage = z.object({
items: z.array(logItem), items: z.array(logItem),
total: z.number().int(), total: z.number().int(),
summary: logPageSummary,
models: z.array(z.string()),
}); });
export const keyItem = z.object({ export const keyItem = z.object({
@@ -79,6 +89,7 @@ export const keyItem = z.object({
status: z.enum(["active", "disabled"]), status: z.enum(["active", "disabled"]),
member_id: z.number().int().nullable(), member_id: z.number().int().nullable(),
member_name: z.string().nullable(), member_name: z.string().nullable(),
billing_domain: z.string().nullable(),
registered_at: z.number().int(), registered_at: z.number().int(),
log_synced_at: z.number().int().nullable(), log_synced_at: z.number().int().nullable(),
usage_synced_at: z.number().int().nullable(), usage_synced_at: z.number().int().nullable(),
@@ -92,11 +103,13 @@ export const keyItem = z.object({
export const registerKeyBody = z.object({ export const registerKeyBody = z.object({
sk_key: z.string().min(8), sk_key: z.string().min(8),
member_id: z.number().int().positive().nullable().optional(), member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
}); });
export const patchKeyBody = z.object({ export const patchKeyBody = z.object({
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
member_id: z.number().int().positive().nullable().optional(), member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
}); });
export const memberItem = z.object({ export const memberItem = z.object({
@@ -115,7 +128,9 @@ export const createMemberBody = z.object({
}); });
export const patchMemberBody = z.object({ export const patchMemberBody = z.object({
status: z.enum(["active", "disabled"]), name: z.string().min(1).max(64).optional(),
remark: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
}); });
export const memberWithToken = z.object({ export const memberWithToken = z.object({
@@ -139,6 +154,7 @@ export const subscriptionItem = z.object({
quota_usd: z.string(), quota_usd: z.string(),
paid_amount: z.number().nullable(), paid_amount: z.number().nullable(),
paid_at: z.number().int().nullable(), paid_at: z.number().int().nullable(),
billing_domain: z.string().nullable(),
remark: z.string(), remark: z.string(),
created_at: z.number().int(), created_at: z.number().int(),
}); });
@@ -155,6 +171,7 @@ export const topupItem = z.object({
paid_at: z.number().int(), paid_at: z.number().int(),
quota_derived: z.number().int(), quota_derived: z.number().int(),
quota_usd: z.string(), quota_usd: z.string(),
billing_domain: z.string().nullable(),
remark: z.string(), remark: z.string(),
created_at: z.number().int(), created_at: z.number().int(),
}); });
@@ -216,6 +233,7 @@ export const createSubscriptionBody = z
quota_granted: z.number().int().positive(), quota_granted: z.number().int().positive(),
paid_amount: z.number().nullable().optional(), paid_amount: z.number().nullable().optional(),
paid_at: z.string().min(1).nullable().optional(), paid_at: z.string().min(1).nullable().optional(),
billing_domain: z.string().nullable().optional(),
remark: z.string().default(""), remark: z.string().default(""),
}); });
@@ -228,6 +246,7 @@ export const createTopupBody = z.object({
payment_provider: z.string().nullable().optional(), payment_provider: z.string().nullable().optional(),
status: z.string().nullable().optional(), status: z.string().nullable().optional(),
paid_at: z.string().min(1), paid_at: z.string().min(1),
billing_domain: z.string().nullable().optional(),
remark: z.string().default(""), remark: z.string().default(""),
}); });
+20
View File
@@ -46,6 +46,7 @@ export interface KeyItem {
status: KeyStatus; status: KeyStatus;
member_id: number | null; member_id: number | null;
member_name: string | null; member_name: string | null;
billing_domain: string | null;
registered_at: UnixSec; registered_at: UnixSec;
log_synced_at: UnixSec | null; log_synced_at: UnixSec | null;
usage_synced_at: UnixSec | null; usage_synced_at: UnixSec | null;
@@ -66,6 +67,7 @@ export interface SubscriptionItem {
quota_usd: QuotaUsd; quota_usd: QuotaUsd;
paid_amount: PaidAmount | null; paid_amount: PaidAmount | null;
paid_at: UnixSec | null; paid_at: UnixSec | null;
billing_domain: string | null;
remark: string; remark: string;
created_at: UnixSec; created_at: UnixSec;
} }
@@ -83,6 +85,7 @@ export interface TopupItem {
paid_at: UnixSec; paid_at: UnixSec;
quota_derived: Quota; quota_derived: Quota;
quota_usd: QuotaUsd; quota_usd: QuotaUsd;
billing_domain: string | null;
remark: string; remark: string;
created_at: UnixSec; created_at: UnixSec;
} }
@@ -111,6 +114,21 @@ export interface Paginated<T> {
total: number; total: number;
} }
/** 日志页汇总:条数跟全部筛选;用量恒 type=2 + 其余筛选 */
export interface LogPageSummary {
hit_count: number;
usage_count: number;
error_count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
/** 调用日志分页响应(含汇总条与模型下拉选项) */
export interface LogPage extends Paginated<LogItem> {
summary: LogPageSummary;
models: string[];
}
export interface EngineLastRound { export interface EngineLastRound {
started_at: UnixSec; started_at: UnixSec;
keys_total: number; keys_total: number;
@@ -138,10 +156,12 @@ export type AuditAction =
| "payment.update" | "payment.update"
| "payment.delete" | "payment.delete"
| "member.create" | "member.create"
| "member.update"
| "member.enable" | "member.enable"
| "member.disable" | "member.disable"
| "member.token_issue" | "member.token_issue"
| "member.token_reissue" | "member.token_reissue"
| "member.delete"
| "account.password_change" | "account.password_change"
| "account.init"; | "account.init";
@@ -0,0 +1,3 @@
ALTER TABLE "api_keys" ADD COLUMN "billing_domain" text;--> statement-breakpoint
ALTER TABLE "subscriptions" ADD COLUMN "billing_domain" text;--> statement-breakpoint
ALTER TABLE "topups" ADD COLUMN "billing_domain" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1791551016051, "when": 1791551016051,
"tag": "0000_loose_goblin_queen", "tag": "0000_loose_goblin_queen",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791878178512,
"tag": "0001_aromatic_hawkeye",
"breakpoints": true
} }
] ]
} }
+6
View File
@@ -1,4 +1,5 @@
import { drizzle } from "drizzle-orm/postgres-js"; import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres"; import postgres from "postgres";
import * as schema from "./schema.ts"; import * as schema from "./schema.ts";
@@ -9,5 +10,10 @@ export function createDb(connectionString: string) {
export type Db = ReturnType<typeof createDb>; export type Db = ReturnType<typeof createDb>;
/** 启动时按 journal 应用缺失迁移(幂等);`migrationsFolder` 指向 drizzle/ 目录 */
export async function runMigrations(db: Db, migrationsFolder: string) {
await migrate(db, { migrationsFolder });
}
export * from "./schema.ts"; export * from "./schema.ts";
export * from "./repos.ts"; export * from "./repos.ts";
+23
View File
@@ -0,0 +1,23 @@
import { createDb, runMigrations } from "./index.ts";
/**
* 部署用迁移入口:按 journal 幂等应用 packages/db/drizzle,成功 exit 0。
* 只依赖 DATABASE_URL;MIGRATIONS_FOLDER 缺省为同目录 ./drizzle。
*/
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error("[migrate] DATABASE_URL is required");
process.exit(1);
}
const migrationsFolder = process.env.MIGRATIONS_FOLDER ?? "./drizzle";
const db = createDb(databaseUrl);
try {
await runMigrations(db, migrationsFolder);
console.log(`[migrate] ok (folder=${migrationsFolder})`);
process.exit(0);
} catch (err) {
console.error("[migrate] failed:", err);
process.exit(1);
}
+218 -50
View File
@@ -32,6 +32,50 @@ export interface KeyListRow extends ApiKeyRow {
member_name: string | null; member_name: string | null;
} }
type LogFilterParams = {
from: Date;
to: Date;
api_key_id?: number;
member_id?: number | "unassigned";
billing_source?: string | "none";
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
/** 明细/汇总共用的 WHERE 片段(时间左闭右开;联 api_keys 时才带 member 归属) */
function buildLogConds(params: LogFilterParams) {
const conds = [
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
if (params.model_name) {
conds.push(eq(usageLogs.model_name, params.model_name));
}
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.member_id === "unassigned") {
conds.push(isNull(apiKeys.member_id));
} else if (params.member_id != null) {
conds.push(eq(apiKeys.member_id, params.member_id));
}
return conds;
}
/** /**
* 仓储层(R14):数据访问只出现在 packages/db。 * 仓储层(R14):数据访问只出现在 packages/db。
* apps/server 不 import drizzle-orm / postgres 驱动。 * apps/server 不 import drizzle-orm / postgres 驱动。
@@ -115,6 +159,7 @@ export function createRepos(db: Db) {
async listAll(filter?: { async listAll(filter?: {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: string; status?: string;
billing_domain?: string | "none";
}): Promise<KeyListRow[]> { }): Promise<KeyListRow[]> {
const conds = []; const conds = [];
if (filter?.member_id === "unassigned") { if (filter?.member_id === "unassigned") {
@@ -125,6 +170,11 @@ export function createRepos(db: Db) {
if (filter?.status) { if (filter?.status) {
conds.push(eq(apiKeys.status, filter.status)); conds.push(eq(apiKeys.status, filter.status));
} }
if (filter?.billing_domain === "none") {
conds.push(isNull(apiKeys.billing_domain));
} else if (filter?.billing_domain) {
conds.push(eq(apiKeys.billing_domain, filter.billing_domain));
}
const base = db const base = db
.select({ .select({
id: apiKeys.id, id: apiKeys.id,
@@ -132,6 +182,7 @@ export function createRepos(db: Db) {
key_ciphertext: apiKeys.key_ciphertext, key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status, status: apiKeys.status,
member_id: apiKeys.member_id, member_id: apiKeys.member_id,
billing_domain: apiKeys.billing_domain,
registered_at: apiKeys.registered_at, registered_at: apiKeys.registered_at,
log_synced_at: apiKeys.log_synced_at, log_synced_at: apiKeys.log_synced_at,
usage_synced_at: apiKeys.usage_synced_at, usage_synced_at: apiKeys.usage_synced_at,
@@ -220,6 +271,7 @@ export function createRepos(db: Db) {
key_hash: string; key_hash: string;
key_ciphertext: Uint8Array; key_ciphertext: Uint8Array;
member_id: number | null; member_id: number | null;
billing_domain?: string | null;
token_name?: string; token_name?: string;
}): Promise<ApiKeyRow> { }): Promise<ApiKeyRow> {
const [row] = await db const [row] = await db
@@ -228,6 +280,7 @@ export function createRepos(db: Db) {
key_hash: values.key_hash, key_hash: values.key_hash,
key_ciphertext: values.key_ciphertext, key_ciphertext: values.key_ciphertext,
member_id: values.member_id, member_id: values.member_id,
billing_domain: values.billing_domain ?? null,
token_name: values.token_name ?? "", token_name: values.token_name ?? "",
}) })
.returning(); .returning();
@@ -294,6 +347,23 @@ export function createRepos(db: Db) {
.returning(); .returning();
return row!; return row!;
}, },
async updateProfile(
id: number,
values: { name?: string; remark?: string },
): Promise<MemberRow> {
const patch: { name?: string; remark?: string } = {};
if (values.name !== undefined) patch.name = values.name;
if (values.remark !== undefined) patch.remark = values.remark;
const [row] = await db
.update(members)
.set(patch)
.where(eq(members.id, id))
.returning();
return row!;
},
async deleteById(id: number): Promise<void> {
await db.delete(members).where(eq(members.id, id));
},
async reissueToken( async reissueToken(
id: number, id: number,
token_hash: string, token_hash: string,
@@ -387,11 +457,23 @@ export function createRepos(db: Db) {
}, },
subscriptions: { subscriptions: {
async listAll(): Promise<SubscriptionRow[]> { async listAll(filter?: {
return db billing_domain?: string | "none";
}): Promise<SubscriptionRow[]> {
const conds = [];
if (filter?.billing_domain === "none") {
conds.push(isNull(subscriptions.billing_domain));
} else if (filter?.billing_domain) {
conds.push(eq(subscriptions.billing_domain, filter.billing_domain));
}
const base = db
.select() .select()
.from(subscriptions) .from(subscriptions)
.orderBy(desc(subscriptions.period_start)); .orderBy(desc(subscriptions.period_start));
if (conds.length > 0) {
return base.where(and(...conds));
}
return base;
}, },
async findById(id: number): Promise<SubscriptionRow | null> { async findById(id: number): Promise<SubscriptionRow | null> {
const rows = await db const rows = await db
@@ -434,8 +516,20 @@ export function createRepos(db: Db) {
}, },
topups: { topups: {
async listAll(): Promise<TopupRow[]> { async listAll(filter?: {
return db.select().from(topups).orderBy(desc(topups.paid_at)); billing_domain?: string | "none";
}): Promise<TopupRow[]> {
const conds = [];
if (filter?.billing_domain === "none") {
conds.push(isNull(topups.billing_domain));
} else if (filter?.billing_domain) {
conds.push(eq(topups.billing_domain, filter.billing_domain));
}
const base = db.select().from(topups).orderBy(desc(topups.paid_at));
if (conds.length > 0) {
return base.where(and(...conds));
}
return base;
}, },
async findById(id: number): Promise<TopupRow | null> { async findById(id: number): Promise<TopupRow | null> {
const rows = await db const rows = await db
@@ -507,35 +601,12 @@ export function createRepos(db: Db) {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
billing_source?: string | "none"; billing_source?: string | "none";
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
page: number; page: number;
page_size: number; page_size: number;
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> { }): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
const conds = [ const conds = buildLogConds(params);
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.member_id === "unassigned") {
conds.push(isNull(apiKeys.member_id));
} else if (params.member_id != null) {
conds.push(eq(apiKeys.member_id, params.member_id));
}
const totalRows = await db const totalRows = await db
.select({ n: count() }) .select({ n: count() })
@@ -655,29 +726,10 @@ export function createRepos(db: Db) {
api_key_id?: number; api_key_id?: number;
billing_source?: string | "none"; billing_source?: string | "none";
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
}) { }) {
const conds = [ const conds = buildLogConds({ ...params, member_id: params.member_id });
eq(apiKeys.member_id, params.member_id),
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
const totalRows = await db const totalRows = await db
.select({ n: count() }) .select({ n: count() })
.from(usageLogs) .from(usageLogs)
@@ -721,6 +773,122 @@ export function createRepos(db: Db) {
.offset((params.page - 1) * params.page_size); .offset((params.page - 1) * params.page_size);
return { rows, total }; return { rows, total };
}, },
/**
* 日志页汇总:条数跟全部筛选;用量行恒 type=2(不含 type 筛选)。
* usage_rows 交上层 sumUsage 做按行 QPU 微分。
*/
async summarize(params: LogFilterParams) {
const fullConds = buildLogConds(params);
const usageConds = buildLogConds({ ...params, type: 2 });
const hitRows = await db
.select({
hit_count: count(),
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...fullConds));
const hit = hitRows[0] ?? {
hit_count: 0,
usage_count: 0,
error_count: 0,
};
const usage_rows = await db
.select({
quota: usageLogs.quota,
created_at: usageLogs.created_at,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...usageConds));
return {
hit_count: Number(hit.hit_count) || 0,
usage_count: Number(hit.usage_count) || 0,
error_count: Number(hit.error_count) || 0,
usage_rows,
};
},
async summarizeMember(params: LogFilterParams & { member_id: number }) {
const fullConds = buildLogConds({ ...params, member_id: params.member_id });
const usageConds = buildLogConds({
...params,
member_id: params.member_id,
type: 2,
});
const hitRows = await db
.select({
hit_count: count(),
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...fullConds));
const hit = hitRows[0] ?? {
hit_count: 0,
usage_count: 0,
error_count: 0,
};
const usage_rows = await db
.select({
quota: usageLogs.quota,
created_at: usageLogs.created_at,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...usageConds));
return {
hit_count: Number(hit.hit_count) || 0,
usage_count: Number(hit.usage_count) || 0,
error_count: Number(hit.error_count) || 0,
usage_rows,
};
},
/** 模型下拉选项:时间窗 + 主体/来源/订阅筛选下去重(不含 model_name / type) */
async listModels(
params: Omit<LogFilterParams, "type" | "model_name">,
): Promise<string[]> {
const conds = buildLogConds({ ...params, type: undefined, model_name: undefined });
const rows = await db
.select({ model_name: usageLogs.model_name })
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.groupBy(usageLogs.model_name)
.orderBy(asc(usageLogs.model_name));
return rows.map((r) => r.model_name).filter((m) => m !== "");
},
async listMemberModels(
params: Omit<LogFilterParams, "type" | "model_name"> & {
member_id: number;
},
): Promise<string[]> {
const conds = buildLogConds({
...params,
member_id: params.member_id,
type: undefined,
model_name: undefined,
});
const rows = await db
.select({ model_name: usageLogs.model_name })
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.groupBy(usageLogs.model_name)
.orderBy(asc(usageLogs.model_name));
return rows.map((r) => r.model_name).filter((m) => m !== "");
},
}, },
keysExtra: { keysExtra: {
+3
View File
@@ -44,6 +44,7 @@ export const apiKeys = pgTable(
member_id: bigint("member_id", { mode: "number" }).references( member_id: bigint("member_id", { mode: "number" }).references(
() => members.id, () => members.id,
), ),
billing_domain: text("billing_domain"),
registered_at: timestamp("registered_at", { registered_at: timestamp("registered_at", {
withTimezone: true, withTimezone: true,
mode: "date", mode: "date",
@@ -135,6 +136,7 @@ export const subscriptions = pgTable(
mode: "number", mode: "number",
}), }),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }), paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""), remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: true, withTimezone: true,
@@ -161,6 +163,7 @@ export const topups = pgTable("topups", {
payment_provider: text("payment_provider"), payment_provider: text("payment_provider"),
status: text("status"), status: text("status"),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(), paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""), remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: true, withTimezone: true,
+255
View File
@@ -0,0 +1,255 @@
/**
* pack 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。
* 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。
*/
import { defineCommand, runMain } from "citty";
import * as p from "@clack/prompts";
import {
PACK_ITEMS,
PACK_TARGETS,
currentPlatformTarget,
packRelease,
type PackItem,
} from "./pack-core.ts";
function isInteractive(yes: boolean | undefined): boolean {
if (yes) return false;
return Boolean(process.stdin.isTTY);
}
function cancel(): never {
p.cancel("Operation cancelled");
process.exit(0);
}
function assertNotCancel<T>(value: T | symbol): T {
if (p.isCancel(value)) cancel();
return value as T;
}
const targetOption = {
target: {
type: "string",
description: `编译目标,如 ${PACK_TARGETS[0]};默认交互选择或 ${PACK_TARGETS[0]}`,
},
} as const;
const release = defineCommand({
meta: {
name: "release",
description: "打部署产物包(dist + 单文件 + drizzle)",
},
args: {
...targetOption,
only: {
type: "string",
description: `产物范围,逗号分隔;可选 ${PACK_ITEMS.join("|")}`,
},
out: {
type: "string",
default: "release",
description: "输出目录",
},
tarball: {
type: "boolean",
description: "额外打 tar.gz(默认交互询问,否则否)",
},
yes: {
type: "boolean",
alias: "y",
description: "全部默认,不进交互",
},
},
async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined);
p.intro("PACK 打包");
// --- target ---
let target = args.target as string | undefined;
if (!target) {
if (interactive) {
const picked = assertNotCancel(
await p.select({
message: "目标平台",
options: (() => {
const current = currentPlatformTarget();
const base = PACK_TARGETS.map((t) => ({
label: t === "bun-linux-x64" ? `${t}(服务器常用)` : t,
value: t,
}));
// 当前平台已在列表中则不再追加,避免重复
return PACK_TARGETS.includes(current as (typeof PACK_TARGETS)[number])
? base
: [
...base,
{
label: `${current}(当前平台,仅本地)`,
value: current,
},
];
})(),
initialValue: "bun-linux-x64",
}),
);
target = picked as string;
} else {
target = "bun-linux-x64";
}
}
// --- only ---
let only: PackItem[];
const onlyArg = args.only as string | undefined;
if (onlyArg) {
only = onlyArg
.split(",")
.map((s) => s.trim())
.filter(Boolean) as PackItem[];
for (const item of only) {
if (!PACK_ITEMS.includes(item)) {
p.cancel(`未知产物: ${item}`);
process.exit(1);
}
}
} else if (interactive) {
const picked = assertNotCancel(
await p.multiselect({
message: "打包内容",
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
initialValues: [...PACK_ITEMS],
required: true,
}),
);
only = picked as PackItem[];
} else {
only = [...PACK_ITEMS];
}
// --- out ---
const outDir = (args.out as string) || "release";
// --- tarball ---
let tarball = args.tarball as boolean | undefined;
if (tarball === undefined) {
if (interactive) {
tarball = assertNotCancel(
await p.confirm({ message: "是否额外打 tar.gz?", initialValue: false }),
) as boolean;
} else {
tarball = false;
}
}
const s = p.spinner();
s.start(`编译 ${target} · ${only.join(" + ")}`);
try {
const out = await packRelease({
target,
only,
outDir,
tarball,
onLog: (msg) => {
// spinner 期间不逐条刷屏,保留关键节点可在结束后看
if (msg.startsWith("tar ") || msg.startsWith("target=")) {
s.message(msg);
}
},
});
s.stop(`完成 → ${out}`);
p.outro(
[
`产物:${only.join(" · ")}`,
`目标:${target}`,
tarball ? "已附 tar.gz(仓库根目录)" : "未打 tar.gz",
"",
"服务器:覆盖 /opt/naku/release/ 后执行 docker compose up -d --build",
].join("\n"),
);
} catch (err) {
s.stop("失败");
p.cancel(err instanceof Error ? err.message : String(err));
process.exit(1);
}
},
});
const build = defineCommand({
meta: {
name: "build",
description: "构建产物到 bin/(当前平台,适合本地试跑)",
},
args: {
item: {
type: "positional",
required: false,
description: `可选:${PACK_ITEMS.join("|")},不填则交互多选`,
},
...targetOption,
yes: {
type: "boolean",
alias: "y",
description: "全部默认,不进交互",
},
},
async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined);
const target =
(args.target as string | undefined) ?? currentPlatformTarget();
let only: PackItem[];
const positional = args.item as string | undefined;
if (positional) {
if (!PACK_ITEMS.includes(positional as PackItem)) {
p.cancel(`未知产物: ${positional}`);
process.exit(1);
}
only = [positional as PackItem];
} else if (interactive) {
p.intro("PACK 本地构建");
only = assertNotCancel(
await p.multiselect({
message: "构建什么",
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
// 默认不选,由用户自选
initialValues: [],
required: true,
}),
) as PackItem[];
} else {
only = ["naku-server", "naku-engine"];
}
const s = p.spinner();
s.start(`构建 ${only.join(" + ")} → bin/`);
try {
await packRelease({
target,
only,
outDir: "bin",
tarball: false,
// 前端进 bin/dist/,避免与单文件混放
nestDist: true,
onLog: (msg) => {
if (msg.startsWith("compile") || msg.startsWith("build")) s.message(msg);
},
});
s.stop("完成");
p.outro(`已写入 bin/(${target})`);
} catch (err) {
s.stop("失败");
p.cancel(err instanceof Error ? err.message : String(err));
process.exit(1);
}
},
});
const main = defineCommand({
meta: {
name: "pack",
version: "0.0.0",
description: "PACK 打包 / 本地构建",
},
subCommands: { release, build },
});
runMain(main);
+153
View File
@@ -0,0 +1,153 @@
/**
* 打包核心:供 CLI / 脚本共用,不带交互。
* 产物:dist/ · naku-server · naku-engine · naku-migrate · drizzle/
*/
import { cp, mkdir, rm, stat, readdir } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
export const PACK_ITEMS = [
"dist",
"naku-server",
"naku-engine",
"naku-migrate",
"drizzle",
] as const;
export type PackItem = (typeof PACK_ITEMS)[number];
export const PACK_TARGETS = [
"bun-linux-x64",
"bun-linux-arm64",
"bun-darwin-arm64",
"bun-darwin-x64",
"bun-windows-x64",
"bun-windows-arm64",
] as const;
export function currentPlatformTarget(): string {
const arch = process.arch === "arm64" ? "arm64" : "x64";
if (process.platform === "win32") return `bun-windows-${arch}`;
if (process.platform === "darwin") return `bun-darwin-${arch}`;
return `bun-linux-${arch}`;
}
export interface PackOptions {
target: string;
only: PackItem[];
outDir: string;
tarball?: boolean;
root?: string;
/** true:前端放到 <out>/dist/(部署包);false:直接铺在 <out>/(本地 dist) */
nestDist?: boolean;
onLog?: (msg: string) => void;
}
function fail(msg: string): never {
throw new Error(msg);
}
async function mustExist(path: string, label: string): Promise<void> {
if (!existsSync(path)) fail(`missing ${label}: ${path}`);
}
async function nonEmptyDir(path: string, label: string): Promise<void> {
const entries = await readdir(path);
if (entries.length === 0) fail(`empty ${label}: ${path}`);
}
async function run(cmd: string[], cwd: string, onLog?: (m: string) => void) {
onLog?.(`$ ${cmd.join(" ")}`);
const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" });
const code = await proc.exited;
if (code !== 0) fail(`command failed (${code}): ${cmd.join(" ")}`);
}
export async function packRelease(opts: PackOptions): Promise<string> {
const root = opts.root ?? resolve(import.meta.dir, "..");
const releaseDir = resolve(root, opts.outDir);
const log = opts.onLog ?? (() => {});
const only = new Set(opts.only);
const needBin = (name: PackItem) => only.has(name);
log(`target=${opts.target}`);
log(`out=${releaseDir}`);
// 输出目录:只删本次会写入的条目,避免误删未选产物的旧文件?——部署包语义为整包覆盖,仍清空
await rm(releaseDir, { recursive: true, force: true });
await mkdir(releaseDir, { recursive: true });
if (needBin("dist")) {
log("build web…");
await run(["bun", "run", "build:web"], root, log);
const webDist = join(root, "apps", "web", "dist");
await mustExist(webDist, "web dist");
const dest = opts.nestDist === false ? releaseDir : join(releaseDir, "dist");
await cp(webDist, dest, { recursive: true });
}
const compileOne = async (entry: string, name: PackItem) => {
if (!needBin(name)) return;
log(`compile ${name}…`);
await run(
[
"bun",
"build",
"--compile",
`--target=${opts.target}`,
`--outfile=${join(releaseDir, name)}`,
entry,
],
root,
log,
);
};
await compileOne("apps/server/src/index.ts", "naku-server");
await compileOne("apps/engine/src/index.ts", "naku-engine");
await compileOne("packages/db/src/migrate-cli.ts", "naku-migrate");
if (needBin("drizzle")) {
const drizzleSrc = join(root, "packages", "db", "drizzle");
await mustExist(drizzleSrc, "drizzle folder");
await cp(drizzleSrc, join(releaseDir, "drizzle"), { recursive: true });
}
for (const item of opts.only) {
if (item === "dist" && opts.nestDist === false) {
// web 铺在 outDir 根:检查 outDir 非空即可
await nonEmptyDir(releaseDir, "outDir");
continue;
}
// Windows 本机编译可能带 .exe
const candidates = [
join(releaseDir, item),
join(releaseDir, `${item}.exe`),
];
const found = candidates.find((c) => existsSync(c));
if (!found) fail(`missing ${item}: ${candidates[0]}`);
if (item === "dist" || item === "drizzle") {
await nonEmptyDir(found, `release/${item}`);
} else {
const s = await stat(found);
if (s.size <= 0) fail(`empty file: ${item}`);
}
}
if (opts.tarball) {
const date = new Date();
const ymd = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
const tarName = `naku-release-${ymd}.tar.gz`;
const tarPath = join(root, tarName);
log(`tar ${tarName}…`);
// 在 outDir 的父目录打包,使 tar 内为 release/ 根
const parent = resolve(releaseDir, "..");
const folderName = releaseDir.split(/[\\/]/).pop() ?? "release";
await run(
["tar", "-czf", tarPath, "-C", parent, folderName],
root,
log,
);
log(`tarball → ${tarPath}`);
}
return releaseDir;
}
+2 -1
View File
@@ -26,6 +26,7 @@
"apps/server/test/**/*.ts", "apps/server/test/**/*.ts",
"packages/*/src/**/*.ts", "packages/*/src/**/*.ts",
"packages/*/test/**/*.ts", "packages/*/test/**/*.ts",
"packages/*/drizzle.config.ts" "packages/*/drizzle.config.ts",
"scripts/**/*.ts"
] ]
} }