Files
NianAIGC/lib/server/billing-service.ts
2026-08-17 23:44:14 +08:00

627 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()) 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 };