feat: 账本域 billing_domain 与成员支付可见性收紧

三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
This commit is contained in:
2026-10-13 20:42:26 +08:00
parent 843ab4433a
commit b0e9ea750b
31 changed files with 1463 additions and 53 deletions
+1 -1
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)。
+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,
+15 -4
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,
+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;
+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;
} }
@@ -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"
+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'"
+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;
+1 -1
View File
@@ -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>
+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>
+4 -4
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,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:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
+8 -5
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,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
+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. 空态
+7 -7
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);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
@@ -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` |
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明 - 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」 - **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导** - 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
+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 选用弹层(订阅 / 充值同规则)
+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 审阅定稿
+7
View File
@@ -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(""),
}); });
+3
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;
} }
@@ -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
} }
] ]
} }
+37 -4
View File
@@ -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
+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,