Compare commits
15
Commits
e293a8e5a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29019fc5d5 | ||
|
|
8d4a5c4f94 | ||
|
|
ddb1b56459 | ||
|
|
fdc5942a72 | ||
|
|
199972a2dc | ||
|
|
b0e9ea750b | ||
|
|
843ab4433a | ||
|
|
75a567611b | ||
|
|
c0051cff03 | ||
|
|
2dbd625e87 | ||
|
|
905d4ce77e | ||
|
|
86782c63ad | ||
|
|
9966f03347 | ||
|
|
d0bab545b1 | ||
|
|
f4f5ed0b0f |
+5
-3
@@ -1,12 +1,14 @@
|
|||||||
|
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
|
||||||
.git
|
.git
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/dist
|
apps
|
||||||
**/.tmp
|
packages
|
||||||
**/.vite
|
scripts
|
||||||
|
docs
|
||||||
*.log
|
*.log
|
||||||
api*.json
|
api*.json
|
||||||
session-archive
|
session-archive
|
||||||
|
|||||||
+8
-2
@@ -1,9 +1,9 @@
|
|||||||
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
|
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
|
||||||
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
|
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
|
||||||
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
|
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
|
||||||
# 部署:docker compose up -d --build(见 README)
|
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
|
||||||
|
|
||||||
# 本服务端口(避开 3000/3001 等常见占用;容器内固定 18456 即可)
|
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
|
||||||
PORT=18456
|
PORT=18456
|
||||||
|
|
||||||
# PostgreSQL 连接串——数据库配置的唯一来源
|
# PostgreSQL 连接串——数据库配置的唯一来源
|
||||||
@@ -24,8 +24,14 @@ NEWAPI_BASE=http://127.0.0.1:8080
|
|||||||
# 会话 cookie 是否带 Secure(默认 true)。
|
# 会话 cookie 是否带 Secure(默认 true)。
|
||||||
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
|
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
|
||||||
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
|
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
|
||||||
|
# HTTPS + 域名部署务必 true。
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
|
||||||
|
# 仅影响宿主机映射;容器内仍是 80/443
|
||||||
|
HTTP_PORT=80
|
||||||
|
HTTPS_PORT=443
|
||||||
|
|
||||||
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
|
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,11 @@
|
|||||||
# 依赖与构建产物
|
# 依赖与构建产物
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
bin/
|
||||||
build/
|
build/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
release/
|
||||||
|
.bun-cache/
|
||||||
|
|
||||||
# 测试与工具
|
# 测试与工具
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
|
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
|
||||||
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
|
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
|
||||||
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||||
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
|
|
||||||
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
|
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
|
||||||
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
|
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
|
||||||
|
|
||||||
@@ -27,14 +26,16 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
|
||||||
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||||
| `bun run db:push` | 推送到本地库(开发期) |
|
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
|
||||||
| `bun run db:studio` | Drizzle Studio 看库 |
|
| `bun run db:studio` | Drizzle Studio 看库 |
|
||||||
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
||||||
| `bun run dev:server` | 开发跑后端(hot reload) |
|
| `bun run dev:server` | 开发跑后端(hot reload) |
|
||||||
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
||||||
| `bun run dev:web` | 开发跑前端 Vite |
|
| `bun run dev:web` | 开发跑前端 Vite |
|
||||||
| `bun run build:web` | 前端类型检查 + 产物构建 |
|
| `bun run build:web` | 前端类型检查 + 产物构建 |
|
||||||
| `docker compose up -d --build` | 部署一键起(server / engine / caddy / pg) |
|
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
|
||||||
|
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
|
||||||
|
| `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
|
||||||
|
|
||||||
环境变量见 `.env.example`(复制为 `.env` 后填写)。
|
环境变量见 `.env.example`(复制为 `.env` 后填写)。
|
||||||
|
|
||||||
|
|||||||
+14
-30
@@ -1,37 +1,21 @@
|
|||||||
# 语法:docker/dockerfile:1
|
# 语法:docker/dockerfile:1
|
||||||
# 构建产物:/app/naku-server · /app/naku-engine · /app/drizzle · /srv(前端)
|
# 只装配 release/ 产物,构建期不编译业务代码
|
||||||
|
|
||||||
# ---------- 构建:依赖 + 前端 + 两个单文件 ----------
|
# ---------- 应用运行时 ----------
|
||||||
FROM oven/bun:1.4-alpine AS build
|
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
|
||||||
WORKDIR /src
|
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
|
||||||
|
FROM debian:bookworm-slim AS app
|
||||||
COPY package.json bun.lock ./
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY apps ./apps
|
ca-certificates libstdc++6 \
|
||||||
COPY packages ./packages
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# 前端静态资源
|
|
||||||
RUN bun run --cwd apps/web build-only
|
|
||||||
|
|
||||||
# 单文件(linux-x64;构建发生在 Linux 容器内,无需交叉编译)
|
|
||||||
RUN mkdir -p /out \
|
|
||||||
&& bun build --compile --outfile /out/naku-server apps/server/src/index.ts \
|
|
||||||
&& bun build --compile --outfile /out/naku-engine apps/engine/src/index.ts
|
|
||||||
|
|
||||||
# ---------- 应用运行时:两个二进制 + 迁移 SQL ----------
|
|
||||||
FROM alpine:3.20 AS app
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
# Bun 单文件仍需 C++ 运行时与 CA;从构建镜像拷贝,避免 apk 联网
|
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
|
||||||
COPY --from=build /usr/lib/libstdc++.so.6 /usr/lib/libgcc_s.so.1 /usr/lib/
|
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
|
||||||
COPY --from=build /etc/ssl/certs /etc/ssl/certs
|
COPY release/drizzle /app/drizzle
|
||||||
COPY --from=build /out/naku-server /out/naku-engine /app/
|
|
||||||
COPY --from=build /src/packages/db/drizzle /app/drizzle
|
|
||||||
ENV MIGRATIONS_FOLDER=/app/drizzle
|
ENV MIGRATIONS_FOLDER=/app/drizzle
|
||||||
# 默认跑 server;compose 里 engine 覆盖 command
|
|
||||||
CMD ["/app/naku-server"]
|
CMD ["/app/naku-server"]
|
||||||
|
|
||||||
# ---------- 网关:Caddy + 前端静态资源 ----------
|
# ---------- 网关(Caddy + 前端静态) ----------
|
||||||
FROM caddy:2-alpine AS proxy
|
FROM caddy:2-alpine AS proxy
|
||||||
COPY deploy/Caddyfile /etc/caddy/Caddyfile
|
COPY Caddyfile /etc/caddy/Caddyfile
|
||||||
COPY --from=build /src/apps/web/dist /srv
|
COPY release/dist /srv
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
| 技术栈 | 全栈 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
|
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
|
||||||
- 全场景统一小写 kebab-case:仓库名 / 包名一致
|
- 仓库名 / 包名统一小写 kebab-case
|
||||||
|
|
||||||
## 背景与约束
|
## 背景与约束
|
||||||
|
|
||||||
- 真实场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
|
- 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
|
||||||
- 项目作者是该实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
|
- 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
|
||||||
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
|
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
|
||||||
- 交付形态为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
|
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`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`) |
|
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
|
||||||
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;对成员完全公开透明 |
|
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
|
||||||
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
|
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
|
||||||
|
|
||||||
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
|
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
|
||||||
@@ -69,7 +71,7 @@ packages/
|
|||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
|
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
|
||||||
bun run db:push # 开发期推送 schema
|
bun run db:push # 开发期推送 schema(启动不自动迁)
|
||||||
bun run dev:server # 后端(默认 18456)
|
bun run dev:server # 后端(默认 18456)
|
||||||
bun run dev:engine # 同步引擎(另开终端)
|
bun run dev:engine # 同步引擎(另开终端)
|
||||||
bun run dev:web # 前端 Vite(5175)
|
bun run dev:web # 前端 Vite(5175)
|
||||||
@@ -81,55 +83,39 @@ bun run dev:web # 前端 Vite(5175)
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
生产形态:**Caddy**(前端静态 + `/api` 反代)+ **server / engine 单文件**(Bun `--compile`)+ PostgreSQL;启动时自动应用 `packages/db/drizzle` 迁移。开发不依赖本 Compose——把 `DATABASE_URL` 指到任意可用 PostgreSQL 即可。
|
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
|
||||||
|
|
||||||
### 起服务
|
### 本地打包
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
bun run pack release
|
||||||
# 必改:DATABASE_URL、SK_KEY_ENCRYPTION_SECRET、NEWAPI_BASE、COOKIE_SECURE
|
# 交互选:目标平台 · 打包内容 · 是否 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 up -d --build
|
||||||
|
docker compose ps # migrate 应为 exited(0),其余 running/healthy
|
||||||
```
|
```
|
||||||
|
|
||||||
- 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs`
|
- 页面:`http://<服务器>/`(若改了 `HTTP_PORT` 则加端口);接口文档:`http://<服务器>/api/docs`
|
||||||
- 升级:`git pull` 后重复 `docker compose up -d --build`(改表随启动迁移)
|
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
|
||||||
|
- 端口:默认 80/443;宿主机被占用时在 `.env` 设 `HTTP_PORT` / `HTTPS_PORT`,再执行固定命令
|
||||||
### 环境变量要点
|
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
|
||||||
|
|
||||||
| 变量 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `DATABASE_URL` | 数据库配置的**唯一来源**(用户 / 密码 / 库名都在串里)。开发指向本机库;部署同一份即可,compose 会把主机改写为容器服务名 `postgres` |
|
|
||||||
| `SK_KEY_ENCRYPTION_SECRET` | ≥32 字节随机数(base64url)。**生成一次、入库后不可更换**——丢失则已登记 key 无法解密,与库同等备份 |
|
|
||||||
| `NEWAPI_BASE` | 上游 NewAPI 实例根地址 |
|
|
||||||
| `COOKIE_SECURE` | HTTPS / 域名用 `true`;纯 IP + HTTP 用 `false`(否则登录后立刻 401) |
|
|
||||||
| `ADMIN_INIT_USERNAME` / `ADMIN_INIT_PASSWORD` | 可选;同时存在时空库首次静默建管理员 |
|
|
||||||
|
|
||||||
### 服务分工
|
|
||||||
|
|
||||||
| 服务 | 职责 | 对外 |
|
|
||||||
|------|------|------|
|
|
||||||
| `caddy` | 前端静态 + `/api` 反代 | 仅 80 / 443 |
|
|
||||||
| `server` | 管理面 / 成员视图 API | 内网 |
|
|
||||||
| `engine` | 轮询上游、去重入库 | 内网 |
|
|
||||||
| `postgres` | 数据(卷 `pgdata`) | 内网 |
|
|
||||||
|
|
||||||
### HTTPS
|
|
||||||
|
|
||||||
把 `deploy/Caddyfile` 站点行从 `:80` 改成域名(如 `your.domain.example`),重建后 Caddy 自动签证书;同时设 `COOKIE_SECURE=true`。
|
|
||||||
|
|
||||||
### 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose ps # 运行状态
|
|
||||||
docker compose logs -f engine # 看同步
|
|
||||||
docker compose restart server # 只重启后端
|
|
||||||
docker compose down # 全停(数据在卷里,不丢)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 注意
|
### 注意
|
||||||
|
|
||||||
1. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
|
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
|
||||||
2. `SK_KEY_ENCRYPTION_SECRET` 更换等于已登记 key 全部作废
|
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
|
||||||
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
|
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
|
||||||
|
|
||||||
## 文档地图
|
## 文档地图
|
||||||
@@ -138,11 +124,12 @@ docker compose down # 全停(数据在卷里,不丢)
|
|||||||
|------|-----------|------|
|
|------|-----------|------|
|
||||||
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
|
| [`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) | 技术栈裁决:全栈 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/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
|
||||||
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
|
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
|
||||||
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
|
| [`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/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 定稿(2026-10-12) |
|
||||||
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 待审阅(2026-10-09) |
|
| [`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/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
|
||||||
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
|
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
|
||||||
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
|
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
|
||||||
@@ -150,20 +137,22 @@ docker compose down # 全停(数据在卷里,不丢)
|
|||||||
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
|
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
|
||||||
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
|
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
|
||||||
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
|
| [`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/交互设计-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) | 支付记录(两 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-10) |
|
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
|
||||||
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
|
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
|
||||||
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
|
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
|
||||||
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 待审阅(2026-10-09 支付拆表);R16 定稿 |
|
| [`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-09) |
|
| [`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/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
|
||||||
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
|
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
|
||||||
|
|
||||||
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
|
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
|
||||||
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
|
||||||
|
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
|
||||||
|
|
||||||
## 关键事实清单(实测 / 源码确认,设计时不得违反)
|
## 关键事实清单(实测 / 源码确认,设计时不得违反)
|
||||||
|
|
||||||
@@ -175,3 +164,9 @@ docker compose down # 全停(数据在卷里,不丢)
|
|||||||
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
|
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
|
||||||
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
|
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
|
||||||
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
|
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](LICENSE) © 2026 skycurtain
|
||||||
|
|
||||||
|
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
||||||
import { createDb, createRepos, runMigrations } from "@new-api-key-usage/db";
|
import { createDb, createRepos } from "@new-api-key-usage/db";
|
||||||
import { loadEnv } from "./env.ts";
|
import { loadEnv } from "./env.ts";
|
||||||
import { createSyncLoop } from "./loop.ts";
|
import { createSyncLoop } from "./loop.ts";
|
||||||
|
|
||||||
@@ -10,12 +9,6 @@ const log = appLogger("engine");
|
|||||||
|
|
||||||
const db = createDb(env.DATABASE_URL);
|
const db = createDb(env.DATABASE_URL);
|
||||||
|
|
||||||
const migrationsFolder =
|
|
||||||
process.env.MIGRATIONS_FOLDER ??
|
|
||||||
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
|
|
||||||
await runMigrations(db, migrationsFolder);
|
|
||||||
log.info("数据库迁移已就绪");
|
|
||||||
|
|
||||||
const repos = createRepos(db);
|
const repos = createRepos(db);
|
||||||
|
|
||||||
const loop = createSyncLoop({
|
const loop = createSyncLoop({
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
|
|||||||
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
|
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
|
||||||
{ name: "引擎", description: "同步引擎状态" },
|
{ name: "引擎", description: "同步引擎状态" },
|
||||||
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
|
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
|
||||||
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
|
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
|
||||||
{ name: "成员视图", description: "令牌自助查询" },
|
{ name: "成员视图", description: "令牌自助查询" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
import { loadEnv } from "./env.ts";
|
import { loadEnv } from "./env.ts";
|
||||||
import { createDb, runMigrations } from "@new-api-key-usage/db";
|
import { createDb } from "@new-api-key-usage/db";
|
||||||
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
||||||
|
|
||||||
const env = loadEnv(process.env);
|
const env = loadEnv(process.env);
|
||||||
@@ -9,13 +8,6 @@ setupLogging(env.LOG_LEVEL);
|
|||||||
|
|
||||||
const db = createDb(env.DATABASE_URL);
|
const db = createDb(env.DATABASE_URL);
|
||||||
|
|
||||||
const migrationsFolder =
|
|
||||||
process.env.MIGRATIONS_FOLDER ??
|
|
||||||
fileURLToPath(new URL("../../../packages/db/drizzle", import.meta.url));
|
|
||||||
|
|
||||||
await runMigrations(db, migrationsFolder);
|
|
||||||
appLogger("server").info("数据库迁移已就绪");
|
|
||||||
|
|
||||||
const app = createApp({
|
const app = createApp({
|
||||||
db,
|
db,
|
||||||
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
|
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
|
|||||||
status: row.status as "active" | "disabled",
|
status: row.status as "active" | "disabled",
|
||||||
member_id: row.member_id,
|
member_id: row.member_id,
|
||||||
member_name: row.member_name ?? null,
|
member_name: row.member_name ?? null,
|
||||||
|
billing_domain: row.billing_domain,
|
||||||
registered_at: toUnixSec(row.registered_at)!,
|
registered_at: toUnixSec(row.registered_at)!,
|
||||||
log_synced_at: toUnixSec(row.log_synced_at),
|
log_synced_at: toUnixSec(row.log_synced_at),
|
||||||
usage_synced_at: toUnixSec(row.usage_synced_at),
|
usage_synced_at: toUnixSec(row.usage_synced_at),
|
||||||
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
|
|||||||
query: z.object({
|
query: z.object({
|
||||||
member_id: z.string().optional(),
|
member_id: z.string().optional(),
|
||||||
status: z.enum(["active", "disabled"]).optional(),
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
|
billing_domain: z.string().optional(),
|
||||||
recent_failed: z.enum(["true", "false"]).optional(),
|
recent_failed: z.enum(["true", "false"]).optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
|
|||||||
const filter: {
|
const filter: {
|
||||||
member_id?: number | "unassigned";
|
member_id?: number | "unassigned";
|
||||||
status?: string;
|
status?: string;
|
||||||
|
billing_domain?: string | "none";
|
||||||
} = {};
|
} = {};
|
||||||
if (q.member_id === "unassigned") filter.member_id = "unassigned";
|
if (q.member_id === "unassigned") filter.member_id = "unassigned";
|
||||||
else if (q.member_id) filter.member_id = Number(q.member_id);
|
else if (q.member_id) filter.member_id = Number(q.member_id);
|
||||||
if (q.status === "active" || q.status === "disabled") {
|
if (q.status === "active" || q.status === "disabled") {
|
||||||
filter.status = q.status;
|
filter.status = q.status;
|
||||||
}
|
}
|
||||||
|
if (q.billing_domain === "none") filter.billing_domain = "none";
|
||||||
|
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
|
||||||
let rows = await c.get("repos").keys.listAll(filter);
|
let rows = await c.get("repos").keys.listAll(filter);
|
||||||
if (q.recent_failed === "true") {
|
if (q.recent_failed === "true") {
|
||||||
const snap = await c.get("repos").engine.getSnapshot();
|
const snap = await c.get("repos").engine.getSnapshot();
|
||||||
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
|
|||||||
}
|
}
|
||||||
member_id = n;
|
member_id = n;
|
||||||
}
|
}
|
||||||
|
let billing_domain: string | null = null;
|
||||||
|
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
|
||||||
|
billing_domain = body.billing_domain.trim();
|
||||||
|
}
|
||||||
if (!sk_key || !sk_key.startsWith("sk-")) {
|
if (!sk_key || !sk_key.startsWith("sk-")) {
|
||||||
return jsonError(
|
return jsonError(
|
||||||
c,
|
c,
|
||||||
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
|
|||||||
key_hash,
|
key_hash,
|
||||||
key_ciphertext,
|
key_ciphertext,
|
||||||
member_id,
|
member_id,
|
||||||
|
billing_domain,
|
||||||
token_name: usage.name ?? "",
|
token_name: usage.name ?? "",
|
||||||
});
|
});
|
||||||
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
|
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
|
||||||
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/keys/{id}",
|
path: "/keys/{id}",
|
||||||
summary: "启停 / 归属变更",
|
summary: "启停 / 归属 / 账本域变更",
|
||||||
description: "部分更新;审计按实际变更字段各落一行",
|
description: "部分更新;审计按实际变更字段各落一行",
|
||||||
tags: ["Key 管理"],
|
tags: ["Key 管理"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (body && "billing_domain" in body) {
|
||||||
|
let nextDomain: string | null = null;
|
||||||
|
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
|
||||||
|
nextDomain = body.billing_domain.trim();
|
||||||
|
}
|
||||||
|
if (nextDomain !== before.billing_domain) {
|
||||||
|
patch.billing_domain = nextDomain;
|
||||||
|
actions.push({
|
||||||
|
action: "key.reassign",
|
||||||
|
summary: `key #${before.id} · 账本域变更`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
if (Object.keys(patch).length === 0) {
|
if (Object.keys(patch).length === 0) {
|
||||||
return jsonError(
|
return jsonError(
|
||||||
c,
|
c,
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
|
|||||||
createRoute({
|
createRoute({
|
||||||
method: "patch",
|
method: "patch",
|
||||||
path: "/members/{id}",
|
path: "/members/{id}",
|
||||||
summary: "停用 / 启用",
|
summary: "档案修改 / 停用 / 启用",
|
||||||
|
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
|
||||||
tags: ["成员管理"],
|
tags: ["成员管理"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
request: {
|
request: {
|
||||||
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
|
|||||||
},
|
},
|
||||||
401: err401,
|
401: err401,
|
||||||
404: err404,
|
404: err404,
|
||||||
|
409: err409,
|
||||||
422: err422,
|
422: err422,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
const body = await c.req.json().catch(() => null);
|
const body = await c.req.json().catch(() => null);
|
||||||
const status = body?.status;
|
|
||||||
if (status !== "active" && status !== "disabled") {
|
|
||||||
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
|
|
||||||
}
|
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const before = await repos.members.findById(id);
|
const before = await repos.members.findById(id);
|
||||||
if (!before) return jsonError(c, 404, ERR.not_found());
|
if (!before) return jsonError(c, 404, ERR.not_found());
|
||||||
const row = await repos.members.updateStatus(id, status);
|
|
||||||
|
const profile: { name?: string; remark?: string } = {};
|
||||||
|
const actions: Array<{ action: string; summary: string }> = [];
|
||||||
|
|
||||||
|
if (typeof body?.name === "string") {
|
||||||
|
const name = body.name.trim();
|
||||||
|
if (!name || name.length > 64) {
|
||||||
|
return jsonError(
|
||||||
|
c,
|
||||||
|
422,
|
||||||
|
ERR.validation_failed({ name: "名称无效" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (name !== before.name) {
|
||||||
|
const taken = await repos.members.findByName(name);
|
||||||
|
if (taken && taken.id !== id) {
|
||||||
|
return jsonError(c, 409, ERR.member_name_taken());
|
||||||
|
}
|
||||||
|
profile.name = name;
|
||||||
|
actions.push({
|
||||||
|
action: "member.update",
|
||||||
|
summary: `成员 ${before.name} · 改名为 ${name}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof body?.remark === "string") {
|
||||||
|
if (body.remark !== before.remark) {
|
||||||
|
profile.remark = body.remark;
|
||||||
|
actions.push({
|
||||||
|
action: "member.update",
|
||||||
|
summary: `成员 ${before.name} · 修改备注`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nextStatus: "active" | "disabled" | null = null;
|
||||||
|
if (body?.status === "active" || body?.status === "disabled") {
|
||||||
|
if (body.status !== before.status) {
|
||||||
|
nextStatus = body.status;
|
||||||
|
actions.push({
|
||||||
|
action: body.status === "active" ? "member.enable" : "member.disable",
|
||||||
|
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(profile).length === 0 && !nextStatus) {
|
||||||
|
return jsonError(
|
||||||
|
c,
|
||||||
|
422,
|
||||||
|
ERR.validation_failed({ body: "没有需要更新的字段" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = before;
|
||||||
|
if (Object.keys(profile).length > 0) {
|
||||||
|
row = await repos.members.updateProfile(id, profile);
|
||||||
|
}
|
||||||
|
if (nextStatus) {
|
||||||
|
row = await repos.members.updateStatus(id, nextStatus);
|
||||||
|
}
|
||||||
|
for (const a of actions) {
|
||||||
await repos.audit.insert({
|
await repos.audit.insert({
|
||||||
operator: c.get("adminUsername") ?? null,
|
operator: c.get("adminUsername") ?? null,
|
||||||
domain: "member",
|
domain: "member",
|
||||||
action: status === "active" ? "member.enable" : "member.disable",
|
action: a.action,
|
||||||
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
|
summary: a.summary,
|
||||||
});
|
});
|
||||||
return c.json(toMemberItem(row, 0), 200);
|
}
|
||||||
|
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: "member.delete",
|
||||||
|
summary: `删除成员 ${before.name}`,
|
||||||
|
});
|
||||||
|
return c.body(null, 204);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function toSubscriptionItem(
|
|||||||
quota_usd,
|
quota_usd,
|
||||||
paid_amount: row.paid_amount,
|
paid_amount: row.paid_amount,
|
||||||
paid_at: toUnixSec(row.paid_at),
|
paid_at: toUnixSec(row.paid_at),
|
||||||
|
billing_domain: row.billing_domain,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
created_at: toUnixSec(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
};
|
};
|
||||||
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
|||||||
quota_granted: number;
|
quota_granted: number;
|
||||||
paid_amount: number | null;
|
paid_amount: number | null;
|
||||||
paid_at: Date | null;
|
paid_at: Date | null;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
|||||||
quota_granted: body.quota_granted,
|
quota_granted: body.quota_granted,
|
||||||
paid_amount: body.paid_amount ?? null,
|
paid_amount: body.paid_amount ?? null,
|
||||||
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
||||||
|
billing_domain:
|
||||||
|
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||||
|
? body.billing_domain.trim()
|
||||||
|
: null,
|
||||||
remark: body.remark ?? "",
|
remark: body.remark ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
|
|||||||
summary: "订阅权益清单",
|
summary: "订阅权益清单",
|
||||||
tags: ["支付记录"],
|
tags: ["支付记录"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
|
request: {
|
||||||
|
query: z.object({
|
||||||
|
billing_domain: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "subscription 数组",
|
description: "subscription 数组",
|
||||||
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const rows = await repos.subscriptions.listAll();
|
const q = c.req.query();
|
||||||
|
const filter =
|
||||||
|
q.billing_domain === "none"
|
||||||
|
? { billing_domain: "none" as const }
|
||||||
|
: q.billing_domain
|
||||||
|
? { billing_domain: q.billing_domain }
|
||||||
|
: undefined;
|
||||||
|
const rows = await repos.subscriptions.listAll(filter);
|
||||||
return c.json(
|
return c.json(
|
||||||
rows.map((row) =>
|
rows.map((row) =>
|
||||||
toSubscriptionItem(
|
toSubscriptionItem(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
|
|||||||
paid_at: toUnixSec(row.paid_at)!,
|
paid_at: toUnixSec(row.paid_at)!,
|
||||||
quota_derived,
|
quota_derived,
|
||||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||||
|
billing_domain: row.billing_domain,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
created_at: toUnixSec(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
|||||||
payment_provider: string | null;
|
payment_provider: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
paid_at: Date;
|
paid_at: Date;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
|||||||
payment_provider: body.payment_provider ?? null,
|
payment_provider: body.payment_provider ?? null,
|
||||||
status: body.status ?? "success",
|
status: body.status ?? "success",
|
||||||
paid_at: new Date(body.paid_at),
|
paid_at: new Date(body.paid_at),
|
||||||
|
billing_domain:
|
||||||
|
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||||
|
? body.billing_domain.trim()
|
||||||
|
: null,
|
||||||
remark: body.remark ?? "",
|
remark: body.remark ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
|
|||||||
summary: "充值入账清单",
|
summary: "充值入账清单",
|
||||||
tags: ["支付记录"],
|
tags: ["支付记录"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
|
request: {
|
||||||
|
query: z.object({
|
||||||
|
billing_domain: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "topup 数组",
|
description: "topup 数组",
|
||||||
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const rows = await repos.topups.listAll();
|
const q = c.req.query();
|
||||||
|
const filter =
|
||||||
|
q.billing_domain === "none"
|
||||||
|
? { billing_domain: "none" as const }
|
||||||
|
: q.billing_domain
|
||||||
|
? { billing_domain: q.billing_domain }
|
||||||
|
: undefined;
|
||||||
|
const rows = await repos.topups.listAll(filter);
|
||||||
return c.json(
|
return c.json(
|
||||||
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export function adminUsageRoutes() {
|
|||||||
path: "/logs",
|
path: "/logs",
|
||||||
summary: "日志明细(下钻)",
|
summary: "日志明细(下钻)",
|
||||||
description:
|
description:
|
||||||
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
|
"分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
|
||||||
tags: ["用量"],
|
tags: ["用量"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
request: {
|
request: {
|
||||||
@@ -290,12 +290,13 @@ export function adminUsageRoutes() {
|
|||||||
member_id: z.string().optional(),
|
member_id: z.string().optional(),
|
||||||
billing_source: z.string().optional(),
|
billing_source: z.string().optional(),
|
||||||
subscription_id: z.string().optional(),
|
subscription_id: z.string().optional(),
|
||||||
|
model_name: z.string().optional(),
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "items/total",
|
description: "items/total/summary/models",
|
||||||
content: { "application/json": { schema: logPageSchema } },
|
content: { "application/json": { schema: logPageSchema } },
|
||||||
},
|
},
|
||||||
401: err401,
|
401: err401,
|
||||||
@@ -312,15 +313,13 @@ export function adminUsageRoutes() {
|
|||||||
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
|
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const { rows, total } = await repos.logs.listPage({
|
const filters = {
|
||||||
from: range.from,
|
from: range.from,
|
||||||
to: range.to,
|
to: range.to,
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||||
member_id:
|
member_id:
|
||||||
q.member_id === "unassigned"
|
q.member_id === "unassigned"
|
||||||
? "unassigned"
|
? ("unassigned" as const)
|
||||||
: q.member_id
|
: q.member_id
|
||||||
? Number(q.member_id)
|
? Number(q.member_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -329,13 +328,22 @@ export function adminUsageRoutes() {
|
|||||||
subscription_id: q.subscription_id
|
subscription_id: q.subscription_id
|
||||||
? Number(q.subscription_id)
|
? Number(q.subscription_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
model_name: q.model_name || undefined,
|
||||||
type:
|
type:
|
||||||
q.type === "error"
|
q.type === "error"
|
||||||
? "error"
|
? ("error" as const)
|
||||||
: q.type != null && q.type !== ""
|
: q.type != null && q.type !== ""
|
||||||
? Number(q.type)
|
? Number(q.type)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
};
|
||||||
|
const { rows, total } = await repos.logs.listPage({
|
||||||
|
...filters,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
});
|
});
|
||||||
|
const sum = await repos.logs.summarize(filters);
|
||||||
|
const usage = sumUsage(sum.usage_rows, timeline);
|
||||||
|
const models = await repos.logs.listModels(filters);
|
||||||
|
|
||||||
const items: LogItem[] = rows.map((row) => {
|
const items: LogItem[] = rows.map((row) => {
|
||||||
const { quota_usd } = usageRowPrecise(
|
const { quota_usd } = usageRowPrecise(
|
||||||
@@ -389,7 +397,21 @@ export function adminUsageRoutes() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return c.json({ items, total }, 200);
|
return c.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
summary: {
|
||||||
|
hit_count: sum.hit_count,
|
||||||
|
usage_count: sum.usage_count,
|
||||||
|
error_count: sum.error_count,
|
||||||
|
quota: usage.quota,
|
||||||
|
quota_usd: usage.quota_usd,
|
||||||
|
},
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function memberViewRoutes() {
|
|||||||
path: "/overview",
|
path: "/overview",
|
||||||
summary: "单页聚合",
|
summary: "单页聚合",
|
||||||
description:
|
description:
|
||||||
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
|
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
|
||||||
tags: ["成员视图"],
|
tags: ["成员视图"],
|
||||||
security: [{ memberToken: [] }],
|
security: [{ memberToken: [] }],
|
||||||
request: {
|
request: {
|
||||||
@@ -159,8 +159,13 @@ export function memberViewRoutes() {
|
|||||||
usage: sumUsage(v.rows, timeline),
|
usage: sumUsage(v.rows, timeline),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
|
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
|
||||||
|
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
|
||||||
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
|
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
|
||||||
|
const myDomains = new Set<string>();
|
||||||
|
for (const k of allKeys) {
|
||||||
|
if (k.billing_domain) myDomains.add(k.billing_domain);
|
||||||
|
}
|
||||||
const subRows = await repos.subscriptions.listAll();
|
const subRows = await repos.subscriptions.listAll();
|
||||||
const usedSubIds = new Set(
|
const usedSubIds = new Set(
|
||||||
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
|
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
|
||||||
@@ -172,8 +177,11 @@ export function memberViewRoutes() {
|
|||||||
s.period_end > new Date() ||
|
s.period_end > new Date() ||
|
||||||
(s.period_start <= range.to &&
|
(s.period_start <= range.to &&
|
||||||
(s.period_end ?? new Date()) >= range.from);
|
(s.period_end ?? new Date()) >= range.from);
|
||||||
const involved = usedSubIds.has(s.subscription_id) || ongoing;
|
const involved = usedSubIds.has(s.subscription_id);
|
||||||
if (!involved) continue;
|
const inMyDomain =
|
||||||
|
s.billing_domain != null && myDomains.has(s.billing_domain);
|
||||||
|
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
|
||||||
|
if (!involved && !(ongoing && inMyDomain)) continue;
|
||||||
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
|
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
|
||||||
subscriptionsOut.push({
|
subscriptionsOut.push({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
@@ -184,6 +192,7 @@ export function memberViewRoutes() {
|
|||||||
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
|
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
|
||||||
paid_amount: s.paid_amount,
|
paid_amount: s.paid_amount,
|
||||||
paid_at: toUnixSec(s.paid_at),
|
paid_at: toUnixSec(s.paid_at),
|
||||||
|
billing_domain: s.billing_domain,
|
||||||
remark: s.remark,
|
remark: s.remark,
|
||||||
created_at: toUnixSec(s.created_at)!,
|
created_at: toUnixSec(s.created_at)!,
|
||||||
});
|
});
|
||||||
@@ -191,6 +200,7 @@ export function memberViewRoutes() {
|
|||||||
const topupRows = await repos.topups.listAll();
|
const topupRows = await repos.topups.listAll();
|
||||||
const topupsOut = [];
|
const topupsOut = [];
|
||||||
for (const t of topupRows) {
|
for (const t of topupRows) {
|
||||||
|
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
|
||||||
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
|
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
|
||||||
const qpu = qpuAt(t.paid_at, timeline);
|
const qpu = qpuAt(t.paid_at, timeline);
|
||||||
const quota_derived = Math.round(t.amount * qpu);
|
const quota_derived = Math.round(t.amount * qpu);
|
||||||
@@ -204,6 +214,7 @@ export function memberViewRoutes() {
|
|||||||
payment_provider: t.payment_provider,
|
payment_provider: t.payment_provider,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
paid_at: toUnixSec(t.paid_at)!,
|
paid_at: toUnixSec(t.paid_at)!,
|
||||||
|
billing_domain: t.billing_domain,
|
||||||
quota_derived,
|
quota_derived,
|
||||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||||
remark: t.remark,
|
remark: t.remark,
|
||||||
@@ -230,7 +241,8 @@ export function memberViewRoutes() {
|
|||||||
method: "get",
|
method: "get",
|
||||||
path: "/logs",
|
path: "/logs",
|
||||||
summary: "日志明细(名下 key)",
|
summary: "日志明细(名下 key)",
|
||||||
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构",
|
description:
|
||||||
|
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
|
||||||
tags: ["成员视图"],
|
tags: ["成员视图"],
|
||||||
security: [{ memberToken: [] }],
|
security: [{ memberToken: [] }],
|
||||||
request: {
|
request: {
|
||||||
@@ -242,12 +254,13 @@ export function memberViewRoutes() {
|
|||||||
api_key_id: z.string().optional(),
|
api_key_id: z.string().optional(),
|
||||||
billing_source: z.string().optional(),
|
billing_source: z.string().optional(),
|
||||||
subscription_id: z.string().optional(),
|
subscription_id: z.string().optional(),
|
||||||
|
model_name: z.string().optional(),
|
||||||
type: z.string().optional(),
|
type: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "items/total",
|
description: "items/total/summary/models",
|
||||||
content: { "application/json": { schema: logPageSchema } },
|
content: { "application/json": { schema: logPageSchema } },
|
||||||
},
|
},
|
||||||
401: err401,
|
401: err401,
|
||||||
@@ -267,25 +280,33 @@ export function memberViewRoutes() {
|
|||||||
);
|
);
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const { rows, total } = await repos.logs.listMemberPage({
|
const member_id = c.get("memberId")!;
|
||||||
member_id: c.get("memberId")!,
|
const filters = {
|
||||||
|
member_id,
|
||||||
from: range.from,
|
from: range.from,
|
||||||
to: range.to,
|
to: range.to,
|
||||||
page,
|
|
||||||
page_size,
|
|
||||||
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
|
||||||
billing_source:
|
billing_source:
|
||||||
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
q.billing_source === "none" ? "none" : q.billing_source || undefined,
|
||||||
subscription_id: q.subscription_id
|
subscription_id: q.subscription_id
|
||||||
? Number(q.subscription_id)
|
? Number(q.subscription_id)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
model_name: q.model_name || undefined,
|
||||||
type:
|
type:
|
||||||
q.type === "error"
|
q.type === "error"
|
||||||
? "error"
|
? ("error" as const)
|
||||||
: q.type != null && q.type !== ""
|
: q.type != null && q.type !== ""
|
||||||
? Number(q.type)
|
? Number(q.type)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
};
|
||||||
|
const { rows, total } = await repos.logs.listMemberPage({
|
||||||
|
...filters,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
});
|
});
|
||||||
|
const sum = await repos.logs.summarizeMember(filters);
|
||||||
|
const usage = sumUsage(sum.usage_rows, timeline);
|
||||||
|
const models = await repos.logs.listMemberModels(filters);
|
||||||
const items: LogItem[] = rows.map((row) => {
|
const items: LogItem[] = rows.map((row) => {
|
||||||
const { quota_usd } = usageRowPrecise(
|
const { quota_usd } = usageRowPrecise(
|
||||||
row.quota,
|
row.quota,
|
||||||
@@ -337,7 +358,21 @@ export function memberViewRoutes() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return c.json({ items, total }, 200);
|
return c.json(
|
||||||
|
{
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
summary: {
|
||||||
|
hit_count: sum.hit_count,
|
||||||
|
usage_count: sum.usage_count,
|
||||||
|
error_count: sum.error_count,
|
||||||
|
quota: usage.quota,
|
||||||
|
quota_usd: usage.quota_usd,
|
||||||
|
},
|
||||||
|
models,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
|
|||||||
export type KeyListFilters = {
|
export type KeyListFilters = {
|
||||||
member_id?: number | "unassigned";
|
member_id?: number | "unassigned";
|
||||||
status?: "active" | "disabled";
|
status?: "active" | "disabled";
|
||||||
|
billing_domain?: string | "none";
|
||||||
recent_failed?: boolean;
|
recent_failed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
|
|||||||
export async function registerKey(body: {
|
export async function registerKey(body: {
|
||||||
sk_key: string;
|
sk_key: string;
|
||||||
member_id?: number | null;
|
member_id?: number | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
}): Promise<KeyItem> {
|
}): Promise<KeyItem> {
|
||||||
const { data } = await adminHttp.post<KeyItem>("/keys", body);
|
const { data } = await adminHttp.post<KeyItem>("/keys", body);
|
||||||
return data;
|
return data;
|
||||||
@@ -22,7 +24,11 @@ export async function registerKey(body: {
|
|||||||
|
|
||||||
export async function patchKey(
|
export async function patchKey(
|
||||||
id: number,
|
id: number,
|
||||||
body: { status?: "active" | "disabled"; member_id?: number | null },
|
body: {
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
member_id?: number | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
|
},
|
||||||
): Promise<KeyItem> {
|
): Promise<KeyItem> {
|
||||||
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
|
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LogItem, Paginated } from "@new-api-key-usage/core";
|
import type { LogPage } from "@new-api-key-usage/core";
|
||||||
import { adminHttp } from "../http.ts";
|
import { adminHttp } from "../http.ts";
|
||||||
|
|
||||||
export type AdminLogFilters = {
|
export type AdminLogFilters = {
|
||||||
@@ -10,13 +10,12 @@ export type AdminLogFilters = {
|
|||||||
member_id?: number | "unassigned";
|
member_id?: number | "unassigned";
|
||||||
billing_source?: string;
|
billing_source?: string;
|
||||||
subscription_id?: number;
|
subscription_id?: number;
|
||||||
|
model_name?: string;
|
||||||
type?: number | "error";
|
type?: number | "error";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchLogs(
|
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
|
||||||
params: AdminLogFilters,
|
const { data } = await adminHttp.get<LogPage>("/logs", {
|
||||||
): Promise<Paginated<LogItem>> {
|
|
||||||
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
|
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -22,12 +22,21 @@ export async function createMember(body: {
|
|||||||
|
|
||||||
export async function patchMember(
|
export async function patchMember(
|
||||||
id: number,
|
id: number,
|
||||||
body: { status: "active" | "disabled" },
|
body: {
|
||||||
|
name?: string;
|
||||||
|
remark?: string;
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
},
|
||||||
): Promise<MemberItem> {
|
): Promise<MemberItem> {
|
||||||
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 空壳删除:仅 key_count = 0 */
|
||||||
|
export async function deleteMember(id: number): Promise<void> {
|
||||||
|
await adminHttp.delete(`/members/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** 重发:旧令牌即刻失效,明文仅此一次 */
|
/** 重发:旧令牌即刻失效,明文仅此一次 */
|
||||||
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
export async function issueMemberToken(id: number): Promise<{ token: string }> {
|
||||||
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
|
|||||||
quota_granted: number;
|
quota_granted: number;
|
||||||
paid_amount?: number | null;
|
paid_amount?: number | null;
|
||||||
paid_at?: string | null;
|
paid_at?: string | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
|
export async function fetchSubscriptions(filter?: {
|
||||||
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<SubscriptionItem[]> {
|
||||||
|
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
|
||||||
|
params: filter,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ export type TopupBody = {
|
|||||||
payment_provider?: string | null;
|
payment_provider?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
paid_at: string;
|
paid_at: string;
|
||||||
|
billing_domain?: string | null;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchTopups(): Promise<TopupItem[]> {
|
export async function fetchTopups(filter?: {
|
||||||
const { data } = await adminHttp.get<TopupItem[]>("/topups");
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<TopupItem[]> {
|
||||||
|
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
|
||||||
|
params: filter,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
LogItem,
|
LogPage,
|
||||||
Paginated,
|
|
||||||
SubscriptionItem,
|
SubscriptionItem,
|
||||||
TopupItem,
|
TopupItem,
|
||||||
UnixSec,
|
UnixSec,
|
||||||
@@ -55,11 +54,12 @@ export type MemberLogQuery = {
|
|||||||
api_key_id?: number;
|
api_key_id?: number;
|
||||||
billing_source?: string;
|
billing_source?: string;
|
||||||
subscription_id?: number;
|
subscription_id?: number;
|
||||||
|
model_name?: string;
|
||||||
type?: number | "error";
|
type?: number | "error";
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> {
|
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
|
||||||
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
|
const { data } = await memberHttp.get<LogPage>("/logs", {
|
||||||
params: query,
|
params: query,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const qk = {
|
|||||||
range.page_size,
|
range.page_size,
|
||||||
range.api_key_id ?? "",
|
range.api_key_id ?? "",
|
||||||
range.member_id ?? "",
|
range.member_id ?? "",
|
||||||
|
range.model_name ?? "",
|
||||||
range.billing_source ?? "",
|
range.billing_source ?? "",
|
||||||
range.subscription_id ?? "",
|
range.subscription_id ?? "",
|
||||||
range.type ?? "",
|
range.type ?? "",
|
||||||
@@ -60,6 +61,7 @@ export const qk = {
|
|||||||
range.page,
|
range.page,
|
||||||
range.page_size,
|
range.page_size,
|
||||||
range.api_key_id ?? "",
|
range.api_key_id ?? "",
|
||||||
|
range.model_name ?? "",
|
||||||
range.billing_source ?? "",
|
range.billing_source ?? "",
|
||||||
range.subscription_id ?? "",
|
range.subscription_id ?? "",
|
||||||
range.type ?? "",
|
range.type ?? "",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { message } from "antdv-next";
|
import { message } from "antdv-next";
|
||||||
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
|
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
|
||||||
|
import { useClipboard } from "@vueuse/core";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -27,9 +28,12 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
|
||||||
|
const { copy } = useClipboard({ legacy: true });
|
||||||
|
|
||||||
async function onCopy() {
|
async function onCopy() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(props.token);
|
await copy(props.token);
|
||||||
message.success("已复制");
|
message.success("已复制");
|
||||||
} catch {
|
} catch {
|
||||||
message.error("复制失败,请手动选中复制");
|
message.error("复制失败,请手动选中复制");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
PlatformTopup,
|
PlatformTopup,
|
||||||
} from "@/api/admin/platform.ts";
|
} from "@/api/admin/platform.ts";
|
||||||
import {
|
import {
|
||||||
|
useBillingDomainOptions,
|
||||||
useCreateSubscription,
|
useCreateSubscription,
|
||||||
useCreateTopup,
|
useCreateTopup,
|
||||||
useQpuLatest,
|
useQpuLatest,
|
||||||
@@ -78,6 +79,8 @@ const registeredTopIds = computed(
|
|||||||
.filter((v): v is number => v != null),
|
.filter((v): v is number => v != null),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const domainOptions = useBillingDomainOptions();
|
||||||
|
|
||||||
const subForm = reactive({
|
const subForm = reactive({
|
||||||
subscription_id: null as number | null,
|
subscription_id: null as number | null,
|
||||||
period_start: null as Dayjs | null,
|
period_start: null as Dayjs | null,
|
||||||
@@ -87,6 +90,7 @@ const subForm = reactive({
|
|||||||
quota_usd: null as number | null,
|
quota_usd: null as number | null,
|
||||||
paid_amount: null as number | null,
|
paid_amount: null as number | null,
|
||||||
paid_at: null as Dayjs | null,
|
paid_at: null as Dayjs | null,
|
||||||
|
billing_domain: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,6 +102,7 @@ const topForm = reactive({
|
|||||||
payment_method: "",
|
payment_method: "",
|
||||||
payment_provider: "",
|
payment_provider: "",
|
||||||
paid_at: null as Dayjs | null,
|
paid_at: null as Dayjs | null,
|
||||||
|
billing_domain: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,6 +116,7 @@ function resetSub() {
|
|||||||
quota_usd: null,
|
quota_usd: null,
|
||||||
paid_amount: null,
|
paid_amount: null,
|
||||||
paid_at: null,
|
paid_at: null,
|
||||||
|
billing_domain: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -124,6 +130,7 @@ function resetTop() {
|
|||||||
payment_method: "",
|
payment_method: "",
|
||||||
payment_provider: "",
|
payment_provider: "",
|
||||||
paid_at: null,
|
paid_at: null,
|
||||||
|
billing_domain: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -147,6 +154,7 @@ watch(
|
|||||||
subForm.paid_amount = s.paid_amount;
|
subForm.paid_amount = s.paid_amount;
|
||||||
subForm.paid_at =
|
subForm.paid_at =
|
||||||
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
|
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
|
||||||
|
subForm.billing_domain = s.billing_domain ?? "";
|
||||||
subForm.remark = s.remark;
|
subForm.remark = s.remark;
|
||||||
} else {
|
} else {
|
||||||
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
|
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
|
||||||
@@ -162,6 +170,7 @@ watch(
|
|||||||
topForm.payment_method = t.payment_method ?? "";
|
topForm.payment_method = t.payment_method ?? "";
|
||||||
topForm.payment_provider = t.payment_provider ?? "";
|
topForm.payment_provider = t.payment_provider ?? "";
|
||||||
topForm.paid_at = unixToBeijingWall(t.paid_at);
|
topForm.paid_at = unixToBeijingWall(t.paid_at);
|
||||||
|
topForm.billing_domain = t.billing_domain ?? "";
|
||||||
topForm.remark = t.remark;
|
topForm.remark = t.remark;
|
||||||
} else {
|
} else {
|
||||||
resetTop();
|
resetTop();
|
||||||
@@ -239,6 +248,7 @@ async function onSubmit() {
|
|||||||
quota_granted: quota,
|
quota_granted: quota,
|
||||||
paid_amount: subForm.paid_amount,
|
paid_amount: subForm.paid_amount,
|
||||||
paid_at: iso(subForm.paid_at),
|
paid_at: iso(subForm.paid_at),
|
||||||
|
billing_domain: subForm.billing_domain.trim() || null,
|
||||||
remark: subForm.remark,
|
remark: subForm.remark,
|
||||||
};
|
};
|
||||||
if (props.editingSub) {
|
if (props.editingSub) {
|
||||||
@@ -268,6 +278,7 @@ async function onSubmit() {
|
|||||||
payment_provider: topForm.payment_provider || null,
|
payment_provider: topForm.payment_provider || null,
|
||||||
status: props.editingTop?.status ?? "success",
|
status: props.editingTop?.status ?? "success",
|
||||||
paid_at: iso(topForm.paid_at)!,
|
paid_at: iso(topForm.paid_at)!,
|
||||||
|
billing_domain: topForm.billing_domain.trim() || null,
|
||||||
remark: topForm.remark,
|
remark: topForm.remark,
|
||||||
};
|
};
|
||||||
if (props.editingTop) {
|
if (props.editingTop) {
|
||||||
@@ -381,6 +392,13 @@ async function onSubmit() {
|
|||||||
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
|
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="账本域">
|
||||||
|
<a-auto-complete
|
||||||
|
v-model:value="subForm.billing_domain"
|
||||||
|
:options="domainOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="备注">
|
<a-form-item label="备注">
|
||||||
<a-input v-model:value="subForm.remark" />
|
<a-input v-model:value="subForm.remark" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
@@ -432,6 +450,13 @@ async function onSubmit() {
|
|||||||
style="width: 120px; margin-left: 8px"
|
style="width: 120px; margin-left: 8px"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="账本域">
|
||||||
|
<a-auto-complete
|
||||||
|
v-model:value="topForm.billing_domain"
|
||||||
|
:options="domainOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item label="备注">
|
<a-form-item label="备注">
|
||||||
<a-input v-model:value="topForm.remark" />
|
<a-input v-model:value="topForm.remark" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { computed, reactive, ref, watch } from "vue";
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
import type { MemberItem } from "@new-api-key-usage/core";
|
import type { MemberItem } from "@new-api-key-usage/core";
|
||||||
import type { ApiError } from "@/api/http.ts";
|
import type { ApiError } from "@/api/http.ts";
|
||||||
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
|
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
|
||||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -20,10 +20,12 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const register = useRegisterKey();
|
const register = useRegisterKey();
|
||||||
const createMember = useCreateMember();
|
const createMember = useCreateMember();
|
||||||
|
const domainOptions = useBillingDomainOptions();
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
sk_key: "",
|
sk_key: "",
|
||||||
member_id: null as number | "unassigned" | null,
|
member_id: null as number | "unassigned" | null,
|
||||||
|
billing_domain: "",
|
||||||
});
|
});
|
||||||
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
|
||||||
null,
|
null,
|
||||||
@@ -41,6 +43,7 @@ watch(
|
|||||||
if (!v) {
|
if (!v) {
|
||||||
form.sk_key = "";
|
form.sk_key = "";
|
||||||
form.member_id = null;
|
form.member_id = null;
|
||||||
|
form.billing_domain = "";
|
||||||
resultMsg.value = null;
|
resultMsg.value = null;
|
||||||
showNewMember.value = false;
|
showNewMember.value = false;
|
||||||
newMemberName.value = "";
|
newMemberName.value = "";
|
||||||
@@ -67,6 +70,7 @@ async function onSubmit() {
|
|||||||
form.member_id === "unassigned" || form.member_id == null
|
form.member_id === "unassigned" || form.member_id == null
|
||||||
? null
|
? null
|
||||||
: form.member_id,
|
: form.member_id,
|
||||||
|
billing_domain: form.billing_domain.trim() || null,
|
||||||
});
|
});
|
||||||
resultMsg.value = {
|
resultMsg.value = {
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -170,6 +174,13 @@ async function onCreateMember() {
|
|||||||
+ 新建成员
|
+ 新建成员
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
<a-form-item label="账本域">
|
||||||
|
<a-auto-complete
|
||||||
|
v-model:value="form.billing_domain"
|
||||||
|
:options="domainOptions"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
<a-form-item v-if="showNewMember" label="新成员名称">
|
<a-form-item v-if="showNewMember" label="新成员名称">
|
||||||
<a-input
|
<a-input
|
||||||
v-model:value="newMemberName"
|
v-model:value="newMemberName"
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 调用日志汇总条(交互设计-调用日志 §2.5):
|
||||||
|
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
|
||||||
|
*/
|
||||||
|
import QuotaText from "./quota-text.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
hitCount: number;
|
||||||
|
usageCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
quota: number;
|
||||||
|
quotaUsd: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
|
||||||
|
<div class="logs-summary-bar__seg">
|
||||||
|
<span class="logs-summary-bar__label">请求次数</span>
|
||||||
|
<span class="logs-summary-bar__value">
|
||||||
|
<template v-if="loading">—</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ hitCount }} 条<template v-if="errorCount > 0">
|
||||||
|
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="logs-summary-bar__seg">
|
||||||
|
<span class="logs-summary-bar__label">消费用量</span>
|
||||||
|
<span class="logs-summary-bar__value">
|
||||||
|
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
|
||||||
|
<template v-else>—</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logs-summary-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid #e8ecf4;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-summary-bar.is-loading {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-summary-bar__seg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-summary-bar__label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a8f99;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-summary-bar__value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #1f2329;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import dayjs from "dayjs";
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import {
|
import {
|
||||||
recentDaysRange,
|
recentDaysRange,
|
||||||
|
todayRange,
|
||||||
wallAsBeijingIso,
|
wallAsBeijingIso,
|
||||||
type RangeValue,
|
type RangeValue,
|
||||||
} from "@/utils/time.ts";
|
} from "@/utils/time.ts";
|
||||||
@@ -28,6 +29,10 @@ const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
|
|||||||
|
|
||||||
const presets = computed(() => {
|
const presets = computed(() => {
|
||||||
const builtIn = [
|
const builtIn = [
|
||||||
|
{
|
||||||
|
label: "今天",
|
||||||
|
value: rangeToDayjs(todayRange()),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "最近 7 天",
|
label: "最近 7 天",
|
||||||
value: rangeToDayjs(recentDaysRange(7)),
|
value: rangeToDayjs(recentDaysRange(7)),
|
||||||
@@ -51,6 +56,13 @@ function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
|
|||||||
function detectKind(value: RangeValue): RangeKind {
|
function detectKind(value: RangeValue): RangeKind {
|
||||||
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
const fromD = dayjs(value.from).format("YYYY-MM-DD");
|
||||||
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
const toD = dayjs(value.to).format("YYYY-MM-DD");
|
||||||
|
const today = todayRange();
|
||||||
|
if (
|
||||||
|
dayjs(today.from).format("YYYY-MM-DD") === fromD &&
|
||||||
|
dayjs(today.to).format("YYYY-MM-DD") === toD
|
||||||
|
) {
|
||||||
|
return "today";
|
||||||
|
}
|
||||||
for (const days of [7, 30] as const) {
|
for (const days of [7, 30] as const) {
|
||||||
const r = recentDaysRange(days);
|
const r = recentDaysRange(days);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { computed, onUnmounted, ref } from "vue";
|
import { computed, onUnmounted, ref } from "vue";
|
||||||
import { recentDaysRange } from "@/utils/time.ts";
|
import { recentDaysRange, todayRange } from "@/utils/time.ts";
|
||||||
|
|
||||||
export type RangeValue = { from: string; to: string };
|
export type RangeValue = { from: string; to: string };
|
||||||
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可跟随「今天」推进的时间范围:
|
* 可跟随「今天」推进的时间范围:
|
||||||
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
* - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
|
||||||
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
|
||||||
*/
|
*/
|
||||||
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
|
||||||
@@ -21,6 +21,7 @@ export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-
|
|||||||
|
|
||||||
const range = computed<RangeValue>(() => {
|
const range = computed<RangeValue>(() => {
|
||||||
void nowTick.value;
|
void nowTick.value;
|
||||||
|
if (kind.value === "today") return todayRange();
|
||||||
if (kind.value === "recent-7") return recentDaysRange(7);
|
if (kind.value === "recent-7") return recentDaysRange(7);
|
||||||
if (kind.value === "recent-30") return recentDaysRange(30);
|
if (kind.value === "recent-30") return recentDaysRange(30);
|
||||||
return fixed.value;
|
return fixed.value;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { computed, type ComputedRef } from "vue";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import {
|
import {
|
||||||
fetchKeys,
|
fetchKeys,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
} from "@/api/admin/topups.ts";
|
} from "@/api/admin/topups.ts";
|
||||||
import {
|
import {
|
||||||
createMember,
|
createMember,
|
||||||
|
deleteMember,
|
||||||
fetchMembers,
|
fetchMembers,
|
||||||
issueMemberToken,
|
issueMemberToken,
|
||||||
patchMember,
|
patchMember,
|
||||||
@@ -83,7 +85,11 @@ export function usePatchKey() {
|
|||||||
body,
|
body,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
body: { status?: "active" | "disabled"; member_id?: number | null };
|
body: {
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
member_id?: number | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
|
};
|
||||||
}) => patchKey(id, body),
|
}) => patchKey(id, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
@@ -105,7 +111,7 @@ export function useAdminLogs(
|
|||||||
export function useSubscriptions() {
|
export function useSubscriptions() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.admin.subscriptions(),
|
queryKey: qk.admin.subscriptions(),
|
||||||
queryFn: fetchSubscriptions,
|
queryFn: () => fetchSubscriptions(),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -144,7 +150,7 @@ export function useDeleteSubscription() {
|
|||||||
export function useTopups() {
|
export function useTopups() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.admin.topups(),
|
queryKey: qk.admin.topups(),
|
||||||
queryFn: fetchTopups,
|
queryFn: () => fetchTopups(),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -207,10 +213,26 @@ export function usePatchMember() {
|
|||||||
body,
|
body,
|
||||||
}: {
|
}: {
|
||||||
id: number;
|
id: number;
|
||||||
body: { status: "active" | "disabled" };
|
body: {
|
||||||
|
name?: string;
|
||||||
|
remark?: string;
|
||||||
|
status?: "active" | "disabled";
|
||||||
|
};
|
||||||
}) => patchMember(id, body),
|
}) => patchMember(id, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteMember() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: deleteMember,
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.admin.members() });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -233,3 +255,25 @@ export function useAuditLogs(
|
|||||||
queryFn: () => fetchAuditLogs(params()),
|
queryFn: () => fetchAuditLogs(params()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
|
||||||
|
export function useBillingDomainOptions(): ComputedRef<
|
||||||
|
Array<{ value: string }>
|
||||||
|
> {
|
||||||
|
const keysQuery = useKeys(() => ({}));
|
||||||
|
const subsQuery = useSubscriptions();
|
||||||
|
const topupsQuery = useTopups();
|
||||||
|
return computed(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const k of keysQuery.data.value ?? []) {
|
||||||
|
if (k.billing_domain) set.add(k.billing_domain);
|
||||||
|
}
|
||||||
|
for (const s of subsQuery.data.value ?? []) {
|
||||||
|
if (s.billing_domain) set.add(s.billing_domain);
|
||||||
|
}
|
||||||
|
for (const t of topupsQuery.data.value ?? []) {
|
||||||
|
if (t.billing_domain) set.add(t.billing_domain);
|
||||||
|
}
|
||||||
|
return [...set].sort().map((value) => ({ value }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { computed, ref } from "vue";
|
|||||||
import { recentDaysRange } from "@/utils/time.ts";
|
import { recentDaysRange } from "@/utils/time.ts";
|
||||||
|
|
||||||
export type RangeValue = { from: string; to: string };
|
export type RangeValue = { from: string; to: string };
|
||||||
export type RangeKind = "recent-7" | "recent-30" | "fixed";
|
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
|
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export function recentDaysRange(
|
|||||||
return { from: shanghaiIso(from), to: shanghaiIso(to) };
|
return { from: shanghaiIso(from), to: shanghaiIso(to) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 快捷项:今天(北京时间日界,左闭右开) */
|
||||||
|
export function todayRange(nowMs: number = Date.now()): {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
} {
|
||||||
|
return recentDaysRange(1, nowMs);
|
||||||
|
}
|
||||||
|
|
||||||
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
|
||||||
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
export function formatRangeCaption(fromIso: string, toIso: string): string {
|
||||||
if (!fromIso || !toIso) return "—";
|
if (!fromIso || !toIso) return "—";
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ApiError } from "@/api/http.ts";
|
|||||||
import PageHeader from "@/components/common/page-header.vue";
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||||
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
|
||||||
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts";
|
import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
|
||||||
import { formatInt } from "@/utils/format.ts";
|
import { formatInt } from "@/utils/format.ts";
|
||||||
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
import { formatMinute, hoursSince } from "@/utils/time.ts";
|
||||||
|
|
||||||
@@ -23,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
|
|||||||
|
|
||||||
const filterMember = ref<number | "unassigned" | null>(null);
|
const filterMember = ref<number | "unassigned" | null>(null);
|
||||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||||
|
const filterDomain = ref<string | "none" | null>(null);
|
||||||
|
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const filterRecentFailed = ref(false);
|
const filterRecentFailed = ref(false);
|
||||||
|
|
||||||
/** URL → 筛选(总览跳入) */
|
/** URL → 筛选(总览跳入) */
|
||||||
@@ -35,6 +37,10 @@ function syncFromRoute() {
|
|||||||
|
|
||||||
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
|
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
|
||||||
else filterStatus.value = null;
|
else filterStatus.value = null;
|
||||||
|
if (q.billing_domain === "none") filterDomain.value = "none";
|
||||||
|
else if (typeof q.billing_domain === "string" && q.billing_domain) {
|
||||||
|
filterDomain.value = q.billing_domain;
|
||||||
|
} else filterDomain.value = null;
|
||||||
|
|
||||||
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
|
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
|
||||||
|
|
||||||
@@ -52,18 +58,21 @@ function pushQuery() {
|
|||||||
if (filterMember.value === "unassigned") query.member_id = "unassigned";
|
if (filterMember.value === "unassigned") query.member_id = "unassigned";
|
||||||
else if (filterMember.value != null) query.member_id = String(filterMember.value);
|
else if (filterMember.value != null) query.member_id = String(filterMember.value);
|
||||||
if (filterStatus.value) query.status = filterStatus.value;
|
if (filterStatus.value) query.status = filterStatus.value;
|
||||||
|
if (filterDomain.value === "none") query.billing_domain = "none";
|
||||||
|
else if (filterDomain.value) query.billing_domain = filterDomain.value;
|
||||||
if (filterRecentFailed.value) query.recent_failed = "1";
|
if (filterRecentFailed.value) query.recent_failed = "1";
|
||||||
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
|
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
|
||||||
void router.replace({ path: "/admin/keys", query });
|
void router.replace({ path: "/admin/keys", query });
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([filterMember, filterStatus, filterRecentFailed], () => {
|
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
|
||||||
pushQuery();
|
pushQuery();
|
||||||
});
|
});
|
||||||
|
|
||||||
const filters = computed(() => ({
|
const filters = computed(() => ({
|
||||||
member_id: filterMember.value ?? undefined,
|
member_id: filterMember.value ?? undefined,
|
||||||
status: filterStatus.value ?? undefined,
|
status: filterStatus.value ?? undefined,
|
||||||
|
billing_domain: filterDomain.value ?? undefined,
|
||||||
recent_failed: filterRecentFailed.value ? true : undefined,
|
recent_failed: filterRecentFailed.value ? true : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -91,6 +100,7 @@ const hasActiveFilter = computed(
|
|||||||
() =>
|
() =>
|
||||||
filterMember.value != null ||
|
filterMember.value != null ||
|
||||||
filterStatus.value != null ||
|
filterStatus.value != null ||
|
||||||
|
filterDomain.value != null ||
|
||||||
filterRecentFailed.value,
|
filterRecentFailed.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,6 +109,8 @@ const memberOptions = computed(() => [
|
|||||||
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const domainOptions = useBillingDomainOptions();
|
||||||
|
|
||||||
function isStalled(logSyncedAt: number | null | undefined): boolean {
|
function isStalled(logSyncedAt: number | null | undefined): boolean {
|
||||||
const h = hoursSince(logSyncedAt);
|
const h = hoursSince(logSyncedAt);
|
||||||
return h != null && h > 2;
|
return h != null && h > 2;
|
||||||
@@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) {
|
|||||||
const columns = [
|
const columns = [
|
||||||
{ title: "名称", key: "token_name", width: 160 },
|
{ title: "名称", key: "token_name", width: 160 },
|
||||||
{ title: "归属", key: "member_name", width: 140 },
|
{ title: "归属", key: "member_name", width: 140 },
|
||||||
|
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||||
{ title: "状态", key: "status", width: 100 },
|
{ title: "状态", key: "status", width: 100 },
|
||||||
{
|
{
|
||||||
title: tipTitle(
|
title: tipTitle(
|
||||||
@@ -156,6 +169,36 @@ async function onMemberChange(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onDomainChange(record: KeyItem, value: string) {
|
||||||
|
const next = value.trim() || null;
|
||||||
|
if ((record.billing_domain ?? null) === next) return;
|
||||||
|
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
|
||||||
|
const id = record.id;
|
||||||
|
domainSaveTimer = setTimeout(() => {
|
||||||
|
domainSaveTimer = null;
|
||||||
|
void saveDomain(id, next);
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDomainBlur(record: KeyItem, value: string) {
|
||||||
|
const next = value.trim() || null;
|
||||||
|
if (domainSaveTimer != null) {
|
||||||
|
clearTimeout(domainSaveTimer);
|
||||||
|
domainSaveTimer = null;
|
||||||
|
}
|
||||||
|
if ((record.billing_domain ?? null) === next) return;
|
||||||
|
await saveDomain(record.id, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDomain(id: number, billing_domain: string | null) {
|
||||||
|
try {
|
||||||
|
await patchKey.mutateAsync({ id, body: { billing_domain } });
|
||||||
|
message.success("账本域已更新");
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "账本域更新失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onToggleStatus(record: KeyItem) {
|
async function onToggleStatus(record: KeyItem) {
|
||||||
const next = record.status === "active" ? "disabled" : "active";
|
const next = record.status === "active" ? "disabled" : "active";
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) {
|
|||||||
placeholder="状态"
|
placeholder="状态"
|
||||||
allow-clear
|
allow-clear
|
||||||
/>
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterDomain"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部账本域' },
|
||||||
|
{ value: 'none', label: '未标注' },
|
||||||
|
...domainOptions,
|
||||||
|
]"
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="账本域"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
|
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) {
|
|||||||
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'billing_domain'">
|
||||||
|
<a-auto-complete
|
||||||
|
:value="record.billing_domain ?? ''"
|
||||||
|
:options="domainOptions"
|
||||||
|
size="small"
|
||||||
|
style="width: 120px"
|
||||||
|
allow-clear
|
||||||
|
@change="(v: string) => onDomainChange(record, v)"
|
||||||
|
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-badge
|
<a-badge
|
||||||
:status="record.status === 'active' ? 'success' : 'default'"
|
:status="record.status === 'active' ? 'success' : 'default'"
|
||||||
|
|||||||
@@ -3,16 +3,20 @@
|
|||||||
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
|
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||||
import type { LogItem } from "@new-api-key-usage/core";
|
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import PageHeader from "@/components/common/page-header.vue";
|
import PageHeader from "@/components/common/page-header.vue";
|
||||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||||
import QuotaText from "@/components/common/quota-text.vue";
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
import { useLiveTimeRange } from "@/composables/use-live-time-range.ts";
|
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
|
||||||
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts";
|
import {
|
||||||
|
useLiveTimeRange,
|
||||||
|
type RangeKind,
|
||||||
|
} from "@/composables/use-live-time-range.ts";
|
||||||
|
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
|
||||||
import { formatDateTime } from "@/utils/time.ts";
|
import { formatDateTime } from "@/utils/time.ts";
|
||||||
import { formatInt } from "@/utils/format.ts";
|
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,6 +24,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
|
|||||||
|
|
||||||
const memberId = ref<number | "unassigned" | null>(null);
|
const memberId = ref<number | "unassigned" | null>(null);
|
||||||
const apiKeyId = ref<number | null>(null);
|
const apiKeyId = ref<number | null>(null);
|
||||||
|
const modelFilter = ref<string | null>(null);
|
||||||
const billingSource = ref<string | null>(null);
|
const billingSource = ref<string | null>(null);
|
||||||
const subscriptionId = ref<number | null>(null);
|
const subscriptionId = ref<number | null>(null);
|
||||||
const typeFilter = ref<number | "error" | null>(null);
|
const typeFilter = ref<number | "error" | null>(null);
|
||||||
@@ -47,6 +52,7 @@ const params = computed(() => ({
|
|||||||
page_size: pageSize.value,
|
page_size: pageSize.value,
|
||||||
api_key_id: apiKeyId.value ?? undefined,
|
api_key_id: apiKeyId.value ?? undefined,
|
||||||
member_id: memberId.value ?? undefined,
|
member_id: memberId.value ?? undefined,
|
||||||
|
model_name: modelFilter.value ?? undefined,
|
||||||
billing_source: billingSource.value ?? undefined,
|
billing_source: billingSource.value ?? undefined,
|
||||||
subscription_id: subscriptionId.value ?? undefined,
|
subscription_id: subscriptionId.value ?? undefined,
|
||||||
type: typeFilter.value ?? undefined,
|
type: typeFilter.value ?? undefined,
|
||||||
@@ -55,6 +61,11 @@ const params = computed(() => ({
|
|||||||
const logsQuery = useAdminLogs(() => params.value);
|
const logsQuery = useAdminLogs(() => params.value);
|
||||||
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||||
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||||
|
const summary = computed(() => logsQuery.data.value?.summary);
|
||||||
|
const modelOptions = computed(() => [
|
||||||
|
{ value: null, label: "全部模型" },
|
||||||
|
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
|
||||||
|
]);
|
||||||
|
|
||||||
const memberOptions = computed(() => [
|
const memberOptions = computed(() => [
|
||||||
{ value: null, label: "全部成员" },
|
{ value: null, label: "全部成员" },
|
||||||
@@ -102,6 +113,8 @@ function syncFromRoute() {
|
|||||||
else if (typeof m === "string" && m) memberId.value = Number(m);
|
else if (typeof m === "string" && m) memberId.value = Number(m);
|
||||||
const k = q.api_key_id ?? q.key_id;
|
const k = q.api_key_id ?? q.key_id;
|
||||||
if (typeof k === "string" && k) apiKeyId.value = Number(k);
|
if (typeof k === "string" && k) apiKeyId.value = Number(k);
|
||||||
|
modelFilter.value =
|
||||||
|
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
|
||||||
if (typeof q.billing_source === "string" && q.billing_source) {
|
if (typeof q.billing_source === "string" && q.billing_source) {
|
||||||
billingSource.value = q.billing_source;
|
billingSource.value = q.billing_source;
|
||||||
}
|
}
|
||||||
@@ -128,6 +141,7 @@ function pushQuery() {
|
|||||||
if (memberId.value === "unassigned") query.member_id = "unassigned";
|
if (memberId.value === "unassigned") query.member_id = "unassigned";
|
||||||
else if (memberId.value != null) query.member_id = String(memberId.value);
|
else if (memberId.value != null) query.member_id = String(memberId.value);
|
||||||
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
||||||
|
if (modelFilter.value) query.model_name = modelFilter.value;
|
||||||
if (billingSource.value) query.billing_source = billingSource.value;
|
if (billingSource.value) query.billing_source = billingSource.value;
|
||||||
if (subscriptionId.value != null) {
|
if (subscriptionId.value != null) {
|
||||||
query.subscription_id = String(subscriptionId.value);
|
query.subscription_id = String(subscriptionId.value);
|
||||||
@@ -143,7 +157,7 @@ watch(
|
|||||||
() => syncFromRoute(),
|
() => syncFromRoute(),
|
||||||
);
|
);
|
||||||
watch(
|
watch(
|
||||||
[memberId, apiKeyId, billingSource, subscriptionId, typeFilter],
|
[memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
|
||||||
() => {
|
() => {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
pushQuery();
|
pushQuery();
|
||||||
@@ -153,12 +167,54 @@ watch(page, () => pushQuery());
|
|||||||
|
|
||||||
function sourceBadge(record: LogItem) {
|
function sourceBadge(record: LogItem) {
|
||||||
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
if (record.billing_source === "subscription" && record.subscription_id != null) {
|
||||||
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const };
|
return {
|
||||||
|
text: `订阅 #${record.subscription_id}`,
|
||||||
|
color: "blue" as const,
|
||||||
|
kind: "sub" as const,
|
||||||
|
id: record.subscription_id,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (record.billing_source === "wallet") {
|
if (record.billing_source === "wallet") {
|
||||||
return { text: "充值", color: "gold" as const };
|
return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
|
||||||
}
|
}
|
||||||
return { text: "未关联", color: undefined };
|
return { text: "未关联", color: undefined, kind: "none" as const, id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payModalOpen = ref(false);
|
||||||
|
const payModalSubId = ref<number | null>(null);
|
||||||
|
const subsQuery = useSubscriptions();
|
||||||
|
const paymentBySub = computed(() => {
|
||||||
|
const m = new Map<number, SubscriptionItem>();
|
||||||
|
for (const p of subsQuery.data.value ?? []) {
|
||||||
|
m.set(p.subscription_id, p);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
const payModalPayment = computed(() =>
|
||||||
|
payModalSubId.value == null
|
||||||
|
? undefined
|
||||||
|
: paymentBySub.value.get(payModalSubId.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
function onBadge(record: LogItem, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (sourceBadge(record).kind !== "sub") return;
|
||||||
|
payModalSubId.value = sourceBadge(record).id;
|
||||||
|
payModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodText(p: SubscriptionItem): string {
|
||||||
|
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
|
||||||
|
return `${formatDateTime(p.period_start)} ~ ${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPayments() {
|
||||||
|
if (payModalSubId.value == null) return;
|
||||||
|
payModalOpen.value = false;
|
||||||
|
void router.push({
|
||||||
|
path: "/admin/payments",
|
||||||
|
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function goKey(keyId: number) {
|
function goKey(keyId: number) {
|
||||||
@@ -177,7 +233,7 @@ let ro: ResizeObserver | null = null;
|
|||||||
|
|
||||||
function onRangeChange(
|
function onRangeChange(
|
||||||
v: { from: string; to: string },
|
v: { from: string; to: string },
|
||||||
k: "recent-7" | "recent-30" | "fixed",
|
k: RangeKind,
|
||||||
) {
|
) {
|
||||||
setRange(v, k);
|
setRange(v, k);
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
@@ -242,6 +298,15 @@ onUnmounted(() => {
|
|||||||
show-search
|
show-search
|
||||||
option-filter-prop="label"
|
option-filter-prop="label"
|
||||||
/>
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="modelFilter"
|
||||||
|
:options="modelOptions"
|
||||||
|
style="width: 140px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="模型"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="billingSource"
|
v-model:value="billingSource"
|
||||||
:options="[
|
:options="[
|
||||||
@@ -273,6 +338,15 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LogsSummaryBar
|
||||||
|
:hit-count="summary?.hit_count ?? 0"
|
||||||
|
:usage-count="summary?.usage_count ?? 0"
|
||||||
|
:error-count="summary?.error_count ?? 0"
|
||||||
|
:quota="summary?.quota ?? 0"
|
||||||
|
:quota-usd="summary?.quota_usd ?? '0.00'"
|
||||||
|
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||||
|
/>
|
||||||
|
|
||||||
<TableRefresh
|
<TableRefresh
|
||||||
:loading="logsQuery.isFetching.value"
|
:loading="logsQuery.isFetching.value"
|
||||||
@refresh="() => logsQuery.refetch()"
|
@refresh="() => logsQuery.refetch()"
|
||||||
@@ -322,7 +396,19 @@ onUnmounted(() => {
|
|||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'source'">
|
<template v-else-if="column.key === 'source'">
|
||||||
<a-tag :color="sourceBadge(record).color">
|
<a-tooltip
|
||||||
|
v-if="sourceBadge(record).kind === 'sub'"
|
||||||
|
title="点击查看该订阅支付参数"
|
||||||
|
>
|
||||||
|
<a-tag
|
||||||
|
color="blue"
|
||||||
|
class="call-logs__badge"
|
||||||
|
@click="(e: MouseEvent) => onBadge(record, e)"
|
||||||
|
>
|
||||||
|
{{ sourceBadge(record).text }}
|
||||||
|
</a-tag>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-tag v-else :color="sourceBadge(record).color">
|
||||||
{{ sourceBadge(record).text }}
|
{{ sourceBadge(record).text }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -400,6 +486,41 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-modal>
|
</a-modal>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="payModalOpen"
|
||||||
|
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
|
||||||
|
:footer="null"
|
||||||
|
width="480"
|
||||||
|
centered
|
||||||
|
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
|
||||||
|
>
|
||||||
|
<div v-if="payModalPayment" class="call-logs__pay">
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">期间</span>
|
||||||
|
<span>{{ periodText(payModalPayment) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">额度</span>
|
||||||
|
<QuotaText
|
||||||
|
:quota="payModalPayment.quota_granted"
|
||||||
|
:quota-usd="payModalPayment.quota_usd"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">金额</span>
|
||||||
|
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="payModalPayment.remark" class="call-logs__kv">
|
||||||
|
<span class="call-logs__note">备注</span>
|
||||||
|
<span>{{ payModalPayment.remark }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="call-logs__note">该订阅无登记信息</div>
|
||||||
|
<div class="call-logs__foot">
|
||||||
|
<a @click="goPayments">在支付记录中查看</a>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -476,4 +597,21 @@ onUnmounted(() => {
|
|||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.call-logs__badge {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__pay {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-logs__foot {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
|
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
|
||||||
*/
|
*/
|
||||||
import { computed, h, reactive, ref, watch } from "vue";
|
import { computed, h, reactive, ref, watch } from "vue";
|
||||||
import { Tooltip, message } from "antdv-next";
|
import { Tooltip, message } from "antdv-next";
|
||||||
@@ -16,6 +16,7 @@ import TableRefresh from "@/components/common/table-refresh.vue";
|
|||||||
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
|
||||||
import {
|
import {
|
||||||
useCreateMember,
|
useCreateMember,
|
||||||
|
useDeleteMember,
|
||||||
useIssueMemberToken,
|
useIssueMemberToken,
|
||||||
useMembers,
|
useMembers,
|
||||||
usePatchMember,
|
usePatchMember,
|
||||||
@@ -25,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
|
|||||||
const membersQuery = useMembers();
|
const membersQuery = useMembers();
|
||||||
const createMember = useCreateMember();
|
const createMember = useCreateMember();
|
||||||
const patchMember = usePatchMember();
|
const patchMember = usePatchMember();
|
||||||
|
const deleteMember = useDeleteMember();
|
||||||
const issueToken = useIssueMemberToken();
|
const issueToken = useIssueMemberToken();
|
||||||
|
|
||||||
const createOpen = ref(false);
|
const createOpen = ref(false);
|
||||||
|
const editOpen = ref(false);
|
||||||
const tokenOpen = ref(false);
|
const tokenOpen = ref(false);
|
||||||
const plainToken = ref("");
|
const plainToken = ref("");
|
||||||
const tokenMemberName = ref("");
|
const tokenMemberName = ref("");
|
||||||
@@ -35,6 +38,9 @@ const tokenMemberName = ref("");
|
|||||||
const createForm = reactive({ name: "", remark: "" });
|
const createForm = reactive({ name: "", remark: "" });
|
||||||
const createError = ref("");
|
const createError = ref("");
|
||||||
|
|
||||||
|
const editForm = reactive({ id: 0, name: "", remark: "" });
|
||||||
|
const editError = ref("");
|
||||||
|
|
||||||
const filterName = ref("");
|
const filterName = ref("");
|
||||||
const filterStatus = ref<"active" | "disabled" | null>(null);
|
const filterStatus = ref<"active" | "disabled" | null>(null);
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ const columns = [
|
|||||||
width: 170,
|
width: 170,
|
||||||
},
|
},
|
||||||
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
{ title: "名下 Key 数", key: "key_count", width: 110 },
|
||||||
{ title: "操作", key: "actions", width: 200 },
|
{ title: "操作", key: "actions", width: 280 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rows = computed(() => {
|
const rows = computed(() => {
|
||||||
@@ -166,11 +172,48 @@ async function onToggle(record: MemberItem) {
|
|||||||
message.error((e as ApiError).message || "状态更新失败");
|
message.error((e as ApiError).message || "状态更新失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEdit(record: MemberItem) {
|
||||||
|
editForm.id = record.id;
|
||||||
|
editForm.name = record.name;
|
||||||
|
editForm.remark = record.remark ?? "";
|
||||||
|
editError.value = "";
|
||||||
|
editOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEdit() {
|
||||||
|
editError.value = "";
|
||||||
|
const name = editForm.name.trim();
|
||||||
|
if (!name) {
|
||||||
|
editError.value = "请填写成员名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await patchMember.mutateAsync({
|
||||||
|
id: editForm.id,
|
||||||
|
body: { name, remark: editForm.remark },
|
||||||
|
});
|
||||||
|
editOpen.value = false;
|
||||||
|
message.success("已保存");
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as ApiError;
|
||||||
|
editError.value = err.message || "保存失败";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(record: MemberItem) {
|
||||||
|
try {
|
||||||
|
await deleteMember.mutateAsync(record.id);
|
||||||
|
message.success("已删除——仅清理未挂 key 的空壳");
|
||||||
|
} catch (e) {
|
||||||
|
message.error((e as ApiError).message || "删除失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section>
|
<section>
|
||||||
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
|
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
|
||||||
</template>
|
</template>
|
||||||
@@ -242,6 +285,7 @@ async function onToggle(record: MemberItem) {
|
|||||||
>
|
>
|
||||||
查调用
|
查调用
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
title="重发后旧令牌立即失效"
|
title="重发后旧令牌立即失效"
|
||||||
description="该成员正在用的访问将中断"
|
description="该成员正在用的访问将中断"
|
||||||
@@ -264,11 +308,46 @@ async function onToggle(record: MemberItem) {
|
|||||||
<a-button v-else size="small" @click="() => onToggle(record)">
|
<a-button v-else size="small" @click="() => onToggle(record)">
|
||||||
启用
|
启用
|
||||||
</a-button>
|
</a-button>
|
||||||
|
<a-tooltip
|
||||||
|
v-if="record.key_count > 0"
|
||||||
|
title="名下仍有 key,只能停用"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger disabled>删除</a-button>
|
||||||
|
</a-tooltip>
|
||||||
|
<a-popconfirm
|
||||||
|
v-else
|
||||||
|
title="删除后不可恢复(名称与令牌一并作废)"
|
||||||
|
description="仅清理未挂 key 的空壳"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
@confirm="() => onDelete(record)"
|
||||||
|
>
|
||||||
|
<a-button size="small" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
|
<a-modal
|
||||||
|
v-model:open="editOpen"
|
||||||
|
title="编辑成员"
|
||||||
|
:confirm-loading="patchMember.isPending.value"
|
||||||
|
ok-text="保存"
|
||||||
|
cancel-text="取消"
|
||||||
|
@ok="onEdit"
|
||||||
|
>
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="名称" required>
|
||||||
|
<a-input v-model:value="editForm.name" placeholder="成员名称" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="备注">
|
||||||
|
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
|
||||||
|
</a-form-item>
|
||||||
|
<a-alert v-if="editError" type="error" show-icon :message="editError" />
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
|
||||||
<a-modal
|
<a-modal
|
||||||
v-model:open="createOpen"
|
v-model:open="createOpen"
|
||||||
title="新建成员"
|
title="新建成员"
|
||||||
|
|||||||
@@ -25,6 +25,49 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const subsQuery = useSubscriptions();
|
const subsQuery = useSubscriptions();
|
||||||
const topupsQuery = useTopups();
|
const topupsQuery = useTopups();
|
||||||
|
|
||||||
|
const filterDomain = ref<string | "none" | null>(null);
|
||||||
|
const filterSubId = ref<number | null>(null);
|
||||||
|
const filterTradeNo = ref<string | null>(null);
|
||||||
|
const domainOptions = computed(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const s of subsQuery.data.value ?? []) {
|
||||||
|
if (s.billing_domain) set.add(s.billing_domain);
|
||||||
|
}
|
||||||
|
for (const t of topupsQuery.data.value ?? []) {
|
||||||
|
if (t.billing_domain) set.add(t.billing_domain);
|
||||||
|
}
|
||||||
|
return [...set].sort().map((v) => ({ value: v, label: v }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredSubs = computed(() => {
|
||||||
|
let list = subsQuery.data.value ?? [];
|
||||||
|
if (filterSubId.value != null) {
|
||||||
|
list = list.filter((r) => r.subscription_id === filterSubId.value);
|
||||||
|
}
|
||||||
|
if (!filterDomain.value) return list;
|
||||||
|
if (filterDomain.value === "none") {
|
||||||
|
return list.filter((r) => r.billing_domain == null);
|
||||||
|
}
|
||||||
|
return list.filter((r) => r.billing_domain === filterDomain.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredTops = computed(() => {
|
||||||
|
let list = topupsQuery.data.value ?? [];
|
||||||
|
if (filterTradeNo.value) {
|
||||||
|
const q = filterTradeNo.value.trim().toLowerCase();
|
||||||
|
list = list.filter(
|
||||||
|
(r) =>
|
||||||
|
(r.trade_no ?? "").toLowerCase().includes(q) ||
|
||||||
|
(r.upstream_id != null && String(r.upstream_id).includes(q)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!filterDomain.value) return list;
|
||||||
|
if (filterDomain.value === "none") {
|
||||||
|
return list.filter((r) => r.billing_domain == null);
|
||||||
|
}
|
||||||
|
return list.filter((r) => r.billing_domain === filterDomain.value);
|
||||||
|
});
|
||||||
const deleteSub = useDeleteSubscription();
|
const deleteSub = useDeleteSubscription();
|
||||||
const deleteTop = useDeleteTopup();
|
const deleteTop = useDeleteTopup();
|
||||||
|
|
||||||
@@ -37,22 +80,27 @@ const checking = ref(false);
|
|||||||
|
|
||||||
function syncFromRoute() {
|
function syncFromRoute() {
|
||||||
tab.value = route.query.tab === "topups" ? "topups" : "subs";
|
tab.value = route.query.tab === "topups" ? "topups" : "subs";
|
||||||
|
const sid = route.query.subscription_id;
|
||||||
|
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
|
||||||
|
const tn = route.query.trade_no;
|
||||||
|
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushTab() {
|
function pushQuery() {
|
||||||
void router.replace({
|
const query: Record<string, string> = {};
|
||||||
path: "/admin/payments",
|
if (tab.value === "topups") query.tab = "topups";
|
||||||
query: tab.value === "topups" ? { tab: "topups" } : {},
|
if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
|
||||||
});
|
if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
|
||||||
|
void router.replace({ path: "/admin/payments", query });
|
||||||
}
|
}
|
||||||
|
|
||||||
syncFromRoute();
|
syncFromRoute();
|
||||||
watch(() => route.query, () => syncFromRoute());
|
watch(() => route.query, () => syncFromRoute());
|
||||||
watch(tab, () => pushTab());
|
watch([tab, filterSubId, filterTradeNo], () => pushQuery());
|
||||||
|
|
||||||
const subRows = computed(() => [...(subsQuery.data.value ?? [])]);
|
const subRows = computed(() => [...filteredSubs.value]);
|
||||||
const topRows = computed(() => {
|
const topRows = computed(() => {
|
||||||
const list = [...(topupsQuery.data.value ?? [])];
|
const list = [...filteredTops.value];
|
||||||
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
|
||||||
return list;
|
return list;
|
||||||
});
|
});
|
||||||
@@ -62,6 +110,7 @@ const subColumns = [
|
|||||||
{ title: "期间", key: "period", width: 200 },
|
{ title: "期间", key: "period", width: 200 },
|
||||||
{ title: "额度", key: "quota", width: 160 },
|
{ title: "额度", key: "quota", width: 160 },
|
||||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||||
|
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", key: "remark" },
|
{ title: "备注", key: "remark" },
|
||||||
{ title: "登记", key: "created_at", width: 150 },
|
{ title: "登记", key: "created_at", width: 150 },
|
||||||
{ title: "操作", key: "actions", width: 120 },
|
{ title: "操作", key: "actions", width: 120 },
|
||||||
@@ -78,6 +127,7 @@ const topColumns = [
|
|||||||
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
|
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
|
||||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||||
{ title: "派生额度", key: "quota", width: 150 },
|
{ title: "派生额度", key: "quota", width: 150 },
|
||||||
|
{ title: "账本域", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", key: "remark" },
|
{ title: "备注", key: "remark" },
|
||||||
{ title: "操作", key: "actions", width: 120 },
|
{ title: "操作", key: "actions", width: 120 },
|
||||||
];
|
];
|
||||||
@@ -198,6 +248,38 @@ watch(
|
|||||||
<a-tab-pane key="topups" tab="充值" />
|
<a-tab-pane key="topups" tab="充值" />
|
||||||
</a-tabs>
|
</a-tabs>
|
||||||
|
|
||||||
|
<div class="payments-toolbar">
|
||||||
|
<template v-if="tab === 'subs'">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="filterSubId"
|
||||||
|
:min="1"
|
||||||
|
:precision="0"
|
||||||
|
style="width: 140px"
|
||||||
|
placeholder="订阅 ID"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-input
|
||||||
|
v-model:value="filterTradeNo"
|
||||||
|
style="width: 180px"
|
||||||
|
placeholder="交易号 / 上游 ID"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterDomain"
|
||||||
|
:options="[
|
||||||
|
{ value: null, label: '全部账本域' },
|
||||||
|
{ value: 'none', label: '未标注' },
|
||||||
|
...domainOptions,
|
||||||
|
]"
|
||||||
|
style="width: 160px"
|
||||||
|
placeholder="账本域"
|
||||||
|
allow-clear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-alert
|
<a-alert
|
||||||
v-if="tab === 'subs' && missingSubs.length"
|
v-if="tab === 'subs' && missingSubs.length"
|
||||||
type="warning"
|
type="warning"
|
||||||
@@ -253,6 +335,9 @@ watch(
|
|||||||
: "—"
|
: "—"
|
||||||
}}
|
}}
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'billing_domain'">
|
||||||
|
{{ record.billing_domain || "—" }}
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'remark'">
|
<template v-else-if="column.key === 'remark'">
|
||||||
{{ record.remark || "—" }}
|
{{ record.remark || "—" }}
|
||||||
</template>
|
</template>
|
||||||
@@ -316,6 +401,9 @@ watch(
|
|||||||
<template v-else-if="column.key === 'quota'">
|
<template v-else-if="column.key === 'quota'">
|
||||||
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
|
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'billing_domain'">
|
||||||
|
{{ record.billing_domain || "—" }}
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'remark'">
|
<template v-else-if="column.key === 'remark'">
|
||||||
{{ record.remark || "—" }}
|
{{ record.remark || "—" }}
|
||||||
</template>
|
</template>
|
||||||
@@ -346,6 +434,10 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.payments-toolbar {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.pay-period {
|
.pay-period {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import PageHeader from "@/components/common/page-header.vue";
|
|||||||
import TableRefresh from "@/components/common/table-refresh.vue";
|
import TableRefresh from "@/components/common/table-refresh.vue";
|
||||||
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
import TimeRangePicker from "@/components/common/time-range-picker.vue";
|
||||||
import QuotaText from "@/components/common/quota-text.vue";
|
import QuotaText from "@/components/common/quota-text.vue";
|
||||||
|
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
|
||||||
import { useMemberRange } from "@/composables/use-member-range.ts";
|
import { useMemberRange } from "@/composables/use-member-range.ts";
|
||||||
|
import type { RangeKind } from "@/stores/member-range.ts";
|
||||||
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
|
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
|
||||||
import { formatDateTime } from "@/utils/time.ts";
|
import { formatDateTime } from "@/utils/time.ts";
|
||||||
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
|
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
|
||||||
@@ -22,6 +24,7 @@ const router = useRouter();
|
|||||||
const { range, setRange } = useMemberRange();
|
const { range, setRange } = useMemberRange();
|
||||||
|
|
||||||
const apiKeyId = ref<number | null>(null);
|
const apiKeyId = ref<number | null>(null);
|
||||||
|
const modelFilter = ref<string | null>(null);
|
||||||
const billingSource = ref<string | null>(null);
|
const billingSource = ref<string | null>(null);
|
||||||
const subscriptionId = ref<number | null>(null);
|
const subscriptionId = ref<number | null>(null);
|
||||||
const typeFilter = ref<number | "error" | null>(null);
|
const typeFilter = ref<number | "error" | null>(null);
|
||||||
@@ -39,6 +42,7 @@ const params = computed(() => ({
|
|||||||
page: page.value,
|
page: page.value,
|
||||||
page_size: pageSize.value,
|
page_size: pageSize.value,
|
||||||
api_key_id: apiKeyId.value ?? undefined,
|
api_key_id: apiKeyId.value ?? undefined,
|
||||||
|
model_name: modelFilter.value ?? undefined,
|
||||||
billing_source: billingSource.value ?? undefined,
|
billing_source: billingSource.value ?? undefined,
|
||||||
subscription_id: subscriptionId.value ?? undefined,
|
subscription_id: subscriptionId.value ?? undefined,
|
||||||
type: typeFilter.value ?? undefined,
|
type: typeFilter.value ?? undefined,
|
||||||
@@ -47,6 +51,11 @@ const params = computed(() => ({
|
|||||||
const logsQuery = useMemberLogs(() => params.value);
|
const logsQuery = useMemberLogs(() => params.value);
|
||||||
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
const rows = computed(() => logsQuery.data.value?.items ?? []);
|
||||||
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
const total = computed(() => logsQuery.data.value?.total ?? 0);
|
||||||
|
const summary = computed(() => logsQuery.data.value?.summary);
|
||||||
|
const modelOptions = computed(() => [
|
||||||
|
{ value: null, label: "全部模型" },
|
||||||
|
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
|
||||||
|
]);
|
||||||
|
|
||||||
const keyOptions = computed(() => [
|
const keyOptions = computed(() => [
|
||||||
{ value: null, label: "全部 Key" },
|
{ value: null, label: "全部 Key" },
|
||||||
@@ -136,6 +145,8 @@ function syncFromRoute() {
|
|||||||
const q = route.query;
|
const q = route.query;
|
||||||
const k = q.api_key_id ?? q.key_id;
|
const k = q.api_key_id ?? q.key_id;
|
||||||
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
|
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
|
||||||
|
modelFilter.value =
|
||||||
|
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
|
||||||
billingSource.value =
|
billingSource.value =
|
||||||
typeof q.billing_source === "string" && q.billing_source
|
typeof q.billing_source === "string" && q.billing_source
|
||||||
? q.billing_source
|
? q.billing_source
|
||||||
@@ -159,6 +170,7 @@ function pushQuery() {
|
|||||||
to: range.value.to,
|
to: range.value.to,
|
||||||
};
|
};
|
||||||
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
|
||||||
|
if (modelFilter.value) query.model_name = modelFilter.value;
|
||||||
if (billingSource.value) query.billing_source = billingSource.value;
|
if (billingSource.value) query.billing_source = billingSource.value;
|
||||||
if (subscriptionId.value != null) {
|
if (subscriptionId.value != null) {
|
||||||
query.subscription_id = String(subscriptionId.value);
|
query.subscription_id = String(subscriptionId.value);
|
||||||
@@ -170,14 +182,14 @@ function pushQuery() {
|
|||||||
|
|
||||||
syncFromRoute();
|
syncFromRoute();
|
||||||
watch(() => route.query, () => syncFromRoute());
|
watch(() => route.query, () => syncFromRoute());
|
||||||
watch([apiKeyId, billingSource, subscriptionId, typeFilter], () => {
|
watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
|
||||||
if (applyingRoute.value) return;
|
if (applyingRoute.value) return;
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
pushQuery();
|
pushQuery();
|
||||||
});
|
});
|
||||||
watch(page, () => pushQuery());
|
watch(page, () => pushQuery());
|
||||||
|
|
||||||
function onRangeChange(v: { from: string; to: string }, k: "recent-7" | "recent-30" | "fixed") {
|
function onRangeChange(v: { from: string; to: string }, k: RangeKind) {
|
||||||
setRange(v, k);
|
setRange(v, k);
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
pushQuery();
|
pushQuery();
|
||||||
@@ -245,6 +257,15 @@ watch(
|
|||||||
show-search
|
show-search
|
||||||
option-filter-prop="label"
|
option-filter-prop="label"
|
||||||
/>
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="modelFilter"
|
||||||
|
:options="modelOptions"
|
||||||
|
style="width: 140px"
|
||||||
|
allow-clear
|
||||||
|
placeholder="模型"
|
||||||
|
show-search
|
||||||
|
option-filter-prop="label"
|
||||||
|
/>
|
||||||
<a-select
|
<a-select
|
||||||
v-model:value="billingSource"
|
v-model:value="billingSource"
|
||||||
:options="[
|
:options="[
|
||||||
@@ -276,6 +297,15 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LogsSummaryBar
|
||||||
|
:hit-count="summary?.hit_count ?? 0"
|
||||||
|
:usage-count="summary?.usage_count ?? 0"
|
||||||
|
:error-count="summary?.error_count ?? 0"
|
||||||
|
:quota="summary?.quota ?? 0"
|
||||||
|
:quota-usd="summary?.quota_usd ?? '0.00'"
|
||||||
|
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
|
||||||
|
/>
|
||||||
|
|
||||||
<TableRefresh
|
<TableRefresh
|
||||||
:loading="logsQuery.isFetching.value"
|
:loading="logsQuery.isFetching.value"
|
||||||
@refresh="() => logsQuery.refetch()"
|
@refresh="() => logsQuery.refetch()"
|
||||||
@@ -438,7 +468,7 @@ watch(
|
|||||||
<span>{{ payModalPayment.remark }}</span>
|
<span>{{ payModalPayment.remark }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-logs__foot">
|
<div class="member-logs__foot">
|
||||||
支付参数对成员完全公开;币种由成员与作者约定
|
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="member-logs__note">该订阅无登记信息</div>
|
<div v-else class="member-logs__note">该订阅无登记信息</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const subColumns = [
|
|||||||
{ title: "期间", key: "period", width: 200 },
|
{ title: "期间", key: "period", width: 200 },
|
||||||
{ title: "额度", key: "quota", width: 160 },
|
{ title: "额度", key: "quota", width: 160 },
|
||||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||||
|
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ const topColumns = [
|
|||||||
},
|
},
|
||||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||||
{ title: "派生额度", key: "quota", width: 150 },
|
{ title: "派生额度", key: "quota", width: 150 },
|
||||||
|
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@@ -76,7 +78,7 @@ const topColumns = [
|
|||||||
<section>
|
<section>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="支付参数"
|
title="支付参数"
|
||||||
subtitle="订阅与充值的登记参数 · 对成员完全公开"
|
subtitle="订阅与充值的登记参数 · 同账本域内可见"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<span class="mv-range-label">时间范围</span>
|
<span class="mv-range-label">时间范围</span>
|
||||||
@@ -186,7 +188,7 @@ const topColumns = [
|
|||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
<div class="mv-foot">
|
<div class="mv-foot">
|
||||||
支付参数对成员完全公开;币种由成员与作者约定
|
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "new-api-key-usage",
|
"name": "new-api-key-usage",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@clack/prompts": "^1.8.1",
|
||||||
"@types/bun": "^1.4.2",
|
"@types/bun": "^1.4.2",
|
||||||
|
"citty": "^0.2.2",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -180,6 +182,10 @@
|
|||||||
|
|
||||||
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
|
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
|
||||||
|
|
||||||
|
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
|
||||||
|
|
||||||
|
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
|
||||||
|
|
||||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
|
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
|
||||||
|
|
||||||
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
|
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
|
||||||
@@ -714,6 +720,8 @@
|
|||||||
|
|
||||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||||
|
|
||||||
|
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||||
|
|
||||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||||
|
|
||||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
@@ -822,6 +830,12 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||||
|
|
||||||
|
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||||
|
|
||||||
|
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||||
|
|
||||||
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
@@ -1120,6 +1134,8 @@
|
|||||||
|
|
||||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||||
|
|
||||||
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||||
|
|||||||
+34
-12
@@ -1,15 +1,17 @@
|
|||||||
# 部署一键起:docker compose up -d --build
|
# 一键部署:docker compose up -d --build
|
||||||
# 配置只认 DATABASE_URL(见 .env.example);开发不依赖本文件
|
# 验收:docker compose ps(migrate 应为 exited 0)
|
||||||
# 注意:PASSWORD 含 @ : / 等字符时需 URL 编码
|
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
|
||||||
|
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
container_name: naku-postgres
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
|
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
|
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/entrypoint-pg.sh:/entrypoint-pg.sh:ro
|
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
|
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
|
||||||
@@ -18,7 +20,25 @@ services:
|
|||||||
retries: 12
|
retries: 12
|
||||||
restart: unless-stopped
|
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:
|
server:
|
||||||
|
container_name: naku-server
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: app
|
target: app
|
||||||
@@ -35,13 +55,14 @@ services:
|
|||||||
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
|
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
|
||||||
MIGRATIONS_FOLDER: /app/drizzle
|
MIGRATIONS_FOLDER: /app/drizzle
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/entrypoint-app.sh:/entrypoint-app.sh:ro
|
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
migrate:
|
||||||
condition: service_healthy
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
engine:
|
engine:
|
||||||
|
container_name: naku-engine
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: app
|
target: app
|
||||||
@@ -55,19 +76,20 @@ services:
|
|||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
MIGRATIONS_FOLDER: /app/drizzle
|
MIGRATIONS_FOLDER: /app/drizzle
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/entrypoint-app.sh:/entrypoint-app.sh:ro
|
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
migrate:
|
||||||
condition: service_healthy
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
|
container_name: naku-caddy
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
target: proxy
|
target: proxy
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${HTTP_PORT:-80}:80"
|
||||||
- "443:443"
|
- "${HTTPS_PORT:-443}:443"
|
||||||
depends_on:
|
depends_on:
|
||||||
- server
|
- server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
|
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
|
||||||
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
|
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
|
||||||
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
|
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
|
||||||
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
|
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
|
||||||
|
|
||||||
## 3. 错误信封
|
## 3. 错误信封
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 待审阅(2026-10-09 支付拆表——overview 分 `subscriptions`/`topups`) |
|
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||||
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
|
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
|
||||||
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
|
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
|
||||||
|
|
||||||
@@ -48,13 +48,13 @@
|
|||||||
"subscriptions": [
|
"subscriptions": [
|
||||||
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
|
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
|
||||||
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
|
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
|
||||||
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "remark": "九月拼车",
|
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "billing_domain": "carpool", "remark": "九月拼车",
|
||||||
"created_at": "2026-09-02T12:00:00Z" }
|
"created_at": "2026-09-02T12:00:00Z" }
|
||||||
],
|
],
|
||||||
"topups": [
|
"topups": [
|
||||||
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
|
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
|
||||||
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
|
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
|
||||||
"status": "success", "paid_at": "…",
|
"status": "success", "paid_at": "…", "billing_domain": "carpool",
|
||||||
"quota_derived": 500000, "quota_usd": "1.00",
|
"quota_derived": 500000, "quota_usd": "1.00",
|
||||||
"remark": "", "created_at": "…" }
|
"remark": "", "created_at": "…" }
|
||||||
]
|
]
|
||||||
@@ -62,16 +62,18 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
|
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
|
||||||
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
|
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
|
||||||
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
|
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
|
||||||
|
|
||||||
## 4. 日志明细
|
## 4. 日志明细
|
||||||
|
|
||||||
### GET `/api/member/logs?from&to` → 200 `{items, total}`
|
### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
|
||||||
|
|
||||||
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
|
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
|
||||||
|
|
||||||
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
|
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
|
||||||
|
|
||||||
|
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
|
||||||
|
|
||||||
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
|
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
## 5. 本稿裁定项(请作者重点审)
|
## 5. 本稿裁定项(请作者重点审)
|
||||||
|
|
||||||
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
|
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
|
||||||
2. **「该时段涉及」登记的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项);充值 = `paid_at ∈ [from, to)`(入账事件按付费时刻锚定);补录审计看各表 `created_at`(不进本判定);
|
2. **「该时段涉及」登记的判定**(账本域隔离):成员的**我的域** = 名下 key 的 `billing_domain` 非空并集。订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行(**事实直连**,含未标域) **∪**(进行中 **∧** `billing_domain ∈ 我的域`)——未标域进行中订阅不放行;充值 = **`billing_domain ∈ 我的域`** ∧ `paid_at ∈ [from, to)`——未标域充值不放行。管理面不受此限;
|
||||||
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
|
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
|
||||||
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
|
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 待审阅(2026-10-09 支付拆表修订——§7);R16 定稿(2026-10-10——§8) |
|
| 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
|
||||||
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
|
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
|
||||||
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
|
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
|
||||||
|
|
||||||
@@ -119,12 +119,13 @@
|
|||||||
|
|
||||||
### GET `/api/admin/keys` → 200 `[key]`
|
### GET `/api/admin/keys` → 200 `[key]`
|
||||||
|
|
||||||
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
|
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`billing_domain`(值或 `none` = 未标注)、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": 5, "token_name": "小张-01", "status": "active",
|
"id": 5, "token_name": "小张-01", "status": "active",
|
||||||
"member_id": 1, "member_name": "小张",
|
"member_id": 1, "member_name": "小张",
|
||||||
|
"billing_domain": "carpool",
|
||||||
"registered_at": "2026-09-20T10:00:00Z",
|
"registered_at": "2026-09-20T10:00:00Z",
|
||||||
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
|
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
|
||||||
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
|
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
|
||||||
@@ -136,7 +137,7 @@
|
|||||||
|
|
||||||
### POST `/api/admin/keys` → 201 `key`
|
### POST `/api/admin/keys` → 201 `key`
|
||||||
|
|
||||||
`{ "sk_key": "sk-…", "member_id": null }`——登记四态:
|
`{ "sk_key": "sk-…", "member_id": null, "billing_domain": null }`——登记四态:
|
||||||
|
|
||||||
| 结果 | 响应 |
|
| 结果 | 响应 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -147,13 +148,34 @@
|
|||||||
|
|
||||||
### PATCH `/api/admin/keys/:id` → 200 `key`
|
### PATCH `/api/admin/keys/:id` → 200 `key`
|
||||||
|
|
||||||
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
|
`{ "status": "disabled" }`(启用 / 停用)· `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)或 `{ "billing_domain": "office" }`(账本域变更;`null` = 未标域)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
|
||||||
|
|
||||||
## 6. 日志明细(管理面下钻)
|
## 6. 日志明细(管理面下钻)
|
||||||
|
|
||||||
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
|
### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
|
||||||
|
|
||||||
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
|
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
|
||||||
|
|
||||||
|
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"summary": {
|
||||||
|
"hit_count": 131, "usage_count": 128, "error_count": 3,
|
||||||
|
"quota": 1250000, "quota_usd": "2.50"
|
||||||
|
},
|
||||||
|
"models": ["claude-sonnet-4", "gpt-5"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 口径 |
|
||||||
|
|------|------|
|
||||||
|
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
|
||||||
|
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
|
||||||
|
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
|
||||||
|
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
|
||||||
|
|
||||||
|
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -184,6 +206,7 @@
|
|||||||
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
|
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
|
||||||
"quota_granted": 12500000, "quota_usd": "25.00",
|
"quota_granted": 12500000, "quota_usd": "25.00",
|
||||||
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
|
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
|
||||||
|
"billing_domain": "carpool",
|
||||||
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
|
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -193,7 +216,7 @@
|
|||||||
|
|
||||||
### POST `/api/admin/subscriptions` → 201 `subscription`
|
### POST `/api/admin/subscriptions` → 201 `subscription`
|
||||||
|
|
||||||
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `remark`。美元双输入为前端换算,落库恒原始 quota。
|
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `billing_domain` · `remark`。美元双输入为前端换算,落库恒原始 quota。列表筛选支持 `billing_domain`(值或 `none`)。
|
||||||
|
|
||||||
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
|
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
|
||||||
|
|
||||||
@@ -209,6 +232,7 @@
|
|||||||
"payment_method": "alipay", "payment_provider": "epay",
|
"payment_method": "alipay", "payment_provider": "epay",
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"paid_at": "2026-10-09T12:22:56Z",
|
"paid_at": "2026-10-09T12:22:56Z",
|
||||||
|
"billing_domain": "carpool",
|
||||||
"quota_derived": 500000, "quota_usd": "1.00",
|
"quota_derived": 500000, "quota_usd": "1.00",
|
||||||
"remark": "", "created_at": "2026-10-09T13:00:00Z"
|
"remark": "", "created_at": "2026-10-09T13:00:00Z"
|
||||||
}
|
}
|
||||||
@@ -218,7 +242,7 @@
|
|||||||
|
|
||||||
### POST `/api/admin/topups` → 201 `topup`
|
### POST `/api/admin/topups` → 201 `topup`
|
||||||
|
|
||||||
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `remark`。
|
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
|
||||||
|
|
||||||
### PUT `/api/admin/topups/:id` / DELETE → 204
|
### PUT `/api/admin/topups/:id` / DELETE → 204
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
|
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
|
||||||
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
|
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
|
||||||
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
|
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
|
||||||
|
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
|
||||||
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
|
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
|
||||||
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
|
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
|
||||||
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
|
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
|
|
||||||
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
|
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
|
||||||
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
|
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
|
||||||
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
|
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**
|
||||||
|
|
||||||
> **注解:为什么是两个字段(给非密码学背景的读者)**
|
> **注解:为什么是两个字段(给非密码学背景的读者)**
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 待审阅(2026-10-09 拆出自原 `payments` 单表) |
|
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||||
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
|
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
|
||||||
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
|
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
|
||||||
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
|
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
|
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
|
||||||
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
|
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
|
||||||
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
|
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
|
||||||
|
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
|
||||||
| `remark` | text | 人工填写 | 备注 |
|
| `remark` | text | 人工填写 | 备注 |
|
||||||
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
|
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
|
||||||
|
|
||||||
@@ -60,5 +61,5 @@
|
|||||||
|
|
||||||
## 6. 对外边界
|
## 6. 对外边界
|
||||||
|
|
||||||
- 参数对成员**完全公开透明**(含 `remark` / 金额)——访问主体模型裁决
|
- 参数对成员**同账本域内可见**(含 `remark` / 金额)——同域透明、跨域隔离;未标域默认对成员隐藏,例外:该订阅 `subscription_id` 出现在本人名下 key 的 `type = 2` 日志中(事实直连)
|
||||||
- 无「应付」、无分摊字段
|
- 无「应付」、无分摊字段
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 待审阅(2026-10-09 拆表 + 充值接口实测;事件时刻单列) |
|
| 状态 | 定稿(2026-10-12,作者认可实现) |
|
||||||
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
|
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
|
||||||
| 范围 | 列结构 + 语义 + 写入时机 |
|
| 范围 | 列结构 + 语义 + 写入时机 |
|
||||||
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
|
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
|
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
|
||||||
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
|
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
|
||||||
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
|
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
|
||||||
|
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
|
||||||
| `remark` | text | — | 人工备注 |
|
| `remark` | text | — | 人工备注 |
|
||||||
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
|
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
|
|
||||||
## 6. 对外边界
|
## 6. 对外边界
|
||||||
|
|
||||||
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线
|
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
|
||||||
- 纯用量模式可不登记
|
- 纯用量模式可不登记
|
||||||
|
|
||||||
## 7. 迁移说明
|
## 7. 迁移说明
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|----|------|------|
|
|----|------|------|
|
||||||
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
|
||||||
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
|
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
|
||||||
|
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
|
||||||
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
|
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
|
||||||
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
|
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
|
||||||
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
|
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
|
|
||||||
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
|
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
|
||||||
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
|
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
|
||||||
|
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
|
||||||
3. 提交 → 服务端上游验证,四态反馈:
|
3. 提交 → 服务端上游验证,四态反馈:
|
||||||
|
|
||||||
| 结果 | 反馈 |
|
| 结果 | 反馈 |
|
||||||
@@ -45,10 +47,11 @@
|
|||||||
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
|
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
|
||||||
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
|
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
|
||||||
|
|
||||||
## 4. 归属变更
|
## 4. 归属 / 账本域变更
|
||||||
|
|
||||||
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
|
- 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
|
||||||
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义)
|
- 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
|
||||||
|
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
|
||||||
|
|
||||||
## 5. 空态
|
## 5. 空态
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
### 3.1 时间范围选择器(核心组件)
|
### 3.1 时间范围选择器(核心组件)
|
||||||
|
|
||||||
- 一切统计的公共维度:**任意时段** + 快捷项
|
- 一切统计的公共维度:**任意时段** + 快捷项
|
||||||
- 快捷项:最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
|
- 快捷项:**今天** / 最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
|
||||||
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
|
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
|
||||||
|
|
||||||
### 3.2 数值呈现三约定
|
### 3.2 数值呈现三约定
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# 交互设计 · 成员视图
|
# 交互设计 · 成员视图
|
||||||
|
|
||||||
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
|
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、同账本域内可见的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
|
||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 定稿(2026-10-09,作者认可布局重写) |
|
| 状态 | 定稿(2026-10-09,作者认可布局重写) |
|
||||||
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
|
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
|
||||||
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(完全可见) |
|
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(同账本域内可见) |
|
||||||
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
|
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
|------|------|-----------|-----------|
|
|------|------|-----------|-----------|
|
||||||
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
|
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
|
||||||
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
|
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
|
||||||
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(完全公开) | |
|
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(同账本域内可见) | |
|
||||||
|
|
||||||
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
|
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
|
||||||
|
|
||||||
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅看期间重叠 ∪ 进行中,充值看 `paid_at`。
|
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅 = 涉及我日志的 `subscription_id` ∪ 我域内进行中;充值 = 我域内且 `paid_at` 落在窗口内。
|
||||||
|
|
||||||
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
|
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
|
||||||
|
|
||||||
@@ -83,8 +83,9 @@
|
|||||||
### 4.1 页头与筛选
|
### 4.1 页头与筛选
|
||||||
|
|
||||||
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
|
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
|
||||||
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)
|
- 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
|
||||||
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链)
|
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
|
||||||
|
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
|
||||||
|
|
||||||
### 4.2 明细表(页面主体,表体滚动)
|
### 4.2 明细表(页面主体,表体滚动)
|
||||||
|
|
||||||
@@ -113,11 +114,11 @@
|
|||||||
|
|
||||||
## 5. 支付参数页
|
## 5. 支付参数页
|
||||||
|
|
||||||
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
|
同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
|
||||||
|
|
||||||
### 5.1 页头
|
### 5.1 页头
|
||||||
|
|
||||||
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。
|
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
|
||||||
|
|
||||||
### 5.2 订阅表
|
### 5.2 订阅表
|
||||||
|
|
||||||
@@ -139,7 +140,7 @@
|
|||||||
| 派生额度 | `amount × qpu` + $(展示) |
|
| 派生额度 | `amount × qpu` + $(展示) |
|
||||||
| 备注 | `remark` |
|
| 备注 | `remark` |
|
||||||
|
|
||||||
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
|
- 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
|
||||||
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
|
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
|
||||||
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
|
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 待审阅(2026-10-09 随拆表重写) |
|
| 状态 | 定稿(2026-10-12,作者认可实现;补订阅 ID / 交易号筛选) |
|
||||||
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
|
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
|
||||||
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
|
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
|
||||||
|
|
||||||
@@ -19,14 +19,15 @@
|
|||||||
| **订阅** | 订阅权益表 | 登记订阅 |
|
| **订阅** | 订阅权益表 | 登记订阅 |
|
||||||
| **充值** | 充值入账表 | 登记充值 |
|
| **充值** | 充值入账表 | 登记充值 |
|
||||||
|
|
||||||
筛选随 Tab:
|
筛选随 Tab(均为前端过滤——清单全量直返):
|
||||||
|
|
||||||
| Tab | 筛选 | URL |
|
| Tab | 筛选 | URL |
|
||||||
|-----|------|-----|
|
|-----|------|-----|
|
||||||
| 订阅 | 订阅 ID / 期间与所选窗口重叠(拆表后) | `tab=subs` |
|
| 订阅 | 订阅 ID(精确,可空)· 账本域(含「未标注」) | `tab=subs` · `subscription_id` |
|
||||||
| 充值 | **仅类型**(付费时间筛选已撤——`paid_at` 语义待拆表钉死) | `tab=topups` · `kind` |
|
| 充值 | 交易号(子串,可空)· 账本域 | `tab=topups` · `trade_no` |
|
||||||
|
|
||||||
当前实现(拆表前):仅「类型」筛选;补录审计可看「登记」列(`created_at`)。
|
- 切换 Tab 保留另一 Tab 已填筛选;URL 同步便于从调用日志来源徽章深链(`subscription_id`)
|
||||||
|
- 补录审计可看「登记」列(`created_at`)
|
||||||
|
|
||||||
## 2. 订阅清单列
|
## 2. 订阅清单列
|
||||||
|
|
||||||
@@ -50,13 +51,14 @@
|
|||||||
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
|
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
|
||||||
| 派生额度 | `amount × qpu` + $(展示,不入库) |
|
| 派生额度 | `amount × qpu` + $(展示,不入库) |
|
||||||
| 备注 / 登记 | `remark` · `created_at` |
|
| 备注 / 登记 | `remark` · `created_at` |
|
||||||
|
| 账本域 | `billing_domain`(可空;列表可按域筛,含「未标注」) |
|
||||||
| 操作 | 编辑 / 删除 |
|
| 操作 | 编辑 / 删除 |
|
||||||
|
|
||||||
## 4. 登记表单(分 Tab,不再 `kind` 切换)
|
## 4. 登记表单(分 Tab,不再 `kind` 切换)
|
||||||
|
|
||||||
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
|
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
|
||||||
|
|
||||||
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
|
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
|
||||||
|
|
||||||
### 4.1 选用弹层(订阅 / 充值同规则)
|
### 4.1 选用弹层(订阅 / 充值同规则)
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
| 项目 | 内容 |
|
| 项目 | 内容 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 状态 | 定稿(2026-10-08) |
|
| 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
|
||||||
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
|
| 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
|
||||||
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) |
|
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,12 +23,26 @@
|
|||||||
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
|
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
|
||||||
| 成员 | Select,含「未归属」 | `member_id` |
|
| 成员 | Select,含「未归属」 | `member_id` |
|
||||||
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
|
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
|
||||||
|
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
|
||||||
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
|
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
|
||||||
| 订阅 ID | 数字(可空) | `subscription_id` |
|
| 订阅 ID | 数字(可空) | `subscription_id` |
|
||||||
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
|
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
|
||||||
|
|
||||||
- 切换任一筛选 → 回第 1 页
|
- 切换任一筛选 → 回第 1 页
|
||||||
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
|
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`model_name`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
|
||||||
|
|
||||||
|
## 2.5 汇总条(筛选条与表格之间)
|
||||||
|
|
||||||
|
低高度**单行**读数条(约 32–36px),只回答「这批条件加起来多少」:
|
||||||
|
|
||||||
|
| 段 | 内容 | 口径 |
|
||||||
|
|----|------|------|
|
||||||
|
| 请求次数 | 当前筛选条数;错误数 > 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) |
|
||||||
|
| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 |
|
||||||
|
|
||||||
|
- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」)
|
||||||
|
- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零
|
||||||
|
- 成员端同构(见《交互设计-成员视图》§4)
|
||||||
|
|
||||||
## 3. 日志表(页面主体)
|
## 3. 日志表(页面主体)
|
||||||
|
|
||||||
@@ -45,7 +59,8 @@
|
|||||||
| 操作 | 「详情」按钮 | 开详情弹窗 |
|
| 操作 | 「详情」按钮 | 开详情弹窗 |
|
||||||
|
|
||||||
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
|
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
|
||||||
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选
|
- **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
|
||||||
|
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
|
||||||
- 空态:「该时段内无调用日志」
|
- 空态:「该时段内无调用日志」
|
||||||
- 窄屏:表格横向滚动(共性 §3.5)
|
- 窄屏:表格横向滚动(共性 §3.5)
|
||||||
|
|
||||||
@@ -58,6 +73,7 @@
|
|||||||
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
|
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
|
||||||
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
|
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
|
||||||
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
|
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
|
||||||
|
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
|
||||||
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
|
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
|
||||||
|
|
||||||
## 5. 状态与边界
|
## 5. 状态与边界
|
||||||
|
|||||||
@@ -4,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_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` |
|
||||||
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
|
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ apps/engine ──► 定时 syncRound() ──► packages/db
|
|||||||
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
|
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
|
||||||
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
|
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
|
||||||
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
|
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
|
||||||
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) |
|
| 运行形态 | 开发:两个命令(`dev:server` / `dev:engine`);生产两个进程(均连同一 PostgreSQL) |
|
||||||
|
|
||||||
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
|
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ apps/
|
|||||||
|
|
||||||
## 9. 验收清单(实现后)
|
## 9. 验收清单(实现后)
|
||||||
|
|
||||||
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进
|
- [ ] 只启 `dev:engine`(或只跑 engine + db):日志入库、`log_synced_at` 前进
|
||||||
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
|
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
|
||||||
- [ ] 同一数据重复轮次不产生重复行
|
- [ ] 同一数据重复轮次不产生重复行
|
||||||
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
|
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# 实现方案 · 日志模型筛选与汇总条
|
||||||
|
|
||||||
|
> 调用日志页(管理端 + 成员端)的两项增强:**按模型名筛选**、**当前筛选条件下的用量汇总条**。交互方向已在讨论中议定——汇总条只做「条数 + 花费」、高度压低;本篇落 API / 数据层 / 前端改造清单与验收口径。
|
||||||
|
|
||||||
|
| 项目 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 状态 | 已实现(2026-10-13) |
|
||||||
|
| 关联 | 《交互设计-调用日志》·《交互设计-成员视图》§4 · 《API契约-管理面》§6 · 《API契约-成员视图》· 《API契约-公共约定》(分页、quota 双单位、聚合口径)· `usage_logs表字段设计.md` · 需求清单 R5 / R9(多维统计含模型) |
|
||||||
|
| 数据 | `usage_logs.model_name`(已入库)· 明细分页筛选与消费汇总(派生查询) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 问题与目标
|
||||||
|
|
||||||
|
日志表已展示 `model_name`,筛选区却没有模型维度;总览有三维聚合,日志页却回答不了「这批条件加起来花了多少」。本方案补上:
|
||||||
|
|
||||||
|
1. **模型筛选**——筛选条第六格,与时间 / 成员 / Key / 来源 / 订阅 / 类型并列
|
||||||
|
2. **汇总条**——筛选条与表格之间一条**低高度**读数条,只展示**请求次数** + **消费用量(quota + $)**
|
||||||
|
|
||||||
|
范围仅两端调用日志页及其数据链路;**不**改总览 by_member / by_key / by_source,**不**加 by_model 分组卡(R5/R9 的「按模型」分组统计另议)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 交互(已议定,实施以此为准)
|
||||||
|
|
||||||
|
### 2.1 页面骨架(两端同构)
|
||||||
|
|
||||||
|
```
|
||||||
|
PageHeader(标题 + 时间范围)
|
||||||
|
筛选条(成员/Key | 模型 | 计费来源 | 订阅 ID | 类型) ← 模型新增
|
||||||
|
汇总条(请求次数 · 消费用量) ← 新增,低高度单行
|
||||||
|
日志表(既有列不变)
|
||||||
|
分页
|
||||||
|
```
|
||||||
|
|
||||||
|
成员端无「成员」筛选,其余同构。
|
||||||
|
|
||||||
|
### 2.2 模型筛选
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|----|------|
|
||||||
|
| 位置 | 筛选条内,Key 与计费来源之间(谁 → 调了什么 → 记在哪) |
|
||||||
|
| 形态 | Select,`showSearch` + `allowClear`;空值 = 全部模型 |
|
||||||
|
| 选项 | 当前 `from/to` 与主体筛选(成员/Key)下、时间窗内去重的 `model_name`;**不含**模型自身与 type 筛选,避免「选了只剩自己」 |
|
||||||
|
| 变更 | 回第 1 页;URL 同步 `model`(与 `member_id` 等同级) |
|
||||||
|
| 宽度 | 与相邻筛选格一致(约 120–140px),可搜避免长模型名截断误选 |
|
||||||
|
|
||||||
|
### 2.3 汇总条
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|----|------|
|
||||||
|
| 位置 | 筛选条下方、表格上方——改条件 → 扫总量 → 下钻明细 |
|
||||||
|
| 高度 | **单行**,约 32–36px;浅底或细分隔,不做成大卡片 |
|
||||||
|
| 内容 | 两段:**请求次数**(当前筛选下总条数)· **消费用量**(quota + $,`QuotaText`) |
|
||||||
|
| 口径 | **请求次数**跟全部筛选(含 type);**消费用量**恒 `type = 2` + 其余筛选(既有裁决:聚合仅消费行) |
|
||||||
|
| 类型筛「错误」时 | 请求次数 = 错误条数;消费用量仍显示同期同条件的消费口径,标签保持「消费用量」不叫「花费」,避免误读为错误行开销 |
|
||||||
|
| 加载 | 查询中数字区骨架/淡化,不闪上一轮数字 |
|
||||||
|
| 空结果 | 汇总条保留,数字归零/`—`;表格空态不变 |
|
||||||
|
|
||||||
|
文案示例:
|
||||||
|
|
||||||
|
- 类型全部:`请求次数 131 条(消费 128 / 错误 3)` + `消费用量 1,250,000 quota ≈ $2.50`
|
||||||
|
- 类型=错误:`请求次数 3 条` + `消费用量 …`(消费口径不变)
|
||||||
|
- 零结果:`请求次数 0 条` + `消费用量 0 quota ≈ $0.00`
|
||||||
|
|
||||||
|
「消费 128 / 错误 3」在错误数为 0 时可只显示 `请求次数 128 条`,保持条更短。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 契约变更
|
||||||
|
|
||||||
|
两端 `GET /api/admin/logs` / `GET /api/member/logs` **同名同义**扩展;DTO 继续一份复用。
|
||||||
|
|
||||||
|
### 3.1 请求
|
||||||
|
|
||||||
|
新增可选查询参数:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `model_name` | string | 精确匹配 `usage_logs.model_name`;空/缺省 = 不筛 |
|
||||||
|
|
||||||
|
其余参数不变。`from/to` 仍必填。
|
||||||
|
|
||||||
|
### 3.2 响应
|
||||||
|
|
||||||
|
在现有 `{ items, total }` 上增加 `summary` 与 `models`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [ /* 不变 */ ],
|
||||||
|
"total": 131,
|
||||||
|
"summary": {
|
||||||
|
"hit_count": 131,
|
||||||
|
"usage_count": 128,
|
||||||
|
"error_count": 3,
|
||||||
|
"quota": 1250000,
|
||||||
|
"quota_usd": "2.50"
|
||||||
|
},
|
||||||
|
"models": ["claude-sonnet-4", "gpt-5"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 语义 |
|
||||||
|
|------|------|
|
||||||
|
| `total` | 既有:当前筛选下命中总数(分页用) |
|
||||||
|
| `summary.hit_count` | 与 `total` 同值(汇总条「请求次数」;保留字段便于前端不依赖分页语义) |
|
||||||
|
| `summary.usage_count` | 当前筛选下 **type=2** 条数 |
|
||||||
|
| `summary.error_count` | 当前筛选下 **type≠2** 条数(`hit_count = usage_count + error_count`) |
|
||||||
|
| `summary.quota` / `quota_usd` | 当前筛选下 **type=2** 行的 quota 合计与美元(公共约定 §5:微分累加、一次取整) |
|
||||||
|
| `models` | 时间窗 + 主体筛选下去重的 `model_name` 升序列表(选项源;**不含** `model_name` / `type` 筛选) |
|
||||||
|
|
||||||
|
口径备注(写入契约裁定区):
|
||||||
|
|
||||||
|
1. `summary.usage_*` / `quota*` **不含** type 筛选与 `model_name` 筛选以外的 type 限制——即 **model_name 与主体/时间/来源/订阅参与,type 不参与用量字段**;`hit_count` / `error_count` / `usage_count` 的**条数**则跟全部筛选。
|
||||||
|
2. 更精确说:
|
||||||
|
- **条数字段**(`hit_count` / `usage_count` / `error_count`)→ 全部筛选(含 `type`、`model_name`)
|
||||||
|
- **用量字段**(`quota` / `quota_usd`)→ 同等筛选但 **固定 type=2**(即使请求 `type=error`)
|
||||||
|
3. `quota_usd` 换算沿「按日志时刻取 QPU」——汇总不得用单一当前汇率乘总 quota。
|
||||||
|
|
||||||
|
`models` 轻量查询:`DISTINCT model_name`,条件 = 时间窗 + 主体/来源/订阅(与列表一致),**刻意不套** `model_name` 与 `type`。办公室规模(模型十来个)直接全量返回,不分页。
|
||||||
|
|
||||||
|
### 3.3 契约文档落点
|
||||||
|
|
||||||
|
- 《API契约-管理面》§6:过滤表加 `model_name`;响应从 `{items,total}` 扩为含 `summary`/`models`
|
||||||
|
- 《API契约-成员视图》日志节:同名同义同步
|
||||||
|
- 《API契约-公共约定》:若需,补一句「列表响应可附带与筛选同源的汇总块」——或在各自契约内写死,避免总纲膨胀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据层(`packages/db`)
|
||||||
|
|
||||||
|
### 4.1 筛选
|
||||||
|
|
||||||
|
`repos.logs.listPage` / `listMemberPage` 参数增加 `model_name?: string`,条件 `eq(usageLogs.model_name, model_name)`(非空时)。
|
||||||
|
|
||||||
|
### 4.2 汇总
|
||||||
|
|
||||||
|
新增 `repos.logs.summarize` / `summarizeMember`(或 list 方法返回体扩展,推荐**独立方法**以免分页热路径多跑聚合):
|
||||||
|
|
||||||
|
- 条数:SQL `count()`,条件与列表筛选一致(含 type)
|
||||||
|
- 用量:`count() filter (type=2)` + `sum(quota) filter (type=2)`;`quota_usd` 需按行 QPU——**取 type=2 行的 `(quota, created_at)` 交给 `sumUsage`**(与总览聚合同一套微分规则)
|
||||||
|
- 选项:`select distinct model_name`,条件同列表但去掉 `model_name` 与 `type`
|
||||||
|
|
||||||
|
办公室查询频率低、命中行数量级可控;**不做**预计算表(需求清单「统计 = 派生视图」)。
|
||||||
|
|
||||||
|
时间范围仍走 `created_at` 索引;`model_name` 无索引,精确等值 + 时间窗过滤后残余行少,**暂不**加索引(表设计不为此改;若日后慢查询再评估)。
|
||||||
|
|
||||||
|
### 4.3 类型共享
|
||||||
|
|
||||||
|
`packages/core`:
|
||||||
|
|
||||||
|
- `logPage` schema:增加 `summary`(见下)与 `models: z.array(z.string())`
|
||||||
|
- 新导出 `LogPageSummary`(或复用/扩展 `UsageTotals`——字段更多,独立类型更清晰):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface LogPageSummary {
|
||||||
|
hit_count: number;
|
||||||
|
usage_count: number;
|
||||||
|
error_count: number;
|
||||||
|
quota: Quota;
|
||||||
|
quota_usd: QuotaUsd;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 服务端路由
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `apps/server/src/routes/admin-usage.ts` | `GET /logs` query 增 `model_name`;调用 list + summarize + distinct models;组 `summary`(`sumUsage` → quota_usd);响应扩字段 |
|
||||||
|
| `apps/server/src/routes/member-view.ts` | 同上(`listMemberPage` / member 侧汇总与选项) |
|
||||||
|
| `apps/server/src/api-schemas.ts` | `logPageSchema` 随 core 更新 |
|
||||||
|
|
||||||
|
OpenAPI 描述字符串同步(summary 文案提到模型筛选与汇总块)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端(`apps/web`)
|
||||||
|
|
||||||
|
### 6.1 API 层
|
||||||
|
|
||||||
|
- `api/admin/logs.ts`:`AdminLogFilters` 加 `model_name?: string`;返回类型改为带 `summary` / `models` 的分页结构
|
||||||
|
- `api/member/me.ts`:`MemberLogQuery` 同上
|
||||||
|
|
||||||
|
### 6.2 汇总条组件
|
||||||
|
|
||||||
|
新建共享组件(两端复用,避免双份样式漂移):
|
||||||
|
|
||||||
|
- 路径建议:`apps/web/src/components/common/logs-summary-bar.vue`
|
||||||
|
- Props:`hitCount` · `usageCount` · `errorCount` · `quota` · `quotaUsd` · `loading`
|
||||||
|
- 布局:单行 flex,左「请求次数…」右「消费用量…」;`loading` 时数字占位
|
||||||
|
- 花费数字复用 `QuotaText`
|
||||||
|
- 样式对齐共性:弱标签 + 主数字,高度 32–36px,间距遵循页面既有 `gap`
|
||||||
|
|
||||||
|
### 6.3 两端 logs-view
|
||||||
|
|
||||||
|
| 项 | 改动 |
|
||||||
|
|----|------|
|
||||||
|
| 状态 | `modelFilter: ref<string \| null>` |
|
||||||
|
| params | 带上 `model_name` |
|
||||||
|
| 模型选项 | 读响应 `models`(或独立 query 缓存按 `from/to+主体` 键) |
|
||||||
|
| 模板 | 筛选条插入模型 Select;筛选条与 `a-table` 之间插入 `LogsSummaryBar` |
|
||||||
|
| URL | `syncFromRoute` / 写回增加 `model` |
|
||||||
|
| 筛选变更 | 既有「回第 1 页」逻辑覆盖 model |
|
||||||
|
|
||||||
|
列结构、详情弹窗、跳转链路**不动**。
|
||||||
|
|
||||||
|
### 6.4 样式
|
||||||
|
|
||||||
|
筛选条若因第六格换行,允许两行(共性已允许一行或两行);汇总条始终单行不换行,过窄时隐藏次要括注(如「/ 错误 3」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 设计文档同步
|
||||||
|
|
||||||
|
实现时同步修订(不另开版本叙事):
|
||||||
|
|
||||||
|
| 文档 | 修订点 |
|
||||||
|
|------|--------|
|
||||||
|
| 《交互设计-调用日志》 | §2 筛选表加「模型」;§3 前插入汇总条小节(位置、两段、口径);URL 同步参数补 `model` |
|
||||||
|
| 《交互设计-成员视图》§4 | 同构引用或摘要 + 成员端差异 |
|
||||||
|
| 《API契约-管理面》§6 | 过滤 + 响应扩展 + 口径裁定 |
|
||||||
|
| 《API契约-成员视图》 | 同名同义 |
|
||||||
|
| README 文档地图 | 本篇条目 |
|
||||||
|
|
||||||
|
状态流转:本篇「待审阅」→ 实现完成后「已实现(日期)」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 实现顺序
|
||||||
|
|
||||||
|
1. ✅ `packages/core`:`LogPageSummary` + `LogPage` + `logPage` schema 扩展
|
||||||
|
2. ✅ `packages/db`:`buildLogConds`、`model_name` 筛选、`summarize` / `summarizeMember`、`listModels` / `listMemberModels`
|
||||||
|
3. ✅ `apps/server`:两端 `/logs` 接线(summary + models)
|
||||||
|
4. ✅ `apps/web`:API 类型 → `logs-summary-bar` → admin / member logs-view
|
||||||
|
5. ✅ 设计文档修订 + README 文档地图
|
||||||
|
6. ✅ `bun run typecheck` + `bun run build:web` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收口径
|
||||||
|
|
||||||
|
- [x] 管理端 / 成员端均可按模型筛选,选项来自时间窗内真实数据,可搜可清空
|
||||||
|
- [x] 筛选变更回第 1 页;URL 带 `model_name`,深链可还原
|
||||||
|
- [x] 汇总条位于筛选条与表格之间,**单行低高度**,仅「条数 + 消费用量」
|
||||||
|
- [x] 消费用量恒 type=2 + 其余筛选;类型筛错误时命中=错误数、用量仍为消费口径
|
||||||
|
- [x] `quota_usd` 按行 QPU 微分汇总(`sumUsage`)
|
||||||
|
- [x] 切换筛选时汇总加载态不闪旧数字(`is-loading` 淡化 + 占位)
|
||||||
|
- [x] typecheck / build 通过;无新依赖
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 明确不做
|
||||||
|
|
||||||
|
- 总览页 by_model 分组卡(R5/R9 多维统计的另一落点,单议)
|
||||||
|
- 模型多选、模糊匹配(先精确单选;不够再扩)
|
||||||
|
- `model_name` 索引 / 预计算汇总表
|
||||||
|
- Tokens 段进汇总条(已议定砍掉,列内仍有)
|
||||||
|
- 明细列增减、详情弹窗改造
|
||||||
+11
-15
@@ -22,7 +22,6 @@
|
|||||||
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
|
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
|
||||||
| 加密 | WebCrypto(Bun 原生) |
|
| 加密 | WebCrypto(Bun 原生) |
|
||||||
| 前端 | **Vue 3 + antdv-next + Vite** |
|
| 前端 | **Vue 3 + antdv-next + Vite** |
|
||||||
| 部署 | **Docker**(倾向,§6) |
|
|
||||||
|
|
||||||
## 2. 语言:TypeScript 全栈
|
## 2. 语言:TypeScript 全栈
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@
|
|||||||
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
|
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
|
||||||
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
|
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
|
||||||
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
|
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
|
||||||
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§7.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
|
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§6.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
|
||||||
| 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) |
|
| 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) |
|
||||||
| 构建 / 测试 | **Vite**(web);质量门 = `typecheck` + `build` + 人工验收 | 前端构建走 Vite 生态默认;自动化测试不在当前范围 | 需要时再引入测试框架 |
|
| 构建 / 测试 | **Vite**(web);质量门 = `typecheck` + `build` + 人工验收 | 前端构建走 Vite 生态默认;自动化测试不在当前范围 | 需要时再引入测试框架 |
|
||||||
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
|
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
|
||||||
@@ -49,11 +48,13 @@
|
|||||||
```text
|
```text
|
||||||
(仓库根)
|
(仓库根)
|
||||||
├─ apps/
|
├─ apps/
|
||||||
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 进程内调度 · 环境变量与密钥注入
|
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 环境变量与密钥注入
|
||||||
|
│ ├─ engine/ # 同步引擎:独立进程(《同步引擎设计》)
|
||||||
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite
|
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite
|
||||||
├─ packages/
|
├─ packages/
|
||||||
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
|
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
|
||||||
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储实现
|
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI
|
||||||
|
├─ scripts/ # 打包/构建 CLI(pack release / build)
|
||||||
```
|
```
|
||||||
|
|
||||||
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
|
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
|
||||||
@@ -64,24 +65,20 @@
|
|||||||
|
|
||||||
R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留:
|
R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留:
|
||||||
|
|
||||||
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/server` 与 `packages/db`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
|
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/*` 装配层、`packages/db` 与 `scripts/`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
|
||||||
2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。
|
2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。
|
||||||
|
|
||||||
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
|
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
|
||||||
|
|
||||||
## 6. 部署通道(倾向,细则归后续部署设计)
|
## 6. 风险与回退
|
||||||
|
|
||||||
母体以 **Docker 镜像**交付(server 容器 + PostgreSQL,compose 编排):满足仓库「一条命令链可部署」约定;运营方服务器零环境依赖(Bun 运行时随镜像内置),升级 = 换镜像重启;作者不接触服务器,镜像化把运行时选择与运营方环境解耦。
|
|
||||||
|
|
||||||
## 7. 风险与回退
|
|
||||||
|
|
||||||
| 风险 | 评估 | 对策 |
|
| 风险 | 评估 | 对策 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§7.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
|
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§6.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
|
||||||
| Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 |
|
| Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 |
|
||||||
| Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
|
| Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
|
||||||
|
|
||||||
### 7.1 antdv-next 核验记录(2026-10-02,一手源直查)
|
### 6.1 antdv-next 核验记录(2026-10-02,一手源直查)
|
||||||
|
|
||||||
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
|
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
|
||||||
|
|
||||||
@@ -96,7 +93,6 @@ Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手
|
|||||||
|
|
||||||
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
|
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
|
||||||
|
|
||||||
## 8. 待裁决项
|
## 7. 待裁决项
|
||||||
|
|
||||||
1. Docker 细则:基础镜像、compose 形态、密钥注入方式——部署设计时定;
|
1. 前端测试设施是否引入 vitest。
|
||||||
2. 前端测试设施是否引入 vitest。
|
|
||||||
|
|||||||
@@ -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`
|
||||||
+3
-3
@@ -56,14 +56,14 @@
|
|||||||
| # | 需求 | 说明 | 状态 |
|
| # | 需求 | 说明 | 状态 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
| R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) |
|
| R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) |
|
||||||
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);**完全公开透明**(订阅与支付参数对成员可见);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29) |
|
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);支付参数**同账本域内可见**(同域透明、跨域隔离;未标域默认藏,详见各表 `billing_domain` 与《API契约-成员视图》§5);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29;2026-10-13 补账本域可见性) |
|
||||||
| R16 | 成员档案维护与空壳删除 | **修改**:开户后可改 `name` / `remark`(误建、挂 key 前改名等真实场景);`status` 走启停、令牌走重发,不并入档案编辑。**删除**:仅当**当前无任何 key 归属**(`key_count = 0`)才允许硬删——清理空壳,不触碰账本事实;名下有 key 只能停用(软删除语义不变) | **已裁决**(2026-10-10) |
|
| R16 | 成员档案维护与空壳删除 | **修改**:开户后可改 `name` / `remark`(误建、挂 key 前改名等真实场景);`status` 走启停、令牌走重发,不并入档案编辑。**删除**:仅当**当前无任何 key 归属**(`key_count = 0`)才允许硬删——清理空壳,不触碰账本事实;名下有 key 只能停用(软删除语义不变) | **已裁决**(2026-10-10) |
|
||||||
|
|
||||||
## 5. 交付域(已裁决)
|
## 5. 交付域(已裁决)
|
||||||
|
|
||||||
| # | 需求 | 说明 | 状态 |
|
| # | 需求 | 说明 | 状态 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(后端单进程承载 API 路由与同步引擎),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29) |
|
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(`apps/server` 承载 API 路由,`apps/engine` 独立进程同步——见《同步引擎设计》2026-10-05),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29;进程拆分修订 2026-10-05) |
|
||||||
| R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) |
|
| R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) |
|
||||||
|
|
||||||
## 6. 数据模型
|
## 6. 数据模型
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
|
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
|
||||||
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
|
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
|
||||||
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
|
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
|
||||||
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、**完全公开透明**(订阅与支付参数对成员可见)、无「应付」概念
|
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、支付参数**同账本域内可见**、无「应付」概念
|
||||||
|
|
||||||
### 2026-10-01 · R15 审阅定稿
|
### 2026-10-01 · R15 审阅定稿
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "new-api-key-usage",
|
"name": "new-api-key-usage",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": ["apps/*", "packages/*"],
|
"workspaces": [
|
||||||
|
"apps/*",
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
|
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
|
||||||
@@ -11,10 +15,16 @@
|
|||||||
"db:generate": "bun run --cwd packages/db db:generate",
|
"db:generate": "bun run --cwd packages/db db:generate",
|
||||||
"db:push": "bun run --cwd packages/db db:push",
|
"db:push": "bun run --cwd packages/db db:push",
|
||||||
"db:studio": "bun run --cwd packages/db db:studio",
|
"db:studio": "bun run --cwd packages/db db:studio",
|
||||||
"build:web": "bun run --cwd apps/web build"
|
"build:web": "bun run --cwd apps/web build",
|
||||||
|
"build:server": "bun build --compile --outfile=bin/naku-server apps/server/src/index.ts",
|
||||||
|
"build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
|
||||||
|
"pack": "bun run scripts/cli.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@clack/prompts": "^1.8.1",
|
||||||
"@types/bun": "^1.4.2",
|
"@types/bun": "^1.4.2",
|
||||||
|
"citty": "^0.2.2",
|
||||||
"typescript": "^7.0.2"
|
"typescript": "^7.0.2"
|
||||||
}
|
},
|
||||||
|
"packageManager": "bun@1.4.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export const ERR = {
|
|||||||
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
|
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
|
||||||
member_name_taken: () =>
|
member_name_taken: () =>
|
||||||
errorBody("member_name_taken", "成员名称已被占用"),
|
errorBody("member_name_taken", "成员名称已被占用"),
|
||||||
|
member_has_keys: (key_count: number) =>
|
||||||
|
errorBody("member_has_keys", `名下仍有 ${key_count} 枚 key,只能停用`, {
|
||||||
|
fields: { key_count: String(key_count) },
|
||||||
|
}),
|
||||||
upstream_unreachable: () =>
|
upstream_unreachable: () =>
|
||||||
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
|
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
|
||||||
not_found: () => errorBody("not_found", "请求的资源不存在"),
|
not_found: () => errorBody("not_found", "请求的资源不存在"),
|
||||||
|
|||||||
@@ -68,9 +68,19 @@ export const logItem = z.object({
|
|||||||
raw: z.record(z.string(), z.unknown()).optional(),
|
raw: z.record(z.string(), z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const logPageSummary = z.object({
|
||||||
|
hit_count: z.number().int(),
|
||||||
|
usage_count: z.number().int(),
|
||||||
|
error_count: z.number().int(),
|
||||||
|
quota: z.number(),
|
||||||
|
quota_usd: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const logPage = z.object({
|
export const logPage = z.object({
|
||||||
items: z.array(logItem),
|
items: z.array(logItem),
|
||||||
total: z.number().int(),
|
total: z.number().int(),
|
||||||
|
summary: logPageSummary,
|
||||||
|
models: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const keyItem = z.object({
|
export const keyItem = z.object({
|
||||||
@@ -79,6 +89,7 @@ export const keyItem = z.object({
|
|||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
member_id: z.number().int().nullable(),
|
member_id: z.number().int().nullable(),
|
||||||
member_name: z.string().nullable(),
|
member_name: z.string().nullable(),
|
||||||
|
billing_domain: z.string().nullable(),
|
||||||
registered_at: z.number().int(),
|
registered_at: z.number().int(),
|
||||||
log_synced_at: z.number().int().nullable(),
|
log_synced_at: z.number().int().nullable(),
|
||||||
usage_synced_at: z.number().int().nullable(),
|
usage_synced_at: z.number().int().nullable(),
|
||||||
@@ -92,11 +103,13 @@ export const keyItem = z.object({
|
|||||||
export const registerKeyBody = z.object({
|
export const registerKeyBody = z.object({
|
||||||
sk_key: z.string().min(8),
|
sk_key: z.string().min(8),
|
||||||
member_id: z.number().int().positive().nullable().optional(),
|
member_id: z.number().int().positive().nullable().optional(),
|
||||||
|
billing_domain: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const patchKeyBody = z.object({
|
export const patchKeyBody = z.object({
|
||||||
status: z.enum(["active", "disabled"]).optional(),
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
member_id: z.number().int().positive().nullable().optional(),
|
member_id: z.number().int().positive().nullable().optional(),
|
||||||
|
billing_domain: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const memberItem = z.object({
|
export const memberItem = z.object({
|
||||||
@@ -115,7 +128,9 @@ export const createMemberBody = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const patchMemberBody = z.object({
|
export const patchMemberBody = z.object({
|
||||||
status: z.enum(["active", "disabled"]),
|
name: z.string().min(1).max(64).optional(),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const memberWithToken = z.object({
|
export const memberWithToken = z.object({
|
||||||
@@ -139,6 +154,7 @@ export const subscriptionItem = z.object({
|
|||||||
quota_usd: z.string(),
|
quota_usd: z.string(),
|
||||||
paid_amount: z.number().nullable(),
|
paid_amount: z.number().nullable(),
|
||||||
paid_at: z.number().int().nullable(),
|
paid_at: z.number().int().nullable(),
|
||||||
|
billing_domain: z.string().nullable(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
created_at: z.number().int(),
|
created_at: z.number().int(),
|
||||||
});
|
});
|
||||||
@@ -155,6 +171,7 @@ export const topupItem = z.object({
|
|||||||
paid_at: z.number().int(),
|
paid_at: z.number().int(),
|
||||||
quota_derived: z.number().int(),
|
quota_derived: z.number().int(),
|
||||||
quota_usd: z.string(),
|
quota_usd: z.string(),
|
||||||
|
billing_domain: z.string().nullable(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
created_at: z.number().int(),
|
created_at: z.number().int(),
|
||||||
});
|
});
|
||||||
@@ -216,6 +233,7 @@ export const createSubscriptionBody = z
|
|||||||
quota_granted: z.number().int().positive(),
|
quota_granted: z.number().int().positive(),
|
||||||
paid_amount: z.number().nullable().optional(),
|
paid_amount: z.number().nullable().optional(),
|
||||||
paid_at: z.string().min(1).nullable().optional(),
|
paid_at: z.string().min(1).nullable().optional(),
|
||||||
|
billing_domain: z.string().nullable().optional(),
|
||||||
remark: z.string().default(""),
|
remark: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +246,7 @@ export const createTopupBody = z.object({
|
|||||||
payment_provider: z.string().nullable().optional(),
|
payment_provider: z.string().nullable().optional(),
|
||||||
status: z.string().nullable().optional(),
|
status: z.string().nullable().optional(),
|
||||||
paid_at: z.string().min(1),
|
paid_at: z.string().min(1),
|
||||||
|
billing_domain: z.string().nullable().optional(),
|
||||||
remark: z.string().default(""),
|
remark: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface KeyItem {
|
|||||||
status: KeyStatus;
|
status: KeyStatus;
|
||||||
member_id: number | null;
|
member_id: number | null;
|
||||||
member_name: string | null;
|
member_name: string | null;
|
||||||
|
billing_domain: string | null;
|
||||||
registered_at: UnixSec;
|
registered_at: UnixSec;
|
||||||
log_synced_at: UnixSec | null;
|
log_synced_at: UnixSec | null;
|
||||||
usage_synced_at: UnixSec | null;
|
usage_synced_at: UnixSec | null;
|
||||||
@@ -66,6 +67,7 @@ export interface SubscriptionItem {
|
|||||||
quota_usd: QuotaUsd;
|
quota_usd: QuotaUsd;
|
||||||
paid_amount: PaidAmount | null;
|
paid_amount: PaidAmount | null;
|
||||||
paid_at: UnixSec | null;
|
paid_at: UnixSec | null;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: UnixSec;
|
created_at: UnixSec;
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,7 @@ export interface TopupItem {
|
|||||||
paid_at: UnixSec;
|
paid_at: UnixSec;
|
||||||
quota_derived: Quota;
|
quota_derived: Quota;
|
||||||
quota_usd: QuotaUsd;
|
quota_usd: QuotaUsd;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: UnixSec;
|
created_at: UnixSec;
|
||||||
}
|
}
|
||||||
@@ -111,6 +114,21 @@ export interface Paginated<T> {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 日志页汇总:条数跟全部筛选;用量恒 type=2 + 其余筛选 */
|
||||||
|
export interface LogPageSummary {
|
||||||
|
hit_count: number;
|
||||||
|
usage_count: number;
|
||||||
|
error_count: number;
|
||||||
|
quota: Quota;
|
||||||
|
quota_usd: QuotaUsd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用日志分页响应(含汇总条与模型下拉选项) */
|
||||||
|
export interface LogPage extends Paginated<LogItem> {
|
||||||
|
summary: LogPageSummary;
|
||||||
|
models: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface EngineLastRound {
|
export interface EngineLastRound {
|
||||||
started_at: UnixSec;
|
started_at: UnixSec;
|
||||||
keys_total: number;
|
keys_total: number;
|
||||||
@@ -138,10 +156,12 @@ export type AuditAction =
|
|||||||
| "payment.update"
|
| "payment.update"
|
||||||
| "payment.delete"
|
| "payment.delete"
|
||||||
| "member.create"
|
| "member.create"
|
||||||
|
| "member.update"
|
||||||
| "member.enable"
|
| "member.enable"
|
||||||
| "member.disable"
|
| "member.disable"
|
||||||
| "member.token_issue"
|
| "member.token_issue"
|
||||||
| "member.token_reissue"
|
| "member.token_reissue"
|
||||||
|
| "member.delete"
|
||||||
| "account.password_change"
|
| "account.password_change"
|
||||||
| "account.init";
|
| "account.init";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "api_keys" ADD COLUMN "billing_domain" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "billing_domain" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "topups" ADD COLUMN "billing_domain" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1791551016051,
|
"when": 1791551016051,
|
||||||
"tag": "0000_loose_goblin_queen",
|
"tag": "0000_loose_goblin_queen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791878178512,
|
||||||
|
"tag": "0001_aromatic_hawkeye",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -32,6 +32,50 @@ export interface KeyListRow extends ApiKeyRow {
|
|||||||
member_name: string | null;
|
member_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogFilterParams = {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
api_key_id?: number;
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
billing_source?: string | "none";
|
||||||
|
subscription_id?: number;
|
||||||
|
model_name?: string;
|
||||||
|
type?: number | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 明细/汇总共用的 WHERE 片段(时间左闭右开;联 api_keys 时才带 member 归属) */
|
||||||
|
function buildLogConds(params: LogFilterParams) {
|
||||||
|
const conds = [
|
||||||
|
gte(usageLogs.created_at, params.from),
|
||||||
|
lt(usageLogs.created_at, params.to),
|
||||||
|
];
|
||||||
|
if (params.api_key_id != null) {
|
||||||
|
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
||||||
|
}
|
||||||
|
if (params.billing_source === "none") {
|
||||||
|
conds.push(isNull(usageLogs.billing_source));
|
||||||
|
} else if (params.billing_source) {
|
||||||
|
conds.push(eq(usageLogs.billing_source, params.billing_source));
|
||||||
|
}
|
||||||
|
if (params.subscription_id != null) {
|
||||||
|
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
||||||
|
}
|
||||||
|
if (params.model_name) {
|
||||||
|
conds.push(eq(usageLogs.model_name, params.model_name));
|
||||||
|
}
|
||||||
|
if (params.type === "error") {
|
||||||
|
conds.push(ne(usageLogs.type, 2));
|
||||||
|
} else if (typeof params.type === "number") {
|
||||||
|
conds.push(eq(usageLogs.type, params.type));
|
||||||
|
}
|
||||||
|
if (params.member_id === "unassigned") {
|
||||||
|
conds.push(isNull(apiKeys.member_id));
|
||||||
|
} else if (params.member_id != null) {
|
||||||
|
conds.push(eq(apiKeys.member_id, params.member_id));
|
||||||
|
}
|
||||||
|
return conds;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 仓储层(R14):数据访问只出现在 packages/db。
|
* 仓储层(R14):数据访问只出现在 packages/db。
|
||||||
* apps/server 不 import drizzle-orm / postgres 驱动。
|
* apps/server 不 import drizzle-orm / postgres 驱动。
|
||||||
@@ -115,6 +159,7 @@ export function createRepos(db: Db) {
|
|||||||
async listAll(filter?: {
|
async listAll(filter?: {
|
||||||
member_id?: number | "unassigned";
|
member_id?: number | "unassigned";
|
||||||
status?: string;
|
status?: string;
|
||||||
|
billing_domain?: string | "none";
|
||||||
}): Promise<KeyListRow[]> {
|
}): Promise<KeyListRow[]> {
|
||||||
const conds = [];
|
const conds = [];
|
||||||
if (filter?.member_id === "unassigned") {
|
if (filter?.member_id === "unassigned") {
|
||||||
@@ -125,6 +170,11 @@ export function createRepos(db: Db) {
|
|||||||
if (filter?.status) {
|
if (filter?.status) {
|
||||||
conds.push(eq(apiKeys.status, filter.status));
|
conds.push(eq(apiKeys.status, filter.status));
|
||||||
}
|
}
|
||||||
|
if (filter?.billing_domain === "none") {
|
||||||
|
conds.push(isNull(apiKeys.billing_domain));
|
||||||
|
} else if (filter?.billing_domain) {
|
||||||
|
conds.push(eq(apiKeys.billing_domain, filter.billing_domain));
|
||||||
|
}
|
||||||
const base = db
|
const base = db
|
||||||
.select({
|
.select({
|
||||||
id: apiKeys.id,
|
id: apiKeys.id,
|
||||||
@@ -132,6 +182,7 @@ export function createRepos(db: Db) {
|
|||||||
key_ciphertext: apiKeys.key_ciphertext,
|
key_ciphertext: apiKeys.key_ciphertext,
|
||||||
status: apiKeys.status,
|
status: apiKeys.status,
|
||||||
member_id: apiKeys.member_id,
|
member_id: apiKeys.member_id,
|
||||||
|
billing_domain: apiKeys.billing_domain,
|
||||||
registered_at: apiKeys.registered_at,
|
registered_at: apiKeys.registered_at,
|
||||||
log_synced_at: apiKeys.log_synced_at,
|
log_synced_at: apiKeys.log_synced_at,
|
||||||
usage_synced_at: apiKeys.usage_synced_at,
|
usage_synced_at: apiKeys.usage_synced_at,
|
||||||
@@ -220,6 +271,7 @@ export function createRepos(db: Db) {
|
|||||||
key_hash: string;
|
key_hash: string;
|
||||||
key_ciphertext: Uint8Array;
|
key_ciphertext: Uint8Array;
|
||||||
member_id: number | null;
|
member_id: number | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
token_name?: string;
|
token_name?: string;
|
||||||
}): Promise<ApiKeyRow> {
|
}): Promise<ApiKeyRow> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
@@ -228,6 +280,7 @@ export function createRepos(db: Db) {
|
|||||||
key_hash: values.key_hash,
|
key_hash: values.key_hash,
|
||||||
key_ciphertext: values.key_ciphertext,
|
key_ciphertext: values.key_ciphertext,
|
||||||
member_id: values.member_id,
|
member_id: values.member_id,
|
||||||
|
billing_domain: values.billing_domain ?? null,
|
||||||
token_name: values.token_name ?? "",
|
token_name: values.token_name ?? "",
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -294,6 +347,23 @@ export function createRepos(db: Db) {
|
|||||||
.returning();
|
.returning();
|
||||||
return row!;
|
return row!;
|
||||||
},
|
},
|
||||||
|
async updateProfile(
|
||||||
|
id: number,
|
||||||
|
values: { name?: string; remark?: string },
|
||||||
|
): Promise<MemberRow> {
|
||||||
|
const patch: { name?: string; remark?: string } = {};
|
||||||
|
if (values.name !== undefined) patch.name = values.name;
|
||||||
|
if (values.remark !== undefined) patch.remark = values.remark;
|
||||||
|
const [row] = await db
|
||||||
|
.update(members)
|
||||||
|
.set(patch)
|
||||||
|
.where(eq(members.id, id))
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async deleteById(id: number): Promise<void> {
|
||||||
|
await db.delete(members).where(eq(members.id, id));
|
||||||
|
},
|
||||||
async reissueToken(
|
async reissueToken(
|
||||||
id: number,
|
id: number,
|
||||||
token_hash: string,
|
token_hash: string,
|
||||||
@@ -387,11 +457,23 @@ export function createRepos(db: Db) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
async listAll(): Promise<SubscriptionRow[]> {
|
async listAll(filter?: {
|
||||||
return db
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<SubscriptionRow[]> {
|
||||||
|
const conds = [];
|
||||||
|
if (filter?.billing_domain === "none") {
|
||||||
|
conds.push(isNull(subscriptions.billing_domain));
|
||||||
|
} else if (filter?.billing_domain) {
|
||||||
|
conds.push(eq(subscriptions.billing_domain, filter.billing_domain));
|
||||||
|
}
|
||||||
|
const base = db
|
||||||
.select()
|
.select()
|
||||||
.from(subscriptions)
|
.from(subscriptions)
|
||||||
.orderBy(desc(subscriptions.period_start));
|
.orderBy(desc(subscriptions.period_start));
|
||||||
|
if (conds.length > 0) {
|
||||||
|
return base.where(and(...conds));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
},
|
},
|
||||||
async findById(id: number): Promise<SubscriptionRow | null> {
|
async findById(id: number): Promise<SubscriptionRow | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -434,8 +516,20 @@ export function createRepos(db: Db) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
topups: {
|
topups: {
|
||||||
async listAll(): Promise<TopupRow[]> {
|
async listAll(filter?: {
|
||||||
return db.select().from(topups).orderBy(desc(topups.paid_at));
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<TopupRow[]> {
|
||||||
|
const conds = [];
|
||||||
|
if (filter?.billing_domain === "none") {
|
||||||
|
conds.push(isNull(topups.billing_domain));
|
||||||
|
} else if (filter?.billing_domain) {
|
||||||
|
conds.push(eq(topups.billing_domain, filter.billing_domain));
|
||||||
|
}
|
||||||
|
const base = db.select().from(topups).orderBy(desc(topups.paid_at));
|
||||||
|
if (conds.length > 0) {
|
||||||
|
return base.where(and(...conds));
|
||||||
|
}
|
||||||
|
return base;
|
||||||
},
|
},
|
||||||
async findById(id: number): Promise<TopupRow | null> {
|
async findById(id: number): Promise<TopupRow | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -507,35 +601,12 @@ export function createRepos(db: Db) {
|
|||||||
member_id?: number | "unassigned";
|
member_id?: number | "unassigned";
|
||||||
billing_source?: string | "none";
|
billing_source?: string | "none";
|
||||||
subscription_id?: number;
|
subscription_id?: number;
|
||||||
|
model_name?: string;
|
||||||
type?: number | "error";
|
type?: number | "error";
|
||||||
page: number;
|
page: number;
|
||||||
page_size: number;
|
page_size: number;
|
||||||
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
|
}): Promise<{ rows: Array<UsageLogRow & { token_name: string }>; total: number }> {
|
||||||
const conds = [
|
const conds = buildLogConds(params);
|
||||||
gte(usageLogs.created_at, params.from),
|
|
||||||
lt(usageLogs.created_at, params.to),
|
|
||||||
];
|
|
||||||
if (params.api_key_id != null) {
|
|
||||||
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
|
||||||
}
|
|
||||||
if (params.billing_source === "none") {
|
|
||||||
conds.push(isNull(usageLogs.billing_source));
|
|
||||||
} else if (params.billing_source) {
|
|
||||||
conds.push(eq(usageLogs.billing_source, params.billing_source));
|
|
||||||
}
|
|
||||||
if (params.subscription_id != null) {
|
|
||||||
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
|
||||||
}
|
|
||||||
if (params.type === "error") {
|
|
||||||
conds.push(ne(usageLogs.type, 2));
|
|
||||||
} else if (typeof params.type === "number") {
|
|
||||||
conds.push(eq(usageLogs.type, params.type));
|
|
||||||
}
|
|
||||||
if (params.member_id === "unassigned") {
|
|
||||||
conds.push(isNull(apiKeys.member_id));
|
|
||||||
} else if (params.member_id != null) {
|
|
||||||
conds.push(eq(apiKeys.member_id, params.member_id));
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalRows = await db
|
const totalRows = await db
|
||||||
.select({ n: count() })
|
.select({ n: count() })
|
||||||
@@ -655,29 +726,10 @@ export function createRepos(db: Db) {
|
|||||||
api_key_id?: number;
|
api_key_id?: number;
|
||||||
billing_source?: string | "none";
|
billing_source?: string | "none";
|
||||||
subscription_id?: number;
|
subscription_id?: number;
|
||||||
|
model_name?: string;
|
||||||
type?: number | "error";
|
type?: number | "error";
|
||||||
}) {
|
}) {
|
||||||
const conds = [
|
const conds = buildLogConds({ ...params, member_id: params.member_id });
|
||||||
eq(apiKeys.member_id, params.member_id),
|
|
||||||
gte(usageLogs.created_at, params.from),
|
|
||||||
lt(usageLogs.created_at, params.to),
|
|
||||||
];
|
|
||||||
if (params.type === "error") {
|
|
||||||
conds.push(ne(usageLogs.type, 2));
|
|
||||||
} else if (typeof params.type === "number") {
|
|
||||||
conds.push(eq(usageLogs.type, params.type));
|
|
||||||
}
|
|
||||||
if (params.api_key_id != null) {
|
|
||||||
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
|
|
||||||
}
|
|
||||||
if (params.billing_source === "none") {
|
|
||||||
conds.push(isNull(usageLogs.billing_source));
|
|
||||||
} else if (params.billing_source) {
|
|
||||||
conds.push(eq(usageLogs.billing_source, params.billing_source));
|
|
||||||
}
|
|
||||||
if (params.subscription_id != null) {
|
|
||||||
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
|
|
||||||
}
|
|
||||||
const totalRows = await db
|
const totalRows = await db
|
||||||
.select({ n: count() })
|
.select({ n: count() })
|
||||||
.from(usageLogs)
|
.from(usageLogs)
|
||||||
@@ -721,6 +773,122 @@ export function createRepos(db: Db) {
|
|||||||
.offset((params.page - 1) * params.page_size);
|
.offset((params.page - 1) * params.page_size);
|
||||||
return { rows, total };
|
return { rows, total };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志页汇总:条数跟全部筛选;用量行恒 type=2(不含 type 筛选)。
|
||||||
|
* usage_rows 交上层 sumUsage 做按行 QPU 微分。
|
||||||
|
*/
|
||||||
|
async summarize(params: LogFilterParams) {
|
||||||
|
const fullConds = buildLogConds(params);
|
||||||
|
const usageConds = buildLogConds({ ...params, type: 2 });
|
||||||
|
|
||||||
|
const hitRows = await db
|
||||||
|
.select({
|
||||||
|
hit_count: count(),
|
||||||
|
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
|
||||||
|
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
|
||||||
|
})
|
||||||
|
.from(usageLogs)
|
||||||
|
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...fullConds));
|
||||||
|
const hit = hitRows[0] ?? {
|
||||||
|
hit_count: 0,
|
||||||
|
usage_count: 0,
|
||||||
|
error_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const usage_rows = await db
|
||||||
|
.select({
|
||||||
|
quota: usageLogs.quota,
|
||||||
|
created_at: usageLogs.created_at,
|
||||||
|
})
|
||||||
|
.from(usageLogs)
|
||||||
|
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...usageConds));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit_count: Number(hit.hit_count) || 0,
|
||||||
|
usage_count: Number(hit.usage_count) || 0,
|
||||||
|
error_count: Number(hit.error_count) || 0,
|
||||||
|
usage_rows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async summarizeMember(params: LogFilterParams & { member_id: number }) {
|
||||||
|
const fullConds = buildLogConds({ ...params, member_id: params.member_id });
|
||||||
|
const usageConds = buildLogConds({
|
||||||
|
...params,
|
||||||
|
member_id: params.member_id,
|
||||||
|
type: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hitRows = await db
|
||||||
|
.select({
|
||||||
|
hit_count: count(),
|
||||||
|
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
|
||||||
|
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
|
||||||
|
})
|
||||||
|
.from(usageLogs)
|
||||||
|
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...fullConds));
|
||||||
|
const hit = hitRows[0] ?? {
|
||||||
|
hit_count: 0,
|
||||||
|
usage_count: 0,
|
||||||
|
error_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const usage_rows = await db
|
||||||
|
.select({
|
||||||
|
quota: usageLogs.quota,
|
||||||
|
created_at: usageLogs.created_at,
|
||||||
|
})
|
||||||
|
.from(usageLogs)
|
||||||
|
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...usageConds));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit_count: Number(hit.hit_count) || 0,
|
||||||
|
usage_count: Number(hit.usage_count) || 0,
|
||||||
|
error_count: Number(hit.error_count) || 0,
|
||||||
|
usage_rows,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 模型下拉选项:时间窗 + 主体/来源/订阅筛选下去重(不含 model_name / type) */
|
||||||
|
async listModels(
|
||||||
|
params: Omit<LogFilterParams, "type" | "model_name">,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const conds = buildLogConds({ ...params, type: undefined, model_name: undefined });
|
||||||
|
const rows = await db
|
||||||
|
.select({ model_name: usageLogs.model_name })
|
||||||
|
.from(usageLogs)
|
||||||
|
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...conds))
|
||||||
|
.groupBy(usageLogs.model_name)
|
||||||
|
.orderBy(asc(usageLogs.model_name));
|
||||||
|
return rows.map((r) => r.model_name).filter((m) => m !== "");
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMemberModels(
|
||||||
|
params: Omit<LogFilterParams, "type" | "model_name"> & {
|
||||||
|
member_id: number;
|
||||||
|
},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const conds = buildLogConds({
|
||||||
|
...params,
|
||||||
|
member_id: params.member_id,
|
||||||
|
type: undefined,
|
||||||
|
model_name: undefined,
|
||||||
|
});
|
||||||
|
const rows = await db
|
||||||
|
.select({ model_name: usageLogs.model_name })
|
||||||
|
.from(usageLogs)
|
||||||
|
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
|
||||||
|
.where(and(...conds))
|
||||||
|
.groupBy(usageLogs.model_name)
|
||||||
|
.orderBy(asc(usageLogs.model_name));
|
||||||
|
return rows.map((r) => r.model_name).filter((m) => m !== "");
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
keysExtra: {
|
keysExtra: {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const apiKeys = pgTable(
|
|||||||
member_id: bigint("member_id", { mode: "number" }).references(
|
member_id: bigint("member_id", { mode: "number" }).references(
|
||||||
() => members.id,
|
() => members.id,
|
||||||
),
|
),
|
||||||
|
billing_domain: text("billing_domain"),
|
||||||
registered_at: timestamp("registered_at", {
|
registered_at: timestamp("registered_at", {
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
mode: "date",
|
mode: "date",
|
||||||
@@ -135,6 +136,7 @@ export const subscriptions = pgTable(
|
|||||||
mode: "number",
|
mode: "number",
|
||||||
}),
|
}),
|
||||||
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
|
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
|
||||||
|
billing_domain: text("billing_domain"),
|
||||||
remark: text("remark").notNull().default(""),
|
remark: text("remark").notNull().default(""),
|
||||||
created_at: timestamp("created_at", {
|
created_at: timestamp("created_at", {
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
@@ -161,6 +163,7 @@ export const topups = pgTable("topups", {
|
|||||||
payment_provider: text("payment_provider"),
|
payment_provider: text("payment_provider"),
|
||||||
status: text("status"),
|
status: text("status"),
|
||||||
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
|
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
|
||||||
|
billing_domain: text("billing_domain"),
|
||||||
remark: text("remark").notNull().default(""),
|
remark: text("remark").notNull().default(""),
|
||||||
created_at: timestamp("created_at", {
|
created_at: timestamp("created_at", {
|
||||||
withTimezone: true,
|
withTimezone: true,
|
||||||
|
|||||||
+255
@@ -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);
|
||||||
@@ -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
@@ -26,6 +26,7 @@
|
|||||||
"apps/server/test/**/*.ts",
|
"apps/server/test/**/*.ts",
|
||||||
"packages/*/src/**/*.ts",
|
"packages/*/src/**/*.ts",
|
||||||
"packages/*/test/**/*.ts",
|
"packages/*/test/**/*.ts",
|
||||||
"packages/*/drizzle.config.ts"
|
"packages/*/drizzle.config.ts",
|
||||||
|
"scripts/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user