feat: add enhance pro points pricing rules
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-10 14:05:23 +08:00
parent 240b367acd
commit b6e1a6c657
5 changed files with 730 additions and 28 deletions

View File

@ -139,6 +139,7 @@ import {
defaultPointsSystemConfig,
normalizePointsPackages,
normalizePointsSystemConfig,
type EnhanceProPointsResolutionBucket,
type PointsPackage,
type PointsSystemConfig,
} from "@/lib/points/config";
@ -981,6 +982,10 @@ export function AdminSettingsClient() {
const [newOverrideUserId, setNewOverrideUserId] = useState("");
const [newOverrideEmail, setNewOverrideEmail] = useState("");
const [newOverridePlanId, setNewOverridePlanId] = useState("");
const pointsSystemConfig = useMemo(
() => normalizePointsSystemConfig(siteSettings.pointsSystemConfig),
[siteSettings.pointsSystemConfig]
);
useEffect(() => {
if (siteSettingsData) {
@ -2615,6 +2620,9 @@ export function AdminSettingsClient() {
const parsedPointsPackages = normalizePointsPackages(
siteSettings.pointsPackages
);
const parsedPointsSystemConfig = normalizePointsSystemConfig(
siteSettings.pointsSystemConfig
);
const payload = {
...siteSettings,
@ -2626,6 +2634,7 @@ export function AdminSettingsClient() {
},
membershipConfig: parsedMembershipConfig,
membershipUserOverrides: parsedMembershipOverrides,
pointsSystemConfig: parsedPointsSystemConfig,
pointsPackages: parsedPointsPackages,
};
@ -2741,6 +2750,78 @@ export function AdminSettingsClient() {
});
};
const updateEnhanceProPointsBucket = (
index: number,
updater: (item: EnhanceProPointsResolutionBucket) => EnhanceProPointsResolutionBucket
) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
const list = [...config.enhanceProPricing.resolutionBuckets];
if (!list[index]) return prev;
list[index] = updater(list[index]);
return {
...prev,
pointsSystemConfig: {
...config,
enhanceProPricing: {
...config.enhanceProPricing,
resolutionBuckets: list,
},
},
};
});
};
const addEnhanceProPointsBucket = () => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
const list = [...config.enhanceProPricing.resolutionBuckets];
const lastBucket = list[list.length - 1];
const nextMaxMegapixels = Math.min(
100,
Math.max(1, Number((Number(lastBucket?.maxMegapixels || 8) * 2).toFixed(2)))
);
list.push({
id: `enhance-pro-bucket-${Date.now()}`,
name: `${nextMaxMegapixels}MP 以内`,
maxMegapixels: nextMaxMegapixels,
cost: Math.max(1, Number(lastBucket?.cost || 45)),
});
return {
...prev,
pointsSystemConfig: {
...config,
enhanceProPricing: {
...config.enhanceProPricing,
resolutionBuckets: list,
},
},
};
});
};
const removeEnhanceProPointsBucket = (index: number) => {
setSiteSettings((prev) => {
const config = normalizePointsSystemConfig(prev.pointsSystemConfig);
const list = [...config.enhanceProPricing.resolutionBuckets];
if (list.length <= 1) {
toast.error("至少保留一个分辨率档位");
return prev;
}
list.splice(index, 1);
return {
...prev,
pointsSystemConfig: {
...config,
enhanceProPricing: {
...config.enhanceProPricing,
resolutionBuckets: list,
},
},
};
});
};
const updateMembershipConfigState = (
updater: (
config: ReturnType<typeof normalizeMembershipConfig>
@ -7813,14 +7894,12 @@ export function AdminSettingsClient() {
</div>
<Switch
id="pointsEnabled"
checked={
siteSettings.pointsSystemConfig?.enabled === true
}
checked={pointsSystemConfig.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
...pointsSystemConfig,
enabled: checked,
},
})
@ -7838,15 +7917,12 @@ export function AdminSettingsClient() {
</div>
<Switch
id="pointsBlockOnInsufficient"
checked={
siteSettings.pointsSystemConfig?.blockOnInsufficient !==
false
}
checked={pointsSystemConfig.blockOnInsufficient !== false}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
...pointsSystemConfig,
blockOnInsufficient: checked,
},
})
@ -7869,15 +7945,12 @@ export function AdminSettingsClient() {
id="points3dModelCost"
min={0}
type="number"
value={
siteSettings.pointsSystemConfig?.creditCostPer3DModel ??
0
}
value={pointsSystemConfig.creditCostPer3DModel}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
...pointsSystemConfig,
creditCostPer3DModel: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
@ -7902,15 +7975,12 @@ export function AdminSettingsClient() {
id="points3dWorldCost"
min={0}
type="number"
value={
siteSettings.pointsSystemConfig?.creditCostPer3DWorld ??
0
}
value={pointsSystemConfig.creditCostPer3DWorld}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...siteSettings.pointsSystemConfig,
...pointsSystemConfig,
creditCostPer3DWorld: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
@ -7922,6 +7992,284 @@ export function AdminSettingsClient() {
</div>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-sm font-medium">
图像增强 Pro 积分规则
</Label>
<div className="text-xs text-muted-foreground">
Krea 官方文档没有公开精确单价公式,建议按输出分辨率档位、候选数量和模型倍率计算本地积分。
</div>
</div>
<Switch
checked={pointsSystemConfig.enhanceProPricing.enabled === true}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
enabled: checked,
},
},
})
}
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label>计费模式</Label>
<div className="text-xs text-muted-foreground">
固定单价适合先快速上线,分辨率档位更适合贴近增强成本。
</div>
</div>
<div className="mt-2 w-full max-w-xs">
<Select
onValueChange={(value) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
chargeMode:
value === "fixed"
? "fixed"
: "resolution_bucket",
},
},
})
}
value={pointsSystemConfig.enhanceProPricing.chargeMode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="resolution_bucket">
分辨率档位
</SelectItem>
<SelectItem value="fixed">固定单价</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-lg border p-3">
<div className="space-y-1">
<Label htmlFor="enhanceProFixedCost">
固定模式单次基础积分
</Label>
<div className="text-xs text-muted-foreground">
仅在“固定单价”模式下生效,后续仍会叠加候选数量和模型倍率。
</div>
</div>
<div className="mt-2 w-full max-w-xs">
<Input
id="enhanceProFixedCost"
min={0}
type="number"
value={pointsSystemConfig.enhanceProPricing.fixedCost}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
fixedCost: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
},
},
})
}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label>按候选结果数量线性乘倍</Label>
<div className="text-xs text-muted-foreground">
开启后 batchSize 为 4 时,基础积分会乘以 4。
</div>
</div>
<Switch
checked={
pointsSystemConfig.enhanceProPricing
.batchSizeMultiplierEnabled !== false
}
onCheckedChange={(checked) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
batchSizeMultiplierEnabled: checked,
},
},
})
}
/>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>分辨率档位</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addEnhanceProPointsBucket}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
新增档位
</Button>
</div>
<div className="space-y-3">
{pointsSystemConfig.enhanceProPricing.resolutionBuckets.map(
(bucket, index) => (
<div
key={`${bucket.id}-${index}`}
className="rounded-lg border p-3"
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-8">
<div className="space-y-1 md:col-span-3">
<Label className="text-xs text-muted-foreground">
档位名称
</Label>
<Input
value={bucket.name}
onChange={(e) =>
updateEnhanceProPointsBucket(index, (item) => ({
...item,
name: e.target.value,
}))
}
placeholder="例如:4MP 以内"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label className="text-xs text-muted-foreground">
最大像素面积(MP)
</Label>
<Input
min={0.1}
step="0.1"
type="number"
value={bucket.maxMegapixels}
onChange={(e) =>
updateEnhanceProPointsBucket(index, (item) => ({
...item,
maxMegapixels: Math.max(
0.1,
Number.parseFloat(e.target.value || "0.1") || 0.1
),
}))
}
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label className="text-xs text-muted-foreground">
该档积分
</Label>
<Input
min={0}
type="number"
value={bucket.cost}
onChange={(e) =>
updateEnhanceProPointsBucket(index, (item) => ({
...item,
cost: Math.max(
0,
Number.parseInt(e.target.value || "0", 10) || 0
),
}))
}
/>
</div>
<div className="flex items-end md:col-span-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeEnhanceProPointsBucket(index)}
className="h-10 w-10"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
)
)}
</div>
</div>
<div className="space-y-2">
<Label>模型倍率</Label>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{(
[
"Redefine",
"Recovery",
"Recovery V2",
"Reimagine",
] as const
).map((modelKey) => (
<div key={modelKey} className="rounded-lg border p-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{modelKey}
</Label>
<Input
min={0.1}
step="0.05"
type="number"
value={
pointsSystemConfig.enhanceProPricing
.modelMultipliers[modelKey]
}
onChange={(e) =>
setSiteSettings({
...siteSettings,
pointsSystemConfig: {
...pointsSystemConfig,
enhanceProPricing: {
...pointsSystemConfig.enhanceProPricing,
modelMultipliers: {
...pointsSystemConfig.enhanceProPricing
.modelMultipliers,
[modelKey]: Math.max(
0.1,
Number.parseFloat(
e.target.value || "1"
) || 1
),
},
},
},
})
}
/>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">
建议算法:按输出分辨率档位取基础积分,再乘以候选数量和模型倍率,最终向上取整。
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>积分套餐</Label>

View File

@ -1,11 +1,12 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
import { getPointsSystemConfig, getSystemSetting } from "@/lib/db/queries";
import {
ENHANCE_PRO_KREA_SETTING_KEY,
normalizeEnhanceProKreaConfig,
sanitizeEnhanceProKreaConfigForClient,
} from "@/lib/enhance-pro/config";
import { normalizeEnhanceProPointsPricingConfig } from "@/lib/points/config";
export async function GET() {
try {
@ -16,10 +17,18 @@ export async function GET() {
const setting = await getSystemSetting({ key: ENHANCE_PRO_KREA_SETTING_KEY });
const config = normalizeEnhanceProKreaConfig(setting?.value);
const pointsConfig = await getPointsSystemConfig();
return NextResponse.json({
success: true,
config: sanitizeEnhanceProKreaConfigForClient(config),
pointsConfig: {
enabled: pointsConfig.enabled === true,
blockOnInsufficient: pointsConfig.blockOnInsufficient !== false,
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
pointsConfig.enhanceProPricing
),
},
});
} catch (error) {
console.error("[Enhance Pro Config] 获取配置失败:", error);

View File

@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import {
consumeUserPoints,
createMediaAssetRelation,
getPointsSystemConfig,
getMediaAssetsByUserAndUrls,
getSystemSetting,
getUserById,
upsertMediaAsset,
} from "@/lib/db/queries";
import { generatedImage } from "@/lib/db/schema";
@ -16,6 +19,7 @@ import {
type EnhanceProOutputFormat,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { calculateEnhanceProPointsEstimate } from "@/lib/points/config";
import { uploadFile } from "@/lib/storage/service";
const ENHANCE_PRO_CREATE_TIMEOUT_MS = 120_000;
@ -586,6 +590,41 @@ export async function POST(request: NextRequest) {
const batchSize = config.allowBatchSize
? clampInteger(body.batchSize, 1, config.maxBatchSize, 1)
: 1;
const selectedModel: EnhanceProKreaModel =
body.model === "Recovery" ||
body.model === "Recovery V2" ||
body.model === "Reimagine" ||
body.model === "Redefine"
? body.model
: config.defaultModel;
const pointsConfig = await getPointsSystemConfig();
const pointsEstimate = calculateEnhanceProPointsEstimate(pointsConfig, {
width: clampedDimensions.width,
height: clampedDimensions.height,
batchSize,
model: selectedModel,
});
if (pointsEstimate.chargeable && pointsConfig.blockOnInsufficient !== false) {
const userData = await getUserById({ id: session.user.id });
if (!userData?.isHighVip) {
const currentBalance = Math.max(0, Number(userData?.pointsBalance || 0));
if (currentBalance < pointsEstimate.cost) {
const message = `积分不足,当前余额 ${currentBalance},本次预计消耗 ${pointsEstimate.cost}。`;
return NextResponse.json(
{
error: message,
code: "insufficient_points",
message,
balance: currentBalance,
expectedCost: pointsEstimate.cost,
unitCost: pointsEstimate.cost,
},
{ status: 402 },
);
}
}
}
const startTime = Date.now();
const createdJob = await createEnhanceProJob({
@ -593,6 +632,7 @@ export async function POST(request: NextRequest) {
requestBody: {
...body,
batchSize,
model: selectedModel,
},
sourceImageUrl: sourceInput.sourceImageUrl,
width: clampedDimensions.width,
@ -608,6 +648,22 @@ export async function POST(request: NextRequest) {
);
}
if (pointsEstimate.chargeable) {
try {
await consumeUserPoints({
userId: session.user.id,
amount: pointsEstimate.cost,
reason: "enhance_pro_generation",
modelId: `${ENHANCE_PRO_MODEL_ID}:${selectedModel}`,
chatId,
messageId,
note: "图像增强 Pro 消耗积分",
});
} catch (pointsError) {
console.error("[Points] Failed to consume Enhance Pro points:", pointsError);
}
}
if (createdStatus === "completed") {
const responsePayload = await finalizeEnhanceProTaskResult({
userId: session.user.id,
@ -620,7 +676,10 @@ export async function POST(request: NextRequest) {
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json(responsePayload);
return NextResponse.json({
...responsePayload,
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
});
}
try {
@ -636,7 +695,10 @@ export async function POST(request: NextRequest) {
width: clampedDimensions.width,
height: clampedDimensions.height,
});
return NextResponse.json(responsePayload);
return NextResponse.json({
...responsePayload,
chargedPoints: pointsEstimate.chargeable ? pointsEstimate.cost : 0,
});
} catch (pollError) {
if (isEnhanceProTaskStillProcessing(pollError)) {
return buildEnhanceProTaskPendingResponse(taskId);

View File

@ -46,6 +46,11 @@ import {
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
calculateEnhanceProPointsEstimate,
defaultPointsSystemConfig,
type EnhanceProPointsPricingConfig,
} from "@/lib/points/config";
import { cn } from "@/lib/utils";
import {
type EnhanceProImageResult,
@ -68,6 +73,12 @@ type EnhanceProClientConfig = {
presets: EnhanceProPreset[];
};
type EnhanceProClientPointsConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
type FormState = {
prompt: string;
width: number;
@ -103,6 +114,12 @@ const FALLBACK_CONFIG: EnhanceProClientConfig = {
presets: defaultEnhanceProPresets(),
};
const FALLBACK_POINTS_CONFIG: EnhanceProClientPointsConfig = {
enabled: false,
blockOnInsufficient: true,
enhanceProPricing: defaultPointsSystemConfig.enhanceProPricing,
};
const PANEL_SHELL_CLASS =
"pointer-events-auto rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_20px_40px_rgba(0,0,0,0.06)]";
const SOLID_PANEL_FIELD_CLASS =
@ -383,12 +400,17 @@ export function EnhanceProWorkspace({
const { data: configPayload, isLoading } = useSWR<{
success?: boolean;
config?: EnhanceProClientConfig;
pointsConfig?: EnhanceProClientPointsConfig;
}>("/api/enhance/pro/config", fetcher);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config]
);
const pointsConfig = useMemo(
() => configPayload?.pointsConfig || FALLBACK_POINTS_CONFIG,
[configPayload?.pointsConfig]
);
const presets = useMemo(
() => (config.presets.length ? config.presets : FALLBACK_CONFIG.presets),
[config.presets]
@ -412,6 +434,24 @@ export function EnhanceProWorkspace({
: generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布";
const pointsEstimate = useMemo(
() =>
calculateEnhanceProPointsEstimate(
{
...defaultPointsSystemConfig,
enabled: pointsConfig.enabled,
blockOnInsufficient: pointsConfig.blockOnInsufficient,
enhanceProPricing: pointsConfig.enhanceProPricing,
},
{
width: formState.width,
height: formState.height,
batchSize: formState.batchSize,
model: formState.model,
}
),
[formState.batchSize, formState.height, formState.model, formState.width, pointsConfig]
);
useEffect(() => {
const imageSource = String(sourceImageUrl || "").trim();
@ -782,6 +822,14 @@ export function EnhanceProWorkspace({
{actionHint}
</span>
</div>
{pointsEstimate.chargeable ? (
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#111111] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
预计消耗 {pointsEstimate.cost} 积分
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
? ` · ${pointsEstimate.bucketName}`
: ""}
</div>
) : null}
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-[32px] border border-[#e5e5e5] bg-white p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_18px_38px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<Button
className={cn(

View File

@ -1,11 +1,32 @@
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 = {
@ -22,6 +43,50 @@ export const defaultPointsSystemConfig: PointsSystemConfig = {
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[] = [
@ -43,6 +108,176 @@ export const defaultPointsPackages: PointsPackage[] = [
},
];
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;
@ -50,14 +285,10 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
const raw = value as Partial<PointsSystemConfig> & {
creditCostPer3D?: number;
};
const normalizeCost = (input: unknown) => {
const parsedValue = Number(input);
return Number.isFinite(parsedValue) && parsedValue > 0
? Math.min(100000, Math.round(parsedValue))
: 0;
creditCostPerEnhancePro?: number;
};
const legacy3DCost = normalizeCost(raw.creditCostPer3D);
const legacyEnhanceProCost = normalizeCost(raw.creditCostPerEnhancePro);
return {
enabled: raw.enabled === true,
blockOnInsufficient: raw.blockOnInsufficient !== false,
@ -67,6 +298,10 @@ export function normalizePointsSystemConfig(value: unknown): PointsSystemConfig
creditCostPer3DWorld: normalizeCost(
raw.creditCostPer3DWorld ?? legacy3DCost
),
enhanceProPricing: normalizeEnhanceProPointsPricingConfig(
raw.enhanceProPricing,
legacyEnhanceProCost
),
};
}