627 lines
24 KiB
TypeScript
627 lines
24 KiB
TypeScript
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 };
|