fix: add 3d model feature visibility and refunds
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-05-08 11:21:15 +08:00
parent b64a512dfb
commit 434d4d0d8d
6 changed files with 323 additions and 56 deletions

View File

@ -17386,7 +17386,8 @@ export function AdminSettingsClient() {
<div className="space-y-1.5">
<CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle>
<CardDescription>
配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。
配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D
生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
</CardDescription>
</div>
<LocalAiSectionToggleButton
@ -17404,6 +17405,66 @@ export function AdminSettingsClient() {
前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。
</div>
<div className="space-y-3 rounded-xl border p-4">
<div className="space-y-1">
<div className="text-base font-medium">
3D 生成附加选项显隐
</div>
<div className="text-sm text-muted-foreground">
关闭后,用户侧 3D 生成面板会隐藏对应开关;服务端也会忽略该附加选项,避免隐藏功能继续产生额外扣分。
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{(
[
{
key: "pbr",
label: "PBR 材质",
description: "控制 3D 模型生成里的 PBR 材质开关。",
},
{
key: "quad",
label: "四边面优化",
description: "控制 3D 模型生成里的四边面优化开关。",
},
{
key: "smartLowPoly",
label: "智能低模",
description: "控制 3D 模型生成里的智能低模开关。",
},
] as const
).map((item) => (
<div
className="flex items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3"
key={item.key}
>
<div className="space-y-0.5">
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs text-muted-foreground">
{item.description}
</div>
</div>
<Switch
checked={
threeDModelAliasConfig.featureVisibility?.[
item.key
] !== false
}
onCheckedChange={(checked) =>
setThreeDModelAliasConfig((prev) => ({
...prev,
featureVisibility: {
...prev.featureVisibility,
[item.key]: checked,
},
}))
}
/>
</div>
))}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">

View File

@ -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<string, unknown>);
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
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 }
);

View File

@ -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,

View File

@ -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";

View File

@ -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() {
</SelectContent>
</Select>
</div>
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
PBR 材质
<CostPill label={pbrExtraCostLabel} />
</span>
<Switch
checked={pbr}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setPbr}
/>
{modelFeatureVisibility.pbr !== false ||
modelFeatureVisibility.quad !== false ||
modelFeatureVisibility.smartLowPoly !== false ? (
<div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
{modelFeatureVisibility.pbr !== false ? (
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
PBR 材质
<CostPill label={pbrExtraCostLabel} />
</span>
<Switch
checked={pbr}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setPbr}
/>
</div>
) : null}
{modelFeatureVisibility.quad !== false ? (
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
四边面优化
<CostPill label={quadExtraCostLabel} />
</span>
<Switch
checked={quad}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setQuad}
/>
</div>
) : null}
{modelFeatureVisibility.smartLowPoly !== false ? (
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
智能低模
<CostPill label={smartLowPolyExtraCostLabel} />
</span>
<Switch
checked={smartLowPoly}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setSmartLowPoly}
/>
</div>
) : null}
</div>
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
四边面优化
<CostPill label={quadExtraCostLabel} />
</span>
<Switch
checked={quad}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setQuad}
/>
</div>
<div className="flex items-center justify-between text-xs text-zinc-400">
<span className="flex items-center gap-2">
智能低模
<CostPill label={smartLowPolyExtraCostLabel} />
</span>
<Switch
checked={smartLowPoly}
className="data-[state=checked]:bg-yellow-500"
onCheckedChange={setSmartLowPoly}
/>
</div>
</div>
) : null}
</CollapsibleContent>
</Collapsible>
<Button
className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
disabled={isSubmitting}
disabled={isSubmitting || tripoModelOptions.length === 0}
onClick={handleGenerate}
size="lg"
>
@ -3079,7 +3097,9 @@ export default function CanvasModel3DPage() {
)}
{isSubmitting
? "生成中..."
: `开始生成 · ${modelFeatureCostLabel}`}
: tripoModelOptions.length === 0
? "暂无可用模型"
: `开始生成 · ${modelFeatureCostLabel}`}
</Button>
</div>
</>

View File

@ -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<ThreeDModelFeatureVisibilityConfig>)
: {};
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),
};
}