feat: add direct PostgreSQL and ACK deployment support
This commit is contained in:
1 parent
d0192081c7
commit
c44274f098
55 files changed
+3317
-1064
No files matched your search
+177
-102
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { isPostgresBackend, queryDatabase } from "@/lib/server/database";
|
||||
import { createId } from "@/lib/server/ids";
|
||||
import { dataDir, ensureRuntimeDirs } from "@/lib/server/runtime";
|
||||
import type {
|
||||
@@ -62,13 +64,14 @@ export class InsufficientBalanceError extends BillingStoreError {
|
||||
}
|
||||
|
||||
export async function listBillingPriceRules(options: { includeDisabled?: boolean } = {}): Promise<BillingPriceRule[]> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
let query = supabase.from("billing_price_rules").select("*").order("provider").order("capability").order("updated_at", { ascending: false });
|
||||
if (!options.includeDisabled) query = query.eq("enabled", true);
|
||||
const { data, error } = await query;
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return (data || []).map(priceRuleFromRow);
|
||||
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
|
||||
@@ -77,11 +80,9 @@ export async function listBillingPriceRules(options: { includeDisabled?: boolean
|
||||
}
|
||||
|
||||
export async function getBillingPriceRule(id: string): Promise<BillingPriceRule | null> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("billing_price_rules").select("*").eq("id", id).maybeSingle();
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return data ? priceRuleFromRow(data) : 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;
|
||||
@@ -95,11 +96,24 @@ export async function createBillingPriceRule(input: BillingPriceRuleInput): Prom
|
||||
createdAt: input.createdAt || now,
|
||||
updatedAt: input.updatedAt || now
|
||||
};
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("billing_price_rules").insert(priceRuleToRow(rule)).select("*").single();
|
||||
if (error) throw new BillingStoreError(error.message, error.code === "23505" ? 409 : 500);
|
||||
return priceRuleFromRow(data);
|
||||
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);
|
||||
@@ -115,11 +129,22 @@ export async function updateBillingPriceRule(id: string, patch: Partial<Omit<Bil
|
||||
const existing = await getBillingPriceRule(id);
|
||||
if (!existing) return null;
|
||||
const updated: BillingPriceRule = { ...existing, ...patch, updatedAt: new Date().toISOString() };
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("billing_price_rules").update(priceRuleToRow(updated)).eq("id", id).select("*").maybeSingle();
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return data ? priceRuleFromRow(data) : null;
|
||||
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);
|
||||
@@ -156,22 +181,17 @@ export async function updateBillingPriceTierMultiplier(input: {
|
||||
}
|
||||
|
||||
export async function getOrganizationWallet(organizationId: string): Promise<OrganizationWallet> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("billing_wallets").select("*").eq("organization_id", organizationId).maybeSingle();
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return data ? walletFromRow(data) : emptyWallet(organizationId);
|
||||
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[]> {
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.from("billing_wallets").select("*").order("updated_at", { ascending: false });
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return (data || []).map(walletFromRow);
|
||||
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));
|
||||
@@ -179,31 +199,26 @@ export async function listOrganizationWallets(): Promise<OrganizationWallet[]> {
|
||||
|
||||
export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry: BillingLedgerEntry; wallet: OrganizationWallet }> {
|
||||
const deltaFen = Math.trunc(input.deltaFen);
|
||||
if (!Number.isFinite(deltaFen) || deltaFen === 0) throw new BillingStoreError("账务变动金额不能为 0。", 400);
|
||||
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 || {};
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
const { data, error } = await supabase.rpc("billing_post_wallet_entry", {
|
||||
p_ledger_id: createId("ledger"),
|
||||
p_organization_id: input.organizationId,
|
||||
p_account_id: accountId || null,
|
||||
p_job_id: input.jobId || null,
|
||||
p_kind: input.kind,
|
||||
p_delta_fen: deltaFen,
|
||||
p_currency: currency,
|
||||
p_idempotency_key: input.idempotencyKey,
|
||||
p_description: input.description,
|
||||
p_metadata: 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 (error) {
|
||||
if (/BILLING_INSUFFICIENT_BALANCE/i.test(error.message)) throw new InsufficientBalanceError();
|
||||
throw new BillingStoreError(error.message, 500);
|
||||
}
|
||||
const row = firstRpcRow(data);
|
||||
if (!row) throw new BillingStoreError("账务服务未返回流水结果。", 500);
|
||||
return {
|
||||
entry: ledgerFromRpcRow(row, input, accountId),
|
||||
@@ -212,8 +227,17 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry:
|
||||
}
|
||||
|
||||
return mutateLocalState((state) => {
|
||||
const existing = state.ledgerEntries.find((entry) => entry.idempotencyKey === input.idempotencyKey);
|
||||
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 };
|
||||
}
|
||||
@@ -222,9 +246,9 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry:
|
||||
const now = new Date().toISOString();
|
||||
const nextWallet: OrganizationWallet = {
|
||||
...wallet,
|
||||
balanceFen: wallet.balanceFen + deltaFen,
|
||||
totalRechargedFen: wallet.totalRechargedFen + (input.kind === "recharge" && deltaFen > 0 ? deltaFen : 0),
|
||||
totalChargedFen: wallet.totalChargedFen + (input.kind === "charge" && deltaFen < 0 ? Math.abs(deltaFen) : 0),
|
||||
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 = {
|
||||
@@ -251,16 +275,18 @@ export async function postWalletEntry(input: WalletEntryInput): Promise<{ entry:
|
||||
|
||||
export async function listBillingLedgerEntries(filters: BillingLedgerFilters = {}): Promise<BillingLedgerEntry[]> {
|
||||
const limit = Math.max(1, Math.min(filters.limit || 100, 500));
|
||||
const supabase = getSupabaseAdmin();
|
||||
if (supabase) {
|
||||
let query = supabase.from("billing_ledger").select("*").order("created_at", { ascending: false }).limit(limit);
|
||||
if (filters.organizationId) query = query.eq("organization_id", filters.organizationId);
|
||||
if (filters.accountId) query = query.eq("account_id", filters.accountId);
|
||||
if (filters.jobId) query = query.eq("job_id", filters.jobId);
|
||||
if (filters.kind) query = query.eq("kind", filters.kind);
|
||||
const { data, error } = await query;
|
||||
if (error) throw new BillingStoreError(error.message, 500);
|
||||
return (data || []).map(ledgerFromRow);
|
||||
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
|
||||
@@ -321,13 +347,6 @@ function emptyWallet(organizationId: string): OrganizationWallet {
|
||||
};
|
||||
}
|
||||
|
||||
function getSupabaseAdmin(): SupabaseClient | null {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
if (!url || !serviceRoleKey) return null;
|
||||
return createClient(url, serviceRoleKey, { auth: { persistSession: false } });
|
||||
}
|
||||
|
||||
function priceRuleToRow(rule: Partial<BillingPriceRule>) {
|
||||
return {
|
||||
id: rule.id,
|
||||
@@ -336,10 +355,10 @@ function priceRuleToRow(rule: Partial<BillingPriceRule>) {
|
||||
req_key: rule.reqKey || null,
|
||||
variant_key: rule.variantKey || null,
|
||||
unit: rule.unit,
|
||||
standard_unit_price_fen: rule.standardUnitPriceFen,
|
||||
markup_multiplier: rule.markupMultiplier,
|
||||
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: rule.conditions || {},
|
||||
conditions: canonicalConditionValue(rule.conditions || {}) as BillingRuleConditions,
|
||||
quantity_source: rule.quantitySource || null,
|
||||
priority: rule.priority || 0,
|
||||
note: rule.note || null,
|
||||
@@ -358,8 +377,8 @@ function priceRuleFromRow(row: Record<string, unknown>): BillingPriceRule {
|
||||
reqKey: optionalString(row.req_key),
|
||||
variantKey: optionalString(row.variant_key),
|
||||
unit: row.unit as BillingPriceRule["unit"],
|
||||
standardUnitPriceFen: Number(row.standard_unit_price_fen || 0),
|
||||
markupMultiplier: Number(row.markup_multiplier || 1),
|
||||
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"
|
||||
@@ -368,9 +387,9 @@ function priceRuleFromRow(row: Record<string, unknown>): BillingPriceRule {
|
||||
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: Array.isArray(row.parameter_dimensions) ? row.parameter_dimensions as BillingParameterDimension[] : undefined,
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.updated_at)
|
||||
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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,20 +416,20 @@ function canonicalConditionValue(value: unknown): unknown {
|
||||
function walletFromRow(row: Record<string, unknown>): OrganizationWallet {
|
||||
return {
|
||||
organizationId: String(row.organization_id),
|
||||
balanceFen: Number(row.balance_fen || 0),
|
||||
totalRechargedFen: Number(row.total_recharged_fen || 0),
|
||||
totalChargedFen: Number(row.total_charged_fen || 0),
|
||||
updatedAt: String(row.updated_at || new Date().toISOString())
|
||||
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: Number(row.balance_fen || row.balance_after_fen || 0),
|
||||
totalRechargedFen: Number(row.total_recharged_fen || 0),
|
||||
totalChargedFen: Number(row.total_charged_fen || 0),
|
||||
updatedAt: String(row.updated_at || row.created_at || new Date().toISOString())
|
||||
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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,13 +440,13 @@ function ledgerFromRow(row: Record<string, unknown>): BillingLedgerEntry {
|
||||
accountId: optionalString(row.account_id),
|
||||
jobId: optionalString(row.job_id),
|
||||
kind: row.kind as BillingLedgerKind,
|
||||
deltaFen: Number(row.delta_fen || 0),
|
||||
balanceAfterFen: Number(row.balance_after_fen || 0),
|
||||
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: String(row.created_at)
|
||||
createdAt: databaseTimestamp(row.created_at, "billing_ledger.created_at")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -438,13 +457,13 @@ function ledgerFromRpcRow(row: Record<string, unknown>, input: WalletEntryInput,
|
||||
accountId,
|
||||
jobId: input.jobId,
|
||||
kind: input.kind,
|
||||
deltaFen: Number(row.delta_fen ?? input.deltaFen),
|
||||
balanceAfterFen: Number(row.balance_after_fen || 0),
|
||||
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: String(row.created_at || new Date().toISOString())
|
||||
createdAt: databaseTimestamp(row.created_at ?? new Date(), "billing_post_wallet_entry.created_at")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,11 +472,6 @@ function effectiveLedgerAccountId(input: Pick<WalletEntryInput, "kind" | "accoun
|
||||
return optionalString(input.accountId);
|
||||
}
|
||||
|
||||
function firstRpcRow(value: unknown): Record<string, unknown> | null {
|
||||
if (Array.isArray(value)) return isRecord(value[0]) ? value[0] : null;
|
||||
return isRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -468,10 +482,71 @@ function optionalString(value: unknown): string | undefined {
|
||||
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)
|
||||
&& /(schema cache|relation .* does not exist|table .* does not exist|column .* does not exist|could not find|function .* does not exist)/i.test(message)) {
|
||||
return "计费数据库尚未初始化或未完成升级,请在 Supabase SQL Editor 执行仓库中的 supabase/schema.sql 后重启服务。";
|
||||
&& /(relation .* does not exist|table .* does not exist|column .* does not exist|function .* does not exist)/i.test(message)) {
|
||||
return "计费数据库尚未初始化或未完成升级,请先对 PostgreSQL 数据库运行仓库中的版本化迁移,再重启服务。";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
Reference in new issue
Block a user