import "server-only"; import { readFile, rename, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { isPostgresBackend, queryDatabase } from "@/lib/server/database"; import { createId } from "@/lib/server/ids"; import { dataDir, ensureRuntimeDirs } from "@/lib/server/runtime"; import type { BillingCurrency, BillingLedgerEntry, BillingLedgerKind, BillingParameterDimension, BillingPriceRule, BillingRuleConditions, OrganizationWallet } from "@/lib/types"; const STORE_FILE = "billing-state.json"; let localWriteQueue: Promise = Promise.resolve(); type BillingState = { priceRules: BillingPriceRule[]; wallets: OrganizationWallet[]; ledgerEntries: BillingLedgerEntry[]; }; export type BillingPriceRuleInput = Omit & Partial>; export type BillingLedgerFilters = { organizationId?: string; accountId?: string; jobId?: string; kind?: BillingLedgerKind; limit?: number; }; export type WalletEntryInput = { organizationId: string; accountId?: string; jobId?: string; kind: BillingLedgerKind; deltaFen: number; currency?: BillingCurrency; idempotencyKey: string; description: string; metadata?: Record; }; export class BillingStoreError extends Error { status: number; constructor(message: string, status = 400) { super(normalizeBillingStoreErrorMessage(message)); this.name = "BillingStoreError"; this.status = status; } } export class InsufficientBalanceError extends BillingStoreError { constructor(message = "余额不足,请先充值。") { super(message, 402); this.name = "InsufficientBalanceError"; } } export async function listBillingPriceRules(options: { includeDisabled?: boolean } = {}): Promise { if (isPostgresBackend()) { const result = await billingQuery( `SELECT * FROM billing_price_rules WHERE ($1::boolean OR enabled = true) ORDER BY provider ASC, capability ASC, updated_at DESC`, [Boolean(options.includeDisabled)] ); return result.map(priceRuleFromRow); } const state = await readState(); return state.priceRules .filter((rule) => options.includeDisabled || rule.enabled) .sort((left, right) => left.provider.localeCompare(right.provider) || left.capability.localeCompare(right.capability) || right.updatedAt.localeCompare(left.updatedAt)); } export async function getBillingPriceRule(id: string): Promise { if (isPostgresBackend()) { const [row] = await billingQuery("SELECT * FROM billing_price_rules WHERE id = $1 LIMIT 1", [id]); return row ? priceRuleFromRow(row) : null; } const state = await readState(); return state.priceRules.find((rule) => rule.id === id) || null; } export async function createBillingPriceRule(input: BillingPriceRuleInput): Promise { const now = new Date().toISOString(); const rule: BillingPriceRule = { ...input, id: input.id || createId("price"), createdAt: input.createdAt || now, updatedAt: input.updatedAt || now }; if (isPostgresBackend()) { const row = priceRuleToRow(rule); const [created] = await billingQuery( `INSERT INTO billing_price_rules ( id, provider, capability, req_key, variant_key, unit, standard_unit_price_fen, markup_multiplier, enabled, conditions, quantity_source, priority, note, source, parameter_dimensions, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, $13, $14::jsonb, $15::jsonb, $16, $17 ) RETURNING *`, [row.id, row.provider, row.capability, row.req_key, row.variant_key, row.unit, row.standard_unit_price_fen, row.markup_multiplier, row.enabled, JSON.stringify(row.conditions), row.quantity_source, row.priority, row.note, JSON.stringify(row.source), JSON.stringify(row.parameter_dimensions), row.created_at, row.updated_at], true ); return priceRuleFromRow(created); } return mutateLocalState((state) => { if (state.priceRules.some((item) => item.id === rule.id)) throw new BillingStoreError("计费规则 ID 已存在。", 409); if (state.priceRules.some((item) => priceRuleMatchKey(item) === priceRuleMatchKey(rule))) { throw new BillingStoreError("相同服务商、能力、模型和变体的计费规则已存在。", 409); } state.priceRules.unshift(rule); return rule; }); } export async function updateBillingPriceRule(id: string, patch: Partial>): Promise { const existing = await getBillingPriceRule(id); if (!existing) return null; const updated: BillingPriceRule = { ...existing, ...patch, updatedAt: new Date().toISOString() }; if (isPostgresBackend()) { const row = priceRuleToRow(updated); const [saved] = await billingQuery( `UPDATE billing_price_rules SET provider = $2, capability = $3, req_key = $4, variant_key = $5, unit = $6, standard_unit_price_fen = $7, markup_multiplier = $8, enabled = $9, conditions = $10::jsonb, quantity_source = $11, priority = $12, note = $13, source = $14::jsonb, parameter_dimensions = $15::jsonb, updated_at = $16 WHERE id = $1 RETURNING *`, [id, row.provider, row.capability, row.req_key, row.variant_key, row.unit, row.standard_unit_price_fen, row.markup_multiplier, row.enabled, JSON.stringify(row.conditions), row.quantity_source, row.priority, row.note, JSON.stringify(row.source), JSON.stringify(row.parameter_dimensions), row.updated_at], true ); return saved ? priceRuleFromRow(saved) : null; } return mutateLocalState((state) => { const index = state.priceRules.findIndex((item) => item.id === id); if (index === -1) return null; if (state.priceRules.some((item) => item.id !== id && priceRuleMatchKey(item) === priceRuleMatchKey(updated))) { throw new BillingStoreError("相同服务商、能力、模型和参数条件的计费规则已存在。", 409); } state.priceRules[index] = updated; return updated; }); } export async function updateBillingPriceTierMultiplier(input: { ruleId: string; dimensionKey: string; tierValue: string; markupMultiplier: number; }): Promise { if (!Number.isFinite(input.markupMultiplier) || input.markupMultiplier < 1 || input.markupMultiplier > 1000) { throw new BillingStoreError("上浮倍率必须在 1.00 至 1000.00 之间。", 400); } const existing = await getBillingPriceRule(input.ruleId); if (!existing) return null; const dimensions = existing.parameterDimensions || []; const dimension = dimensions.find((item) => item.key === input.dimensionKey); if (!dimension) throw new BillingStoreError("平台价格参数不存在。", 404); const tierIndex = dimension.tiers.findIndex((item) => String(item.value) === input.tierValue); if (tierIndex === -1) throw new BillingStoreError("平台价格档位不存在。", 404); const nextDimensions = dimensions.map((item) => item.key !== input.dimensionKey ? item : { ...item, tiers: item.tiers.map((tier, index) => index === tierIndex ? { ...tier, markupMultiplier: input.markupMultiplier } : tier) }); return updateBillingPriceRule(input.ruleId, { parameterDimensions: nextDimensions }); } export async function getOrganizationWallet(organizationId: string): Promise { if (isPostgresBackend()) { const [row] = await billingQuery("SELECT * FROM billing_wallets WHERE organization_id = $1 LIMIT 1", [organizationId]); return row ? walletFromRow(row) : emptyWallet(organizationId); } const state = await readState(); return state.wallets.find((wallet) => wallet.organizationId === organizationId) || emptyWallet(organizationId); } export async function listOrganizationWallets(): Promise { if (isPostgresBackend()) { return (await billingQuery("SELECT * FROM billing_wallets ORDER BY updated_at DESC")).map(walletFromRow); } const state = await readState(); return [...state.wallets].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: BillingLedgerEntry; wallet: OrganizationWallet }> { const deltaFen = Math.trunc(input.deltaFen); if (!Number.isSafeInteger(deltaFen) || deltaFen === 0) throw new BillingStoreError("账务变动金额必须是安全整数且不能为 0。", 400); if (!input.organizationId) throw new BillingStoreError("组织 ID 不能为空。", 400); if (!input.idempotencyKey) throw new BillingStoreError("账务幂等键不能为空。", 400); const accountId = effectiveLedgerAccountId(input); const currency = input.currency || "CNY"; const metadata = input.metadata || {}; if (isPostgresBackend()) { const [row] = await billingQuery( `SELECT * FROM billing_post_wallet_entry( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb )`, [createId("ledger"), input.organizationId, accountId || null, input.jobId || null, input.kind, deltaFen, currency, input.idempotencyKey, input.description, JSON.stringify(metadata)] ).catch((error: unknown) => { if (error instanceof BillingStoreError && /BILLING_INSUFFICIENT_BALANCE/i.test(error.message)) throw new InsufficientBalanceError(); if (error instanceof BillingStoreError && /BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH/i.test(error.message)) { throw new BillingStoreError("账务幂等键已被不同请求使用。", 409); } throw error; }); if (!row) throw new BillingStoreError("账务服务未返回流水结果。", 500); return { entry: ledgerFromRpcRow(row, input, accountId), wallet: walletFromRpcRow(row, input.organizationId) }; } return mutateLocalState((state) => { const existing = state.ledgerEntries.find( (entry) => entry.organizationId === input.organizationId && entry.idempotencyKey === input.idempotencyKey ); if (existing) { if (existing.accountId !== accountId || existing.jobId !== input.jobId || existing.kind !== input.kind || existing.deltaFen !== deltaFen || existing.currency !== currency) { throw new BillingStoreError("账务幂等键已被不同请求使用。", 409); } const wallet = state.wallets.find((item) => item.organizationId === input.organizationId) || emptyWallet(input.organizationId); return { entry: existing, wallet }; } const wallet = state.wallets.find((item) => item.organizationId === input.organizationId) || emptyWallet(input.organizationId); if (deltaFen < 0 && wallet.balanceFen < Math.abs(deltaFen)) throw new InsufficientBalanceError(); const now = new Date().toISOString(); const nextWallet: OrganizationWallet = { ...wallet, balanceFen: safeInteger(wallet.balanceFen + deltaFen, "local billing wallet balance"), totalRechargedFen: safeInteger(wallet.totalRechargedFen + (input.kind === "recharge" && deltaFen > 0 ? deltaFen : 0), "local billing wallet total recharged"), totalChargedFen: safeInteger(wallet.totalChargedFen + (input.kind === "charge" && deltaFen < 0 ? Math.abs(deltaFen) : 0), "local billing wallet total charged"), updatedAt: now }; const entry: BillingLedgerEntry = { id: createId("ledger"), organizationId: input.organizationId, accountId, jobId: input.jobId, kind: input.kind, deltaFen, balanceAfterFen: nextWallet.balanceFen, currency, idempotencyKey: input.idempotencyKey, description: input.description, metadata, createdAt: now }; const walletIndex = state.wallets.findIndex((item) => item.organizationId === input.organizationId); if (walletIndex === -1) state.wallets.push(nextWallet); else state.wallets[walletIndex] = nextWallet; state.ledgerEntries.unshift(entry); return { entry, wallet: nextWallet }; }); } export async function listBillingLedgerEntries(filters: BillingLedgerFilters = {}): Promise { const limit = Math.max(1, Math.min(filters.limit || 100, 500)); if (isPostgresBackend()) { const rows = await billingQuery( `SELECT * FROM billing_ledger WHERE ($1::text IS NULL OR organization_id = $1) AND ($2::text IS NULL OR account_id = $2) AND ($3::text IS NULL OR job_id = $3) AND ($4::text IS NULL OR kind = $4) ORDER BY created_at DESC LIMIT $5`, [filters.organizationId || null, filters.accountId || null, filters.jobId || null, filters.kind || null, limit] ); return rows.map(ledgerFromRow); } const state = await readState(); return state.ledgerEntries .filter((entry) => !filters.organizationId || entry.organizationId === filters.organizationId) .filter((entry) => !filters.accountId || entry.accountId === filters.accountId) .filter((entry) => !filters.jobId || entry.jobId === filters.jobId) .filter((entry) => !filters.kind || entry.kind === filters.kind) .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) .slice(0, limit); } async function readState(): Promise { await ensureRuntimeDirs(); const path = join(dataDir(), STORE_FILE); try { return normalizeState(JSON.parse(await readFile(path, "utf8"))); } catch { const state = normalizeState({}); await writeState(state); return state; } } async function writeState(state: BillingState): Promise { await ensureRuntimeDirs(); const path = join(dataDir(), STORE_FILE); const temp = `${path}.${createId("tmp")}.tmp`; await writeFile(temp, JSON.stringify(state, null, 2)); await rename(temp, path); } async function mutateLocalState(mutator: (state: BillingState) => T): Promise { const run = localWriteQueue.then(async () => { const state = await readState(); const result = mutator(state); await writeState(state); return result; }); localWriteQueue = run.catch(() => undefined); return run; } function normalizeState(raw: Partial): BillingState { return { priceRules: Array.isArray(raw.priceRules) ? raw.priceRules : [], wallets: Array.isArray(raw.wallets) ? raw.wallets : [], ledgerEntries: Array.isArray(raw.ledgerEntries) ? raw.ledgerEntries : [] }; } function emptyWallet(organizationId: string): OrganizationWallet { return { organizationId, balanceFen: 0, totalRechargedFen: 0, totalChargedFen: 0, updatedAt: new Date(0).toISOString() }; } function priceRuleToRow(rule: Partial) { return { id: rule.id, provider: rule.provider, capability: rule.capability, req_key: rule.reqKey || null, variant_key: rule.variantKey || null, unit: rule.unit, standard_unit_price_fen: safeInteger(rule.standardUnitPriceFen, "billing price rule standard unit price"), markup_multiplier: finiteNumber(rule.markupMultiplier, "billing price rule markup multiplier"), enabled: rule.enabled, conditions: canonicalConditionValue(rule.conditions || {}) as BillingRuleConditions, quantity_source: rule.quantitySource || null, priority: rule.priority || 0, note: rule.note || null, source: rule.source || null, parameter_dimensions: rule.parameterDimensions || [], created_at: rule.createdAt, updated_at: rule.updatedAt }; } function priceRuleFromRow(row: Record): BillingPriceRule { return { id: String(row.id), provider: row.provider as BillingPriceRule["provider"], capability: row.capability as BillingPriceRule["capability"], reqKey: optionalString(row.req_key), variantKey: optionalString(row.variant_key), unit: row.unit as BillingPriceRule["unit"], standardUnitPriceFen: safeInteger(row.standard_unit_price_fen, "billing_price_rules.standard_unit_price_fen"), markupMultiplier: finiteNumber(row.markup_multiplier ?? 1, "billing_price_rules.markup_multiplier"), enabled: row.enabled !== false, conditions: isRecord(row.conditions) ? row.conditions as BillingRuleConditions : undefined, quantitySource: row.quantity_source === "request" || row.quantity_source === "image_count" || row.quantity_source === "duration" ? row.quantity_source : undefined, priority: Number.isFinite(Number(row.priority)) ? Number(row.priority) : 0, note: optionalString(row.note), source: isRecord(row.source) ? row.source as BillingPriceRule["source"] : undefined, parameterDimensions: billingParameterDimensions(row.parameter_dimensions), createdAt: databaseTimestamp(row.created_at, "billing_price_rules.created_at"), updatedAt: databaseTimestamp(row.updated_at, "billing_price_rules.updated_at") }; } function priceRuleMatchKey(rule: Pick): string { return [rule.provider, rule.capability, rule.reqKey || "", rule.variantKey || "", canonicalConditions(rule.conditions)].join("\u0000"); } function canonicalConditions(conditions?: BillingRuleConditions): string { if (!conditions || !isRecord(conditions)) return "{}"; const entries = Object.entries(conditions) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, value]) => [key, canonicalConditionValue(value)] as const); return JSON.stringify(Object.fromEntries(entries)); } function canonicalConditionValue(value: unknown): unknown { if (Array.isArray(value)) return value.map(canonicalConditionValue).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))); if (isRecord(value)) { return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, canonicalConditionValue(item)])); } return value; } function walletFromRow(row: Record): OrganizationWallet { return { organizationId: String(row.organization_id), balanceFen: safeInteger(row.balance_fen, "billing_wallets.balance_fen"), totalRechargedFen: safeInteger(row.total_recharged_fen, "billing_wallets.total_recharged_fen"), totalChargedFen: safeInteger(row.total_charged_fen, "billing_wallets.total_charged_fen"), updatedAt: databaseTimestamp(row.updated_at ?? new Date(), "billing_wallets.updated_at") }; } function walletFromRpcRow(row: Record, organizationId: string): OrganizationWallet { return { organizationId, balanceFen: safeInteger(row.balance_fen ?? row.balance_after_fen, "billing_post_wallet_entry.balance_fen"), totalRechargedFen: safeInteger(row.total_recharged_fen, "billing_post_wallet_entry.total_recharged_fen"), totalChargedFen: safeInteger(row.total_charged_fen, "billing_post_wallet_entry.total_charged_fen"), updatedAt: databaseTimestamp(row.updated_at ?? row.created_at ?? new Date(), "billing_post_wallet_entry.updated_at") }; } function ledgerFromRow(row: Record): BillingLedgerEntry { return { id: String(row.id), organizationId: String(row.organization_id), accountId: optionalString(row.account_id), jobId: optionalString(row.job_id), kind: row.kind as BillingLedgerKind, deltaFen: safeInteger(row.delta_fen, "billing_ledger.delta_fen"), balanceAfterFen: safeInteger(row.balance_after_fen, "billing_ledger.balance_after_fen"), currency: row.currency === "CNY" ? "CNY" : "CNY", idempotencyKey: String(row.idempotency_key), description: String(row.description || ""), metadata: isRecord(row.metadata) ? row.metadata : {}, createdAt: databaseTimestamp(row.created_at, "billing_ledger.created_at") }; } function ledgerFromRpcRow(row: Record, input: WalletEntryInput, accountId?: string): BillingLedgerEntry { return { id: String(row.ledger_id || row.id), organizationId: input.organizationId, accountId, jobId: input.jobId, kind: input.kind, deltaFen: safeInteger(row.delta_fen ?? input.deltaFen, "billing_post_wallet_entry.delta_fen"), balanceAfterFen: safeInteger(row.balance_after_fen, "billing_post_wallet_entry.balance_after_fen"), currency: input.currency || "CNY", idempotencyKey: input.idempotencyKey, description: input.description, metadata: input.metadata || {}, createdAt: databaseTimestamp(row.created_at ?? new Date(), "billing_post_wallet_entry.created_at") }; } function effectiveLedgerAccountId(input: Pick): string | undefined { if (input.kind === "recharge" || input.kind === "adjustment") return undefined; return optionalString(input.accountId); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function optionalString(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); return trimmed || undefined; } async function billingQuery( text: string, values: readonly unknown[] = [], conflictOnUniqueViolation = false ): Promise[]> { try { const result = await queryDatabase>(text, values); return result.rows; } catch (error) { if (error instanceof BillingStoreError) throw error; const message = error instanceof Error ? error.message : String(error); const code = isRecord(error) ? optionalString(error.code) : undefined; throw new BillingStoreError(message, conflictOnUniqueViolation && code === "23505" ? 409 : 500); } } function safeInteger(value: unknown, field: string): number { const numberValue = typeof value === "bigint" ? Number(value) : Number(value ?? 0); if (!Number.isSafeInteger(numberValue)) { throw new BillingStoreError(`数据库字段 ${field} 超出 JavaScript 安全整数范围。`, 500); } return numberValue; } function finiteNumber(value: unknown, field: string): number { const numberValue = Number(value); if (!Number.isFinite(numberValue)) throw new BillingStoreError(`数据库字段 ${field} 不是有限数值。`, 500); return numberValue; } function databaseTimestamp(value: unknown, field: string): string { if (value instanceof Date && Number.isFinite(value.getTime())) return value.toISOString(); if (typeof value === "string") { const parsed = new Date(value); if (Number.isFinite(parsed.getTime())) return parsed.toISOString(); } throw new BillingStoreError(`数据库字段 ${field} 不是有效时间。`, 500); } function billingParameterDimensions(value: unknown): BillingParameterDimension[] | undefined { if (!Array.isArray(value)) return undefined; return value.map((dimension, dimensionIndex) => { if (!isRecord(dimension) || !Array.isArray(dimension.tiers)) { throw new BillingStoreError(`数据库字段 billing_price_rules.parameter_dimensions[${dimensionIndex}] 格式无效。`, 500); } return { ...dimension, tiers: dimension.tiers.map((tier, tierIndex) => { if (!isRecord(tier)) { throw new BillingStoreError(`数据库字段 billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}] 格式无效。`, 500); } return { ...tier, standardFactor: finiteNumber(tier.standardFactor, `billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}].standardFactor`), markupMultiplier: finiteNumber(tier.markupMultiplier, `billing_price_rules.parameter_dimensions[${dimensionIndex}].tiers[${tierIndex}].markupMultiplier`) }; }) } as BillingParameterDimension; }); } function normalizeBillingStoreErrorMessage(message: string): string { if (/(billing_|billing_post_wallet_entry|variant_key|standard_unit_price_fen|markup_multiplier|source)/i.test(message) && /(relation .* does not exist|table .* does not exist|column .* does not exist|function .* does not exist)/i.test(message)) { return "计费数据库尚未初始化或未完成升级,请先对 PostgreSQL 数据库运行仓库中的版本化迁移,再重启服务。"; } return message; }