Compare commits

..
2 Commits
Author SHA1 Message Date
skycurtain 199972a2dc feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量 2026-10-14 08:01:58 +08:00
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00
39 changed files with 2168 additions and 137 deletions
+3 -2
View File
@@ -31,7 +31,7 @@
|---|------|------| |---|------|------|
| 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)。
@@ -135,7 +135,8 @@ 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) | 调用日志:筛选、汇总条、明细表、与 key/成员/总览的跳转链 | 定稿(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):订阅权益 / 充值入账登记、核对提示 | 定稿(2026-10-12) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) | | [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
+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'"
+30 -1
View File
@@ -9,6 +9,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 { import {
useLiveTimeRange, useLiveTimeRange,
type RangeKind, type RangeKind,
@@ -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();
@@ -245,6 +256,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 +296,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()"
+58 -2
View File
@@ -25,6 +25,36 @@ 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 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(() => {
const list = subsQuery.data.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(() => {
const list = topupsQuery.data.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 deleteSub = useDeleteSubscription(); const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup(); const deleteTop = useDeleteTopup();
@@ -50,9 +80,9 @@ syncFromRoute();
watch(() => route.query, () => syncFromRoute()); watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab()); watch(tab, () => pushTab());
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 +92,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 +109,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 +230,20 @@ watch(
<a-tab-pane key="topups" tab="充值" /> <a-tab-pane key="topups" tab="充值" />
</a-tabs> </a-tabs>
<div class="payments-toolbar">
<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 +299,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 +365,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 +398,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` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明 - 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」 - **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导** - 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
+3 -2
View File
@@ -50,13 +50,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 选用弹层(订阅 / 充值同规则)
+18 -4
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. 日志表(页面主体)
@@ -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 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+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 审阅定稿
+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,