Files
NianAIGC/lib/billing.ts
T
2026-10-12 13:57:23 +08:00

149 lines
6.4 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 type {
BillingConditionValue,
BillingRuleConditions,
BillingParameterSnapshot,
BillingPriceRule,
BillingQuote,
BillingSelectedParameterTier,
BillingUnit,
GenerationCapability,
GenerationProvider
} from "@/lib/types";
export const BILLING_CURRENCY = "CNY" as const;
export const BILLING_UNIT_OPTIONS: Array<{ value: BillingUnit; label: string }> = [
{ value: "request", label: "按次" },
{ value: "image", label: "按张" },
{ value: "video_second", label: "按秒" }
];
export function calculateBillingAmountFen(standardUnitPriceFen: number, quantity: number, markupMultiplier: number): number {
if (!Number.isFinite(standardUnitPriceFen) || standardUnitPriceFen < 0) throw new Error("标准单价必须是非负金额。");
if (!Number.isFinite(quantity) || quantity <= 0) throw new Error("计费数量必须大于 0。");
if (!Number.isFinite(markupMultiplier) || markupMultiplier < 1) throw new Error("上浮倍率不能低于 1.00。");
return Math.max(0, Math.ceil(standardUnitPriceFen * quantity * markupMultiplier));
}
export function quoteFromPriceRule(input: {
rule: BillingPriceRule;
provider: GenerationProvider;
capability: GenerationCapability;
reqKey: string;
quantity: number;
parameters?: BillingParameterSnapshot;
conditions?: BillingRuleConditions;
quantitySource?: BillingPriceRule["quantitySource"];
}): BillingQuote {
const { rule } = input;
if (rule.provider !== input.provider || rule.capability !== input.capability) {
throw new Error("计费规则与生成服务不匹配。");
}
const parameterPricing = resolveBillingParameterPricing(rule, input.parameters);
if (!parameterPricing) throw new Error("当前生成参数没有对应的平台标准价格。");
const standardUnitPriceFen = parameterPricing.standardUnitPriceFen;
const markupMultiplier = parameterPricing.markupMultiplier;
return {
priceRuleId: rule.id,
provider: input.provider,
capability: input.capability,
reqKey: input.reqKey,
variantKey: rule.variantKey,
unit: rule.unit,
quantity: input.quantity,
standardUnitPriceFen,
markupMultiplier,
amountFen: calculateBillingAmountFen(standardUnitPriceFen, input.quantity, markupMultiplier),
currency: BILLING_CURRENCY,
conditions: input.conditions ?? rule.conditions,
quantitySource: input.quantitySource ?? rule.quantitySource,
parameters: input.parameters,
baseStandardUnitPriceFen: rule.parameterDimensions?.length ? rule.standardUnitPriceFen : undefined,
parameterTiers: parameterPricing.parameterTiers.length ? parameterPricing.parameterTiers : undefined,
source: rule.source
};
}
export type BillingParameterPricing = {
standardUnitPriceFen: number;
markupMultiplier: number;
parameterTiers: BillingSelectedParameterTier[];
};
export function resolveBillingParameterPricing(rule: BillingPriceRule, parameters: BillingParameterSnapshot = {}): BillingParameterPricing | null {
const dimensions = rule.parameterDimensions?.filter((dimension) => dimension.tiers.length) || [];
if (!dimensions.length) {
return {
standardUnitPriceFen: rule.standardUnitPriceFen,
markupMultiplier: rule.markupMultiplier,
parameterTiers: []
};
}
let standardFactor = 1;
let markupMultiplier = rule.parameterDimensions?.length ? 1 : rule.markupMultiplier;
const parameterTiers: BillingSelectedParameterTier[] = [];
for (const dimension of dimensions) {
const actualValue = parameters[dimension.key] ?? dimension.defaultValue ?? dimension.baselineValue;
const tier = dimension.tiers.find((candidate) => candidate.enabled && billingConditionMatchesValue(candidate.match ?? candidate.value, actualValue));
if (!tier) return null;
const factor = Number(tier.standardFactor);
const tierMarkup = Number(tier.markupMultiplier);
if (!Number.isFinite(factor) || factor <= 0 || !Number.isFinite(tierMarkup) || tierMarkup < 1) return null;
standardFactor *= factor;
// A combination has one customer-facing uplift. The highest selected
// tier multiplier wins so editing one expensive tier never lowers the
// configured uplift of another selected tier.
markupMultiplier = Math.max(markupMultiplier, tierMarkup);
parameterTiers.push({
...tier,
dimensionKey: dimension.key,
dimensionLabel: dimension.label,
actualValue,
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * factor)
});
}
return {
standardUnitPriceFen: Math.ceil(rule.standardUnitPriceFen * standardFactor),
markupMultiplier,
parameterTiers
};
}
export function billingConditionMatchesValue(condition: BillingConditionValue, actual: string | number | boolean): boolean {
if (typeof condition === "object" && condition !== null && !Array.isArray(condition)) {
if (Array.isArray(condition.values) && !condition.values.some((value) => billingScalarEquals(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;
}
return billingScalarEquals(condition as string | number | boolean, actual);
}
function billingScalarEquals(left: string | number | boolean, right: string | number | boolean): 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 String(left).trim().toLowerCase() === String(right).trim().toLowerCase();
}
export function formatBillingAmount(fen: number, currency = BILLING_CURRENCY): string {
const amount = Number(fen || 0) / 100;
const formatted = new Intl.NumberFormat("zh-CN", {
style: "currency",
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(Math.abs(amount));
return amount < 0 ? `−${formatted}` : formatted;
}
export function billingUnitLabel(unit: BillingUnit): string {
return BILLING_UNIT_OPTIONS.find((item) => item.value === unit)?.label || unit;
}
export function billingQuantityLabel(quantity: number, unit: BillingUnit): string {
const value = Number.isInteger(quantity) ? String(quantity) : quantity.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
return `${value}${unit === "video_second" ? " 秒" : unit === "image" ? " 张" : " 次"}`;
}