feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
This commit is contained in:
@@ -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)。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ function toSubscriptionItem(
|
|||||||
quota_usd,
|
quota_usd,
|
||||||
paid_amount: row.paid_amount,
|
paid_amount: row.paid_amount,
|
||||||
paid_at: toUnixSec(row.paid_at),
|
paid_at: toUnixSec(row.paid_at),
|
||||||
|
billing_domain: row.billing_domain,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
created_at: toUnixSec(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
};
|
};
|
||||||
@@ -52,6 +53,7 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
|||||||
quota_granted: number;
|
quota_granted: number;
|
||||||
paid_amount: number | null;
|
paid_amount: number | null;
|
||||||
paid_at: Date | null;
|
paid_at: Date | null;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
@@ -74,6 +76,10 @@ function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
|
|||||||
quota_granted: body.quota_granted,
|
quota_granted: body.quota_granted,
|
||||||
paid_amount: body.paid_amount ?? null,
|
paid_amount: body.paid_amount ?? null,
|
||||||
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
paid_at: body.paid_at ? new Date(body.paid_at) : null,
|
||||||
|
billing_domain:
|
||||||
|
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||||
|
? body.billing_domain.trim()
|
||||||
|
: null,
|
||||||
remark: body.remark ?? "",
|
remark: body.remark ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() {
|
|||||||
summary: "订阅权益清单",
|
summary: "订阅权益清单",
|
||||||
tags: ["支付记录"],
|
tags: ["支付记录"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
|
request: {
|
||||||
|
query: z.object({
|
||||||
|
billing_domain: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "subscription 数组",
|
description: "subscription 数组",
|
||||||
@@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const rows = await repos.subscriptions.listAll();
|
const q = c.req.query();
|
||||||
|
const filter =
|
||||||
|
q.billing_domain === "none"
|
||||||
|
? { billing_domain: "none" as const }
|
||||||
|
: q.billing_domain
|
||||||
|
? { billing_domain: q.billing_domain }
|
||||||
|
: undefined;
|
||||||
|
const rows = await repos.subscriptions.listAll(filter);
|
||||||
return c.json(
|
return c.json(
|
||||||
rows.map((row) =>
|
rows.map((row) =>
|
||||||
toSubscriptionItem(
|
toSubscriptionItem(
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem {
|
|||||||
paid_at: toUnixSec(row.paid_at)!,
|
paid_at: toUnixSec(row.paid_at)!,
|
||||||
quota_derived,
|
quota_derived,
|
||||||
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
quota_usd: quotaToUsdStr(quota_derived, qpu),
|
||||||
|
billing_domain: row.billing_domain,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
created_at: toUnixSec(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
};
|
};
|
||||||
@@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
|||||||
payment_provider: string | null;
|
payment_provider: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
paid_at: Date;
|
paid_at: Date;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
} {
|
} {
|
||||||
@@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
|
|||||||
payment_provider: body.payment_provider ?? null,
|
payment_provider: body.payment_provider ?? null,
|
||||||
status: body.status ?? "success",
|
status: body.status ?? "success",
|
||||||
paid_at: new Date(body.paid_at),
|
paid_at: new Date(body.paid_at),
|
||||||
|
billing_domain:
|
||||||
|
typeof body.billing_domain === "string" && body.billing_domain.trim()
|
||||||
|
? body.billing_domain.trim()
|
||||||
|
: null,
|
||||||
remark: body.remark ?? "",
|
remark: body.remark ?? "",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -87,6 +93,11 @@ export function adminTopupRoutes() {
|
|||||||
summary: "充值入账清单",
|
summary: "充值入账清单",
|
||||||
tags: ["支付记录"],
|
tags: ["支付记录"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
|
request: {
|
||||||
|
query: z.object({
|
||||||
|
billing_domain: z.string().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
responses: {
|
responses: {
|
||||||
200: {
|
200: {
|
||||||
description: "topup 数组",
|
description: "topup 数组",
|
||||||
@@ -100,7 +111,14 @@ export function adminTopupRoutes() {
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const repos = c.get("repos");
|
const repos = c.get("repos");
|
||||||
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
|
||||||
const rows = await repos.topups.listAll();
|
const q = c.req.query();
|
||||||
|
const filter =
|
||||||
|
q.billing_domain === "none"
|
||||||
|
? { billing_domain: "none" as const }
|
||||||
|
: q.billing_domain
|
||||||
|
? { billing_domain: q.billing_domain }
|
||||||
|
: undefined;
|
||||||
|
const rows = await repos.topups.listAll(filter);
|
||||||
return c.json(
|
return c.json(
|
||||||
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
|
||||||
200,
|
200,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ export type SubscriptionBody = {
|
|||||||
quota_granted: number;
|
quota_granted: number;
|
||||||
paid_amount?: number | null;
|
paid_amount?: number | null;
|
||||||
paid_at?: string | null;
|
paid_at?: string | null;
|
||||||
|
billing_domain?: string | null;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchSubscriptions(): Promise<SubscriptionItem[]> {
|
export async function fetchSubscriptions(filter?: {
|
||||||
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions");
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<SubscriptionItem[]> {
|
||||||
|
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
|
||||||
|
params: filter,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ export type TopupBody = {
|
|||||||
payment_provider?: string | null;
|
payment_provider?: string | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
paid_at: string;
|
paid_at: string;
|
||||||
|
billing_domain?: string | null;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function fetchTopups(): Promise<TopupItem[]> {
|
export async function fetchTopups(filter?: {
|
||||||
const { data } = await adminHttp.get<TopupItem[]>("/topups");
|
billing_domain?: string | "none";
|
||||||
|
}): Promise<TopupItem[]> {
|
||||||
|
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
|
||||||
|
params: filter,
|
||||||
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ watch(
|
|||||||
<span>{{ payModalPayment.remark }}</span>
|
<span>{{ payModalPayment.remark }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="member-logs__foot">
|
<div class="member-logs__foot">
|
||||||
支付参数对成员完全公开;币种由成员与作者约定
|
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="member-logs__note">该订阅无登记信息</div>
|
<div v-else class="member-logs__note">该订阅无登记信息</div>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const subColumns = [
|
|||||||
{ title: "期间", key: "period", width: 200 },
|
{ title: "期间", key: "period", width: 200 },
|
||||||
{ title: "额度", key: "quota", width: 160 },
|
{ title: "额度", key: "quota", width: 160 },
|
||||||
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
{ title: "付款金额 / 时间", key: "paid", width: 170 },
|
||||||
|
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ const topColumns = [
|
|||||||
},
|
},
|
||||||
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
{ title: "交易号 / 渠道", key: "trade", width: 180 },
|
||||||
{ title: "派生额度", key: "quota", width: 150 },
|
{ title: "派生额度", key: "quota", width: 150 },
|
||||||
|
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
|
||||||
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
@@ -76,7 +78,7 @@ const topColumns = [
|
|||||||
<section>
|
<section>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="支付参数"
|
title="支付参数"
|
||||||
subtitle="订阅与充值的登记参数 · 对成员完全公开"
|
subtitle="订阅与充值的登记参数 · 同账本域内可见"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<span class="mv-range-label">时间范围</span>
|
<span class="mv-range-label">时间范围</span>
|
||||||
@@ -186,7 +188,7 @@ const topColumns = [
|
|||||||
</a-table>
|
</a-table>
|
||||||
|
|
||||||
<div class="mv-foot">
|
<div class="mv-foot">
|
||||||
支付参数对成员完全公开;币种由成员与作者约定
|
支付参数在你所属账本域内公开;币种由成员与作者约定
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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,7 +62,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `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. 日志明细
|
||||||
@@ -92,7 +92,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:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +148,7 @@
|
|||||||
|
|
||||||
### 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. 日志明细(管理面下钻)
|
||||||
|
|
||||||
@@ -184,6 +185,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 +195,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 +211,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 +221,7 @@
|
|||||||
|
|
||||||
### POST `/api/admin/topups` → 201 `topup`
|
### POST `/api/admin/topups` → 201 `topup`
|
||||||
|
|
||||||
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `remark`。
|
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
|
||||||
|
|
||||||
### PUT `/api/admin/topups/:id` / DELETE → 204
|
### PUT `/api/admin/topups/:id` / DELETE → 204
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
|
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
|
||||||
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
|
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
|
||||||
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
|
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
|
||||||
|
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
|
||||||
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
|
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
|
||||||
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
|
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
|
||||||
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
|
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
|
||||||
|
|||||||
@@ -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` 日志中(事实直连)
|
||||||
- 无「应付」、无分摊字段
|
- 无「应付」、无分摊字段
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
|
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
|
||||||
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
|
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
|
||||||
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
|
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
|
||||||
|
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
|
||||||
| `remark` | text | — | 人工备注 |
|
| `remark` | text | — | 人工备注 |
|
||||||
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
|
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@
|
|||||||
|
|
||||||
## 6. 对外边界
|
## 6. 对外边界
|
||||||
|
|
||||||
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注;无逐笔与模型日志的连线
|
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
|
||||||
- 纯用量模式可不登记
|
- 纯用量模式可不登记
|
||||||
|
|
||||||
## 7. 迁移说明
|
## 7. 迁移说明
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
|----|------|------|
|
|----|------|------|
|
||||||
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
|
||||||
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
|
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
|
||||||
|
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
|
||||||
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
|
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
|
||||||
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
|
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
|
||||||
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
|
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
|
|
||||||
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
|
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
|
||||||
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
|
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
|
||||||
|
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
|
||||||
3. 提交 → 服务端上游验证,四态反馈:
|
3. 提交 → 服务端上游验证,四态反馈:
|
||||||
|
|
||||||
| 结果 | 反馈 |
|
| 结果 | 反馈 |
|
||||||
@@ -45,10 +47,11 @@
|
|||||||
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
|
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
|
||||||
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
|
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
|
||||||
|
|
||||||
## 4. 归属变更
|
## 4. 归属 / 账本域变更
|
||||||
|
|
||||||
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
|
- 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
|
||||||
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义)
|
- 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
|
||||||
|
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
|
||||||
|
|
||||||
## 5. 空态
|
## 5. 空态
|
||||||
|
|
||||||
|
|||||||
@@ -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);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@
|
|||||||
|
|
||||||
## 5. 支付参数页
|
## 5. 支付参数页
|
||||||
|
|
||||||
完全公开透明的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
|
同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
|
||||||
|
|
||||||
### 5.1 页头
|
### 5.1 页头
|
||||||
|
|
||||||
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 对成员完全公开」;右侧时间范围(全局共享窗口)。
|
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
|
||||||
|
|
||||||
### 5.2 订阅表
|
### 5.2 订阅表
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
| 派生额度 | `amount × qpu` + $(展示) |
|
| 派生额度 | `amount × qpu` + $(展示) |
|
||||||
| 备注 | `remark` |
|
| 备注 | `remark` |
|
||||||
|
|
||||||
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
|
- 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
|
||||||
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
|
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
|
||||||
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
|
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
|
||||||
|
|
||||||
|
|||||||
@@ -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 选用弹层(订阅 / 充值同规则)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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 审阅定稿
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,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 +93,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 +144,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 +161,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 +223,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 +236,7 @@ export const createTopupBody = z.object({
|
|||||||
payment_provider: z.string().nullable().optional(),
|
payment_provider: z.string().nullable().optional(),
|
||||||
status: z.string().nullable().optional(),
|
status: z.string().nullable().optional(),
|
||||||
paid_at: z.string().min(1),
|
paid_at: z.string().min(1),
|
||||||
|
billing_domain: z.string().nullable().optional(),
|
||||||
remark: z.string().default(""),
|
remark: z.string().default(""),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface KeyItem {
|
|||||||
status: KeyStatus;
|
status: KeyStatus;
|
||||||
member_id: number | null;
|
member_id: number | null;
|
||||||
member_name: string | null;
|
member_name: string | null;
|
||||||
|
billing_domain: string | null;
|
||||||
registered_at: UnixSec;
|
registered_at: UnixSec;
|
||||||
log_synced_at: UnixSec | null;
|
log_synced_at: UnixSec | null;
|
||||||
usage_synced_at: UnixSec | null;
|
usage_synced_at: UnixSec | null;
|
||||||
@@ -66,6 +67,7 @@ export interface SubscriptionItem {
|
|||||||
quota_usd: QuotaUsd;
|
quota_usd: QuotaUsd;
|
||||||
paid_amount: PaidAmount | null;
|
paid_amount: PaidAmount | null;
|
||||||
paid_at: UnixSec | null;
|
paid_at: UnixSec | null;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: UnixSec;
|
created_at: UnixSec;
|
||||||
}
|
}
|
||||||
@@ -83,6 +85,7 @@ export interface TopupItem {
|
|||||||
paid_at: UnixSec;
|
paid_at: UnixSec;
|
||||||
quota_derived: Quota;
|
quota_derived: Quota;
|
||||||
quota_usd: QuotaUsd;
|
quota_usd: QuotaUsd;
|
||||||
|
billing_domain: string | null;
|
||||||
remark: string;
|
remark: string;
|
||||||
created_at: UnixSec;
|
created_at: UnixSec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "api_keys" ADD COLUMN "billing_domain" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "billing_domain" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "topups" ADD COLUMN "billing_domain" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1791551016051,
|
"when": 1791551016051,
|
||||||
"tag": "0000_loose_goblin_queen",
|
"tag": "0000_loose_goblin_queen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791878178512,
|
||||||
|
"tag": "0001_aromatic_hawkeye",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -115,6 +115,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 +126,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 +138,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 +227,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 +236,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 +413,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 +472,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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user