feat: add local auth billing and usage management

This commit is contained in:
inman committed 2026-08-12 12:13:06 +08:00
1 parent f642b5e71f
commit 196fdde83f
119 files changed
+15695 -2650

No files matched your search

+626
View File
@@ -0,0 +1,626 @@
import { quoteFromPriceRule, resolveBillingParameterPricing } from "@/lib/billing";
import {
getOrganizationWallet,
InsufficientBalanceError,
listBillingPriceRules,
listBillingLedgerEntries,
postWalletEntry
} from "@/lib/server/billing-store";
import { ensureDefaultBillingPriceRules } from "@/lib/server/billing-catalog";
import {
calculateSeedanceActualAmountFen,
estimateSeedanceAmountFen,
requestHasInputVideo,
requestInputVideoDurationSeconds,
seedanceTokenPriceFenPerMillion
} from "@/lib/server/seedance-billing";
import { getGenerationJob, updateGenerationJob } from "@/lib/server/data-store";
import type {
BillingAccountConfig,
BillingConditionValue,
BillingJobCharge,
BillingParameterSnapshot,
BillingPriceRule,
BillingQuantitySource,
BillingRuleConditions,
BillingScalar,
GenerationCapability,
GenerationJob,
GenerationProvider,
UsageContext
} from "@/lib/types";
export class BillingConfigurationError extends Error {
status = 503;
constructor(message: string) {
super(message);
this.name = "BillingConfigurationError";
}
}
export type BillingGenerationInput = {
provider: GenerationProvider;
capability: GenerationCapability;
reqKey: string;
requestPayload: Record<string, unknown>;
usageContext?: UsageContext;
externalClientId?: string;
allowUnboundOrganization?: boolean;
};
export async function quoteGenerationCharge(input: BillingGenerationInput): Promise<BillingJobCharge | undefined> {
if (!billingEnabled() || input.provider === "mock") return undefined;
const quotaExempt = isQuotaExemptUsageContext(input.usageContext);
if (!input.usageContext?.organizationId && !quotaExempt) {
if (input.externalClientId || input.usageContext?.source === "api") return undefined;
if (!input.allowUnboundOrganization) {
throw new BillingConfigurationError("当前账号未绑定组织,暂时无法提交计费生成任务。");
}
}
await ensureDefaultBillingPriceRules();
const rule = await findMatchingPriceRule(input);
if (!rule) {
throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 的计费规则,请联系超级管理员。`);
}
const parameters = normalizeBillingParameters(input.requestPayload);
if (!resolveBillingParameterPricing(rule, parameters)) {
throw new BillingConfigurationError(`尚未配置 ${input.provider} / ${input.capability} 当前参数组合的标准价格,请联系超级管理员。`);
}
const quantity = quantityForRule(rule, parameters);
const quote: BillingJobCharge = {
...quoteFromPriceRule({
rule,
provider: input.provider,
capability: input.capability,
reqKey: input.reqKey,
quantity,
parameters,
conditions: effectiveBillingRuleConditions(rule)
}),
status: "pending",
quotaExempt: quotaExempt || undefined
};
if (input.provider !== "seedance" || input.reqKey !== "doubao-seedance-2-0-260128") {
return quote;
}
const inputVideo = requestHasInputVideo(input.requestPayload);
const estimatedAmountFen = estimateSeedanceAmountFen({
resolution: parameters.resolution,
aspectRatio: parameters.aspectRatio,
outputDurationSeconds: quantity,
inputVideo,
inputVideoDurationSeconds: requestInputVideoDurationSeconds(input.requestPayload),
markupMultiplier: quote.markupMultiplier
});
const reservedAmountFen = Math.max(quote.amountFen, estimatedAmountFen);
return {
...quote,
amountFen: reservedAmountFen,
reservedAmountFen,
settlementStatus: "pending"
};
}
export async function chargeGenerationJob(job: GenerationJob): Promise<GenerationJob> {
const billing = job.billing;
if (!billing || billing.status !== "pending") return job;
if (isQuotaExemptBilling(job)) {
return updateGenerationJob(job.id, {
billing: {
...billing,
quotaExempt: true,
status: "not_charged",
settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus
}
});
}
if (!job.usageContext?.organizationId) return job;
const reservedAmountFen = billing.reservedAmountFen ?? billing.amountFen;
const result = await postWalletEntry({
organizationId: job.usageContext.organizationId,
accountId: job.usageContext.accountId,
jobId: job.id,
kind: "charge",
deltaFen: -reservedAmountFen,
idempotencyKey: `job-charge:${job.id}`,
description: `${capabilityLabel(job.capability)} · ${job.reqKey}`,
metadata: {
quote: { ...billing, reservedAmountFen },
accountName: job.usageContext.displayName,
organizationName: job.usageContext.organizationName
}
});
return updateGenerationJob(job.id, {
billing: {
...billing,
reservedAmountFen,
status: "charged",
settlementStatus: job.provider === "seedance" ? "pending" : billing.settlementStatus,
ledgerEntryId: result.entry.id,
chargedAt: result.entry.createdAt
}
});
}
export async function refundGenerationCharge(jobOrId: GenerationJob | string, reason: string): Promise<GenerationJob | null> {
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
if (!job) return null;
if (!["failed", "expired", "cancelled"].includes(job.status)) return job;
const billing = job.billing;
if (!billing || isQuotaExemptBilling(job) || billing.status !== "charged" || !job.usageContext?.organizationId) return job;
const result = await postWalletEntry({
organizationId: job.usageContext.organizationId,
accountId: job.usageContext.accountId,
jobId: job.id,
kind: "refund",
deltaFen: billing.amountFen,
idempotencyKey: `job-refund:${job.id}`,
description: `${capabilityLabel(job.capability)}失败退款 · ${reason}`,
metadata: {
chargeLedgerEntryId: billing.ledgerEntryId,
reason,
quote: billing
}
});
return updateGenerationJob(job.id, {
billing: {
...billing,
status: "refunded",
refundLedgerEntryId: result.entry.id,
refundedAt: result.entry.createdAt,
refundReason: reason
}
});
}
export async function settleSeedanceGenerationCharge(
jobOrId: GenerationJob | string,
completionTokens?: number
): Promise<GenerationJob | null> {
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
if (!job) return null;
const billing = job.billing;
const quotaExempt = isQuotaExemptBilling(job);
const organizationId = job.usageContext?.organizationId;
const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged";
if (job.provider !== "seedance" || !billing || !chargeReady || !quotaExempt && !organizationId) return job;
if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job;
if (!Number.isFinite(completionTokens) || Number(completionTokens) <= 0) {
return updateGenerationJob(job.id, {
billing: {
...billing,
settlementStatus: "estimated",
settlementReason: "provider_usage_unavailable",
settledAt: new Date().toISOString()
}
});
}
const inputVideo = requestHasInputVideo(job.requestPayload);
const resolution = billing.parameters?.resolution;
const actualAmountFen = calculateSeedanceActualAmountFen({
resolution,
inputVideo,
completionTokens: Number(completionTokens),
markupMultiplier: billing.markupMultiplier
});
const deltaFen = actualAmountFen - billing.amountFen;
let settlementLedgerEntryId: string | undefined;
let settledAt = new Date().toISOString();
if (deltaFen !== 0 && !quotaExempt && organizationId) {
const settlement = await postWalletEntry({
organizationId,
accountId: job.usageContext?.accountId,
jobId: job.id,
kind: deltaFen > 0 ? "charge" : "refund",
deltaFen: -deltaFen,
idempotencyKey: `job-settlement:${job.id}`,
description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`,
metadata: {
operation: "seedance_actual_settlement",
reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen,
chargedAmountFen: billing.amountFen,
actualAmountFen,
completionTokens: Math.floor(Number(completionTokens)),
inputVideo,
resolution: String(resolution || "720p")
}
});
settlementLedgerEntryId = settlement.entry.id;
settledAt = settlement.entry.createdAt;
}
return updateGenerationJob(job.id, {
billing: {
...billing,
amountFen: actualAmountFen,
settlementStatus: "settled",
settlementLedgerEntryId,
settledAt,
providerUsage: {
completionTokens: Math.floor(Number(completionTokens)),
resolution: String(resolution || "720p"),
inputVideo,
tokenPriceFenPerMillion: seedanceTokenPriceFenPerMillion(resolution, inputVideo)
}
}
});
}
export async function getOrganizationBillingSnapshot(organizationId: string) {
const wallet = await getOrganizationWallet(organizationId);
return {
wallet,
balanceFen: wallet.balanceFen,
balanceYuan: wallet.balanceFen / 100
};
}
export function getBillingAccountConfig(): BillingAccountConfig {
return {
accountName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NAME"),
bankName: optionalEnv("ZHINIAN_BILLING_ACCOUNT_BANK"),
accountNumber: optionalEnv("ZHINIAN_BILLING_ACCOUNT_NUMBER"),
contact: optionalEnv("ZHINIAN_BILLING_CONTACT")
};
}
/**
* Direct organization top-up entry point for administrator actions and future
* payment-success callbacks. The caller must only invoke it after the payment
* provider has already confirmed the amount when self-service payments exist.
*/
export async function postOrganizationTopUp(input: {
organizationId: string;
amountFen: number;
idempotencyKey: string;
description?: string;
metadata?: Record<string, unknown>;
}) {
if (!Number.isFinite(input.amountFen) || input.amountFen <= 0) {
throw Object.assign(new Error("上账金额必须大于 0。"), { status: 400 });
}
return postWalletEntry({
organizationId: input.organizationId,
kind: "recharge",
deltaFen: Math.round(input.amountFen),
idempotencyKey: input.idempotencyKey,
description: input.description || "组织余额上账",
metadata: {
operation: "organization_top_up",
...input.metadata
}
});
}
export async function getBillingOverview(input: { organizationId: string; accountId: string }) {
const [wallet, ledger, personalLedger] = await Promise.all([
getOrganizationWallet(input.organizationId),
listBillingLedgerEntries({ organizationId: input.organizationId, limit: 500 }),
listBillingLedgerEntries({ organizationId: input.organizationId, accountId: input.accountId, limit: 500 })
]);
return {
wallet,
ledger,
billingAccount: getBillingAccountConfig(),
summary: summarizeLedger(ledger),
personal: summarizeLedger(personalLedger)
};
}
function summarizeLedger(entries: Array<{ kind: string; deltaFen: number }>) {
const rechargeFen = entries.filter((entry) => entry.kind === "recharge" || entry.kind === "adjustment" && entry.deltaFen > 0).reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0);
const chargedFen = entries.filter((entry) => entry.kind === "charge").reduce((sum, entry) => sum + Math.max(0, -entry.deltaFen), 0);
const refundedFen = entries.filter((entry) => entry.kind === "refund").reduce((sum, entry) => sum + Math.max(0, entry.deltaFen), 0);
return { rechargeFen, chargedFen, refundedFen, netConsumedFen: Math.max(0, chargedFen - refundedFen) };
}
function billingEnabled(): boolean {
return process.env.ZHINIAN_BILLING_REQUIRED !== "0";
}
function isQuotaExemptUsageContext(usageContext?: UsageContext): boolean {
return usageContext?.source === "platform" && usageContext.role === "super_admin";
}
function isQuotaExemptBilling(job: GenerationJob): boolean {
return Boolean(job.billing?.quotaExempt || isQuotaExemptUsageContext(job.usageContext));
}
export async function findMatchingPriceRule(input: BillingGenerationInput): Promise<BillingPriceRule | null> {
const rules = await listBillingPriceRules({ includeDisabled: false });
return findMatchingBillingPriceRule(rules, input);
}
export function findMatchingBillingPriceRule(rules: BillingPriceRule[], input: Pick<BillingGenerationInput, "provider" | "capability" | "reqKey" | "requestPayload">): BillingPriceRule | null {
const parameters = normalizeBillingParameters(input.requestPayload);
const scoped = rules.filter((rule) => rule.provider === input.provider && rule.capability === input.capability);
const matches = scoped.flatMap((rule) => {
if (rule.reqKey && rule.reqKey !== input.reqKey) return [];
const conditions = effectiveBillingRuleConditions(rule);
if (!conditionsMatchParameters(conditions, parameters)) return [];
return [{
rule,
reqKeySpecificity: rule.reqKey ? 1 : 0,
conditionSpecificity: Object.keys(conditions).length,
priority: Number.isFinite(rule.priority) ? Number(rule.priority) : 0
}];
});
if (!matches.length) return null;
matches.sort((left, right) => right.reqKeySpecificity - left.reqKeySpecificity
|| right.conditionSpecificity - left.conditionSpecificity
|| right.priority - left.priority
|| left.rule.id.localeCompare(right.rule.id));
const winner = matches[0];
const ambiguous = matches.filter((item) => item !== winner
&& item.reqKeySpecificity === winner.reqKeySpecificity
&& item.conditionSpecificity === winner.conditionSpecificity
&& item.priority === winner.priority);
if (ambiguous.length) {
throw new BillingConfigurationError(`计费规则配置存在歧义:${[winner.rule.id, ...ambiguous.map((item) => item.rule.id)].join("、")}。请让条件更具体或调整优先级。`);
}
return winner.rule;
}
export function normalizeBillingParameters(requestPayload: Record<string, unknown>): BillingParameterSnapshot {
const settings = recordValue(requestPayload.settings);
const providerPayload = recordValue(requestPayload.providerPayload);
const input = recordValue(requestPayload.input);
const providerParameters = recordValue(providerPayload?.parameters);
const providerInput = recordValue(providerPayload?.input);
const inputSettings = recordValue(input?.settings);
const assembled = recordValue(requestPayload.assembled);
const parameters: BillingParameterSnapshot = {};
setParameter(parameters, "model", firstValue(providerPayload?.model, input?.model, settings?.model));
setParameter(parameters, "resolution", firstValue(
settings?.resolution,
providerParameters?.resolution,
providerPayload?.resolution,
inputSettings?.resolution,
input?.resolution
), normalizeTextParameter);
setParameter(parameters, "size", firstValue(
providerPayload?.size,
providerParameters?.size,
inputSettings?.size,
input?.size
), normalizeSizeParameter);
const width = numberParameter(firstValue(providerPayload?.width, input?.width));
const height = numberParameter(firstValue(providerPayload?.height, input?.height));
if (parameters.size === undefined && width !== undefined && height !== undefined) {
parameters.size = `${width}*${height}`;
}
const aspectRatio = firstValue(
settings?.ratio,
settings?.aspectRatio,
providerPayload?.ratio,
providerParameters?.ratio,
inputSettings?.ratio,
input?.ratio,
input?.aspectRatio
);
const normalizedAspectRatio = normalizeAspectRatioParameter(aspectRatio, width, height);
if (normalizedAspectRatio !== undefined) parameters.aspectRatio = normalizedAspectRatio;
setParameter(parameters, "quality", firstValue(
input?.quality,
settings?.quality,
providerPayload?.quality,
providerParameters?.quality
), normalizeTextParameter);
setParameter(parameters, "duration", firstValue(
settings?.duration,
providerParameters?.duration,
providerPayload?.duration,
inputSettings?.duration,
input?.duration
), numberParameter);
const imageCount = numberParameter(firstValue(
providerPayload?.n,
providerParameters?.n,
input?.n,
input?.imageCount
));
if (imageCount !== undefined && imageCount > 0) parameters.imageCount = Math.ceil(imageCount);
const referenceImageCount = countReferenceImages({ requestPayload, input, providerPayload, providerInput, assembled });
if (referenceImageCount > 0) parameters.referenceImageCount = referenceImageCount;
const inputVideo = countReferenceVideos({ input, assembled });
if (inputVideo > 0) parameters.inputVideo = true;
setParameter(parameters, "scale", firstValue(input?.scale, settings?.scale), numberParameter);
setParameter(parameters, "generateAudio", firstValue(
settings?.generate_audio,
settings?.generateAudio,
providerParameters?.generate_audio,
providerParameters?.generateAudio,
input?.generate_audio,
input?.generateAudio
), booleanParameter);
return parameters;
}
export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions {
const legacyConditions = parseLegacyVariantKey(rule.variantKey);
return { ...legacyConditions, ...(rule.conditions || {}) };
}
export function quantityForRule(rule: BillingPriceRule, parameters: BillingParameterSnapshot): number {
const source = rule.quantitySource || defaultQuantitySource(rule.unit);
if (source === "request") return 1;
if (source === "duration") {
const duration = Number(parameters.duration);
return Number.isFinite(duration) && duration > 0 ? Math.ceil(duration) : 1;
}
const imageCount = Number(parameters.imageCount);
return Number.isFinite(imageCount) && imageCount > 0 ? Math.ceil(imageCount) : 1;
}
function defaultQuantitySource(unit: BillingPriceRule["unit"]): BillingQuantitySource {
if (unit === "video_second") return "duration";
if (unit === "image") return "image_count";
return "request";
}
function conditionsMatchParameters(conditions: BillingRuleConditions, parameters: BillingParameterSnapshot): boolean {
return Object.entries(conditions).every(([key, condition]) => conditionMatchesValue(condition, parameters[key]));
}
function conditionMatchesValue(condition: BillingConditionValue, actual: BillingScalar | undefined): boolean {
if (actual === undefined) return false;
if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) {
if (Array.isArray(condition.values) && !condition.values.some((value) => scalarEquals(value, actual))) return false;
const numericActual = Number(actual);
if (condition.min !== undefined && (!Number.isFinite(numericActual) || numericActual < condition.min)) return false;
if (condition.max !== undefined && (!Number.isFinite(numericActual) || numericActual > condition.max)) return false;
return true;
}
if (typeof condition === "string" || typeof condition === "number" || typeof condition === "boolean") {
return scalarEquals(condition, actual);
}
return false;
}
function scalarEquals(left: BillingScalar, right: BillingScalar): boolean {
if (typeof left === "number" || typeof right === "number") return Number(left) === Number(right);
if (typeof left === "boolean" || typeof right === "boolean") return Boolean(left) === Boolean(right);
return normalizeTextParameter(left) === normalizeTextParameter(right);
}
function parseLegacyVariantKey(variantKey?: string): BillingRuleConditions {
if (!variantKey) return {};
const conditions: BillingRuleConditions = {};
for (const part of variantKey.split(/[;,]/)) {
const separator = part.indexOf("=");
if (separator <= 0) continue;
const key = part.slice(0, separator).trim();
const value = part.slice(separator + 1).trim();
if (!key || !value) continue;
if (["model", "resolution", "size", "aspectRatio", "ratio", "quality"].includes(key)) {
const normalized = normalizeTextParameter(value);
if (normalized !== undefined) conditions[key === "ratio" ? "aspectRatio" : key] = normalized;
continue;
}
if (["duration", "imageCount", "referenceImageCount", "scale"].includes(key)) {
const number = Number(value);
if (Number.isFinite(number)) conditions[key] = number;
}
}
return conditions;
}
function countReferenceImages(input: {
requestPayload: Record<string, unknown>;
input?: Record<string, unknown>;
providerPayload?: Record<string, unknown>;
providerInput?: Record<string, unknown>;
assembled?: Record<string, unknown>;
}): number {
const urls = [
...stringArray(input.input?.imageUrls),
...stringArray(input.providerPayload?.image_urls),
...stringArray(input.providerPayload?.imageUrls)
];
const materials = [
...(Array.isArray(input.input?.materials) ? input.input.materials : []),
...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : [])
];
const materialImages = materials.filter((item) => {
const record = recordValue(item);
return record?.type === "image" || typeof record?.url === "string";
}).length;
const messages = Array.isArray(input.providerInput?.messages) ? input.providerInput.messages : [];
const messageImages = messages.reduce((count, message) => {
const content = recordValue(message)?.content;
return count + (Array.isArray(content) ? content.filter((item) => Boolean(recordValue(item)?.image)).length : 0);
}, 0);
return Math.max(urls.length, materialImages, messageImages);
}
function countReferenceVideos(input: { input?: Record<string, unknown>; assembled?: Record<string, unknown> }): number {
const materials = [
...(Array.isArray(input.input?.materials) ? input.input.materials : []),
...(Array.isArray(input.assembled?.materials) ? input.assembled.materials : [])
];
return materials.filter((item) => recordValue(item)?.type === "video").length;
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
}
function setParameter(
target: BillingParameterSnapshot,
key: string,
value: unknown,
normalize: (value: unknown) => BillingScalar | undefined = normalizeScalarParameter
) {
const normalized = normalize(value);
if (normalized !== undefined) target[key] = normalized;
}
function firstValue(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null && value !== "");
}
function normalizeScalarParameter(value: unknown): BillingScalar | undefined {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
return undefined;
}
function normalizeTextParameter(value: unknown): string | undefined {
if (typeof value !== "string" && typeof value !== "number") return undefined;
const normalized = String(value).trim().toLowerCase().replace(/\s+/g, "");
return normalized || undefined;
}
function normalizeSizeParameter(value: unknown): string | undefined {
const normalized = normalizeTextParameter(value);
return normalized?.replace(/[×x]/g, "*");
}
function normalizeAspectRatioParameter(value: unknown, width?: number, height?: number): string | undefined {
const normalized = normalizeTextParameter(value);
if (normalized) return normalized;
if (width === undefined || height === undefined || height <= 0) return undefined;
const ratio = width / height;
const common = [[1, 1], [4, 3], [3, 2], [16, 9], [9, 16], [21, 9], [9, 21], [2, 3], [3, 4]] as const;
const match = common.find(([numerator, denominator]) => Math.abs(ratio - numerator / denominator) < 0.02);
return match ? `${match[0]}:${match[1]}` : ratio.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
}
function numberParameter(value: unknown): number | undefined {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
function booleanParameter(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (value === "true" || value === "1") return true;
if (value === "false" || value === "0") return false;
return undefined;
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function capabilityLabel(capability: GenerationCapability): string {
return capability === "video.generate" ? "视频生成" : "图片生成";
}
function optionalEnv(name: string): string | undefined {
const value = process.env[name]?.trim();
return value || undefined;
}
export { InsufficientBalanceError };