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),
};
}