feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -0,0 +1,477 @@
|
||||
import { readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
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[]> {
|
||||
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);
|
||||
}
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
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
|
||||
};
|
||||
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);
|
||||
}
|
||||
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() };
|
||||
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;
|
||||
}
|
||||
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> {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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.isFinite(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 (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),
|
||||
wallet: walletFromRpcRow(row, input.organizationId)
|
||||
};
|
||||
}
|
||||
|
||||
return mutateLocalState((state) => {
|
||||
const existing = state.ledgerEntries.find((entry) => entry.idempotencyKey === input.idempotencyKey);
|
||||
if (existing) {
|
||||
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: 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),
|
||||
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));
|
||||
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);
|
||||
}
|
||||
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 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,
|
||||
provider: rule.provider,
|
||||
capability: rule.capability,
|
||||
req_key: rule.reqKey || null,
|
||||
variant_key: rule.variantKey || null,
|
||||
unit: rule.unit,
|
||||
standard_unit_price_fen: rule.standardUnitPriceFen,
|
||||
markup_multiplier: rule.markupMultiplier,
|
||||
enabled: rule.enabled,
|
||||
conditions: rule.conditions || {},
|
||||
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: Number(row.standard_unit_price_fen || 0),
|
||||
markupMultiplier: Number(row.markup_multiplier || 1),
|
||||
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: Array.isArray(row.parameter_dimensions) ? row.parameter_dimensions as BillingParameterDimension[] : undefined,
|
||||
createdAt: String(row.created_at),
|
||||
updatedAt: String(row.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: 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())
|
||||
};
|
||||
}
|
||||
|
||||
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())
|
||||
};
|
||||
}
|
||||
|
||||
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: Number(row.delta_fen || 0),
|
||||
balanceAfterFen: Number(row.balance_after_fen || 0),
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
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: Number(row.delta_fen ?? input.deltaFen),
|
||||
balanceAfterFen: Number(row.balance_after_fen || 0),
|
||||
currency: input.currency || "CNY",
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
description: input.description,
|
||||
metadata: input.metadata || {},
|
||||
createdAt: String(row.created_at || new Date().toISOString())
|
||||
};
|
||||
}
|
||||
|
||||
function effectiveLedgerAccountId(input: Pick<WalletEntryInput, "kind" | "accountId">): string | undefined {
|
||||
if (input.kind === "recharge" || input.kind === "adjustment") return undefined;
|
||||
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);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
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 后重启服务。";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
Reference in new issue
Block a user