ai-chatbot/scripts/storage/repair-model3d-qiniu-assets.ts

415 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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