Compare commits

...
18 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
77 changed files with 3805 additions and 281 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,勿提交)
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
# 本服务端口(避开 3000/3001 等常见占用)
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 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
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
@@ -19,11 +24,20 @@ NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
# HTTPS + 域名部署务必 true。
COOKIE_SECURE=false
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
# 仅影响宿主机映射;容器内仍是 80/443
HTTP_PORT=80
HTTPS_PORT=443
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
# SYNC_INTERVAL_MS=300000
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME=
+3
View File
@@ -17,8 +17,11 @@
# 依赖与构建产物
node_modules/
dist/
bin/
build/
.turbo/
release/
.bun-cache/
# 测试与工具
coverage/
+4 -3
View File
@@ -8,7 +8,6 @@
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
@@ -27,14 +26,16 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期) |
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
| `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `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` 后填写)。
+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.
+72 -25
View File
@@ -6,32 +6,34 @@
| 项目 | 内容 |
|------|------|
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链与衍生形态待收尾 |
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
| 技术栈 | 全栈 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 分发给多名使用者分摊成本。平台只提供**事实服务**——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
**适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
- 全场景统一小写 kebab-case:仓库名 / 包名一致
- 仓库名 / 包名统一小写 kebab-case
## 背景与约束
- 真实场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 项目作者是该实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
- 交付形态为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
- 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
**双形态并行的动因**:服务器部署形态下,每次迭代都需实例运营方手工升级;部署到作者自控的 Cloudflare 账号后,迭代部署权回到作者手里。母体完整前后端先行,Workers 抽取版延后至功能全集实现后。
**双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
## 核心能力
| # | 能力 | 说明 |
|---|------|------|
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者全员视角在管理面总览,成员视角在成员视图 |
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;对成员完全公开透明 |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
@@ -64,13 +66,12 @@ packages/
## 快速开始
前置:Bun、Docker(开发用 PostgreSQL)。
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
```bash
bun install
docker compose up -d # 起 PostgreSQL
cp .env.example .env # 按注释填写
bun run db:push # 推送 schema
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)
@@ -80,38 +81,78 @@ bun run dev:web # 前端 Vite(5175)
更多命令与协作约定见 [`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–R15 全部定案;含支付拆表裁决 | 定稿(2026-10-09) |
| [`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-09) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(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 归属、成员视图身份与按成员统计的锚点 | 同上 |
| [`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) | 调用日志:筛选、明细表、与 key/成员/总览的跳转链 | 定稿(2026-10-08) |
| [`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):订阅权益 / 充值入账登记、核对提示 | 待审阅(2026-10-09) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 定稿(2026-10-01) |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`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-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 待审阅(2026-10-09) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 待审阅(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 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反)
@@ -123,3 +164,9 @@ bun run dev:web # 前端 Vite(5175)
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
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 db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
+1 -1
View File
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", 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",
member_id: row.member_id,
member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
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);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
}
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-")) {
return jsonError(
c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash,
key_ciphertext,
member_id,
billing_domain,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
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) {
return jsonError(
c,
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
},
401: err401,
404: err404,
409: err409,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const status = body?.status;
if (status !== "active" && status !== "disabled") {
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
}
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
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({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
action: "member.delete",
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,
paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: number;
paid_amount: number | null;
paid_at: Date | null;
billing_domain: string | null;
remark: string;
};
} {
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? 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 ?? "",
},
};
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
summary: "订阅权益清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "subscription 数组",
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
async (c) => {
const repos = c.get("repos");
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(
rows.map((row) =>
toSubscriptionItem(
+19 -1
View File
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
paid_at: toUnixSec(row.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: string | null;
status: string | null;
paid_at: Date;
billing_domain: string | null;
remark: string;
};
} {
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: body.payment_provider ?? null,
status: body.status ?? "success",
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 ?? "",
},
};
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
summary: "充值入账清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "topup 数组",
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
async (c) => {
const repos = c.get("repos");
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(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200,
+30 -8
View File
@@ -279,7 +279,7 @@ export function adminUsageRoutes() {
path: "/logs",
summary: "日志明细(下钻)",
description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
"分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
tags: ["用量"],
security: [{ adminSession: [] }],
request: {
@@ -290,12 +290,13 @@ export function adminUsageRoutes() {
member_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
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 repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({
const filters = {
from: range.from,
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id:
q.member_id === "unassigned"
? "unassigned"
? ("unassigned" as const)
: q.member_id
? Number(q.member_id)
: undefined,
@@ -329,13 +328,22 @@ export function adminUsageRoutes() {
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: 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 { 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",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -159,8 +159,13 @@ export function memberViewRoutes() {
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 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 usedSubIds = new Set(
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_start <= range.to &&
(s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id) || ongoing;
if (!involved) continue;
const involved = usedSubIds.has(s.subscription_id);
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);
subscriptionsOut.push({
id: s.id,
@@ -184,6 +192,7 @@ export function memberViewRoutes() {
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
@@ -191,6 +200,7 @@ export function memberViewRoutes() {
const topupRows = await repos.topups.listAll();
const topupsOut = [];
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;
const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu);
@@ -204,6 +214,7 @@ export function memberViewRoutes() {
payment_provider: t.payment_provider,
status: t.status,
paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
@@ -230,7 +241,8 @@ export function memberViewRoutes() {
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
description:
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -242,12 +254,13 @@ export function memberViewRoutes() {
api_key_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
@@ -267,25 +280,33 @@ export function memberViewRoutes() {
);
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({
member_id: c.get("memberId")!,
const member_id = c.get("memberId")!;
const filters = {
member_id,
from: range.from,
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: 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 { quota_usd } = usageRowPrecise(
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,
);
},
);
+7 -1
View File
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
export type KeyListFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean;
};
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
export async function registerKey(body: {
sk_key: string;
member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data;
@@ -22,7 +24,11 @@ export async function registerKey(body: {
export async function patchKey(
id: number,
body: { status?: "active" | "disabled"; member_id?: number | null },
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
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";
export type AdminLogFilters = {
@@ -10,13 +10,12 @@ export type AdminLogFilters = {
member_id?: number | "unassigned";
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(
params: AdminLogFilters,
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
const { data } = await adminHttp.get<LogPage>("/logs", {
params,
});
return data;
+10 -1
View File
@@ -22,12 +22,21 @@ export async function createMember(body: {
export async function patchMember(
id: number,
body: { status: "active" | "disabled" },
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
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 }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
+7 -2
View File
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
billing_domain?: string | null;
remark?: string;
};
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
export async function fetchSubscriptions(filter?: {
billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data;
}
+7 -2
View File
@@ -10,11 +10,16 @@ export type TopupBody = {
payment_provider?: string | null;
status?: string | null;
paid_at: string;
billing_domain?: string | null;
remark?: string;
};
export async function fetchTopups(): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups");
export async function fetchTopups(filter?: {
billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data;
}
+4 -4
View File
@@ -1,6 +1,5 @@
import type {
LogItem,
Paginated,
LogPage,
SubscriptionItem,
TopupItem,
UnixSec,
@@ -55,11 +54,12 @@ export type MemberLogQuery = {
api_key_id?: number;
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<LogPage>("/logs", {
params: query,
});
return data;
+2
View File
@@ -36,6 +36,7 @@ export const qk = {
range.page_size,
range.api_key_id ?? "",
range.member_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
@@ -60,6 +61,7 @@ export const qk = {
range.page,
range.page_size,
range.api_key_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
@@ -5,6 +5,7 @@
import { ref, watch } from "vue";
import { message } from "antdv-next";
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
import { useClipboard } from "@vueuse/core";
const props = defineProps<{
open: boolean;
@@ -27,9 +28,12 @@ watch(
},
);
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
const { copy } = useClipboard({ legacy: true });
async function onCopy() {
try {
await navigator.clipboard.writeText(props.token);
await copy(props.token);
message.success("已复制");
} catch {
message.error("复制失败,请手动选中复制");
@@ -16,6 +16,7 @@ import type {
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useBillingDomainOptions,
useCreateSubscription,
useCreateTopup,
useQpuLatest,
@@ -78,6 +79,8 @@ const registeredTopIds = computed(
.filter((v): v is number => v != null),
);
const domainOptions = useBillingDomainOptions();
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
@@ -87,6 +90,7 @@ const subForm = reactive({
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
@@ -98,6 +102,7 @@ const topForm = reactive({
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
@@ -111,6 +116,7 @@ function resetSub() {
quota_usd: null,
paid_amount: null,
paid_at: null,
billing_domain: "",
remark: "",
});
}
@@ -124,6 +130,7 @@ function resetTop() {
payment_method: "",
payment_provider: "",
paid_at: null,
billing_domain: "",
remark: "",
});
}
@@ -147,6 +154,7 @@ watch(
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.billing_domain = s.billing_domain ?? "";
subForm.remark = s.remark;
} else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
@@ -162,6 +170,7 @@ watch(
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.billing_domain = t.billing_domain ?? "";
topForm.remark = t.remark;
} else {
resetTop();
@@ -239,6 +248,7 @@ async function onSubmit() {
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
billing_domain: subForm.billing_domain.trim() || null,
remark: subForm.remark,
};
if (props.editingSub) {
@@ -268,6 +278,7 @@ async function onSubmit() {
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
billing_domain: topForm.billing_domain.trim() || null,
remark: topForm.remark,
};
if (props.editingTop) {
@@ -381,6 +392,13 @@ async function onSubmit() {
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</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-input v-model:value="subForm.remark" />
</a-form-item>
@@ -432,6 +450,13 @@ async function onSubmit() {
style="width: 120px; margin-left: 8px"
/>
</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-input v-model:value="topForm.remark" />
</a-form-item>
@@ -5,7 +5,7 @@
import { computed, reactive, ref, watch } from "vue";
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
const props = defineProps<{
@@ -20,10 +20,12 @@ const emit = defineEmits<{
const register = useRegisterKey();
const createMember = useCreateMember();
const domainOptions = useBillingDomainOptions();
const form = reactive({
sk_key: "",
member_id: null as number | "unassigned" | null,
billing_domain: "",
});
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
null,
@@ -41,6 +43,7 @@ watch(
if (!v) {
form.sk_key = "";
form.member_id = null;
form.billing_domain = "";
resultMsg.value = null;
showNewMember.value = false;
newMemberName.value = "";
@@ -67,6 +70,7 @@ async function onSubmit() {
form.member_id === "unassigned" || form.member_id == null
? null
: form.member_id,
billing_domain: form.billing_domain.trim() || null,
});
resultMsg.value = {
type: "success",
@@ -170,6 +174,13 @@ async function onCreateMember() {
+ 新建成员
</a-button>
</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-input
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 {
recentDaysRange,
todayRange,
wallAsBeijingIso,
type RangeValue,
} from "@/utils/time.ts";
@@ -28,6 +29,10 @@ const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
const presets = computed(() => {
const builtIn = [
{
label: "今天",
value: rangeToDayjs(todayRange()),
},
{
label: "最近 7 天",
value: rangeToDayjs(recentDaysRange(7)),
@@ -51,6 +56,13 @@ function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
function detectKind(value: RangeValue): RangeKind {
const fromD = dayjs(value.from).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) {
const r = recentDaysRange(days);
if (
@@ -1,12 +1,12 @@
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 RangeKind = "recent-7" | "recent-30" | "fixed";
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/**
* 可跟随「今天」推进的时间范围:
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
*/
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>(() => {
void nowTick.value;
if (kind.value === "today") return todayRange();
if (kind.value === "recent-7") return recentDaysRange(7);
if (kind.value === "recent-30") return recentDaysRange(30);
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 {
fetchKeys,
@@ -23,6 +24,7 @@ import {
} from "@/api/admin/topups.ts";
import {
createMember,
deleteMember,
fetchMembers,
issueMemberToken,
patchMember,
@@ -83,7 +85,11 @@ export function usePatchKey() {
body,
}: {
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),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
@@ -105,7 +111,7 @@ export function useAdminLogs(
export function useSubscriptions() {
return useQuery({
queryKey: qk.admin.subscriptions(),
queryFn: fetchSubscriptions,
queryFn: () => fetchSubscriptions(),
staleTime: 0,
});
}
@@ -144,7 +150,7 @@ export function useDeleteSubscription() {
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: fetchTopups,
queryFn: () => fetchTopups(),
staleTime: 0,
});
}
@@ -207,10 +213,26 @@ export function usePatchMember() {
body,
}: {
id: number;
body: { status: "active" | "disabled" };
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body),
onSuccess: () => {
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()),
});
}
/** 账本域联想: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({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/admin/overview" },
{ path: "/", redirect: "/member" },
...adminRoutes,
...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";
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) };
}
/** 快捷项:今天(北京时间日界,左闭右开) */
export function todayRange(nowMs: number = Date.now()): {
from: string;
to: string;
} {
return recentDaysRange(1, nowMs);
}
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
export function formatRangeCaption(fromIso: string, toIso: string): string {
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 TableRefresh from "@/components/common/table-refresh.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 { formatMinute, hoursSince } from "@/utils/time.ts";
@@ -23,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | 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);
/** URL → 筛选(总览跳入) */
@@ -35,6 +37,10 @@ function syncFromRoute() {
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
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";
@@ -52,18 +58,21 @@ function pushQuery() {
if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query });
}
watch([filterMember, filterStatus, filterRecentFailed], () => {
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery();
});
const filters = computed(() => ({
member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined,
}));
@@ -91,6 +100,7 @@ const hasActiveFilter = computed(
() =>
filterMember.value != null ||
filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value,
);
@@ -99,6 +109,8 @@ const memberOptions = computed(() => [
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt);
return h != null && h > 2;
@@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) {
const columns = [
{ title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 },
{
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) {
const next = record.status === "active" ? "disabled" : "active";
try {
@@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) {
placeholder="状态"
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-space>
</div>
@@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) {
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
/>
</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'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
+148 -10
View File
@@ -3,16 +3,20 @@
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
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 PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
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 { formatInt } from "@/utils/format.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
@@ -20,6 +24,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
const memberId = ref<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
@@ -47,6 +52,7 @@ const params = computed(() => ({
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
member_id: memberId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
@@ -55,6 +61,11 @@ const params = computed(() => ({
const logsQuery = useAdminLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
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(() => [
{ value: null, label: "全部成员" },
@@ -102,6 +113,8 @@ function syncFromRoute() {
else if (typeof m === "string" && m) memberId.value = Number(m);
const k = q.api_key_id ?? q.key_id;
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) {
billingSource.value = q.billing_source;
}
@@ -128,6 +141,7 @@ function pushQuery() {
if (memberId.value === "unassigned") query.member_id = "unassigned";
else if (memberId.value != null) query.member_id = String(memberId.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 (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
@@ -143,7 +157,7 @@ watch(
() => syncFromRoute(),
);
watch(
[memberId, apiKeyId, billingSource, subscriptionId, typeFilter],
[memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
() => {
page.value = 1;
pushQuery();
@@ -153,12 +167,54 @@ watch(page, () => pushQuery());
function sourceBadge(record: LogItem) {
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") {
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) {
@@ -177,7 +233,7 @@ let ro: ResizeObserver | null = null;
function onRangeChange(
v: { from: string; to: string },
k: "recent-7" | "recent-30" | "fixed",
k: RangeKind,
) {
setRange(v, k);
page.value = 1;
@@ -242,6 +298,15 @@ onUnmounted(() => {
show-search
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
v-model:value="billingSource"
:options="[
@@ -273,6 +338,15 @@ onUnmounted(() => {
/>
</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
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
@@ -322,7 +396,19 @@ onUnmounted(() => {
</a-tag>
</template>
<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 }}
</a-tag>
</template>
@@ -400,6 +486,41 @@ onUnmounted(() => {
</div>
</div>
</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>
</template>
@@ -476,4 +597,21 @@ onUnmounted(() => {
line-height: 20px;
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>
+82 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/
import { computed, h, reactive, ref, watch } from "vue";
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 {
useCreateMember,
useDeleteMember,
useIssueMemberToken,
useMembers,
usePatchMember,
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers();
const createMember = useCreateMember();
const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken();
const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null);
@@ -83,7 +89,7 @@ const columns = [
width: 170,
},
{ title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 200 },
{ title: "操作", key: "actions", width: 280 },
];
const rows = computed(() => {
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
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>
<template>
<section>
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template>
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
>
查调用
</a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm
title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断"
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
<a-button v-else size="small" @click="() => onToggle(record)">
启用
</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>
</template>
</template>
</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
v-model:open="createOpen"
title="新建成员"
+100 -8
View File
@@ -25,6 +25,49 @@ const route = useRoute();
const router = useRouter();
const subsQuery = useSubscriptions();
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 deleteTop = useDeleteTopup();
@@ -37,22 +80,27 @@ const checking = ref(false);
function syncFromRoute() {
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() {
void router.replace({
path: "/admin/payments",
query: tab.value === "topups" ? { tab: "topups" } : {},
});
function pushQuery() {
const query: Record<string, string> = {};
if (tab.value === "topups") query.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();
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 list = [...(topupsQuery.data.value ?? [])];
const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
@@ -62,6 +110,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
@@ -78,6 +127,7 @@ const topColumns = [
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
@@ -198,6 +248,38 @@ watch(
<a-tab-pane key="topups" tab="充值" />
</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
v-if="tab === 'subs' && missingSubs.length"
type="warning"
@@ -253,6 +335,9 @@ watch(
: "—"
}}
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
@@ -316,6 +401,9 @@ watch(
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
@@ -346,6 +434,10 @@ watch(
</template>
<style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period {
display: flex;
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 TimeRangePicker from "@/components/common/time-range-picker.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 type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
@@ -22,6 +24,7 @@ const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
@@ -39,6 +42,7 @@ const params = computed(() => ({
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
@@ -47,6 +51,11 @@ const params = computed(() => ({
const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
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(() => [
{ value: null, label: "全部 Key" },
@@ -136,6 +145,8 @@ function syncFromRoute() {
const q = route.query;
const k = q.api_key_id ?? q.key_id;
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 =
typeof q.billing_source === "string" && q.billing_source
? q.billing_source
@@ -159,6 +170,7 @@ function pushQuery() {
to: range.value.to,
};
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 (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
@@ -170,14 +182,14 @@ function pushQuery() {
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => {
watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
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);
page.value = 1;
pushQuery();
@@ -245,6 +257,15 @@ watch(
show-search
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
v-model:value="billingSource"
:options="[
@@ -276,6 +297,15 @@ watch(
/>
</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
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
@@ -438,7 +468,7 @@ watch(
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</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: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
@@ -68,6 +69,7 @@ const topColumns = [
},
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
</script>
@@ -76,7 +78,7 @@ const topColumns = [
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开"
subtitle="订阅与充值的登记参数 · 同账本域内可见"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
@@ -186,7 +188,7 @@ const topColumns = [
</a-table>
<div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</template>
</section>
+16
View File
@@ -5,7 +5,9 @@
"": {
"name": "new-api-key-usage",
"devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2",
"citty": "^0.2.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=="],
"@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/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=="],
"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=="],
"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-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=="],
"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=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"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=="],
+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:
postgres:
container_name: naku-postgres
image: postgres:17-alpine
container_name: new-api-key-usage-pg
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: new_api_key_usage
ports:
- "5432:5432"
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
volumes:
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
- pgdata:/var/lib/postgresql/data
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
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:
pgdata:
+3 -3
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 状态 | 定稿(2026-10-02;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 各篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
@@ -15,7 +15,7 @@
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除为主——**空壳例外可硬删**:无 key 归属的成员行不承载账本事实,见 R16)
## 2. 路由前缀与隔离机制
@@ -27,7 +27,7 @@
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
## 3. 错误信封
+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 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
@@ -48,13 +48,13 @@
"subscriptions": [
{ "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",
"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" }
],
"topups": [
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
"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",
"remark": "", "created_at": "…" }
]
@@ -62,16 +62,18 @@
```
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
## 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`)。
可选筛选(调用日志页,与管理面 `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 类型两处复用):
@@ -92,7 +94,7 @@
## 5. 本稿裁定项(请作者重点审)
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` 恒定过滤;
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
+44 -11
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 待审阅(2026-10-09 支付拆表修订——§7) |
| 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
@@ -29,7 +29,7 @@
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 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 | 令牌重发(明文仅一次) |
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
@@ -119,12 +119,13 @@
### 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
{
"id": 5, "token_name": "小张-01", "status": "active",
"member_id": 1, "member_name": "小张",
"billing_domain": "carpool",
"registered_at": "2026-09-20T10:00:00Z",
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
@@ -136,7 +137,7 @@
### 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`
`{ "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. 日志明细(管理面下钻)
### 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
{
@@ -184,6 +206,7 @@
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
"billing_domain": "carpool",
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
}
```
@@ -193,7 +216,7 @@
### 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
@@ -209,6 +232,7 @@
"payment_method": "alipay", "payment_provider": "epay",
"status": "success",
"paid_at": "2026-10-09T12:22:56Z",
"billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "2026-10-09T13:00:00Z"
}
@@ -218,7 +242,7 @@
### 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
@@ -259,7 +283,16 @@
### 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
@@ -285,7 +318,7 @@
|----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| 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) |
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
+2 -1
View File
@@ -32,6 +32,7 @@
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
@@ -55,7 +56,7 @@
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
- `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) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9(访问主体模型)· R16(档案可改、空壳删除) |
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
| 字段数 | 7 = 1 主键 + 6 业务字段 |
@@ -21,13 +21,13 @@
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 |
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) |
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) |
| `created_at` | timestamptz | 开户时 | |
| `id` | bigserial PK | 自增 | 不可改 |
| `name` | text UNIQUE | 管理面录入(可改) | 显示名——账目与视图中的署名;开户后可改(R16) |
| `remark` | text | 管理面录入(可改) | 备注(联系方式、是谁等;可空);开户后可改(R16) |
| `status` | text | 管理面启停 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断);**不并入档案编辑** |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次);仅随重发更新 |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索);仅随重发更新 |
| `created_at` | timestamptz | 开户时 | 不可改 |
## 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. 写入时机
| 时机 | 动作 |
|------|------|
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
| 档案修改(R16) | UPDATE `name` 和/或 `remark` |
| 停用 / 启用 | UPDATE `status` |
| 重发令牌 | 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 |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
@@ -29,6 +29,7 @@
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
| `remark` | text | 人工填写 | 备注 |
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
@@ -60,5 +61,5 @@
## 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` |
| 范围 | 列结构 + 语义 + 写入时机 |
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
@@ -30,6 +30,7 @@
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
| `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
@@ -68,7 +69,7 @@
## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
- 纯用量模式可不登记
## 7. 迁移说明
+6 -3
View File
@@ -16,6 +16,7 @@
|----|------|------|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
@@ -30,6 +31,7 @@
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
3. 提交 → 服务端上游验证,四态反馈:
| 结果 | 反馈 |
@@ -45,10 +47,11 @@
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
## 4. 归属变更
## 4. 归属 / 账本域变更
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义)
- 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
## 5. 空态
+2 -2
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `需求清单.md` §9(访问主体模型·审计日志条目)· `members/users表字段设计.md`(写入时机中「落审计」的动作全集) |
| 数据 | 审计日志表(表结构归技术稿——本文锁定信息结构与展示) |
@@ -27,7 +27,7 @@
|----|------|
| Key | 登记 / 启用 / 停用 / 归属变更 |
| 支付记录 | 订阅 / 充值登记、编辑、删除 |
| 成员 | 开户 / 停用 / 启用 / 令牌签发 / 令牌重发 |
| 成员 | 开户 / 档案修改(名称/备注)/ 停用 / 启用 / 令牌签发 / 令牌重发 / 空壳删除 |
| 账号 | 改密 /(空库重建为系统事件,记「初始化」) |
- 每类管理面写操作一行留痕——操作者列当前恒为作者(单管理员),但**审计是不可变历史,操作者归属不能事后补写**:保留该列是前向兼容(未来若扩展多管理员 / 权限引擎,历史记录自然区分),也是审计语义的自描述原则(who did what when 的 who 是本体字段)
+5 -5
View File
@@ -16,9 +16,9 @@
(拓扑见下文描述,实现时以此为准)
```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》);地址栏访问 `/admin`(根路径不指向此处)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图);**根路径 `/` 默认进入本世界**(未知路径同样回退到此)
- **没有第三种入口**:无开放查询页、无游客态、无分发页;根地址即成员入口。管理面的入口形态待定(当前经地址栏 `/admin` 进入)
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
## 2. 管理面导航:侧边栏
@@ -28,7 +28,7 @@
| 菜单 | 页面 | 职责一句话 |
|------|------|-----------|
| 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) |
| 成员管理 | 成员清单 | 开户、令牌、停用 |
| 成员管理 | 成员清单 | 开户、档案修改、令牌、启停、空壳删除 |
| Key 管理 | key 清单 | 登记、启停、归属 |
| 支付记录 | 记录清单 | 订阅/充值登记、核对 |
| 调用日志 | 请求明细 | 全池模型请求查询与筛选 |
@@ -45,7 +45,7 @@
### 3.1 时间范围选择器(核心组件)
- 一切统计的公共维度:**任意时段** + 快捷项
- 快捷项:最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 快捷项:**今天** / 最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
### 3.2 数值呈现三约定
+24 -8
View File
@@ -1,11 +1,11 @@
# 交互设计 · 成员管理
> 成员实体的操作面:开户、令牌签发与重发(明文仅展示一次)、停用。访问主体模型中「凭证事务经作者」的落点。
> 成员实体的操作面:开户、档案修改(名称/备注)、令牌签发与重发(明文仅展示一次)、停用/启用、空壳删除。访问主体模型中「凭证事务经作者」的落点。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、令牌不过期)· R12(成员视图的锚点) |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、档案可改、空壳删除)· R12(成员视图的锚点)· R16 |
| 数据 | `members` 全表 · 名下 key 聚合 |
---
@@ -22,13 +22,21 @@
| 状态 | active / disabled 徽章 |
| 令牌签发时间 | `token_issued_at`(提示「明文已不可找回,如需新令牌请重发」) |
| 名下 key 数 | 聚合计数(hover 列出 key 名) |
| 操作 | 重发令牌 / 停用(或启用) |
| 操作 | 编辑 / 重发令牌 / 停用(或启用)/ 删除(见 §5 条件) |
## 2. 开户
表单:名称(必填、唯一性校验)、备注(可空)→ 提交 → **自动生成令牌** → 明文展示对话框(见 §3)
## 3. 令牌签发与重发(敏感操作的标准交互)
## 3. 档案修改(名称 / 备注)(R16)
- **入口**:卡片操作「编辑」→ 小对话框(或行内编辑,实现期定)
- **可改字段**:名称(必填、唯一性校验)、备注(可空)——**仅此两项**
- 不含启停、不含令牌(各自走 §4 / §5 / 既有重发);`created_at` 等只读展示
- 名称冲突提示与开户一致(「名称已被占用」)
- 典型场景:误建/占位名 → 挂 key 前改名;已发出令牌时改名优于删除重建(重建会作废旧令牌)
## 4. 令牌签发与重发(敏感操作的标准交互)
**明文展示对话框**(开户自动触发,或重发触发):
@@ -38,12 +46,20 @@
- **重发**的确认弹窗先行:「重发后旧令牌立即失效(该成员正在用的访问将中断)」→ 确认 → 新明文对话框
- 对话框关闭后,界面任何地方不再出现明文(库里只有哈希——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,作者认可布局重写) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· 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 的模型请求流水 | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(完全公开) | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(同账本域内可见) | |
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅看期间重叠 ∪ 进行中,充值看 `paid_at`。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅 = 涉及我日志的 `subscription_id` ∪ 我域内进行中;充值 = 我域内且 `paid_at` 落在窗口内。
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
@@ -83,8 +83,9 @@
### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链)
- 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
### 4.2 明细表(页面主体,表体滚动)
@@ -113,11 +114,11 @@
## 5. 支付参数页
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
### 5.1 页头
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
### 5.2 订阅表
@@ -139,7 +140,7 @@
| 派生额度 | `amount × qpu` + $(展示) |
| 备注 | `remark` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
- 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
+9 -7
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 待审阅(2026-10-09 随拆表重写) |
| 状态 | 定稿(2026-10-12,作者认可实现;补订阅 ID / 交易号筛选) |
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
@@ -19,14 +19,15 @@
| **订阅** | 订阅权益表 | 登记订阅 |
| **充值** | 充值入账表 | 登记充值 |
筛选随 Tab:
筛选随 Tab(均为前端过滤——清单全量直返):
| Tab | 筛选 | URL |
|-----|------|-----|
| 订阅 | 订阅 ID / 期间与所选窗口重叠(拆表后) | `tab=subs` |
| 充值 | **仅类型**(付费时间筛选已撤——`paid_at` 语义待拆表钉死) | `tab=topups` · `kind` |
| 订阅 | 订阅 ID(精确,可空)· 账本域(含「未标注」) | `tab=subs` · `subscription_id` |
| 充值 | 交易号(子串,可空)· 账本域 | `tab=topups` · `trade_no` |
当前实现(拆表前):仅「类型」筛选;补录审计可看「登记」列(`created_at`)。
- 切换 Tab 保留另一 Tab 已填筛选;URL 同步便于从调用日志来源徽章深链(`subscription_id`)
- 补录审计可看「登记」列(`created_at`)
## 2. 订阅清单列
@@ -50,13 +51,14 @@
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
| 派生额度 | `amount × qpu` + $(展示,不入库) |
| 备注 / 登记 | `remark` · `created_at` |
| 账本域 | `billing_domain`(可空;列表可按域筛,含「未标注」) |
| 操作 | 编辑 / 删除 |
## 4. 登记表单(分 Tab,不再 `kind` 切换)
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
### 4.1 选用弹层(订阅 / 充值同规则)
+1 -1
View File
@@ -39,7 +39,7 @@ GET 管理面入口
## 4. 成员登录壳
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮
- **唯一入口**:令牌输入框(单字段)+ 进入按钮;**根路径 `/` 默认落到本页**(已持令牌则进成员总览);管理面入口形态(是否在界面露出)待定,当前经地址栏 `/admin` 进入
- 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
+21 -5
View File
@@ -4,9 +4,9 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-08) |
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) |
| 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
| 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
---
@@ -23,12 +23,26 @@
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 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. 日志表(页面主体)
@@ -45,7 +59,8 @@
| 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选
- **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
- 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5)
@@ -58,6 +73,7 @@
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界
+3 -3
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(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/admin/engine` 的状态衔接 · 配置与验收 |
@@ -26,7 +26,7 @@ apps/engine ──► 定时 syncRound() ──► packages/db
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) |
| 运行形态 | 开发:两个命令(`dev:server` / `dev:engine`);生产两个进程(均连同一 PostgreSQL) |
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
@@ -174,7 +174,7 @@ apps/
## 9. 验收清单(实现后)
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:engine`(或只跑 engine + db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
- [ ] 同一数据重复轮次不产生重复行
- [ ] 某 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 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+11 -15
View File
@@ -22,7 +22,6 @@
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
| 加密 | WebCrypto(Bun 原生) |
| 前端 | **Vue 3 + antdv-next + Vite** |
| 部署 | **Docker**(倾向,§6) |
## 2. 语言:TypeScript 全栈
@@ -39,7 +38,7 @@
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§7.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
| 组件库 | **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 垫片(同原语) |
| 构建 / 测试 | **Vite**(web);质量门 = `typecheck` + `build` + 人工验收 | 前端构建走 Vite 生态默认;自动化测试不在当前范围 | 需要时再引入测试框架 |
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
@@ -49,11 +48,13 @@
```text
(仓库根)
├─ apps/
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 进程内调度 · 环境变量与密钥注入
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 环境变量与密钥注入
│ ├─ engine/ # 同步引擎:独立进程(《同步引擎设计》)
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite
├─ packages/
│ ├─ 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 以纯类型依赖引用(前后端共用一份契约类型)。
@@ -64,24 +65,20 @@
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 与仓储接口不动。
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
## 6. 部署通道(倾向,细则归后续部署设计)
母体以 **Docker 镜像**交付(server 容器 + PostgreSQL,compose 编排):满足仓库「一条命令链可部署」约定;运营方服务器零环境依赖(Bun 运行时随镜像内置),升级 = 换镜像重启;作者不接触服务器,镜像化把运行时选择与运营方环境解耦。
## 7. 风险与回退
## 6. 风险与回退
| 风险 | 评估 | 对策 |
|------|------|------|
| 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 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
### 7.1 antdv-next 核验记录(2026-10-02,一手源直查)
### 6.1 antdv-next 核验记录(2026-10-02,一手源直查)
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
@@ -96,7 +93,6 @@ Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
## 8. 待裁决项
## 7. 待裁决项
1. Docker 细则:基础镜像、compose 形态、密钥注入方式——部署设计时定;
2. 前端测试设施是否引入 vitest。
1. 前端测试设施是否引入 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`
+18 -4
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 状态 | 定稿(2026-10-01;R16 定稿 2026-10-10) |
| 关联 | `README.md`(背景、动因与架构约束)· 各篇表字段设计 · `docs/references/NewAPI-接口范围.md`(可用接口参考) |
| 取代 | 《设计议题》(2026-09-29 由本清单取代;其中双形态裁决与 CFW 调研结论已并入本文 §5/§7) |
@@ -56,13 +56,14 @@
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| 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. 交付域(已裁决)
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| 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) |
## 6. 数据模型
@@ -92,6 +93,8 @@
**需求阶段收官(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. 裁决记录
### 2026-09-29 · 访问主体模型(R11 · R12 · 关联 R5)
@@ -134,7 +137,7 @@
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、**完全公开透明**(订阅与支付参数对成员可见)、无「应付」概念
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、支付参数**同账本域内可见**、无「应付」概念
### 2026-10-01 · R15 审阅定稿
@@ -164,6 +167,17 @@
- **展示仍称「支付记录」**:管理面一菜单两 Tab;成员「支付参数」两组清单;R7/R8/R9 哲学不变(只存事实、`subscription_id` 直连、币种无关)
- **原 `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)
**裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点:
+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 -3
View File
@@ -1,8 +1,12 @@
{
"name": "new-api-key-usage",
"private": true,
"license": "MIT",
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit",
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
@@ -11,10 +15,16 @@
"db:generate": "bun run --cwd packages/db db:generate",
"db:push": "bun run --cwd packages/db db:push",
"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": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2",
"citty": "^0.2.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 }),
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: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
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(),
});
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({
items: z.array(logItem),
total: z.number().int(),
summary: logPageSummary,
models: z.array(z.string()),
});
export const keyItem = z.object({
@@ -79,6 +89,7 @@ export const keyItem = z.object({
status: z.enum(["active", "disabled"]),
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
billing_domain: z.string().nullable(),
registered_at: z.number().int(),
log_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({
sk_key: z.string().min(8),
member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
});
export const patchKeyBody = z.object({
status: z.enum(["active", "disabled"]).optional(),
member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
});
export const memberItem = z.object({
@@ -115,7 +128,9 @@ export const createMemberBody = 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({
@@ -139,6 +154,7 @@ export const subscriptionItem = z.object({
quota_usd: z.string(),
paid_amount: z.number().nullable(),
paid_at: z.number().int().nullable(),
billing_domain: z.string().nullable(),
remark: z.string(),
created_at: z.number().int(),
});
@@ -155,6 +171,7 @@ export const topupItem = z.object({
paid_at: z.number().int(),
quota_derived: z.number().int(),
quota_usd: z.string(),
billing_domain: z.string().nullable(),
remark: z.string(),
created_at: z.number().int(),
});
@@ -216,6 +233,7 @@ export const createSubscriptionBody = z
quota_granted: z.number().int().positive(),
paid_amount: z.number().nullable().optional(),
paid_at: z.string().min(1).nullable().optional(),
billing_domain: z.string().nullable().optional(),
remark: z.string().default(""),
});
@@ -228,6 +246,7 @@ export const createTopupBody = z.object({
payment_provider: z.string().nullable().optional(),
status: z.string().nullable().optional(),
paid_at: z.string().min(1),
billing_domain: z.string().nullable().optional(),
remark: z.string().default(""),
});
+20
View File
@@ -46,6 +46,7 @@ export interface KeyItem {
status: KeyStatus;
member_id: number | null;
member_name: string | null;
billing_domain: string | null;
registered_at: UnixSec;
log_synced_at: UnixSec | null;
usage_synced_at: UnixSec | null;
@@ -66,6 +67,7 @@ export interface SubscriptionItem {
quota_usd: QuotaUsd;
paid_amount: PaidAmount | null;
paid_at: UnixSec | null;
billing_domain: string | null;
remark: string;
created_at: UnixSec;
}
@@ -83,6 +85,7 @@ export interface TopupItem {
paid_at: UnixSec;
quota_derived: Quota;
quota_usd: QuotaUsd;
billing_domain: string | null;
remark: string;
created_at: UnixSec;
}
@@ -111,6 +114,21 @@ export interface Paginated<T> {
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 {
started_at: UnixSec;
keys_total: number;
@@ -138,10 +156,12 @@ export type AuditAction =
| "payment.update"
| "payment.delete"
| "member.create"
| "member.update"
| "member.enable"
| "member.disable"
| "member.token_issue"
| "member.token_reissue"
| "member.delete"
| "account.password_change"
| "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,
"tag": "0000_loose_goblin_queen",
"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 { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import * as schema from "./schema.ts";
@@ -9,5 +10,10 @@ export function createDb(connectionString: string) {
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 "./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;
}
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。
* apps/server 不 import drizzle-orm / postgres 驱动。
@@ -115,6 +159,7 @@ export function createRepos(db: Db) {
async listAll(filter?: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
}): Promise<KeyListRow[]> {
const conds = [];
if (filter?.member_id === "unassigned") {
@@ -125,6 +170,11 @@ export function createRepos(db: Db) {
if (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
.select({
id: apiKeys.id,
@@ -132,6 +182,7 @@ export function createRepos(db: Db) {
key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status,
member_id: apiKeys.member_id,
billing_domain: apiKeys.billing_domain,
registered_at: apiKeys.registered_at,
log_synced_at: apiKeys.log_synced_at,
usage_synced_at: apiKeys.usage_synced_at,
@@ -220,6 +271,7 @@ export function createRepos(db: Db) {
key_hash: string;
key_ciphertext: Uint8Array;
member_id: number | null;
billing_domain?: string | null;
token_name?: string;
}): Promise<ApiKeyRow> {
const [row] = await db
@@ -228,6 +280,7 @@ export function createRepos(db: Db) {
key_hash: values.key_hash,
key_ciphertext: values.key_ciphertext,
member_id: values.member_id,
billing_domain: values.billing_domain ?? null,
token_name: values.token_name ?? "",
})
.returning();
@@ -294,6 +347,23 @@ export function createRepos(db: Db) {
.returning();
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(
id: number,
token_hash: string,
@@ -387,11 +457,23 @@ export function createRepos(db: Db) {
},
subscriptions: {
async listAll(): Promise<SubscriptionRow[]> {
return db
async listAll(filter?: {
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()
.from(subscriptions)
.orderBy(desc(subscriptions.period_start));
if (conds.length > 0) {
return base.where(and(...conds));
}
return base;
},
async findById(id: number): Promise<SubscriptionRow | null> {
const rows = await db
@@ -434,8 +516,20 @@ export function createRepos(db: Db) {
},
topups: {
async listAll(): Promise<TopupRow[]> {
return db.select().from(topups).orderBy(desc(topups.paid_at));
async listAll(filter?: {
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> {
const rows = await db
@@ -507,35 +601,12 @@ export function createRepos(db: Db) {
member_id?: number | "unassigned";
billing_source?: string | "none";
subscription_id?: number;
model_name?: string;
type?: number | "error";
page: number;
page_size: number;
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
const conds = [
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "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 conds = buildLogConds(params);
const totalRows = await db
.select({ n: count() })
@@ -655,29 +726,10 @@ export function createRepos(db: Db) {
api_key_id?: number;
billing_source?: string | "none";
subscription_id?: number;
model_name?: string;
type?: number | "error";
}) {
const conds = [
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 conds = buildLogConds({ ...params, member_id: params.member_id });
const totalRows = await db
.select({ n: count() })
.from(usageLogs)
@@ -721,6 +773,122 @@ export function createRepos(db: Db) {
.offset((params.page - 1) * params.page_size);
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: {
+3
View File
@@ -44,6 +44,7 @@ export const apiKeys = pgTable(
member_id: bigint("member_id", { mode: "number" }).references(
() => members.id,
),
billing_domain: text("billing_domain"),
registered_at: timestamp("registered_at", {
withTimezone: true,
mode: "date",
@@ -135,6 +136,7 @@ export const subscriptions = pgTable(
mode: "number",
}),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", {
withTimezone: true,
@@ -161,6 +163,7 @@ export const topups = pgTable("topups", {
payment_provider: text("payment_provider"),
status: text("status"),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", {
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",
"packages/*/src/**/*.ts",
"packages/*/test/**/*.ts",
"packages/*/drizzle.config.ts"
"packages/*/drizzle.config.ts",
"scripts/**/*.ts"
]
}