diff --git a/README.md b/README.md index 3200a4a..0ae53f4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ |---|------|------| | 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者全员视角在管理面总览,成员视角在成员视图 | | 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) | -| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;对成员完全公开透明 | +| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 | | 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 | 完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。 diff --git a/apps/server/src/routes/admin-keys.ts b/apps/server/src/routes/admin-keys.ts index c8659d6..f1691f2 100644 --- a/apps/server/src/routes/admin-keys.ts +++ b/apps/server/src/routes/admin-keys.ts @@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem { status: row.status as "active" | "disabled", member_id: row.member_id, member_name: row.member_name ?? null, + billing_domain: row.billing_domain, registered_at: toUnixSec(row.registered_at)!, log_synced_at: toUnixSec(row.log_synced_at), usage_synced_at: toUnixSec(row.usage_synced_at), @@ -59,6 +60,7 @@ export function adminKeyRoutes() { query: z.object({ member_id: z.string().optional(), status: z.enum(["active", "disabled"]).optional(), + billing_domain: z.string().optional(), recent_failed: z.enum(["true", "false"]).optional(), }), }, @@ -77,12 +79,15 @@ export function adminKeyRoutes() { const filter: { member_id?: number | "unassigned"; status?: string; + billing_domain?: string | "none"; } = {}; if (q.member_id === "unassigned") filter.member_id = "unassigned"; else if (q.member_id) filter.member_id = Number(q.member_id); if (q.status === "active" || q.status === "disabled") { 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); if (q.recent_failed === "true") { const snap = await c.get("repos").engine.getSnapshot(); @@ -144,6 +149,10 @@ export function adminKeyRoutes() { } 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-")) { return jsonError( c, @@ -208,6 +217,7 @@ export function adminKeyRoutes() { key_hash, key_ciphertext, member_id, + billing_domain, token_name: usage.name ?? "", }); // 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补) @@ -241,7 +251,7 @@ export function adminKeyRoutes() { createRoute({ method: "patch", path: "/keys/{id}", - summary: "启停 / 归属变更", + summary: "启停 / 归属 / 账本域变更", description: "部分更新;审计按实际变更字段各落一行", tags: ["Key 管理"], 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) { return jsonError( c, diff --git a/apps/server/src/routes/admin-subscriptions.ts b/apps/server/src/routes/admin-subscriptions.ts index 748e463..6153e77 100644 --- a/apps/server/src/routes/admin-subscriptions.ts +++ b/apps/server/src/routes/admin-subscriptions.ts @@ -37,6 +37,7 @@ function toSubscriptionItem( quota_usd, paid_amount: row.paid_amount, paid_at: toUnixSec(row.paid_at), + billing_domain: row.billing_domain, remark: row.remark, created_at: toUnixSec(row.created_at)!, }; @@ -52,6 +53,7 @@ function parseSubBody(body: z.infer): quota_granted: number; paid_amount: number | null; paid_at: Date | null; + billing_domain: string | null; remark: string; }; } { @@ -74,6 +76,10 @@ function parseSubBody(body: z.infer): quota_granted: body.quota_granted, paid_amount: body.paid_amount ?? 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 ?? "", }, }; @@ -90,6 +96,11 @@ export function adminSubscriptionRoutes() { summary: "订阅权益清单", tags: ["支付记录"], security: [{ adminSession: [] }], + request: { + query: z.object({ + billing_domain: z.string().optional(), + }), + }, responses: { 200: { description: "subscription 数组", @@ -103,7 +114,14 @@ export function adminSubscriptionRoutes() { async (c) => { const repos = c.get("repos"); 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( rows.map((row) => toSubscriptionItem( diff --git a/apps/server/src/routes/admin-topups.ts b/apps/server/src/routes/admin-topups.ts index 15055ce..932e0f8 100644 --- a/apps/server/src/routes/admin-topups.ts +++ b/apps/server/src/routes/admin-topups.ts @@ -36,6 +36,7 @@ function toTopupItem(row: TopupRow, qpu: number): TopupItem { paid_at: toUnixSec(row.paid_at)!, quota_derived, quota_usd: quotaToUsdStr(quota_derived, qpu), + billing_domain: row.billing_domain, remark: row.remark, created_at: toUnixSec(row.created_at)!, }; @@ -53,6 +54,7 @@ function parseTopupBody(body: z.infer): payment_provider: string | null; status: string | null; paid_at: Date; + billing_domain: string | null; remark: string; }; } { @@ -71,6 +73,10 @@ function parseTopupBody(body: z.infer): payment_provider: body.payment_provider ?? null, status: body.status ?? "success", 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 ?? "", }, }; @@ -87,6 +93,11 @@ export function adminTopupRoutes() { summary: "充值入账清单", tags: ["支付记录"], security: [{ adminSession: [] }], + request: { + query: z.object({ + billing_domain: z.string().optional(), + }), + }, responses: { 200: { description: "topup 数组", @@ -100,7 +111,14 @@ export function adminTopupRoutes() { async (c) => { const repos = c.get("repos"); 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( rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))), 200, diff --git a/apps/server/src/routes/member-view.ts b/apps/server/src/routes/member-view.ts index 6648b65..bb52887 100644 --- a/apps/server/src/routes/member-view.ts +++ b/apps/server/src/routes/member-view.ts @@ -71,7 +71,7 @@ export function memberViewRoutes() { path: "/overview", summary: "单页聚合", description: - "名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)", + "名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)", tags: ["成员视图"], security: [{ memberToken: [] }], request: { @@ -159,8 +159,13 @@ export function memberViewRoutes() { 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 myDomains = new Set(); + for (const k of allKeys) { + if (k.billing_domain) myDomains.add(k.billing_domain); + } const subRows = await repos.subscriptions.listAll(); const usedSubIds = new Set( 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_start <= range.to && (s.period_end ?? new Date()) >= range.from); - const involved = usedSubIds.has(s.subscription_id) || ongoing; - if (!involved) continue; + const involved = usedSubIds.has(s.subscription_id); + 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); subscriptionsOut.push({ id: s.id, @@ -184,6 +192,7 @@ export function memberViewRoutes() { quota_usd: quotaToUsdStr(s.quota_granted, qpu), paid_amount: s.paid_amount, paid_at: toUnixSec(s.paid_at), + billing_domain: s.billing_domain, remark: s.remark, created_at: toUnixSec(s.created_at)!, }); @@ -191,6 +200,7 @@ export function memberViewRoutes() { const topupRows = await repos.topups.listAll(); const topupsOut = []; 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; const qpu = qpuAt(t.paid_at, timeline); const quota_derived = Math.round(t.amount * qpu); @@ -204,6 +214,7 @@ export function memberViewRoutes() { payment_provider: t.payment_provider, status: t.status, paid_at: toUnixSec(t.paid_at)!, + billing_domain: t.billing_domain, quota_derived, quota_usd: quotaToUsdStr(quota_derived, qpu), remark: t.remark, diff --git a/apps/web/src/api/admin/keys.ts b/apps/web/src/api/admin/keys.ts index 225f091..8f3b1fb 100644 --- a/apps/web/src/api/admin/keys.ts +++ b/apps/web/src/api/admin/keys.ts @@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts"; export type KeyListFilters = { member_id?: number | "unassigned"; status?: "active" | "disabled"; + billing_domain?: string | "none"; recent_failed?: boolean; }; @@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise { const { data } = await adminHttp.post("/keys", body); return data; @@ -22,7 +24,11 @@ export async function registerKey(body: { export async function patchKey( id: number, - body: { status?: "active" | "disabled"; member_id?: number | null }, + body: { + status?: "active" | "disabled"; + member_id?: number | null; + billing_domain?: string | null; + }, ): Promise { const { data } = await adminHttp.patch(`/keys/${id}`, body); return data; diff --git a/apps/web/src/api/admin/subscriptions.ts b/apps/web/src/api/admin/subscriptions.ts index c82780c..8a301dd 100644 --- a/apps/web/src/api/admin/subscriptions.ts +++ b/apps/web/src/api/admin/subscriptions.ts @@ -8,11 +8,16 @@ export type SubscriptionBody = { quota_granted: number; paid_amount?: number | null; paid_at?: string | null; + billing_domain?: string | null; remark?: string; }; -export async function fetchSubscriptions(): Promise { - const { data } = await adminHttp.get("/subscriptions"); +export async function fetchSubscriptions(filter?: { + billing_domain?: string | "none"; +}): Promise { + const { data } = await adminHttp.get("/subscriptions", { + params: filter, + }); return data; } diff --git a/apps/web/src/api/admin/topups.ts b/apps/web/src/api/admin/topups.ts index efa0cdb..dc16867 100644 --- a/apps/web/src/api/admin/topups.ts +++ b/apps/web/src/api/admin/topups.ts @@ -10,11 +10,16 @@ export type TopupBody = { payment_provider?: string | null; status?: string | null; paid_at: string; + billing_domain?: string | null; remark?: string; }; -export async function fetchTopups(): Promise { - const { data } = await adminHttp.get("/topups"); +export async function fetchTopups(filter?: { + billing_domain?: string | "none"; +}): Promise { + const { data } = await adminHttp.get("/topups", { + params: filter, + }); return data; } diff --git a/apps/web/src/components/admin/payment-form-modal.vue b/apps/web/src/components/admin/payment-form-modal.vue index f6fa017..bf4d28b 100644 --- a/apps/web/src/components/admin/payment-form-modal.vue +++ b/apps/web/src/components/admin/payment-form-modal.vue @@ -16,6 +16,7 @@ import type { PlatformTopup, } from "@/api/admin/platform.ts"; import { + useBillingDomainOptions, useCreateSubscription, useCreateTopup, useQpuLatest, @@ -78,6 +79,8 @@ const registeredTopIds = computed( .filter((v): v is number => v != null), ); +const domainOptions = useBillingDomainOptions(); + const subForm = reactive({ subscription_id: null as number | null, period_start: null as Dayjs | null, @@ -87,6 +90,7 @@ const subForm = reactive({ quota_usd: null as number | null, paid_amount: null as number | null, paid_at: null as Dayjs | null, + billing_domain: "", remark: "", }); @@ -98,6 +102,7 @@ const topForm = reactive({ payment_method: "", payment_provider: "", paid_at: null as Dayjs | null, + billing_domain: "", remark: "", }); @@ -111,6 +116,7 @@ function resetSub() { quota_usd: null, paid_amount: null, paid_at: null, + billing_domain: "", remark: "", }); } @@ -124,6 +130,7 @@ function resetTop() { payment_method: "", payment_provider: "", paid_at: null, + billing_domain: "", remark: "", }); } @@ -147,6 +154,7 @@ watch( subForm.paid_amount = s.paid_amount; subForm.paid_at = s.paid_at != null ? unixToBeijingWall(s.paid_at) : null; + subForm.billing_domain = s.billing_domain ?? ""; subForm.remark = s.remark; } else { // 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天 @@ -162,6 +170,7 @@ watch( topForm.payment_method = t.payment_method ?? ""; topForm.payment_provider = t.payment_provider ?? ""; topForm.paid_at = unixToBeijingWall(t.paid_at); + topForm.billing_domain = t.billing_domain ?? ""; topForm.remark = t.remark; } else { resetTop(); @@ -239,6 +248,7 @@ async function onSubmit() { quota_granted: quota, paid_amount: subForm.paid_amount, paid_at: iso(subForm.paid_at), + billing_domain: subForm.billing_domain.trim() || null, remark: subForm.remark, }; if (props.editingSub) { @@ -268,6 +278,7 @@ async function onSubmit() { payment_provider: topForm.payment_provider || null, status: props.editingTop?.status ?? "success", paid_at: iso(topForm.paid_at)!, + billing_domain: topForm.billing_domain.trim() || null, remark: topForm.remark, }; if (props.editingTop) { @@ -381,6 +392,13 @@ async function onSubmit() { 社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记 + + + @@ -432,6 +450,13 @@ async function onSubmit() { style="width: 120px; margin-left: 8px" /> + + + diff --git a/apps/web/src/components/admin/register-key-modal.vue b/apps/web/src/components/admin/register-key-modal.vue index ab7da6c..8689ebd 100644 --- a/apps/web/src/components/admin/register-key-modal.vue +++ b/apps/web/src/components/admin/register-key-modal.vue @@ -5,7 +5,7 @@ import { computed, reactive, ref, watch } from "vue"; import type { MemberItem } from "@new-api-key-usage/core"; 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"; const props = defineProps<{ @@ -20,10 +20,12 @@ const emit = defineEmits<{ const register = useRegisterKey(); const createMember = useCreateMember(); +const domainOptions = useBillingDomainOptions(); const form = reactive({ sk_key: "", member_id: null as number | "unassigned" | null, + billing_domain: "", }); const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>( null, @@ -41,6 +43,7 @@ watch( if (!v) { form.sk_key = ""; form.member_id = null; + form.billing_domain = ""; resultMsg.value = null; showNewMember.value = false; newMemberName.value = ""; @@ -67,6 +70,7 @@ async function onSubmit() { form.member_id === "unassigned" || form.member_id == null ? null : form.member_id, + billing_domain: form.billing_domain.trim() || null, }); resultMsg.value = { type: "success", @@ -170,6 +174,13 @@ async function onCreateMember() { + 新建成员 + + + patchKey(id, body), onSuccess: () => { void qc.invalidateQueries({ queryKey: ["admin", "keys"] }); @@ -106,7 +111,7 @@ export function useAdminLogs( export function useSubscriptions() { return useQuery({ queryKey: qk.admin.subscriptions(), - queryFn: fetchSubscriptions, + queryFn: () => fetchSubscriptions(), staleTime: 0, }); } @@ -145,7 +150,7 @@ export function useDeleteSubscription() { export function useTopups() { return useQuery({ queryKey: qk.admin.topups(), - queryFn: fetchTopups, + queryFn: () => fetchTopups(), staleTime: 0, }); } @@ -250,3 +255,25 @@ export function useAuditLogs( 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(); + 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 })); + }); +} diff --git a/apps/web/src/views/admin/keys-view.vue b/apps/web/src/views/admin/keys-view.vue index 14daf35..dfeedf2 100644 --- a/apps/web/src/views/admin/keys-view.vue +++ b/apps/web/src/views/admin/keys-view.vue @@ -11,7 +11,7 @@ import type { ApiError } from "@/api/http.ts"; import PageHeader from "@/components/common/page-header.vue"; import TableRefresh from "@/components/common/table-refresh.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 { formatMinute, hoursSince } from "@/utils/time.ts"; @@ -23,6 +23,8 @@ const highlightKeyId = ref(null); const filterMember = ref(null); const filterStatus = ref<"active" | "disabled" | null>(null); +const filterDomain = ref(null); +let domainSaveTimer: ReturnType | null = null; const filterRecentFailed = ref(false); /** URL → 筛选(总览跳入) */ @@ -35,6 +37,10 @@ function syncFromRoute() { if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status; 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"; @@ -52,18 +58,21 @@ function pushQuery() { if (filterMember.value === "unassigned") query.member_id = "unassigned"; else if (filterMember.value != null) query.member_id = String(filterMember.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 (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value); void router.replace({ path: "/admin/keys", query }); } -watch([filterMember, filterStatus, filterRecentFailed], () => { +watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => { pushQuery(); }); const filters = computed(() => ({ member_id: filterMember.value ?? undefined, status: filterStatus.value ?? undefined, + billing_domain: filterDomain.value ?? undefined, recent_failed: filterRecentFailed.value ? true : undefined, })); @@ -91,6 +100,7 @@ const hasActiveFilter = computed( () => filterMember.value != null || filterStatus.value != null || + filterDomain.value != null || filterRecentFailed.value, ); @@ -99,6 +109,8 @@ const memberOptions = computed(() => [ ...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })), ]); +const domainOptions = useBillingDomainOptions(); + function isStalled(logSyncedAt: number | null | undefined): boolean { const h = hoursSince(logSyncedAt); return h != null && h > 2; @@ -122,6 +134,7 @@ function tipTitle(label: string, tip: string) { const columns = [ { title: "名称", key: "token_name", width: 160 }, { title: "归属", key: "member_name", width: 140 }, + { title: "账本域", key: "billing_domain", width: 110 }, { title: "状态", key: "status", width: 100 }, { 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) { const next = record.status === "active" ? "disabled" : "active"; try { @@ -227,6 +270,17 @@ function onRowClass(record: KeyItem) { placeholder="状态" allow-clear /> + 最近轮次失败 @@ -273,6 +327,17 @@ function onRowClass(record: KeyItem) { @change="(v: number | 'unassigned') => onMemberChange(record, v)" /> +