From 434d4d0d8dcbc374eee78da1b0dcafd2dae0d675 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 8 May 2026 11:21:15 +0800 Subject: [PATCH] fix: add 3d model feature visibility and refunds --- .../settings/admin-settings-client.tsx | 63 ++++++++- app/api/canvas/3d/tasks/[taskId]/route.ts | 115 +++++++++++++++++ app/api/canvas/3d/tasks/route.ts | 36 +++++- app/api/user/account-center/route.ts | 2 + app/canvas/[id]/model3d/page.tsx | 122 ++++++++++-------- lib/model3d/model-alias-config.ts | 41 ++++++ 6 files changed, 323 insertions(+), 56 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 00b36a4a9..5285f4d87 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -17386,7 +17386,8 @@ export function AdminSettingsClient() {
3D / 世界模型显示名映射 - 配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。 + 配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D + 生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
+
+
+
+ 3D 生成附加选项显隐 +
+
+ 关闭后,用户侧 3D 生成面板会隐藏对应开关;服务端也会忽略该附加选项,避免隐藏功能继续产生额外扣分。 +
+
+
+ {( + [ + { + key: "pbr", + label: "PBR 材质", + description: "控制 3D 模型生成里的 PBR 材质开关。", + }, + { + key: "quad", + label: "四边面优化", + description: "控制 3D 模型生成里的四边面优化开关。", + }, + { + key: "smartLowPoly", + label: "智能低模", + description: "控制 3D 模型生成里的智能低模开关。", + }, + ] as const + ).map((item) => ( +
+
+
{item.label}
+
+ {item.description} +
+
+ + setThreeDModelAliasConfig((prev) => ({ + ...prev, + featureVisibility: { + ...prev.featureVisibility, + [item.key]: checked, + }, + })) + } + /> +
+ ))} +
+
+
diff --git a/app/api/canvas/3d/tasks/[taskId]/route.ts b/app/api/canvas/3d/tasks/[taskId]/route.ts index 07e65d98b..24efce538 100644 --- a/app/api/canvas/3d/tasks/[taskId]/route.ts +++ b/app/api/canvas/3d/tasks/[taskId]/route.ts @@ -1,5 +1,9 @@ import { NextResponse } from "next/server"; +import { and, eq, sql } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; +import { db } from "@/lib/db"; +import { refundUserPoints } from "@/lib/db/queries"; +import { pointsLedger } from "@/lib/db/schema"; import { TripoApiError, pickTaskModelUrl, @@ -216,7 +220,87 @@ interface RouteParams { params: Promise<{ taskId: string }>; } +function isFailedTripoStatus(status?: string | null) { + const normalized = String(status || "").trim().toLowerCase(); + return ( + normalized === "failed" || + normalized === "error" || + normalized === "cancelled" || + normalized === "canceled" + ); +} + +function getModel3DTaskRefundNote(taskId: string) { + return `3D模型任务失败退回积分(${taskId})`; +} + +async function refundModel3DPointsForFailedTask(params: { + userId: string; + taskId: string; + errorMessage?: string | null; +}) { + const chargeLedgers = await db + .select({ + id: pointsLedger.id, + amount: pointsLedger.amount, + modelId: pointsLedger.modelId, + }) + .from(pointsLedger) + .where( + and( + eq(pointsLedger.userId, params.userId), + eq(pointsLedger.reason, "model3d_task"), + sql`${pointsLedger.note} LIKE ${`3D模型任务消耗积分(% / ${params.taskId})`}`, + ), + ) + .limit(1); + const chargeLedger = chargeLedgers[0]; + const refundAmount = Math.abs(Number(chargeLedger?.amount || 0)); + + if (!chargeLedger || refundAmount <= 0) { + return 0; + } + + const refundNote = getModel3DTaskRefundNote(params.taskId); + const existingRefund = await db + .select({ id: pointsLedger.id }) + .from(pointsLedger) + .where( + and( + eq(pointsLedger.userId, params.userId), + eq(pointsLedger.reason, "model3d_task_refund"), + eq(pointsLedger.note, refundNote), + ), + ) + .limit(1); + + if (existingRefund[0]) { + return refundAmount; + } + + const refundResult = await refundUserPoints({ + userId: params.userId, + amount: refundAmount, + reason: "model3d_task_refund", + modelId: chargeLedger.modelId || "tripo-3d", + note: refundNote, + }); + + if (!refundResult.ok) { + console.error("[Points] Failed to refund failed 3D model task:", { + taskId: params.taskId, + userId: params.userId, + errorMessage: params.errorMessage, + }); + return 0; + } + + return refundAmount; +} + export async function GET(request: Request, { params }: RouteParams) { + let refundContext: { userId: string; taskId: string } | null = null; + try { const session = await auth(); if (!session?.user?.id) { @@ -228,6 +312,7 @@ export async function GET(request: Request, { params }: RouteParams) { if (!normalizedTaskId) { return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); } + refundContext = { userId: session.user.id, taskId: normalizedTaskId }; const { searchParams } = new URL(request.url); const preferredProviderId = searchParams.get("providerId"); @@ -241,6 +326,18 @@ export async function GET(request: Request, { params }: RouteParams) { const output = result.data?.output && typeof result.data.output === "object" ? result.data.output : {}; + const taskStatus = String(result.data.status || ""); + const errorMessage = String(result.data.error_msg || "").trim(); + const refundedPoints = isFailedTripoStatus(taskStatus) + ? await refundModel3DPointsForFailedTask({ + userId: session.user.id, + taskId: normalizedTaskId, + errorMessage, + }).catch((refundError) => { + console.error("[Points] Failed to handle 3D task refund:", refundError); + return 0; + }) + : 0; const modelUrl = pickTaskModelUrl(output as Record); const previewImageUrl = pickTaskPreviewImageUrl(output as Record); const stableModelUrl = await archiveTripoModelIfNeeded({ @@ -302,11 +399,28 @@ export async function GET(request: Request, { params }: RouteParams) { }, errorCode: result.data.error_code ?? null, errorMessage: result.data.error_msg ?? null, + refundedPoints, }, traceId: result.traceId, }); } catch (error) { if (error instanceof TripoApiError) { + const shouldRefundMissingTask = + error.code === 2001 || /task\s+is\s+not\s+found/i.test(error.message); + const refundedPoints = + shouldRefundMissingTask && refundContext + ? await refundModel3DPointsForFailedTask({ + ...refundContext, + errorMessage: error.message, + }).catch((refundError) => { + console.error( + "[Points] Failed to refund missing 3D task:", + refundError + ); + return 0; + }) + : 0; + return NextResponse.json( { success: false, @@ -314,6 +428,7 @@ export async function GET(request: Request, { params }: RouteParams) { code: error.code, suggestion: error.suggestion, traceId: error.traceId, + refundedPoints, }, { status: error.httpStatus || 502 } ); diff --git a/app/api/canvas/3d/tasks/route.ts b/app/api/canvas/3d/tasks/route.ts index e7eec087e..53be57204 100644 --- a/app/api/canvas/3d/tasks/route.ts +++ b/app/api/canvas/3d/tasks/route.ts @@ -336,14 +336,42 @@ export async function POST(request: Request) { animateInPlace: "animate_in_place", }); + const featureVisibility = modelAliasConfig.featureVisibility; + const effectivePbr = featureVisibility.pbr !== false && body.pbr === true; + const effectiveQuad = featureVisibility.quad !== false && body.quad === true; + const effectiveSmartLowPoly = + featureVisibility.smartLowPoly !== false && body.smartLowPoly === true; + + if (featureVisibility.pbr === false) { + delete payload.pbr; + } + if (featureVisibility.quad === false) { + delete payload.quad; + } + if (featureVisibility.smartLowPoly === false) { + delete payload.smart_low_poly; + } + if ( taskType === "text_to_model" || taskType === "image_to_model" || taskType === "multiview_to_model" ) { + const enabledTripoModels = modelAliasConfig.tripoModels.filter( + (entry) => entry.enabled !== false, + ); + if (enabledTripoModels.length === 0) { + return NextResponse.json( + { + success: false, + error: "当前没有启用的 3D 生成模型", + }, + { status: 400 }, + ); + } payload.model_version = resolveThreeDModelRealId( readOptionalTrimmedText(body.modelVersion), - modelAliasConfig.tripoModels, + enabledTripoModels, ); } @@ -354,9 +382,9 @@ export async function POST(request: Request) { const pointsConfig = await getPointsSystemConfig(); const unitCost = get3DTaskCreditCost(pointsConfig, { taskType, - pbr: body.pbr, - quad: body.quad, - smartLowPoly: body.smartLowPoly, + pbr: effectivePbr, + quad: effectiveQuad, + smartLowPoly: effectiveSmartLowPoly, }); const pointsCharge = await prepareFeaturePointsCharge({ userId: session.user.id, diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index 2997c5e0a..016d7db6c 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -23,6 +23,8 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides"; function mapLedgerReasonLabel(reason: string) { if (reason === "image_generation") return "图片生成"; if (reason === "model3d_generation") return "3D模型生成"; + if (reason === "model3d_task") return "3D模型任务"; + if (reason === "model3d_task_refund") return "3D模型任务失败退回"; if (reason === "world3d_generation") return "3D世界生成"; if (reason === "video_generation") return "视频生成"; if (reason === "enhance_pro_generation") return "图像增强 Pro"; diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index d34c32fea..60f6ee37f 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -824,6 +824,9 @@ export default function CanvasModel3DPage() { const isHighVip = membershipSummary?.isHighVip === true; const publicModelAliasConfig = modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; + const modelFeatureVisibility = + publicModelAliasConfig.featureVisibility || + DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility; const selectedBatchImageCount = useMemo( () => batchImageSlots.filter((slot) => Boolean(slot.file)).length, [batchImageSlots] @@ -833,11 +836,21 @@ export default function CanvasModel3DPage() { () => get3DTaskCreditCost(pointsSystemConfig, { taskType: currentModelTaskType, - pbr, - quad, - smartLowPoly, + pbr: modelFeatureVisibility.pbr !== false && pbr, + quad: modelFeatureVisibility.quad !== false && quad, + smartLowPoly: + modelFeatureVisibility.smartLowPoly !== false && smartLowPoly, }), - [currentModelTaskType, pbr, pointsSystemConfig, quad, smartLowPoly] + [ + currentModelTaskType, + modelFeatureVisibility.pbr, + modelFeatureVisibility.quad, + modelFeatureVisibility.smartLowPoly, + pbr, + pointsSystemConfig, + quad, + smartLowPoly, + ] ); const currentModelEstimatedCost = mode === "batch" && selectedBatchImageCount > 0 @@ -941,23 +954,17 @@ export default function CanvasModel3DPage() { siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo; const tripoModelOptions = useMemo( () => { - const enabledEntries = publicModelAliasConfig.tripoModels.filter( + return publicModelAliasConfig.tripoModels.filter( (item) => item.enabled !== false, ); - return enabledEntries.length > 0 - ? enabledEntries - : publicModelAliasConfig.tripoModels; }, [publicModelAliasConfig.tripoModels], ); const worldModelOptions = useMemo( () => { - const enabledEntries = publicModelAliasConfig.worldModels.filter( + return publicModelAliasConfig.worldModels.filter( (item) => item.enabled !== false, ); - return enabledEntries.length > 0 - ? enabledEntries - : publicModelAliasConfig.worldModels; }, [publicModelAliasConfig.worldModels], ); @@ -1529,11 +1536,12 @@ export default function CanvasModel3DPage() { modelVersion, faceLimit, texture, - pbr, + pbr: modelFeatureVisibility.pbr !== false && pbr, autoSize, - quad, + quad: modelFeatureVisibility.quad !== false && quad, generateParts, - smartLowPoly, + smartLowPoly: + modelFeatureVisibility.smartLowPoly !== false && smartLowPoly, geometryQuality, textureQuality, modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined, @@ -3028,47 +3036,57 @@ export default function CanvasModel3DPage() {
-
-
- - PBR 材质 - - - + {modelFeatureVisibility.pbr !== false || + modelFeatureVisibility.quad !== false || + modelFeatureVisibility.smartLowPoly !== false ? ( +
+ {modelFeatureVisibility.pbr !== false ? ( +
+ + PBR 材质 + + + +
+ ) : null} + {modelFeatureVisibility.quad !== false ? ( +
+ + 四边面优化 + + + +
+ ) : null} + {modelFeatureVisibility.smartLowPoly !== false ? ( +
+ + 智能低模 + + + +
+ ) : null}
-
- - 四边面优化 - - - -
-
- - 智能低模 - - - -
-
+ ) : null}
diff --git a/lib/model3d/model-alias-config.ts b/lib/model3d/model-alias-config.ts index 05a88740a..bb7b1e525 100644 --- a/lib/model3d/model-alias-config.ts +++ b/lib/model3d/model-alias-config.ts @@ -9,9 +9,16 @@ export type ThreeDModelAliasEntry = { isDefault: boolean; }; +export type ThreeDModelFeatureVisibilityConfig = { + pbr: boolean; + quad: boolean; + smartLowPoly: boolean; +}; + export type ThreeDModelAliasConfig = { tripoModels: ThreeDModelAliasEntry[]; worldModels: ThreeDModelAliasEntry[]; + featureVisibility: ThreeDModelFeatureVisibilityConfig; }; export type PublicThreeDModelAliasEntry = Omit< @@ -24,6 +31,7 @@ export type PublicThreeDModelAliasEntry = Omit< export type PublicThreeDModelAliasConfig = { tripoModels: PublicThreeDModelAliasEntry[]; worldModels: PublicThreeDModelAliasEntry[]; + featureVisibility: ThreeDModelFeatureVisibilityConfig; }; const DEFAULT_TRIPO_MODEL_ALIASES: ThreeDModelAliasEntry[] = [ @@ -112,6 +120,12 @@ const DEFAULT_WORLD_MODEL_ALIASES: ThreeDModelAliasEntry[] = [ }, ]; +export const DEFAULT_THREED_MODEL_FEATURE_VISIBILITY: ThreeDModelFeatureVisibilityConfig = { + pbr: true, + quad: true, + smartLowPoly: true, +}; + function sanitizeText(value: unknown) { return String(value || "").trim(); } @@ -196,10 +210,35 @@ function normalizeEntryList( ); } +function normalizeFeatureVisibility( + value: unknown, +): ThreeDModelFeatureVisibilityConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Partial) + : {}; + + return { + pbr: + typeof raw.pbr === "boolean" + ? raw.pbr + : DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.pbr, + quad: + typeof raw.quad === "boolean" + ? raw.quad + : DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.quad, + smartLowPoly: + typeof raw.smartLowPoly === "boolean" + ? raw.smartLowPoly + : DEFAULT_THREED_MODEL_FEATURE_VISIBILITY.smartLowPoly, + }; +} + export function defaultThreeDModelAliasConfig(): ThreeDModelAliasConfig { return { tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES), worldModels: cloneEntries(DEFAULT_WORLD_MODEL_ALIASES), + featureVisibility: { ...DEFAULT_THREED_MODEL_FEATURE_VISIBILITY }, }; } @@ -222,6 +261,7 @@ export function normalizeThreeDModelAliasConfig( DEFAULT_WORLD_MODEL_ALIASES, "world-model", ), + featureVisibility: normalizeFeatureVisibility(raw.featureVisibility), }; } @@ -236,6 +276,7 @@ export function toPublicThreeDModelAliasConfig( ...entry, billingModelId: realModelId, })), + featureVisibility: normalizeFeatureVisibility(config.featureVisibility), }; }