Compare commits

..
6 Commits
46 changed files with 2399 additions and 221 deletions
+1 -2
View File
@@ -35,8 +35,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
| `bun run build:web` | 前端类型检查 + 产物构建 | | `bun run build:web` | 前端类型检查 + 产物构建 |
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) | | `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) | | `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
| `bun run naku` | 打包/构建 CLI(citty + clack):`naku pack` 交互打部署包 → `release/`;`naku build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only` | | `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
| `bun run pack:release` | 非交互打部署包(= `naku pack --yes`,目标默认 linux-x64) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。 环境变量见 `.env.example`(复制为 `.env` 后填写)。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 skycurtain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+25 -15
View File
@@ -6,32 +6,34 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));CFW 衍生形态待做 | | 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`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)。
@@ -86,13 +88,13 @@ bun run dev:web # 前端 Vite(5175)
### 本地打包 ### 本地打包
```bash ```bash
bun run naku pack bun run pack release
# 交互选:目标平台 · 打包内容 · 是否 tar.gz # 交互选:目标平台 · 打包内容 · 是否 tar.gz
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/ # 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
# 非交互(CI / 一键): # 非交互(CI / 一键):
bun run pack:release bun run pack release --yes
# 或 bun run naku pack --yes --target=bun-linux-arm64 --only=dist,naku-server # 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
``` ```
### 服务器 ### 服务器
@@ -135,9 +137,10 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
| [`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-12) | | [`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) |
@@ -149,6 +152,7 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策) - 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古) - 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反) ## 关键事实清单(实测 / 源码确认,设计时不得违反)
@@ -160,3 +164,9 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
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,本项目不受其传染,亦不衍生自它。
+24 -1
View File
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled", status: row.status as "active" | "disabled",
member_id: row.member_id, member_id: row.member_id,
member_name: row.member_name ?? null, member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!, registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at), log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at), usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({ query: z.object({
member_id: z.string().optional(), member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(), recent_failed: z.enum(["true", "false"]).optional(),
}), }),
}, },
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: { const filter: {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: string; status?: string;
billing_domain?: string | "none";
} = {}; } = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned"; if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id); else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") { if (q.status === "active" || q.status === "disabled") {
filter.status = q.status; filter.status = q.status;
} }
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter); let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") { if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot(); const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
} }
member_id = n; member_id = n;
} }
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) { if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError( return jsonError(
c, c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash, key_hash,
key_ciphertext, key_ciphertext,
member_id, member_id,
billing_domain,
token_name: usage.name ?? "", token_name: usage.name ?? "",
}); });
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补) // 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({ createRoute({
method: "patch", method: "patch",
path: "/keys/{id}", path: "/keys/{id}",
summary: "启停 / 归属变更", summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行", description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"], tags: ["Key 管理"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
}); });
} }
} }
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
return jsonError( return jsonError(
c, c,
+19 -1
View File
@@ -37,6 +37,7 @@ function toSubscriptionItem(
quota_usd, quota_usd,
paid_amount: row.paid_amount, paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at), paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark, remark: row.remark,
created_at: toUnixSec(row.created_at)!, created_at: toUnixSec(row.created_at)!,
}; };
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: number; quota_granted: number;
paid_amount: number | null; paid_amount: number | null;
paid_at: Date | null; paid_at: Date | null;
billing_domain: string | null;
remark: string; remark: string;
}; };
} { } {
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
quota_granted: body.quota_granted, quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null, paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null, paid_at: body.paid_at ? new Date(body.paid_at) : null,
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "", remark: body.remark ?? "",
}, },
}; };
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
summary: "订阅权益清单", summary: "订阅权益清单",
tags: ["支付记录"], tags: ["支付记录"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: { responses: {
200: { 200: {
description: "subscription 数组", description: "subscription 数组",
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
async (c) => { async (c) => {
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.subscriptions.listAll(); const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.subscriptions.listAll(filter);
return c.json( return c.json(
rows.map((row) => rows.map((row) =>
toSubscriptionItem( toSubscriptionItem(
+19 -1
View File
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
paid_at: toUnixSec(row.paid_at)!, paid_at: toUnixSec(row.paid_at)!,
quota_derived, quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu), quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark, remark: row.remark,
created_at: toUnixSec(row.created_at)!, created_at: toUnixSec(row.created_at)!,
}; };
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: string | null; payment_provider: string | null;
status: string | null; status: string | null;
paid_at: Date; paid_at: Date;
billing_domain: string | null;
remark: string; remark: string;
}; };
} { } {
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
payment_provider: body.payment_provider ?? null, payment_provider: body.payment_provider ?? null,
status: body.status ?? "success", status: body.status ?? "success",
paid_at: new Date(body.paid_at), paid_at: new Date(body.paid_at),
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "", remark: body.remark ?? "",
}, },
}; };
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
summary: "充值入账清单", summary: "充值入账清单",
tags: ["支付记录"], tags: ["支付记录"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: { responses: {
200: { 200: {
description: "topup 数组", description: "topup 数组",
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
async (c) => { async (c) => {
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.topups.listAll(); const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.topups.listAll(filter);
return c.json( return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))), rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200, 200,
+30 -8
View File
@@ -279,7 +279,7 @@ export function adminUsageRoutes() {
path: "/logs", path: "/logs",
summary: "日志明细(下钻)", summary: "日志明细(下钻)",
description: description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名", "分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
tags: ["用量"], tags: ["用量"],
security: [{ adminSession: [] }], security: [{ adminSession: [] }],
request: { request: {
@@ -290,12 +290,13 @@ export function adminUsageRoutes() {
member_id: z.string().optional(), member_id: z.string().optional(),
billing_source: z.string().optional(), billing_source: z.string().optional(),
subscription_id: z.string().optional(), subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(), type: z.string().optional(),
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "items/total", description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } }, content: { "application/json": { schema: logPageSchema } },
}, },
401: err401, 401: err401,
@@ -312,15 +313,13 @@ export function adminUsageRoutes() {
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50)); const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({ const filters = {
from: range.from, from: range.from,
to: range.to, to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id: member_id:
q.member_id === "unassigned" q.member_id === "unassigned"
? "unassigned" ? ("unassigned" as const)
: q.member_id : q.member_id
? Number(q.member_id) ? Number(q.member_id)
: undefined, : undefined,
@@ -329,13 +328,22 @@ export function adminUsageRoutes() {
subscription_id: q.subscription_id subscription_id: q.subscription_id
? Number(q.subscription_id) ? Number(q.subscription_id)
: undefined, : undefined,
model_name: q.model_name || undefined,
type: type:
q.type === "error" q.type === "error"
? "error" ? ("error" as const)
: q.type != null && q.type !== "" : q.type != null && q.type !== ""
? Number(q.type) ? Number(q.type)
: undefined, : undefined,
};
const { rows, total } = await repos.logs.listPage({
...filters,
page,
page_size,
}); });
const sum = await repos.logs.summarize(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listModels(filters);
const items: LogItem[] = rows.map((row) => { const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise( const { quota_usd } = usageRowPrecise(
@@ -389,7 +397,21 @@ export function adminUsageRoutes() {
}; };
}); });
return c.json({ items, total }, 200); return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
}, },
); );
+47 -12
View File
@@ -71,7 +71,7 @@ export function memberViewRoutes() {
path: "/overview", path: "/overview",
summary: "单页聚合", summary: "单页聚合",
description: description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)", "名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"], tags: ["成员视图"],
security: [{ memberToken: [] }], security: [{ memberToken: [] }],
request: { request: {
@@ -159,8 +159,13 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline), usage: sumUsage(v.rows, timeline),
})); }));
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) // 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core"); const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const myDomains = new Set<string>();
for (const k of allKeys) {
if (k.billing_domain) myDomains.add(k.billing_domain);
}
const subRows = await repos.subscriptions.listAll(); const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set( const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null), rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
@@ -172,8 +177,11 @@ export function memberViewRoutes() {
s.period_end > new Date() || s.period_end > new Date() ||
(s.period_start <= range.to && (s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from); (s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id) || ongoing; const involved = usedSubIds.has(s.subscription_id);
if (!involved) continue; const inMyDomain =
s.billing_domain != null && myDomains.has(s.billing_domain);
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
if (!involved && !(ongoing && inMyDomain)) continue;
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline); const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({ subscriptionsOut.push({
id: s.id, id: s.id,
@@ -184,6 +192,7 @@ export function memberViewRoutes() {
quota_usd: quotaToUsdStr(s.quota_granted, qpu), quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount, paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at), paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark, remark: s.remark,
created_at: toUnixSec(s.created_at)!, created_at: toUnixSec(s.created_at)!,
}); });
@@ -191,6 +200,7 @@ export function memberViewRoutes() {
const topupRows = await repos.topups.listAll(); const topupRows = await repos.topups.listAll();
const topupsOut = []; const topupsOut = [];
for (const t of topupRows) { for (const t of topupRows) {
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue; if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline); const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu); const quota_derived = Math.round(t.amount * qpu);
@@ -204,6 +214,7 @@ export function memberViewRoutes() {
payment_provider: t.payment_provider, payment_provider: t.payment_provider,
status: t.status, status: t.status,
paid_at: toUnixSec(t.paid_at)!, paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived, quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu), quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark, remark: t.remark,
@@ -230,7 +241,8 @@ export function memberViewRoutes() {
method: "get", method: "get",
path: "/logs", path: "/logs",
summary: "日志明细(名下 key)", summary: "日志明细(名下 key)",
description: "分页;默认全类型(可 type 筛);DTO 与管理面同构", description:
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
tags: ["成员视图"], tags: ["成员视图"],
security: [{ memberToken: [] }], security: [{ memberToken: [] }],
request: { request: {
@@ -242,12 +254,13 @@ export function memberViewRoutes() {
api_key_id: z.string().optional(), api_key_id: z.string().optional(),
billing_source: z.string().optional(), billing_source: z.string().optional(),
subscription_id: z.string().optional(), subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(), type: z.string().optional(),
}), }),
}, },
responses: { responses: {
200: { 200: {
description: "items/total", description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } }, content: { "application/json": { schema: logPageSchema } },
}, },
401: err401, 401: err401,
@@ -267,25 +280,33 @@ export function memberViewRoutes() {
); );
const repos = c.get("repos"); const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({ const member_id = c.get("memberId")!;
member_id: c.get("memberId")!, const filters = {
member_id,
from: range.from, from: range.from,
to: range.to, to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source: billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined, q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id subscription_id: q.subscription_id
? Number(q.subscription_id) ? Number(q.subscription_id)
: undefined, : undefined,
model_name: q.model_name || undefined,
type: type:
q.type === "error" q.type === "error"
? "error" ? ("error" as const)
: q.type != null && q.type !== "" : q.type != null && q.type !== ""
? Number(q.type) ? Number(q.type)
: undefined, : undefined,
};
const { rows, total } = await repos.logs.listMemberPage({
...filters,
page,
page_size,
}); });
const sum = await repos.logs.summarizeMember(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listMemberModels(filters);
const items: LogItem[] = rows.map((row) => { const items: LogItem[] = rows.map((row) => {
const { quota_usd } = usageRowPrecise( const { quota_usd } = usageRowPrecise(
row.quota, row.quota,
@@ -337,7 +358,21 @@ export function memberViewRoutes() {
}, },
}; };
}); });
return c.json({ items, total }, 200); return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
}, },
); );
+7 -1
View File
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
export type KeyListFilters = { export type KeyListFilters = {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: "active" | "disabled"; status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean; recent_failed?: boolean;
}; };
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
export async function registerKey(body: { export async function registerKey(body: {
sk_key: string; sk_key: string;
member_id?: number | null; member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> { }): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body); const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data; return data;
@@ -22,7 +24,11 @@ export async function registerKey(body: {
export async function patchKey( export async function patchKey(
id: number, id: number,
body: { status?: "active" | "disabled"; member_id?: number | null }, body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> { ): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body); const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data; return data;
+4 -5
View File
@@ -1,4 +1,4 @@
import type { LogItem, Paginated } from "@new-api-key-usage/core"; import type { LogPage } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts"; import { adminHttp } from "../http.ts";
export type AdminLogFilters = { export type AdminLogFilters = {
@@ -10,13 +10,12 @@ export type AdminLogFilters = {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
billing_source?: string; billing_source?: string;
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
}; };
export async function fetchLogs( export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
params: AdminLogFilters, const { data } = await adminHttp.get<LogPage>("/logs", {
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
params, params,
}); });
return data; return data;
+7 -2
View File
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
quota_granted: number; quota_granted: number;
paid_amount?: number | null; paid_amount?: number | null;
paid_at?: string | null; paid_at?: string | null;
billing_domain?: string | null;
remark?: string; remark?: string;
}; };
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> { export async function fetchSubscriptions(filter?: {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions"); billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data; return data;
} }
+7 -2
View File
@@ -10,11 +10,16 @@ export type TopupBody = {
payment_provider?: string | null; payment_provider?: string | null;
status?: string | null; status?: string | null;
paid_at: string; paid_at: string;
billing_domain?: string | null;
remark?: string; remark?: string;
}; };
export async function fetchTopups(): Promise<TopupItem[]> { export async function fetchTopups(filter?: {
const { data } = await adminHttp.get<TopupItem[]>("/topups"); billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data; return data;
} }
+4 -4
View File
@@ -1,6 +1,5 @@
import type { import type {
LogItem, LogPage,
Paginated,
SubscriptionItem, SubscriptionItem,
TopupItem, TopupItem,
UnixSec, UnixSec,
@@ -55,11 +54,12 @@ export type MemberLogQuery = {
api_key_id?: number; api_key_id?: number;
billing_source?: string; billing_source?: string;
subscription_id?: number; subscription_id?: number;
model_name?: string;
type?: number | "error"; type?: number | "error";
}; };
export async function fetchLogs(query: MemberLogQuery): Promise<Paginated<LogItem>> { export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", { const { data } = await memberHttp.get<LogPage>("/logs", {
params: query, params: query,
}); });
return data; return data;
+2
View File
@@ -36,6 +36,7 @@ export const qk = {
range.page_size, range.page_size,
range.api_key_id ?? "", range.api_key_id ?? "",
range.member_id ?? "", range.member_id ?? "",
range.model_name ?? "",
range.billing_source ?? "", range.billing_source ?? "",
range.subscription_id ?? "", range.subscription_id ?? "",
range.type ?? "", range.type ?? "",
@@ -60,6 +61,7 @@ export const qk = {
range.page, range.page,
range.page_size, range.page_size,
range.api_key_id ?? "", range.api_key_id ?? "",
range.model_name ?? "",
range.billing_source ?? "", range.billing_source ?? "",
range.subscription_id ?? "", range.subscription_id ?? "",
range.type ?? "", range.type ?? "",
@@ -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>
+30 -3
View File
@@ -1,3 +1,4 @@
import { computed, type ComputedRef } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { import {
fetchKeys, fetchKeys,
@@ -84,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"] });
@@ -106,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,
}); });
} }
@@ -145,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,
}); });
} }
@@ -250,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 }));
});
}
+67 -2
View File
@@ -11,7 +11,7 @@ import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue"; import TableRefresh from "@/components/common/table-refresh.vue";
import RegisterKeyModal from "@/components/admin/register-key-modal.vue"; import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts"; import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt } from "@/utils/format.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts"; import { formatMinute, hoursSince } from "@/utils/time.ts";
@@ -23,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | null>(null); const filterMember = ref<number | "unassigned" | null>(null);
const filterStatus = ref<"active" | "disabled" | null>(null); const filterStatus = ref<"active" | "disabled" | null>(null);
const filterDomain = ref<string | "none" | null>(null);
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
const filterRecentFailed = ref(false); const filterRecentFailed = ref(false);
/** URL → 筛选(总览跳入) */ /** URL → 筛选(总览跳入) */
@@ -35,6 +37,10 @@ function syncFromRoute() {
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status; if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
else filterStatus.value = null; else filterStatus.value = null;
if (q.billing_domain === "none") filterDomain.value = "none";
else if (typeof q.billing_domain === "string" && q.billing_domain) {
filterDomain.value = q.billing_domain;
} else filterDomain.value = null;
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true"; filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
@@ -52,18 +58,21 @@ function pushQuery() {
if (filterMember.value === "unassigned") query.member_id = "unassigned"; if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value); else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value; if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1"; if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value); if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query }); void router.replace({ path: "/admin/keys", query });
} }
watch([filterMember, filterStatus, filterRecentFailed], () => { watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery(); pushQuery();
}); });
const filters = computed(() => ({ const filters = computed(() => ({
member_id: filterMember.value ?? undefined, member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined, status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined, recent_failed: filterRecentFailed.value ? true : undefined,
})); }));
@@ -91,6 +100,7 @@ const hasActiveFilter = computed(
() => () =>
filterMember.value != null || filterMember.value != null ||
filterStatus.value != null || filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value, filterRecentFailed.value,
); );
@@ -99,6 +109,8 @@ const memberOptions = computed(() => [
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })), ...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]); ]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean { function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt); const h = hoursSince(logSyncedAt);
return h != null && h > 2; return h != null && h > 2;
@@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) {
const columns = [ const columns = [
{ title: "名称", key: "token_name", width: 160 }, { title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 }, { title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 }, { title: "状态", key: "status", width: 100 },
{ {
title: tipTitle( title: tipTitle(
@@ -156,6 +169,36 @@ async function onMemberChange(
} }
} }
async function onDomainChange(record: KeyItem, value: string) {
const next = value.trim() || null;
if ((record.billing_domain ?? null) === next) return;
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
const id = record.id;
domainSaveTimer = setTimeout(() => {
domainSaveTimer = null;
void saveDomain(id, next);
}, 400);
}
async function onDomainBlur(record: KeyItem, value: string) {
const next = value.trim() || null;
if (domainSaveTimer != null) {
clearTimeout(domainSaveTimer);
domainSaveTimer = null;
}
if ((record.billing_domain ?? null) === next) return;
await saveDomain(record.id, next);
}
async function saveDomain(id: number, billing_domain: string | null) {
try {
await patchKey.mutateAsync({ id, body: { billing_domain } });
message.success("账本域已更新");
} catch (e) {
message.error((e as ApiError).message || "账本域更新失败");
}
}
async function onToggleStatus(record: KeyItem) { async function onToggleStatus(record: KeyItem) {
const next = record.status === "active" ? "disabled" : "active"; const next = record.status === "active" ? "disabled" : "active";
try { try {
@@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) {
placeholder="状态" placeholder="状态"
allow-clear allow-clear
/> />
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 140px"
placeholder="账本域"
allow-clear
/>
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox> <a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space> </a-space>
</div> </div>
@@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) {
@change="(v: number | 'unassigned') => onMemberChange(record, v)" @change="(v: number | 'unassigned') => onMemberChange(record, v)"
/> />
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
<a-auto-complete
:value="record.billing_domain ?? ''"
:options="domainOptions"
size="small"
style="width: 120px"
allow-clear
@change="(v: string) => onDomainChange(record, v)"
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
/>
</template>
<template v-else-if="column.key === 'status'"> <template v-else-if="column.key === 'status'">
<a-badge <a-badge
:status="record.status === 'active' ? 'success' : 'default'" :status="record.status === 'active' ? 'success' : 'default'"
+143 -8
View File
@@ -3,19 +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 LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import { import {
useLiveTimeRange, useLiveTimeRange,
type RangeKind, type RangeKind,
} from "@/composables/use-live-time-range.ts"; } from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.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();
@@ -23,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);
@@ -50,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,
@@ -58,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: "全部成员" },
@@ -105,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;
} }
@@ -131,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);
@@ -146,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();
@@ -156,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) {
@@ -245,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="[
@@ -276,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()"
@@ -325,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>
@@ -403,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>
@@ -479,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>
+100 -8
View File
@@ -25,6 +25,49 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const subsQuery = useSubscriptions(); const subsQuery = useSubscriptions();
const topupsQuery = useTopups(); const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((v) => ({ value: v, label: v }));
});
const filteredSubs = computed(() => {
let list = subsQuery.data.value ?? [];
if (filterSubId.value != null) {
list = list.filter((r) => r.subscription_id === filterSubId.value);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const filteredTops = computed(() => {
let list = topupsQuery.data.value ?? [];
if (filterTradeNo.value) {
const q = filterTradeNo.value.trim().toLowerCase();
list = list.filter(
(r) =>
(r.trade_no ?? "").toLowerCase().includes(q) ||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const deleteSub = useDeleteSubscription(); const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup(); const deleteTop = useDeleteTopup();
@@ -37,22 +80,27 @@ const checking = ref(false);
function syncFromRoute() { function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs"; tab.value = route.query.tab === "topups" ? "topups" : "subs";
const sid = route.query.subscription_id;
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
const tn = route.query.trade_no;
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
} }
function pushTab() { function pushQuery() {
void router.replace({ const query: Record<string, string> = {};
path: "/admin/payments", if (tab.value === "topups") query.tab = "topups";
query: tab.value === "topups" ? { tab: "topups" } : {}, if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
}); if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
void router.replace({ path: "/admin/payments", query });
} }
syncFromRoute(); syncFromRoute();
watch(() => route.query, () => syncFromRoute()); watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab()); watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...(subsQuery.data.value ?? [])]); const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => { const topRows = computed(() => {
const list = [...(topupsQuery.data.value ?? [])]; const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0)); list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list; return list;
}); });
@@ -62,6 +110,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 }, { title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 }, { title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 }, { title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 }, { title: "操作", key: "actions", width: 120 },
@@ -78,6 +127,7 @@ const topColumns = [
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 }, { title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 }, { title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 }, { title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" }, { title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 }, { title: "操作", key: "actions", width: 120 },
]; ];
@@ -198,6 +248,38 @@ watch(
<a-tab-pane key="topups" tab="充值" /> <a-tab-pane key="topups" tab="充值" />
</a-tabs> </a-tabs>
<div class="payments-toolbar">
<template v-if="tab === 'subs'">
<a-input-number
v-model:value="filterSubId"
:min="1"
:precision="0"
style="width: 140px"
placeholder="订阅 ID"
allow-clear
/>
</template>
<template v-else>
<a-input
v-model:value="filterTradeNo"
style="width: 180px"
placeholder="交易号 / 上游 ID"
allow-clear
/>
</template>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 160px"
placeholder="账本域"
allow-clear
/>
</div>
<a-alert <a-alert
v-if="tab === 'subs' && missingSubs.length" v-if="tab === 'subs' && missingSubs.length"
type="warning" type="warning"
@@ -253,6 +335,9 @@ watch(
: "—" : "—"
}} }}
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
@@ -316,6 +401,9 @@ watch(
<template v-else-if="column.key === 'quota'"> <template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" /> <QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template> </template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'"> <template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }} {{ record.remark || "—" }}
</template> </template>
@@ -346,6 +434,10 @@ watch(
</template> </template>
<style scoped> <style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period { .pay-period {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+31 -2
View File
@@ -12,6 +12,7 @@ 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 type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts"; import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
@@ -23,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);
@@ -40,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,
@@ -48,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" },
@@ -137,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
@@ -160,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);
@@ -171,7 +182,7 @@ 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();
@@ -246,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="[
@@ -277,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()"
@@ -439,7 +468,7 @@ watch(
<span>{{ payModalPayment.remark }}</span> <span>{{ payModalPayment.remark }}</span>
</div> </div>
<div class="member-logs__foot"> <div class="member-logs__foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数在你所属账本域内公开;币种由成员与作者约定
</div> </div>
</div> </div>
<div v-else class="member-logs__note">该订阅无登记信息</div> <div v-else class="member-logs__note">该订阅无登记信息</div>
+4 -2
View File
@@ -49,6 +49,7 @@ const subColumns = [
{ title: "期间", key: "period", width: 200 }, { title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 }, { title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 }, { title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true }, { title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
]; ];
@@ -68,6 +69,7 @@ const topColumns = [
}, },
{ title: "交易号 / 渠道", key: "trade", width: 180 }, { title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 }, { title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true }, { title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
]; ];
</script> </script>
@@ -76,7 +78,7 @@ const topColumns = [
<section> <section>
<PageHeader <PageHeader
title="支付参数" title="支付参数"
subtitle="订阅与充值的登记参数 · 对成员完全公开" subtitle="订阅与充值的登记参数 · 同账本域内可见"
> >
<template #actions> <template #actions>
<span class="mv-range-label">时间范围</span> <span class="mv-range-label">时间范围</span>
@@ -186,7 +188,7 @@ const topColumns = [
</a-table> </a-table>
<div class="mv-foot"> <div class="mv-foot">
支付参数对成员完全公开;币种由成员与作者约定 支付参数在你所属账本域内公开;币种由成员与作者约定
</div> </div>
</template> </template>
</section> </section>
+8 -6
View File
@@ -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:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
+31 -7
View File
@@ -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
+1
View File
@@ -32,6 +32,7 @@
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) | | `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 | | `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 | | `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 | | `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) | | `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 | | `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
+2 -1
View File
@@ -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` 日志中(事实直连)
- 无「应付」、无分摊字段 - 无「应付」、无分摊字段
+2 -1
View File
@@ -30,6 +30,7 @@
| `payment_provider` | text? | `payment_provider` | 如 `epay` | | `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 | | `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 | | `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
| `remark` | text | — | 人工备注 | | `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) | | `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
@@ -68,7 +69,7 @@
## 6. 对外边界 ## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线 - 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
- 纯用量模式可不登记 - 纯用量模式可不登记
## 7. 迁移说明 ## 7. 迁移说明
+6 -3
View File
@@ -16,6 +16,7 @@
|----|------|------| |----|------|------|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) | | 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) | | 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 | | 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 | | 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 | | 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
@@ -30,6 +31,7 @@
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」 1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》) 2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
3. 提交 → 服务端上游验证,四态反馈: 3. 提交 → 服务端上游验证,四态反馈:
| 结果 | 反馈 | | 结果 | 反馈 |
@@ -45,10 +47,11 @@
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器) - **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里) - **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
## 4. 归属变更 ## 4. 归属 / 账本域变更
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆) - 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义) - 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
## 5. 空态 ## 5. 空态
+10 -9
View File
@@ -1,12 +1,12 @@
# 交互设计 · 成员视图 # 交互设计 · 成员视图
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。 > 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、同账本域内可见的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-09,作者认可布局重写) | | 状态 | 定稿(2026-10-09,作者认可布局重写) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 | | 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(完全可见) | | 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(同账本域内可见) |
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) | | 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
--- ---
@@ -32,11 +32,11 @@
|------|------|-----------|-----------| |------|------|-----------|-----------|
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** | | 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | | | 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(完全公开) | | | 支付参数 | 支付参数 | 订阅 / 充值登记参数(同账本域内可见) | |
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。 顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅看期间重叠 ∪ 进行中,充值看 `paid_at`。 **时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅 = 涉及我日志的 `subscription_id` ∪ 我域内进行中;充值 = 我域内且 `paid_at` 落在窗口内。
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。 **数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
@@ -83,8 +83,9 @@
### 4.1 页头与筛选 ### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上) - `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 类型(全部 / 消费 / 错误)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空) - 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选 + `page`(可深链) - 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
### 4.2 明细表(页面主体,表体滚动) ### 4.2 明细表(页面主体,表体滚动)
@@ -113,11 +114,11 @@
## 5. 支付参数页 ## 5. 支付参数页
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。 同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
### 5.1 页头 ### 5.1 页头
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。 `PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
### 5.2 订阅表 ### 5.2 订阅表
@@ -139,7 +140,7 @@
| 派生额度 | `amount × qpu` + $(展示) | | 派生额度 | `amount × qpu` + $(展示) |
| 备注 | `remark` | | 备注 | `remark` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明 - 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」 - **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导** - 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
+9 -7
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) | | 状态 | 定稿(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 选用弹层(订阅 / 充值同规则)
+21 -5
View File
@@ -4,9 +4,9 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-08) | | 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
| 关联 | 《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` | | 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type`) | | 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
--- ---
@@ -23,12 +23,26 @@
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) | | 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` | | 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` | | Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` | | 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` | | 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) | | 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 1 页 - 切换任一筛选 → 回第 1 页
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链 - URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`model_name`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
## 2.5 汇总条(筛选条与表格之间)
低高度**单行**读数条(约 32–36px),只回答「这批条件加起来多少」:
| 段 | 内容 | 口径 |
|----|------|------|
| 请求次数 | 当前筛选条数;错误数 > 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) |
| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 |
- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」)
- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零
- 成员端同构(见《交互设计-成员视图》§4)
## 3. 日志表(页面主体) ## 3. 日志表(页面主体)
@@ -45,7 +59,8 @@
| 操作 | 「详情」按钮 | 开详情弹窗 | | 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表) - 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选 - **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
- 空态:「该时段内无调用日志」 - 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5) - 窄屏:表格横向滚动(共性 §3.5)
@@ -58,6 +73,7 @@
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` | | 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) | | 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) | | 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 | | 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界 ## 5. 状态与边界
@@ -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 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+1 -1
View File
@@ -54,7 +54,7 @@
├─ packages/ ├─ packages/
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型 │ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI │ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI
├─ scripts/ # 打包/构建 CLI(naku pack / build) ├─ scripts/ # 打包/构建 CLI(pack release / build)
``` ```
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。 **依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
+12 -12
View File
@@ -28,7 +28,7 @@
```text ```text
本地(打包) 服务器(运营方) 本地(打包) 服务器(运营方)
────────── ──────────────── ────────── ────────────────
bun run pack:release /opt/naku/ ← 约定目录 bun run pack release --yes /opt/naku/ ← 约定目录
├─ 前端 dist/ ├─ docker-compose.yml ├─ 前端 dist/ ├─ docker-compose.yml
├─ naku-server(linux 单文件) ├─ Dockerfile ├─ naku-server(linux 单文件) ├─ Dockerfile
├─ naku-engine(linux 单文件) ├─ .dockerignore ├─ naku-engine(linux 单文件) ├─ .dockerignore
@@ -53,7 +53,7 @@ bun run pack:release /opt/naku/ ← 约定目录
## 3. 产物包 `release/` ## 3. 产物包 `release/`
本地 `bun run pack:release` 产出,内容固定为: 本地 `bun run pack release --yes` 产出,内容固定为:
```text ```text
release/ release/
@@ -68,13 +68,13 @@ release/
| 项 | 约定 | | 项 | 约定 |
|----|------| |----|------|
| 命令入口 | 优先 `bun run naku pack`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack:release` 或 `naku pack --yes` | | 命令入口 | 优先 `bun run pack release`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack release --yes` |
| 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑)· `scripts/pack-release.ts`(非交互兼容入口) | | 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑) |
| 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) | | 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) |
| 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz | | 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz |
| 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 | | 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 |
| 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 | | 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 |
| 本地试跑 | `naku build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 | | 本地试跑 | `pack build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 |
### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」 ### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」
@@ -211,7 +211,7 @@ loadEnv → createDb(DATABASE_URL) → runMigrations(db, MIGRATIONS_FOLDER) →
```bash ```bash
# 本地 # 本地
bun run pack:release bun run pack release --yes
# 服务器 # 服务器
# 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/ # 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/
@@ -228,7 +228,7 @@ docker compose ps
```bash ```bash
# 本地 # 本地
bun run pack:release bun run pack release --yes
# 上传,整目录覆盖 /opt/naku/release/ # 上传,整目录覆盖 /opt/naku/release/
# 服务器(固定命令) # 服务器(固定命令)
@@ -299,13 +299,13 @@ docker compose ps
| 文件 | 动作 | | 文件 | 动作 |
|------|------| |------|------|
| `scripts/cli.ts` · `scripts/pack-core.ts` · `scripts/pack-release.ts` | 打包/构建 CLI 与核心逻辑 | | `scripts/cli.ts` · `scripts/pack-core.ts` | 打包/构建 CLI 与核心逻辑 |
| `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 | | `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 |
| `package.json` | `naku` · `pack:release` · `build:server` · `build:engine` | | `package.json` | `pack` · `build:server` · `build:engine` |
| `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) | | `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) |
| `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 | | `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 |
| `README.md` | 「部署」章与文档地图 | | `README.md` | 「部署」章与文档地图 |
| `AGENTS.md` | 命令表(pack / build / naku) | | `AGENTS.md` | 命令表(pack / build) |
| `.env.example` | 部署语义注释 | | `.env.example` | 部署语义注释 |
--- ---
@@ -315,13 +315,13 @@ docker compose ps
1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。 1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。
2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。 2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。
3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。 3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。
4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack:release`。 4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack release --yes`。
--- ---
## 12. 验收清单(实现后) ## 12. 验收清单(实现后)
- [ ] `bun run naku pack --yes`(或 `pack:release`)在 Windows 产出完整 `release/`(五条目齐全) - [ ] `bun run pack release --yes` 在 Windows 产出完整 `release/`(五条目齐全)
- [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4 - [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4
- [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建) - [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建)
- [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证) - [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证)
+2 -2
View File
@@ -56,7 +56,7 @@
| # | 需求 | 说明 | 状态 | | # | 需求 | 说明 | 状态 |
|---|------|------|------| |---|------|------|------|
| 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. 交付域(已裁决)
@@ -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 审阅定稿
+2 -2
View File
@@ -1,6 +1,7 @@
{ {
"name": "new-api-key-usage", "name": "new-api-key-usage",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -17,8 +18,7 @@
"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: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", "build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
"pack:release": "bun run scripts/pack-release.ts", "pack": "bun run scripts/cli.ts"
"naku": "bun run scripts/cli.ts"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
+17
View File
@@ -68,9 +68,19 @@ export const logItem = z.object({
raw: z.record(z.string(), z.unknown()).optional(), raw: z.record(z.string(), z.unknown()).optional(),
}); });
export const logPageSummary = z.object({
hit_count: z.number().int(),
usage_count: z.number().int(),
error_count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
export const logPage = z.object({ export const logPage = z.object({
items: z.array(logItem), items: z.array(logItem),
total: z.number().int(), total: z.number().int(),
summary: logPageSummary,
models: z.array(z.string()),
}); });
export const keyItem = z.object({ export const keyItem = z.object({
@@ -79,6 +89,7 @@ export const keyItem = z.object({
status: z.enum(["active", "disabled"]), status: z.enum(["active", "disabled"]),
member_id: z.number().int().nullable(), member_id: z.number().int().nullable(),
member_name: z.string().nullable(), member_name: z.string().nullable(),
billing_domain: z.string().nullable(),
registered_at: z.number().int(), registered_at: z.number().int(),
log_synced_at: z.number().int().nullable(), log_synced_at: z.number().int().nullable(),
usage_synced_at: z.number().int().nullable(), usage_synced_at: z.number().int().nullable(),
@@ -92,11 +103,13 @@ export const keyItem = z.object({
export const registerKeyBody = z.object({ export const registerKeyBody = z.object({
sk_key: z.string().min(8), sk_key: z.string().min(8),
member_id: z.number().int().positive().nullable().optional(), member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
}); });
export const patchKeyBody = z.object({ export const patchKeyBody = z.object({
status: z.enum(["active", "disabled"]).optional(), status: z.enum(["active", "disabled"]).optional(),
member_id: z.number().int().positive().nullable().optional(), member_id: z.number().int().positive().nullable().optional(),
billing_domain: z.string().nullable().optional(),
}); });
export const memberItem = z.object({ export const memberItem = z.object({
@@ -141,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(),
}); });
@@ -157,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(),
}); });
@@ -218,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(""),
}); });
@@ -230,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(""),
}); });
+18
View File
@@ -46,6 +46,7 @@ export interface KeyItem {
status: KeyStatus; status: KeyStatus;
member_id: number | null; member_id: number | null;
member_name: string | null; member_name: string | null;
billing_domain: string | null;
registered_at: UnixSec; registered_at: UnixSec;
log_synced_at: UnixSec | null; log_synced_at: UnixSec | null;
usage_synced_at: UnixSec | null; usage_synced_at: UnixSec | null;
@@ -66,6 +67,7 @@ export interface SubscriptionItem {
quota_usd: QuotaUsd; quota_usd: QuotaUsd;
paid_amount: PaidAmount | null; paid_amount: PaidAmount | null;
paid_at: UnixSec | null; paid_at: UnixSec | null;
billing_domain: string | null;
remark: string; remark: string;
created_at: UnixSec; created_at: UnixSec;
} }
@@ -83,6 +85,7 @@ export interface TopupItem {
paid_at: UnixSec; paid_at: UnixSec;
quota_derived: Quota; quota_derived: Quota;
quota_usd: QuotaUsd; quota_usd: QuotaUsd;
billing_domain: string | null;
remark: string; remark: string;
created_at: UnixSec; created_at: UnixSec;
} }
@@ -111,6 +114,21 @@ export interface Paginated<T> {
total: number; total: number;
} }
/** 日志页汇总:条数跟全部筛选;用量恒 type=2 + 其余筛选 */
export interface LogPageSummary {
hit_count: number;
usage_count: number;
error_count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
/** 调用日志分页响应(含汇总条与模型下拉选项) */
export interface LogPage extends Paginated<LogItem> {
summary: LogPageSummary;
models: string[];
}
export interface EngineLastRound { export interface EngineLastRound {
started_at: UnixSec; started_at: UnixSec;
keys_total: number; keys_total: number;
@@ -0,0 +1,3 @@
ALTER TABLE "api_keys" ADD COLUMN "billing_domain" text;--> statement-breakpoint
ALTER TABLE "subscriptions" ADD COLUMN "billing_domain" text;--> statement-breakpoint
ALTER TABLE "topups" ADD COLUMN "billing_domain" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1791551016051, "when": 1791551016051,
"tag": "0000_loose_goblin_queen", "tag": "0000_loose_goblin_queen",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1791878178512,
"tag": "0001_aromatic_hawkeye",
"breakpoints": true
} }
] ]
} }
+201 -50
View File
@@ -32,6 +32,50 @@ export interface KeyListRow extends ApiKeyRow {
member_name: string | null; member_name: string | null;
} }
type LogFilterParams = {
from: Date;
to: Date;
api_key_id?: number;
member_id?: number | "unassigned";
billing_source?: string | "none";
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
/** 明细/汇总共用的 WHERE 片段(时间左闭右开;联 api_keys 时才带 member 归属) */
function buildLogConds(params: LogFilterParams) {
const conds = [
gte(usageLogs.created_at, params.from),
lt(usageLogs.created_at, params.to),
];
if (params.api_key_id != null) {
conds.push(eq(usageLogs.api_key_id, params.api_key_id));
}
if (params.billing_source === "none") {
conds.push(isNull(usageLogs.billing_source));
} else if (params.billing_source) {
conds.push(eq(usageLogs.billing_source, params.billing_source));
}
if (params.subscription_id != null) {
conds.push(eq(usageLogs.subscription_id, params.subscription_id));
}
if (params.model_name) {
conds.push(eq(usageLogs.model_name, params.model_name));
}
if (params.type === "error") {
conds.push(ne(usageLogs.type, 2));
} else if (typeof params.type === "number") {
conds.push(eq(usageLogs.type, params.type));
}
if (params.member_id === "unassigned") {
conds.push(isNull(apiKeys.member_id));
} else if (params.member_id != null) {
conds.push(eq(apiKeys.member_id, params.member_id));
}
return conds;
}
/** /**
* 仓储层(R14):数据访问只出现在 packages/db。 * 仓储层(R14):数据访问只出现在 packages/db。
* apps/server 不 import drizzle-orm / postgres 驱动。 * apps/server 不 import drizzle-orm / postgres 驱动。
@@ -115,6 +159,7 @@ export function createRepos(db: Db) {
async listAll(filter?: { async listAll(filter?: {
member_id?: number | "unassigned"; member_id?: number | "unassigned";
status?: string; status?: string;
billing_domain?: string | "none";
}): Promise<KeyListRow[]> { }): Promise<KeyListRow[]> {
const conds = []; const conds = [];
if (filter?.member_id === "unassigned") { if (filter?.member_id === "unassigned") {
@@ -125,6 +170,11 @@ export function createRepos(db: Db) {
if (filter?.status) { if (filter?.status) {
conds.push(eq(apiKeys.status, filter.status)); conds.push(eq(apiKeys.status, filter.status));
} }
if (filter?.billing_domain === "none") {
conds.push(isNull(apiKeys.billing_domain));
} else if (filter?.billing_domain) {
conds.push(eq(apiKeys.billing_domain, filter.billing_domain));
}
const base = db const base = db
.select({ .select({
id: apiKeys.id, id: apiKeys.id,
@@ -132,6 +182,7 @@ export function createRepos(db: Db) {
key_ciphertext: apiKeys.key_ciphertext, key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status, status: apiKeys.status,
member_id: apiKeys.member_id, member_id: apiKeys.member_id,
billing_domain: apiKeys.billing_domain,
registered_at: apiKeys.registered_at, registered_at: apiKeys.registered_at,
log_synced_at: apiKeys.log_synced_at, log_synced_at: apiKeys.log_synced_at,
usage_synced_at: apiKeys.usage_synced_at, usage_synced_at: apiKeys.usage_synced_at,
@@ -220,6 +271,7 @@ export function createRepos(db: Db) {
key_hash: string; key_hash: string;
key_ciphertext: Uint8Array; key_ciphertext: Uint8Array;
member_id: number | null; member_id: number | null;
billing_domain?: string | null;
token_name?: string; token_name?: string;
}): Promise<ApiKeyRow> { }): Promise<ApiKeyRow> {
const [row] = await db const [row] = await db
@@ -228,6 +280,7 @@ export function createRepos(db: Db) {
key_hash: values.key_hash, key_hash: values.key_hash,
key_ciphertext: values.key_ciphertext, key_ciphertext: values.key_ciphertext,
member_id: values.member_id, member_id: values.member_id,
billing_domain: values.billing_domain ?? null,
token_name: values.token_name ?? "", token_name: values.token_name ?? "",
}) })
.returning(); .returning();
@@ -404,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
@@ -451,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
@@ -524,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() })
@@ -672,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)
@@ -738,6 +773,122 @@ export function createRepos(db: Db) {
.offset((params.page - 1) * params.page_size); .offset((params.page - 1) * params.page_size);
return { rows, total }; return { rows, total };
}, },
/**
* 日志页汇总:条数跟全部筛选;用量行恒 type=2(不含 type 筛选)。
* usage_rows 交上层 sumUsage 做按行 QPU 微分。
*/
async summarize(params: LogFilterParams) {
const fullConds = buildLogConds(params);
const usageConds = buildLogConds({ ...params, type: 2 });
const hitRows = await db
.select({
hit_count: count(),
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...fullConds));
const hit = hitRows[0] ?? {
hit_count: 0,
usage_count: 0,
error_count: 0,
};
const usage_rows = await db
.select({
quota: usageLogs.quota,
created_at: usageLogs.created_at,
})
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...usageConds));
return {
hit_count: Number(hit.hit_count) || 0,
usage_count: Number(hit.usage_count) || 0,
error_count: Number(hit.error_count) || 0,
usage_rows,
};
},
async summarizeMember(params: LogFilterParams & { member_id: number }) {
const fullConds = buildLogConds({ ...params, member_id: params.member_id });
const usageConds = buildLogConds({
...params,
member_id: params.member_id,
type: 2,
});
const hitRows = await db
.select({
hit_count: count(),
usage_count: sql<number>`count(*) filter (where ${usageLogs.type} = 2)`,
error_count: sql<number>`count(*) filter (where ${usageLogs.type} != 2)`,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...fullConds));
const hit = hitRows[0] ?? {
hit_count: 0,
usage_count: 0,
error_count: 0,
};
const usage_rows = await db
.select({
quota: usageLogs.quota,
created_at: usageLogs.created_at,
})
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...usageConds));
return {
hit_count: Number(hit.hit_count) || 0,
usage_count: Number(hit.usage_count) || 0,
error_count: Number(hit.error_count) || 0,
usage_rows,
};
},
/** 模型下拉选项:时间窗 + 主体/来源/订阅筛选下去重(不含 model_name / type) */
async listModels(
params: Omit<LogFilterParams, "type" | "model_name">,
): Promise<string[]> {
const conds = buildLogConds({ ...params, type: undefined, model_name: undefined });
const rows = await db
.select({ model_name: usageLogs.model_name })
.from(usageLogs)
.leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.groupBy(usageLogs.model_name)
.orderBy(asc(usageLogs.model_name));
return rows.map((r) => r.model_name).filter((m) => m !== "");
},
async listMemberModels(
params: Omit<LogFilterParams, "type" | "model_name"> & {
member_id: number;
},
): Promise<string[]> {
const conds = buildLogConds({
...params,
member_id: params.member_id,
type: undefined,
model_name: undefined,
});
const rows = await db
.select({ model_name: usageLogs.model_name })
.from(usageLogs)
.innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id))
.where(and(...conds))
.groupBy(usageLogs.model_name)
.orderBy(asc(usageLogs.model_name));
return rows.map((r) => r.model_name).filter((m) => m !== "");
},
}, },
keysExtra: { keysExtra: {
+3
View File
@@ -44,6 +44,7 @@ export const apiKeys = pgTable(
member_id: bigint("member_id", { mode: "number" }).references( member_id: bigint("member_id", { mode: "number" }).references(
() => members.id, () => members.id,
), ),
billing_domain: text("billing_domain"),
registered_at: timestamp("registered_at", { registered_at: timestamp("registered_at", {
withTimezone: true, withTimezone: true,
mode: "date", mode: "date",
@@ -135,6 +136,7 @@ export const subscriptions = pgTable(
mode: "number", mode: "number",
}), }),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }), paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""), remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: true, withTimezone: true,
@@ -161,6 +163,7 @@ export const topups = pgTable("topups", {
payment_provider: text("payment_provider"), payment_provider: text("payment_provider"),
status: text("status"), status: text("status"),
paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(), paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(),
billing_domain: text("billing_domain"),
remark: text("remark").notNull().default(""), remark: text("remark").notNull().default(""),
created_at: timestamp("created_at", { created_at: timestamp("created_at", {
withTimezone: true, withTimezone: true,
+8 -8
View File
@@ -1,5 +1,5 @@
/** /**
* naku 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。 * pack 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。
* 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。 * 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。
*/ */
import { defineCommand, runMain } from "citty"; import { defineCommand, runMain } from "citty";
@@ -34,9 +34,9 @@ const targetOption = {
}, },
} as const; } as const;
const pack = defineCommand({ const release = defineCommand({
meta: { meta: {
name: "pack", name: "release",
description: "打部署产物包(dist + 单文件 + drizzle)", description: "打部署产物包(dist + 单文件 + drizzle)",
}, },
args: { args: {
@@ -62,7 +62,7 @@ const pack = defineCommand({
}, },
async run({ args }) { async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined); const interactive = isInteractive(args.yes as boolean | undefined);
p.intro("NAKU 打包"); p.intro("PACK 打包");
// --- target --- // --- target ---
let target = args.target as string | undefined; let target = args.target as string | undefined;
@@ -205,7 +205,7 @@ const build = defineCommand({
} }
only = [positional as PackItem]; only = [positional as PackItem];
} else if (interactive) { } else if (interactive) {
p.intro("NAKU 本地构建"); p.intro("PACK 本地构建");
only = assertNotCancel( only = assertNotCancel(
await p.multiselect({ await p.multiselect({
message: "构建什么", message: "构建什么",
@@ -245,11 +245,11 @@ const build = defineCommand({
const main = defineCommand({ const main = defineCommand({
meta: { meta: {
name: "naku", name: "pack",
version: "0.0.0", version: "0.0.0",
description: "NAKU 打包 / 本地构建", description: "PACK 打包 / 本地构建",
}, },
subCommands: { pack, build }, subCommands: { release, build },
}); });
runMain(main); runMain(main);
-27
View File
@@ -1,27 +0,0 @@
/**
* 兼容入口:等价于 `naku pack --yes --target=bun-linux-x64`。
* 交互请用 `bun run naku pack`。
*/
import { packRelease } from "./pack-core.ts";
function readTarget(): string {
const arg = process.argv.find((a) => a.startsWith("--target="));
return arg?.slice("--target=".length) || process.env.PACK_TARGET || "bun-linux-x64";
}
const target = readTarget();
console.log(`[pack-release] non-interactive pack (target=${target})`);
try {
const out = await packRelease({
target,
only: ["dist", "naku-server", "naku-engine", "naku-migrate", "drizzle"],
outDir: "release",
tarball: false,
onLog: (m) => console.log("[pack-release]", m),
});
console.log("[pack-release] done →", out);
} catch (err) {
console.error("[pack-release]", err instanceof Error ? err.message : err);
process.exit(1);
}