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"> <div className="space-y-1.5">
<CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle> <CardTitle className="text-xl">3D / 世界模型显示名映射</CardTitle>
<CardDescription> <CardDescription>
配置 Tripo3D 与 World Labs 的前台展示名称。用户侧只会看到这里配置的模型名,服务端会自动映射到真实模型 ID。 配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D
生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
</CardDescription> </CardDescription>
</div> </div>
<LocalAiSectionToggleButton <LocalAiSectionToggleButton
@ -17404,6 +17405,66 @@ export function AdminSettingsClient() {
前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。 前端提交任务时只会发送系统内部别名,真实模型 ID 只在服务端使用。你可以在这里自由改“展示名”和“真实模型 ID”的对应关系,避免用户看到上游模型名。
</div> </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="grid gap-6 xl:grid-cols-2">
<div className="space-y-4 rounded-xl border p-4"> <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"> <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 { NextResponse } from "next/server";
import { and, eq, sql } from "drizzle-orm";
import { auth } from "@/app/(auth)/auth"; import { auth } from "@/app/(auth)/auth";
import { db } from "@/lib/db";
import { refundUserPoints } from "@/lib/db/queries";
import { pointsLedger } from "@/lib/db/schema";
import { import {
TripoApiError, TripoApiError,
pickTaskModelUrl, pickTaskModelUrl,
@ -216,7 +220,87 @@ interface RouteParams {
params: Promise<{ taskId: string }>; 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) { export async function GET(request: Request, { params }: RouteParams) {
let refundContext: { userId: string; taskId: string } | null = null;
try { try {
const session = await auth(); const session = await auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@ -228,6 +312,7 @@ export async function GET(request: Request, { params }: RouteParams) {
if (!normalizedTaskId) { if (!normalizedTaskId) {
return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 }); return NextResponse.json({ success: false, error: "缺少 taskId" }, { status: 400 });
} }
refundContext = { userId: session.user.id, taskId: normalizedTaskId };
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const preferredProviderId = searchParams.get("providerId"); const preferredProviderId = searchParams.get("providerId");
@ -241,6 +326,18 @@ export async function GET(request: Request, { params }: RouteParams) {
const output = const output =
result.data?.output && typeof result.data.output === "object" ? result.data.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 modelUrl = pickTaskModelUrl(output as Record<string, unknown>);
const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>); const previewImageUrl = pickTaskPreviewImageUrl(output as Record<string, unknown>);
const stableModelUrl = await archiveTripoModelIfNeeded({ const stableModelUrl = await archiveTripoModelIfNeeded({
@ -302,11 +399,28 @@ export async function GET(request: Request, { params }: RouteParams) {
}, },
errorCode: result.data.error_code ?? null, errorCode: result.data.error_code ?? null,
errorMessage: result.data.error_msg ?? null, errorMessage: result.data.error_msg ?? null,
refundedPoints,
}, },
traceId: result.traceId, traceId: result.traceId,
}); });
} catch (error) { } catch (error) {
if (error instanceof TripoApiError) { 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( return NextResponse.json(
{ {
success: false, success: false,
@ -314,6 +428,7 @@ export async function GET(request: Request, { params }: RouteParams) {
code: error.code, code: error.code,
suggestion: error.suggestion, suggestion: error.suggestion,
traceId: error.traceId, traceId: error.traceId,
refundedPoints,
}, },
{ status: error.httpStatus || 502 } { status: error.httpStatus || 502 }
); );

View File

@ -336,14 +336,42 @@ export async function POST(request: Request) {
animateInPlace: "animate_in_place", 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 ( if (
taskType === "text_to_model" || taskType === "text_to_model" ||
taskType === "image_to_model" || taskType === "image_to_model" ||
taskType === "multiview_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( payload.model_version = resolveThreeDModelRealId(
readOptionalTrimmedText(body.modelVersion), readOptionalTrimmedText(body.modelVersion),
modelAliasConfig.tripoModels, enabledTripoModels,
); );
} }
@ -354,9 +382,9 @@ export async function POST(request: Request) {
const pointsConfig = await getPointsSystemConfig(); const pointsConfig = await getPointsSystemConfig();
const unitCost = get3DTaskCreditCost(pointsConfig, { const unitCost = get3DTaskCreditCost(pointsConfig, {
taskType, taskType,
pbr: body.pbr, pbr: effectivePbr,
quad: body.quad, quad: effectiveQuad,
smartLowPoly: body.smartLowPoly, smartLowPoly: effectiveSmartLowPoly,
}); });
const pointsCharge = await prepareFeaturePointsCharge({ const pointsCharge = await prepareFeaturePointsCharge({
userId: session.user.id, userId: session.user.id,

View File

@ -23,6 +23,8 @@ const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
function mapLedgerReasonLabel(reason: string) { function mapLedgerReasonLabel(reason: string) {
if (reason === "image_generation") return "图片生成"; if (reason === "image_generation") return "图片生成";
if (reason === "model3d_generation") return "3D模型生成"; 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 === "world3d_generation") return "3D世界生成";
if (reason === "video_generation") return "视频生成"; if (reason === "video_generation") return "视频生成";
if (reason === "enhance_pro_generation") return "图像增强 Pro"; if (reason === "enhance_pro_generation") return "图像增强 Pro";

View File

@ -824,6 +824,9 @@ export default function CanvasModel3DPage() {
const isHighVip = membershipSummary?.isHighVip === true; const isHighVip = membershipSummary?.isHighVip === true;
const publicModelAliasConfig = const publicModelAliasConfig =
modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG; modelAliasConfigResponse?.data || DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG;
const modelFeatureVisibility =
publicModelAliasConfig.featureVisibility ||
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.featureVisibility;
const selectedBatchImageCount = useMemo( const selectedBatchImageCount = useMemo(
() => batchImageSlots.filter((slot) => Boolean(slot.file)).length, () => batchImageSlots.filter((slot) => Boolean(slot.file)).length,
[batchImageSlots] [batchImageSlots]
@ -833,11 +836,21 @@ export default function CanvasModel3DPage() {
() => () =>
get3DTaskCreditCost(pointsSystemConfig, { get3DTaskCreditCost(pointsSystemConfig, {
taskType: currentModelTaskType, taskType: currentModelTaskType,
pbr, pbr: modelFeatureVisibility.pbr !== false && pbr,
quad, quad: modelFeatureVisibility.quad !== false && quad,
smartLowPoly, smartLowPoly:
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
}), }),
[currentModelTaskType, pbr, pointsSystemConfig, quad, smartLowPoly] [
currentModelTaskType,
modelFeatureVisibility.pbr,
modelFeatureVisibility.quad,
modelFeatureVisibility.smartLowPoly,
pbr,
pointsSystemConfig,
quad,
smartLowPoly,
]
); );
const currentModelEstimatedCost = const currentModelEstimatedCost =
mode === "batch" && selectedBatchImageCount > 0 mode === "batch" && selectedBatchImageCount > 0
@ -941,23 +954,17 @@ export default function CanvasModel3DPage() {
siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo; siteLogoDark && siteLogoDark.trim() !== "" ? siteLogoDark : siteLogo;
const tripoModelOptions = useMemo( const tripoModelOptions = useMemo(
() => { () => {
const enabledEntries = publicModelAliasConfig.tripoModels.filter( return publicModelAliasConfig.tripoModels.filter(
(item) => item.enabled !== false, (item) => item.enabled !== false,
); );
return enabledEntries.length > 0
? enabledEntries
: publicModelAliasConfig.tripoModels;
}, },
[publicModelAliasConfig.tripoModels], [publicModelAliasConfig.tripoModels],
); );
const worldModelOptions = useMemo( const worldModelOptions = useMemo(
() => { () => {
const enabledEntries = publicModelAliasConfig.worldModels.filter( return publicModelAliasConfig.worldModels.filter(
(item) => item.enabled !== false, (item) => item.enabled !== false,
); );
return enabledEntries.length > 0
? enabledEntries
: publicModelAliasConfig.worldModels;
}, },
[publicModelAliasConfig.worldModels], [publicModelAliasConfig.worldModels],
); );
@ -1529,11 +1536,12 @@ export default function CanvasModel3DPage() {
modelVersion, modelVersion,
faceLimit, faceLimit,
texture, texture,
pbr, pbr: modelFeatureVisibility.pbr !== false && pbr,
autoSize, autoSize,
quad, quad: modelFeatureVisibility.quad !== false && quad,
generateParts, generateParts,
smartLowPoly, smartLowPoly:
modelFeatureVisibility.smartLowPoly !== false && smartLowPoly,
geometryQuality, geometryQuality,
textureQuality, textureQuality,
modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined, modelSeed: modelSeed.trim() ? Number(modelSeed) : undefined,
@ -3028,47 +3036,57 @@ export default function CanvasModel3DPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<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"> modelFeatureVisibility.quad !== false ||
<span className="flex items-center gap-2"> modelFeatureVisibility.smartLowPoly !== false ? (
PBR 材质 <div className="space-y-3 rounded-lg border border-white/10 bg-zinc-950 p-3">
<CostPill label={pbrExtraCostLabel} /> {modelFeatureVisibility.pbr !== false ? (
</span> <div className="flex items-center justify-between text-xs text-zinc-400">
<Switch <span className="flex items-center gap-2">
checked={pbr} PBR 材质
className="data-[state=checked]:bg-yellow-500" <CostPill label={pbrExtraCostLabel} />
onCheckedChange={setPbr} </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>
<div className="flex items-center justify-between text-xs text-zinc-400"> ) : null}
<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>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
<Button <Button
className="w-full bg-yellow-500 text-black hover:bg-yellow-400" className="w-full bg-yellow-500 text-black hover:bg-yellow-400"
disabled={isSubmitting} disabled={isSubmitting || tripoModelOptions.length === 0}
onClick={handleGenerate} onClick={handleGenerate}
size="lg" size="lg"
> >
@ -3079,7 +3097,9 @@ export default function CanvasModel3DPage() {
)} )}
{isSubmitting {isSubmitting
? "生成中..." ? "生成中..."
: `开始生成 · ${modelFeatureCostLabel}`} : tripoModelOptions.length === 0
? "暂无可用模型"
: `开始生成 · ${modelFeatureCostLabel}`}
</Button> </Button>
</div> </div>
</> </>

View File

@ -9,9 +9,16 @@ export type ThreeDModelAliasEntry = {
isDefault: boolean; isDefault: boolean;
}; };
export type ThreeDModelFeatureVisibilityConfig = {
pbr: boolean;
quad: boolean;
smartLowPoly: boolean;
};
export type ThreeDModelAliasConfig = { export type ThreeDModelAliasConfig = {
tripoModels: ThreeDModelAliasEntry[]; tripoModels: ThreeDModelAliasEntry[];
worldModels: ThreeDModelAliasEntry[]; worldModels: ThreeDModelAliasEntry[];
featureVisibility: ThreeDModelFeatureVisibilityConfig;
}; };
export type PublicThreeDModelAliasEntry = Omit< export type PublicThreeDModelAliasEntry = Omit<
@ -24,6 +31,7 @@ export type PublicThreeDModelAliasEntry = Omit<
export type PublicThreeDModelAliasConfig = { export type PublicThreeDModelAliasConfig = {
tripoModels: PublicThreeDModelAliasEntry[]; tripoModels: PublicThreeDModelAliasEntry[];
worldModels: PublicThreeDModelAliasEntry[]; worldModels: PublicThreeDModelAliasEntry[];
featureVisibility: ThreeDModelFeatureVisibilityConfig;
}; };
const DEFAULT_TRIPO_MODEL_ALIASES: ThreeDModelAliasEntry[] = [ 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) { function sanitizeText(value: unknown) {
return String(value || "").trim(); 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 { export function defaultThreeDModelAliasConfig(): ThreeDModelAliasConfig {
return { return {
tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES), tripoModels: cloneEntries(DEFAULT_TRIPO_MODEL_ALIASES),
worldModels: cloneEntries(DEFAULT_WORLD_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, DEFAULT_WORLD_MODEL_ALIASES,
"world-model", "world-model",
), ),
featureVisibility: normalizeFeatureVisibility(raw.featureVisibility),
}; };
} }
@ -236,6 +276,7 @@ export function toPublicThreeDModelAliasConfig(
...entry, ...entry,
billingModelId: realModelId, billingModelId: realModelId,
})), })),
featureVisibility: normalizeFeatureVisibility(config.featureVisibility),
}; };
} }