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,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 };
|
||||
Reference in new issue
Block a user