fix(model3d): restore archived world assets and previews

This commit is contained in:
134 2026-08-28 22:17:20 +08:00
parent 412fa85a81
commit c39d7260fb
6 changed files with 568 additions and 103 deletions

View File

@ -190,6 +190,48 @@ function assertArchivedAssetBuffer(params: {
}
}
async function verifyArchivedAssetUpload(params: {
url: string;
expectedBytes: number;
validateAs?: ArchiveValidationKind;
}) {
const headResponse = await fetch(params.url, {
method: "HEAD",
cache: "no-store",
});
if (!headResponse.ok) {
throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`);
}
const archivedLength = Number(headResponse.headers.get("content-length"));
if (
Number.isFinite(archivedLength) &&
archivedLength > 0 &&
archivedLength !== params.expectedBytes
) {
throw new Error(
`归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})`
);
}
if (!params.validateAs) return;
const rangeResponse = await fetch(params.url, {
method: "GET",
headers: { Range: "bytes=0-63" },
cache: "no-store",
});
if (!rangeResponse.ok) {
throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`);
}
const headerBuffer = await rangeResponse.arrayBuffer();
assertArchivedAssetBuffer({
buffer: headerBuffer,
contentType: rangeResponse.headers.get("content-type"),
validateAs: params.validateAs,
});
}
async function archiveRemoteAssetIfNeeded(params: {
cacheKey: string;
sourceUrl?: string | null;
@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
);
const filename = `${params.filenamePrefix}.${ext}`;
const uploaded = await uploadFile(filename, assetBuffer);
await verifyArchivedAssetUpload({
url: uploaded.url,
expectedBytes: assetBuffer.byteLength,
validateAs: params.validateAs,
});
cache.set(params.cacheKey, {
sourceUrl,
archivedUrl: uploaded.url,
@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
)
).filter((entry): entry is readonly [string, string] => Boolean(entry));
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null;
if (archivedEntries.length !== entries.length) return null;
return Object.fromEntries(archivedEntries);
}
function getStringValue(...values: unknown[]) {
@ -837,6 +885,19 @@ export async function GET(request: Request, { params }: RouteParams) {
fallbackExtension: "spz",
validateAs: "spz",
});
const sourceSpzEntryCount = Object.keys(
extractedAssetPayload.spzUrls || {}
).length;
const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
(sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount)
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
const assetPayload = {
...externalAssetPayloadBase,
worldId: extractedAssetPayload.worldId,
@ -852,7 +913,9 @@ export async function GET(request: Request, { params }: RouteParams) {
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
storageArchivedAt: new Date().toISOString(),
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending,
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
@ -891,6 +954,7 @@ export async function GET(request: Request, { params }: RouteParams) {
hasPano: Boolean(stablePanoUrl),
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
assetsArchivingPending,
});
return NextResponse.json({
@ -1171,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
: 0;
const hasMissingSpzArchive =
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
const storageArchivedAt =
[
[rawWorldUrl, stableWorldUrl],
[rawMeshUrl, stableModelUrl],
[rawThumbnailUrl, stableThumbnailUrl],
[rawPanoUrl, stablePanoUrl],
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
Boolean(
stableSpzUrls &&
assetPayload.spzUrls &&
Object.entries(stableSpzUrls).some(
([key, value]) =>
typeof assetPayload.spzUrls?.[key] === "string" &&
assetPayload.spzUrls[key] !== value
)
)
? new Date().toISOString()
: null;
const assetsArchivingPending = Boolean(
(rawMeshUrl && !stableModelUrl) ||
(rawThumbnailUrl && !stableThumbnailUrl) ||
(rawPanoUrl && !stablePanoUrl) ||
hasMissingSpzArchive
);
const storageArchivedAt = assetsArchivingPending
? null
: new Date().toISOString();
logger.info("task success status", {
elapsedMs: Date.now() - startedAt,
@ -1200,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
hasThumbnail: Boolean(stableThumbnailUrl),
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
});
const responseAssetPayload = {
@ -1213,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
panoUrl: stablePanoUrl || assetPayload.panoUrl,
meshUrl: stableModelUrl,
spzUrls: stableSpzUrls,
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
storageArchivedAt,
assetsArchivingPending: Boolean(
(rawMeshUrl && !stableModelUrl) ||
hasMissingSpzArchive
),
assetsArchivingPending,
sourceWorldUrl: rawWorldUrl || null,
sourceMeshUrl:
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl

View File

@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
);
}
function getTaskPreviewCandidates(task?: TaskRecord | null) {
if (!task) return [];
const candidates = [
task.previewImageUrl,
task.assetPayload?.thumbnailUrl,
task.assetPayload?.panoUrl,
task.assetPayload?.sourcePreviewImageUrl,
task.assetPayload?.sourceThumbnailUrl,
task.assetPayload?.sourcePanoUrl,
];
const seen = new Set<string>();
return candidates
.map((value) => String(value || "").trim())
.filter((value) => {
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function isPersistableTaskPreviewUrl(value?: string | null) {
return /^https?:\/\//i.test(String(value || "").trim());
}
function TaskAssetPreview({ task }: { task: TaskRecord }) {
const candidates = getTaskPreviewCandidates(task);
const signature = candidates.join("|");
const [candidateIndex, setCandidateIndex] = useState(0);
useEffect(() => {
setCandidateIndex(0);
}, [signature]);
const previewUrl = candidates[candidateIndex] || null;
if (!previewUrl) {
return (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
{task.status === "queued" || task.status === "running" ? (
<Loader2 className="h-7 w-7 animate-spin text-yellow-400/70" />
) : (
<Box className="h-8 w-8" />
)}
</div>
);
}
return (
<img
alt="3D 资产预览"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
onError={() => setCandidateIndex((index) => index + 1)}
src={previewUrl}
/>
);
}
function canImportTaskToCanvas(task?: TaskRecord | null) {
if (!task || task.status !== "success") return false;
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
@ -1126,7 +1182,9 @@ export default function CanvasModel3DPage() {
modelVersion: task.modelVersion,
progress: task.progress ?? null,
modelUrl: task.modelUrl ?? null,
previewImageUrl: task.previewImageUrl ?? null,
previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl)
? task.previewImageUrl
: null,
error: task.error ?? null,
sourceTaskId: task.sourceTaskId ?? null,
exportFormat: task.exportFormat ?? null,
@ -1693,6 +1751,7 @@ export default function CanvasModel3DPage() {
sourceTaskId?: string | null;
exportFormat?: string | null;
assetPayload?: Model3DTaskAssetPayload | null;
previewImageUrl?: string | null;
}) => {
const normalizedPayload =
params.provider === "tripo-3d" &&
@ -1747,6 +1806,7 @@ export default function CanvasModel3DPage() {
createdAt: Date.now(),
sourceTaskId: params.sourceTaskId || null,
exportFormat: params.exportFormat || null,
previewImageUrl: params.previewImageUrl || null,
assetPayload:
params.provider === "worldlabs-3d"
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
@ -1862,6 +1922,7 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null,
},
previewImageUrl: referencePreviewUrl,
});
toast.success("已提交图生3D任务");
return;
@ -1896,6 +1957,8 @@ export default function CanvasModel3DPage() {
providerId: uploadResult.data.providerId || null,
providerName: uploadResult.data.providerName || null,
},
previewImageUrl:
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
});
successCount += 1;
}
@ -1951,6 +2014,7 @@ export default function CanvasModel3DPage() {
providerId: boundTripoProviderId,
providerName: boundTripoProviderName,
},
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
});
toast.success("已提交多视图3D任务");
} catch (error) {
@ -2033,6 +2097,7 @@ export default function CanvasModel3DPage() {
mode: "world-image",
promptText: normalizedPrompt || "图生世界",
modelLabel: worldModel,
previewImageUrl: sourceUrl || worldImagePreviewUrl,
});
toast.success("已提交图生世界任务");
return;
@ -2117,6 +2182,10 @@ export default function CanvasModel3DPage() {
mode: "world-multi-image",
promptText: normalizedPrompt || "多图世界",
modelLabel: worldModel,
previewImageUrl:
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
null,
});
toast.success("已提交多图世界任务");
} catch (error) {
@ -4218,8 +4287,6 @@ export default function CanvasModel3DPage() {
const canImportWorld =
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
return (
<div
className={cn(
@ -4236,17 +4303,7 @@ export default function CanvasModel3DPage() {
onClick={() => handleSelectTask(task)}
type="button"
>
{previewUrl ? (
<img
alt="preview"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
src={previewUrl}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-zinc-700">
<Box className="h-8 w-8" />
</div>
)}
<TaskAssetPreview task={task} />
</button>
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
<div className="flex items-center justify-center gap-1">

View File

@ -1917,9 +1917,6 @@ function WorldSplatCaptureBridge({
function WorldSplatViewer({
splatUrl,
splatUrls,
fallbackMeshUrl,
fallbackMeshSettings,
fallbackMeshLayerVisibility,
worldUrl,
previewImageUrl,
firstPerson,
@ -1927,8 +1924,6 @@ function WorldSplatViewer({
interactionMode,
onPick,
onHotspotSelect,
onFallbackMeshLayersChange,
onFallbackMeshStatsChange,
semanticsMetadata,
resetViewToken,
minimalFrame = false,
@ -1938,9 +1933,6 @@ function WorldSplatViewer({
}: {
splatUrl?: string | null;
splatUrls?: string[] | null;
fallbackMeshUrl?: string | null;
fallbackMeshSettings?: Model3DViewerSettings;
fallbackMeshLayerVisibility?: Record<string, boolean>;
worldUrl?: string | null;
previewImageUrl?: string | null;
minimalFrame?: boolean;
@ -1949,8 +1941,6 @@ function WorldSplatViewer({
interactionMode?: "navigate" | "hotspot";
onPick?: (pick: World3DPickResult) => void;
onHotspotSelect?: (hotspotId: string) => void;
onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void;
onFallbackMeshStatsChange?: (stats: Model3DStats) => void;
semanticsMetadata?: Record<string, unknown> | null;
resetViewToken?: number;
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
@ -1967,7 +1957,6 @@ function WorldSplatViewer({
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
null
);
const [useMeshFallback, setUseMeshFallback] = useState(false);
const resolvedSplatUrls = useMemo(() => {
const rawUrls = [
splatUrl,
@ -1985,16 +1974,13 @@ function WorldSplatViewer({
}, [splatUrl, splatUrls]);
const resolvedSplatSignature = resolvedSplatUrls.join("|");
const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || "";
const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null;
const hasMeshFallback = Boolean(resolvedFallbackMeshUrl);
const loadStartedAtRef = useRef(0);
const movementStateRef = useRef<MovementState>(createMovementState());
useEffect(() => {
setActiveSplatIndex(0);
setFailedSplatLoads([]);
setUseMeshFallback(false);
}, [resolvedFallbackMeshUrl, resolvedSplatSignature]);
}, [resolvedSplatSignature]);
useEffect(() => {
if (!resolvedSplatUrl) return;
@ -2047,10 +2033,6 @@ function WorldSplatViewer({
const failedUrl = resolvedSplatUrl;
const nextIndex = activeSplatIndex + 1;
const nextUrl = resolvedSplatUrls[nextIndex] || "";
const shouldUseMeshFallback =
hasMeshFallback &&
(message.includes("SPZ 加载超时") || !nextUrl);
if (failedUrl) {
setFailedSplatLoads((items) => [
...items,
@ -2058,20 +2040,6 @@ function WorldSplatViewer({
]);
}
if (shouldUseMeshFallback) {
console.warn("[WorldSplatViewer] Switching to mesh fallback", {
error: message,
failedUrl,
attemptedUrls: resolvedSplatUrls.slice(0, nextIndex),
fallbackMeshUrl: resolvedFallbackMeshUrl,
});
setPrefetchedSplat(null);
setLoadProgress(null);
setOverlayPhase("hidden");
setUseMeshFallback(true);
return;
}
if (nextUrl) {
console.warn("[WorldSplatViewer] Falling back to next SPZ asset", {
error: message,
@ -2096,15 +2064,13 @@ function WorldSplatViewer({
},
[
activeSplatIndex,
hasMeshFallback,
resolvedFallbackMeshUrl,
resolvedSplatUrl,
resolvedSplatUrls,
]
);
useEffect(() => {
if (!resolvedSplatUrl || useMeshFallback) return;
if (!resolvedSplatUrl) return;
const abortController = new AbortController();
let cancelled = false;
@ -2141,25 +2107,7 @@ function WorldSplatViewer({
globalThis.clearTimeout(timeoutId);
abortController.abort();
};
}, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]);
if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) {
return (
<Model3DViewer
emptyStateTitle="准备加载 3D Mesh"
layerVisibility={fallbackMeshLayerVisibility}
modelRotation={WORLDLABS_ASSET_ROTATION}
modelUrl={resolvedFallbackMeshUrl}
onCaptureReady={onCaptureReady}
onLayersChange={onFallbackMeshLayersChange}
onStatsChange={onFallbackMeshStatsChange}
previewImageUrl={previewImageUrl}
resetViewToken={resetViewToken}
settings={fallbackMeshSettings || DEFAULT_MESH_SETTINGS}
showMovementControls={showMovementControls}
/>
);
}
}, [handleSplatLoadStateChange, resolvedSplatUrl]);
if (!resolvedSplatUrl) {
return (
@ -2375,13 +2323,8 @@ export function World3DViewer({
<WorldViewerInteractionSurface className={className}>
<WorldSplatViewer
firstPerson={firstPerson}
fallbackMeshLayerVisibility={meshLayerVisibility}
fallbackMeshSettings={meshSettings}
fallbackMeshUrl={meshUrl}
hotspots={hotspots}
interactionMode={interactionMode}
onFallbackMeshLayersChange={onMeshLayersChange}
onFallbackMeshStatsChange={onMeshStatsChange}
onCaptureReady={onCaptureReady}
onHotspotSelect={onHotspotSelect}
onPick={onPick}

View File

@ -282,6 +282,7 @@ export function WorldSharePage({
mode={activeView}
previewImageUrl={activeWorld?.previewImageUrl || null}
semanticsMetadata={activeWorld?.semanticsMetadata || null}
showInfoOverlay={false}
showMovementControls={activeView === "splat" || activeView === "mesh"}
splatUrl={activeWorld?.splatUrl || null}
worldUrl={activeWorld?.worldUrl || null}

View File

@ -55,6 +55,7 @@
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
"storage:configure-upload-lifecycle": "pnpm exec tsx scripts/storage/configure-multipart-lifecycle.ts",
"storage:repair-model3d-qiniu": "pnpm exec tsx scripts/storage/repair-model3d-qiniu-assets.ts",
"verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts",
"providers:consolidate": "pnpm exec tsx scripts/consolidate-ai-providers.ts",
"providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts"

View File

@ -0,0 +1,414 @@
import { config as loadEnv } from "dotenv";
import { desc, eq } from "drizzle-orm";
import { mkdir, writeFile } from "node:fs/promises";
import Module from "node:module";
import path from "node:path";
loadEnv({ path: ".env.local" });
loadEnv();
const moduleLoader = Module as typeof Module & {
_load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown;
};
const originalLoad = moduleLoader._load;
moduleLoader._load = function patchedServerOnlyLoad(
this: typeof moduleLoader,
request,
parent,
isMain
) {
if (request === "server-only") return {};
return originalLoad.call(this, request, parent, isMain);
};
type AssetKind = "glb" | "image" | "spz";
type StringRecord = Record<string, string>;
type JsonRecord = Record<string, unknown>;
const dryRun = process.argv.includes("--dry-run");
const worldOnly = process.argv.includes("--world-only");
const tripoOnly = process.argv.includes("--tripo-only");
const limitArg = process.argv.find((arg) => arg.startsWith("--limit="));
const limit = limitArg ? Math.max(1, Number(limitArg.split("=")[1]) || 1) : null;
const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
function normalizeUrl(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function isHttpUrl(value: string) {
return /^https?:\/\//i.test(value);
}
function hasGlbMagic(bytes: Uint8Array) {
return (
bytes.length >= 12 &&
bytes[0] === 0x67 &&
bytes[1] === 0x6c &&
bytes[2] === 0x54 &&
bytes[3] === 0x46
);
}
function hasSpzMagic(bytes: Uint8Array) {
return (
(bytes.length >= 4 &&
bytes[0] === 0x4e &&
bytes[1] === 0x47 &&
bytes[2] === 0x53 &&
bytes[3] === 0x50) ||
(bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b)
);
}
function looksLikeErrorPayload(bytes: Uint8Array) {
const head = new TextDecoder("utf-8", { fatal: false })
.decode(bytes.slice(0, Math.min(bytes.length, 64)))
.trimStart();
return head.startsWith("<") || head.startsWith("{") || head.startsWith("[");
}
function validateBytes(bytes: Uint8Array, kind: AssetKind) {
if (bytes.byteLength <= 0 || looksLikeErrorPayload(bytes)) return false;
if (kind === "glb") return hasGlbMagic(bytes);
if (kind === "spz") return hasSpzMagic(bytes);
return true;
}
async function probeAsset(url: string, kind: AssetKind) {
if (!isHttpUrl(url)) return false;
try {
const response = await fetch(url, {
method: "GET",
headers: { Range: "bytes=0-63" },
cache: "no-store",
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) return false;
const bytes = new Uint8Array(await response.arrayBuffer());
return validateBytes(bytes, kind);
} catch {
return false;
}
}
async function downloadAsset(url: string, kind: AssetKind) {
const response = await fetch(url, {
method: "GET",
headers: { "User-Agent": "AI-Chatbot-3D-Qiniu-Repair/1.0" },
cache: "no-store",
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`下载失败(HTTP ${response.status})`);
}
const buffer = await response.arrayBuffer();
if (!validateBytes(new Uint8Array(buffer), kind)) {
throw new Error(`${kind.toUpperCase()} 文件内容无效`);
}
return {
buffer,
contentType: response.headers.get("content-type"),
};
}
function resolveExtension(url: string, kind: AssetKind, contentType?: string | null) {
if (kind === "glb") return "glb";
if (kind === "spz") return "spz";
const type = String(contentType || "").toLowerCase();
if (type.includes("webp")) return "webp";
if (type.includes("png")) return "png";
if (type.includes("gif")) return "gif";
try {
const ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
if (ext && ["jpg", "jpeg", "png", "webp", "gif"].includes(ext)) return ext;
} catch {
// ignore
}
return "jpg";
}
function replaceUrlsDeep(value: unknown, replacements: Map<string, string>): unknown {
if (typeof value === "string") return replacements.get(value) || value;
if (Array.isArray(value)) {
return value.map((item) => replaceUrlsDeep(item, replacements));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as JsonRecord).map(([key, item]) => [
key,
replaceUrlsDeep(item, replacements),
])
);
}
return value;
}
function toStringRecord(value: unknown): StringRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value as JsonRecord)
.map(([key, item]) => [key, normalizeUrl(item)] as const)
.filter(([, item]) => isHttpUrl(item))
);
}
async function main() {
const [dbModule, schemaModule, storageModule, tripoClient, tripoServer] =
await Promise.all([
import("../../lib/db"),
import("../../lib/db/schema"),
import("../../lib/storage/service"),
import("../../lib/tripo/client"),
import("../../lib/tripo/server"),
]);
const { db } = dbModule;
const { canvasNode, model3dAsset, storageConfig } = schemaModule;
const { toDbS3UploadConfig, uploadFileWithS3Config } = storageModule;
const qiniuRows = await db
.select()
.from(storageConfig)
.where(eq(storageConfig.type, "qiniu-kodo"))
.orderBy(desc(storageConfig.isDefault), desc(storageConfig.updatedAt));
const qiniuRow = qiniuRows.find((item) => item.isEnabled);
const qiniuConfig = toDbS3UploadConfig(qiniuRow);
if (!qiniuConfig || qiniuConfig.provider !== "qiniu-kodo") {
throw new Error("未找到已启用的七牛云 Kodo 配置,已拒绝使用其他存储");
}
let assets = await db
.select()
.from(model3dAsset)
.where(eq(model3dAsset.status, "success"))
.orderBy(desc(model3dAsset.createdAt));
if (worldOnly) assets = assets.filter((asset) => asset.provider === "worldlabs-3d");
if (tripoOnly) assets = assets.filter((asset) => asset.provider === "tripo-3d");
if (limit) assets = assets.slice(0, limit);
const backupPath = path.join(
process.cwd(),
"backups",
`model3d-assets-before-qiniu-repair-${new Date()
.toISOString()
.replace(/[:.]/g, "-")}.json`
);
if (!dryRun) {
await mkdir(path.dirname(backupPath), { recursive: true });
await writeFile(backupPath, JSON.stringify(assets, null, 2));
console.log(`[repair-3d] backup=${backupPath}`);
}
const summary = {
scanned: 0,
repairedAssets: 0,
repairedFiles: 0,
failedFiles: 0,
updatedNodes: 0,
};
for (const asset of assets) {
summary.scanned += 1;
const payload =
asset.assetPayload && typeof asset.assetPayload === "object"
? ({ ...asset.assetPayload } as JsonRecord)
: {};
const replacements = new Map<string, string>();
let nextModelUrl = normalizeUrl(asset.modelUrl) || null;
let nextPreviewUrl = normalizeUrl(asset.previewImageUrl) || null;
let freshTripoOutput: Record<string, unknown> | null = null;
const uploadRecoveredAsset = async (params: {
currentUrl?: string | null;
sourceUrl?: string | null;
kind: AssetKind;
label: string;
}) => {
const currentUrl = normalizeUrl(params.currentUrl);
if (currentUrl && (await probeAsset(currentUrl, params.kind))) return currentUrl;
let sourceUrl = normalizeUrl(params.sourceUrl);
if (
asset.provider === "tripo-3d" &&
(!sourceUrl || !(await probeAsset(sourceUrl, params.kind)))
) {
if (!freshTripoOutput) {
const preferredProviderId = normalizeUrl(payload.providerId);
const provider = await tripoServer.resolveTripoProviderConfigForTaskId(
asset.taskId,
preferredProviderId
);
const task = await tripoClient.tripoRequest<{
output?: Record<string, unknown>;
}>(provider, {
method: "GET",
path: `/task/${encodeURIComponent(asset.taskId)}`,
});
freshTripoOutput = task.data.output || {};
}
sourceUrl =
params.kind === "glb"
? normalizeUrl(tripoClient.pickTaskModelUrl(freshTripoOutput))
: normalizeUrl(tripoClient.pickTaskPreviewImageUrl(freshTripoOutput));
}
if (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) {
throw new Error(`${params.label} 没有可恢复的源文件`);
}
if (dryRun) return sourceUrl;
const downloaded = await downloadAsset(sourceUrl, params.kind);
const extension = resolveExtension(
sourceUrl,
params.kind,
downloaded.contentType
);
const uploaded = await uploadFileWithS3Config(
`repair-${asset.taskId}-${params.label}.${extension}`,
downloaded.buffer,
qiniuConfig
);
if (!(await probeAsset(uploaded.url, params.kind))) {
throw new Error(`${params.label} 上传七牛云后校验失败`);
}
if (currentUrl) replacements.set(currentUrl, uploaded.url);
if (sourceUrl) replacements.set(sourceUrl, uploaded.url);
summary.repairedFiles += 1;
console.log(
`[repair-3d] repaired task=${asset.taskId} file=${params.label} bytes=${downloaded.buffer.byteLength}`
);
return uploaded.url;
};
try {
if (asset.provider === "worldlabs-3d") {
nextModelUrl = await uploadRecoveredAsset({
currentUrl: nextModelUrl,
sourceUrl: normalizeUrl(payload.sourceMeshUrl),
kind: "glb",
label: "mesh",
}).catch((error) => {
summary.failedFiles += 1;
console.warn(`[repair-3d] ${asset.taskId} mesh: ${String(error)}`);
return nextModelUrl;
});
const nextThumbnailUrl = await uploadRecoveredAsset({
currentUrl: normalizeUrl(payload.thumbnailUrl),
sourceUrl: normalizeUrl(payload.sourceThumbnailUrl),
kind: "image",
label: "thumbnail",
}).catch((error) => {
summary.failedFiles += 1;
console.warn(`[repair-3d] ${asset.taskId} thumbnail: ${String(error)}`);
return normalizeUrl(payload.thumbnailUrl) || null;
});
const nextPanoUrl = await uploadRecoveredAsset({
currentUrl: normalizeUrl(payload.panoUrl),
sourceUrl: normalizeUrl(payload.sourcePanoUrl),
kind: "image",
label: "pano",
}).catch((error) => {
summary.failedFiles += 1;
console.warn(`[repair-3d] ${asset.taskId} pano: ${String(error)}`);
return normalizeUrl(payload.panoUrl) || null;
});
const currentSpzUrls = toStringRecord(payload.spzUrls);
const sourceSpzUrls = toStringRecord(payload.sourceSpzUrls);
const nextSpzUrls: StringRecord = {};
for (const [key, sourceUrl] of Object.entries(sourceSpzUrls)) {
const repairedUrl = await uploadRecoveredAsset({
currentUrl: currentSpzUrls[key],
sourceUrl,
kind: "spz",
label: `spz-${key}`,
}).catch((error) => {
summary.failedFiles += 1;
console.warn(`[repair-3d] ${asset.taskId} spz-${key}: ${String(error)}`);
return currentSpzUrls[key] || null;
});
if (repairedUrl) nextSpzUrls[key] = repairedUrl;
}
for (const [key, currentUrl] of Object.entries(currentSpzUrls)) {
if (!nextSpzUrls[key] && (await probeAsset(currentUrl, "spz"))) {
nextSpzUrls[key] = currentUrl;
}
}
payload.meshUrl = nextModelUrl;
payload.thumbnailUrl = nextThumbnailUrl;
payload.panoUrl = nextPanoUrl;
payload.spzUrls = nextSpzUrls;
nextPreviewUrl = nextThumbnailUrl || nextPanoUrl || nextPreviewUrl;
const allSpzRecovered =
Object.keys(sourceSpzUrls).length > 0 &&
Object.keys(nextSpzUrls).length === Object.keys(sourceSpzUrls).length;
payload.assetsArchivingPending = !allSpzRecovered;
payload.storageArchivedAt = allSpzRecovered ? new Date().toISOString() : null;
payload.spzArchiveValidatedAt = allSpzRecovered
? new Date().toISOString()
: null;
} else if (asset.provider === "tripo-3d") {
nextModelUrl = await uploadRecoveredAsset({
currentUrl: nextModelUrl,
sourceUrl: normalizeUrl(payload.sourceModelUrl),
kind: "glb",
label: "model",
});
nextPreviewUrl = await uploadRecoveredAsset({
currentUrl: nextPreviewUrl,
sourceUrl: normalizeUrl(payload.sourcePreviewImageUrl),
kind: "image",
label: "preview",
});
payload.storageArchivedAt = new Date().toISOString();
payload.assetsArchivingPending = false;
}
} catch (error) {
summary.failedFiles += 1;
console.warn(`[repair-3d] task=${asset.taskId} skipped: ${String(error)}`);
}
if (replacements.size === 0 || dryRun) continue;
await db.transaction(async (tx) => {
await tx
.update(model3dAsset)
.set({
modelUrl: nextModelUrl,
previewImageUrl: nextPreviewUrl,
assetPayload: replaceUrlsDeep(payload, replacements) as JsonRecord,
updatedAt: new Date(),
})
.where(eq(model3dAsset.id, asset.id));
const nodes = await tx
.select({ id: canvasNode.id, data: canvasNode.data })
.from(canvasNode)
.where(eq(canvasNode.projectId, asset.projectId));
for (const node of nodes) {
const serialized = JSON.stringify(node.data || {});
if (!serialized.includes(asset.taskId) && ![...replacements.keys()].some((url) => serialized.includes(url))) {
continue;
}
await tx
.update(canvasNode)
.set({
data: replaceUrlsDeep(node.data, replacements),
updatedAt: new Date(),
})
.where(eq(canvasNode.id, node.id));
summary.updatedNodes += 1;
}
});
summary.repairedAssets += 1;
}
console.log(JSON.stringify({ dryRun, summary }, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});