feat: add enhance pro points pricing rules
This commit is contained in:
parent
240b367acd
commit
b6e1a6c657
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user