ai-chatbot/lib/points/config.ts
Your Name b6e1a6c657
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add enhance pro points pricing rules
2026-04-10 14:05:23 +08:00

340 lines
9.8 KiB
TypeScript

import type { EnhanceProKreaModel } from "@/lib/enhance-pro/config";
export const POINTS_SYSTEM_CONFIG_KEY = "points_system_config";
export const POINTS_PACKAGES_KEY = "points_packages";
export type EnhanceProPointsChargeMode = "fixed" | "resolution_bucket";
export type EnhanceProPointsResolutionBucket = {
id: string;
name: string;
maxMegapixels: number;
cost: number;
};
export type EnhanceProPointsPricingConfig = {
enabled: boolean;
chargeMode: EnhanceProPointsChargeMode;
fixedCost: number;
batchSizeMultiplierEnabled: boolean;
resolutionBuckets: EnhanceProPointsResolutionBucket[];
modelMultipliers: Record<EnhanceProKreaModel, number>;
};
export type PointsSystemConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
creditCostPer3DModel: number;
creditCostPer3DWorld: number;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
export type PointsPackage = {
id: string;
name: string;
points: number;
price: number;
currency: string;
isEnabled: boolean;
};
export const defaultPointsSystemConfig: PointsSystemConfig = {
enabled: false,
blockOnInsufficient: true,
creditCostPer3DModel: 0,
creditCostPer3DWorld: 0,
enhanceProPricing: {
enabled: false,
chargeMode: "resolution_bucket",
fixedCost: 45,
batchSizeMultiplierEnabled: true,
resolutionBuckets: [
{
id: "enhance-pro-1mp",
name: "1MP 以内",
maxMegapixels: 1,
cost: 20,
},
{
id: "enhance-pro-2mp",
name: "2MP 以内",
maxMegapixels: 2,
cost: 30,
},
{
id: "enhance-pro-4mp",
name: "4MP 以内",
maxMegapixels: 4,
cost: 45,
},
{
id: "enhance-pro-8mp",
name: "8MP 以内",
maxMegapixels: 8,
cost: 65,
},
{
id: "enhance-pro-16mp",
name: "16MP 以内",
maxMegapixels: 16,
cost: 90,
},
],
modelMultipliers: {
Redefine: 1,
Recovery: 1,
"Recovery V2": 1.1,
Reimagine: 1.25,
},
},
};
export const defaultPointsPackages: PointsPackage[] = [
{
id: "starter-1000",
name: "入门包",
points: 1000,
price: 9.9,
currency: "CNY",
isEnabled: true,
},
{
id: "pro-5000",
name: "专业包",
points: 5000,
price: 39.9,
currency: "CNY",
isEnabled: true,
},
];
function normalizeCost(input: unknown) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100000, Math.round(parsedValue))
: 0;
}
function normalizeMultiplier(input: unknown, fallback: number) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(10, Math.max(0.1, Number(parsedValue.toFixed(2))))
: fallback;
}
function normalizeMegapixels(input: unknown, fallback: number) {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100, Math.max(0.1, Number(parsedValue.toFixed(2))))
: fallback;
}
function normalizeEnhanceProResolutionBucket(
value: unknown,
index: number,
fallback: EnhanceProPointsResolutionBucket
): EnhanceProPointsResolutionBucket | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return fallback;
}
const raw = value as Partial<EnhanceProPointsResolutionBucket>;
const id = String(raw.id || fallback.id || `enhance-pro-bucket-${index + 1}`).trim();
const name = String(raw.name || fallback.name || `档位 ${index + 1}`).trim();
const maxMegapixels = normalizeMegapixels(
raw.maxMegapixels,
fallback.maxMegapixels
);
const cost = normalizeCost(raw.cost ?? fallback.cost);
if (!id || !name || cost <= 0) {
return null;
}
return {
id: id.slice(0, 64),
name: name.slice(0, 64),
maxMegapixels,
cost,
};
}
export function normalizeEnhanceProPointsPricingConfig(
value: unknown,
legacyFixedCost?: number
): EnhanceProPointsPricingConfig {
const defaults = defaultPointsSystemConfig.enhanceProPricing;
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<EnhanceProPointsPricingConfig>)
: {};
const resolutionBuckets = Array.isArray(raw.resolutionBuckets)
? raw.resolutionBuckets
.map((item, index) =>
normalizeEnhanceProResolutionBucket(
item,
index,
defaults.resolutionBuckets[index] || defaults.resolutionBuckets[defaults.resolutionBuckets.length - 1]
)
)
.filter((item): item is EnhanceProPointsResolutionBucket => Boolean(item))
.sort((left, right) => left.maxMegapixels - right.maxMegapixels)
: defaults.resolutionBuckets;
const modelMultipliersRaw =
raw.modelMultipliers && typeof raw.modelMultipliers === "object"
? raw.modelMultipliers
: defaults.modelMultipliers;
return {
enabled: raw.enabled === true,
chargeMode: raw.chargeMode === "fixed" ? "fixed" : "resolution_bucket",
fixedCost: normalizeCost(raw.fixedCost ?? legacyFixedCost ?? defaults.fixedCost),
batchSizeMultiplierEnabled: raw.batchSizeMultiplierEnabled !== false,
resolutionBuckets:
resolutionBuckets.length > 0 ? resolutionBuckets : defaults.resolutionBuckets,
modelMultipliers: {
Redefine: normalizeMultiplier(
modelMultipliersRaw?.Redefine,
defaults.modelMultipliers.Redefine
),
Recovery: normalizeMultiplier(
modelMultipliersRaw?.Recovery,
defaults.modelMultipliers.Recovery
),
"Recovery V2": normalizeMultiplier(
modelMultipliersRaw?.["Recovery V2"],
defaults.modelMultipliers["Recovery V2"]
),
Reimagine: normalizeMultiplier(
modelMultipliersRaw?.Reimagine,
defaults.modelMultipliers.Reimagine
),
},
};
}
export function getEnhanceProResolutionMegapixels(
width: number,
height: number
) {
const safeWidth = Math.max(1, Math.round(Number(width) || 1));
const safeHeight = Math.max(1, Math.round(Number(height) || 1));
return Number(((safeWidth * safeHeight) / 1_048_576).toFixed(2));
}
export function calculateEnhanceProPointsEstimate(
pointsConfig: PointsSystemConfig,
params: {
width: number;
height: number;
batchSize?: number;
model?: EnhanceProKreaModel;
}
) {
const pricing = normalizeEnhanceProPointsPricingConfig(
pointsConfig.enhanceProPricing
);
const model: EnhanceProKreaModel =
params.model === "Recovery" ||
params.model === "Recovery V2" ||
params.model === "Reimagine" ||
params.model === "Redefine"
? params.model
: "Redefine";
const megapixels = getEnhanceProResolutionMegapixels(params.width, params.height);
const batchSize = Math.max(1, Math.round(Number(params.batchSize) || 1));
const batchMultiplier = pricing.batchSizeMultiplierEnabled ? batchSize : 1;
const modelMultiplier =
pricing.modelMultipliers[model] || defaultPointsSystemConfig.enhanceProPricing.modelMultipliers[model];
const bucket =
pricing.chargeMode === "resolution_bucket"
? pricing.resolutionBuckets.find((item) => megapixels <= item.maxMegapixels) ||
pricing.resolutionBuckets[pricing.resolutionBuckets.length - 1] ||
null
: null;
const baseCost =
pricing.chargeMode === "fixed"
? pricing.fixedCost
: Math.max(0, Number(bucket?.cost || 0));
const rawCost = baseCost * batchMultiplier * modelMultiplier;
const cost = baseCost > 0 ? Math.max(1, Math.ceil(rawCost)) : 0;
const enabled = pointsConfig.enabled === true && pricing.enabled === true;
return {
enabled,
chargeable: enabled && cost > 0,
chargeMode: pricing.chargeMode,
megapixels,
batchSize,
batchMultiplier,
model,
modelMultiplier,
baseCost,
cost,
bucketId: bucket?.id || null,
bucketName: bucket?.name || null,
};
}
export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig {
if (!value || typeof value !== "object") {
return defaultPointsSystemConfig;
}
const raw = value as Partial<PointsSystemConfig> & {
creditCostPer3D?: number;
creditCostPerEnhancePro?: number;
};
const legacy3DCost = normalizeCost(raw.creditCostPer3D);
const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro);
return {
enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false,
creditCostPer3DModel: normalizeCost(
raw.creditCostPer3DModel ?? legacy3DCost
),
creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing,
legacyEnhanceProCost
),
};
}
export function normalizePointsPackages(value: unknown): PointsPackage[] {
if (!Array.isArray(value)) {
return defaultPointsPackages;
}
const normalized = value
.map((item, index) => {
if (!item || typeof item !== "object") return null;
const raw = item as Partial<PointsPackage>;
const id = String(raw.id || `package-${index + 1}`).trim();
const name = String(raw.name || "").trim();
const points = Number(raw.points);
const price = Number(raw.price);
const currency = String(raw.currency || "CNY").trim().toUpperCase();
if (!id || !name || !Number.isFinite(points) || points <= 0) return null;
if (!Number.isFinite(price) || price < 0) return null;
return {
id: id.slice(0, 64),
name: name.slice(0, 64),
points: Math.round(points),
price: Number(price.toFixed(2)),
currency: currency.slice(0, 8) || "CNY",
isEnabled: raw.isEnabled !== false,
} satisfies PointsPackage;
})
.filter((item): item is PointsPackage => Boolean(item))
.slice(0, 30);
return normalized.length > 0 ? normalized : defaultPointsPackages;
}