Files
NianAIGC/lib/server/billing-store.ts

553 lines
24 KiB
TypeScript

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<unknown> = Promise.resolve();
type BillingState = {
priceRules: BillingPriceRule[];
wallets: OrganizationWallet[];
ledgerEntries: BillingLedgerEntry[];
};
export type BillingPriceRuleInput = Omit<BillingPriceRule, "id" | "createdAt" | "updatedAt"> & Partial<Pick<BillingPriceRule, "id" | "createdAt" | "updatedAt">>;
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<string, unknown>;
};
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<BillingPriceRule[]> {
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<BillingPriceRule | null> {
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<BillingPriceRule> {
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<Omit<BillingPriceRule, "id" | "createdAt" | "updatedAt">>): Promise<BillingPriceRule | null> {
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<BillingPriceRule | null> {
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<OrganizationWallet> {
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<OrganizationWallet[]> {
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<BillingLedgerEntry[]> {
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<BillingState> {
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<void> {
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<T>(mutator: (state: BillingState) => T): Promise<T> {
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>): 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<BillingPriceRule>) {
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<string, unknown>): 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<BillingPriceRule, "provider" | "capability" | "reqKey" | "variantKey" | "conditions">): 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<string, unknown>): 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<string, unknown>, 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<string, unknown>): 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<string, unknown>, 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<WalletEntryInput, "kind" | "accountId">): string | undefined {
if (input.kind === "recharge" || input.kind === "adjustment") return undefined;
return optionalString(input.accountId);
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<Record<string, unknown>[]> {
try {
const result = await queryDatabase<Record<string, unknown>>(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;
}