From b6e1a6c65776ca99b48ba07baedf05ce0f4a3490 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 10 Apr 2026 14:05:23 +0800 Subject: [PATCH] feat: add enhance pro points pricing rules --- .../settings/admin-settings-client.tsx | 386 +++++++++++++++++- app/api/enhance/pro/config/route.ts | 11 +- app/api/enhance/pro/route.ts | 66 ++- components/canvas/EnhanceProWorkspace.tsx | 48 +++ lib/points/config.ts | 247 ++++++++++- 5 files changed, 730 insertions(+), 28 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 9651bac56..b5a1638a3 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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 @@ -7813,14 +7894,12 @@ export function AdminSettingsClient() { setSiteSettings({ ...siteSettings, pointsSystemConfig: { - ...siteSettings.pointsSystemConfig, + ...pointsSystemConfig, enabled: checked, }, }) @@ -7838,15 +7917,12 @@ export function AdminSettingsClient() { 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() { +
+
+
+ +
+ Krea 官方文档没有公开精确单价公式,建议按输出分辨率档位、候选数量和模型倍率计算本地积分。 +
+
+ + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + enhanceProPricing: { + ...pointsSystemConfig.enhanceProPricing, + enabled: checked, + }, + }, + }) + } + /> +
+ +
+
+
+ +
+ 固定单价适合先快速上线,分辨率档位更适合贴近增强成本。 +
+
+
+ +
+
+ +
+
+ +
+ 仅在“固定单价”模式下生效,后续仍会叠加候选数量和模型倍率。 +
+
+
+ + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + enhanceProPricing: { + ...pointsSystemConfig.enhanceProPricing, + fixedCost: Math.max( + 0, + Number.parseInt(e.target.value || "0", 10) || 0 + ), + }, + }, + }) + } + /> +
+
+
+ +
+
+ +
+ 开启后 batchSize 为 4 时,基础积分会乘以 4。 +
+
+ + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + enhanceProPricing: { + ...pointsSystemConfig.enhanceProPricing, + batchSizeMultiplierEnabled: checked, + }, + }, + }) + } + /> +
+ +
+
+ + +
+
+ {pointsSystemConfig.enhanceProPricing.resolutionBuckets.map( + (bucket, index) => ( +
+
+
+ + + updateEnhanceProPointsBucket(index, (item) => ({ + ...item, + name: e.target.value, + })) + } + placeholder="例如:4MP 以内" + /> +
+
+ + + updateEnhanceProPointsBucket(index, (item) => ({ + ...item, + maxMegapixels: Math.max( + 0.1, + Number.parseFloat(e.target.value || "0.1") || 0.1 + ), + })) + } + /> +
+
+ + + updateEnhanceProPointsBucket(index, (item) => ({ + ...item, + cost: Math.max( + 0, + Number.parseInt(e.target.value || "0", 10) || 0 + ), + })) + } + /> +
+
+ +
+
+
+ ) + )} +
+
+ +
+ +
+ {( + [ + "Redefine", + "Recovery", + "Recovery V2", + "Reimagine", + ] as const + ).map((modelKey) => ( +
+
+ + + setSiteSettings({ + ...siteSettings, + pointsSystemConfig: { + ...pointsSystemConfig, + enhanceProPricing: { + ...pointsSystemConfig.enhanceProPricing, + modelMultipliers: { + ...pointsSystemConfig.enhanceProPricing + .modelMultipliers, + [modelKey]: Math.max( + 0.1, + Number.parseFloat( + e.target.value || "1" + ) || 1 + ), + }, + }, + }, + }) + } + /> +
+
+ ))} +
+
+ +
+ 建议算法:按输出分辨率档位取基础积分,再乘以候选数量和模型倍率,最终向上取整。 +
+
diff --git a/app/api/enhance/pro/config/route.ts b/app/api/enhance/pro/config/route.ts index 8c3562344..fd2602061 100644 --- a/app/api/enhance/pro/config/route.ts +++ b/app/api/enhance/pro/config/route.ts @@ -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); diff --git a/app/api/enhance/pro/route.ts b/app/api/enhance/pro/route.ts index 90eeb48c7..aeef26628 100644 --- a/app/api/enhance/pro/route.ts +++ b/app/api/enhance/pro/route.ts @@ -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); diff --git a/components/canvas/EnhanceProWorkspace.tsx b/components/canvas/EnhanceProWorkspace.tsx index 27450a74f..9b1aeb600 100644 --- a/components/canvas/EnhanceProWorkspace.tsx +++ b/components/canvas/EnhanceProWorkspace.tsx @@ -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}
+ {pointsEstimate.chargeable ? ( +
+ 预计消耗 {pointsEstimate.cost} 积分 + {pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName + ? ` · ${pointsEstimate.bucketName}` + : ""} +
+ ) : null}