背景:升级到带 models.stats 的版本后,库里已有模型没有分析与缩略图,
审核界面会全部显示"未分析、无预览图"。E2E 验证时还发现一个真问题:
sha256 去重命中旧资产但文件不存在(换过 STORAGE_DIR、或只恢复数据库)时,
分析会静默产出"全 0 结果"。
- POST /api/models/:id/reanalyze:重新分析(面数/骨骼树/humanoid/可驱动性)
并重建缩略图,只改 stats 与 thumbnailAssetId;文件在存储中不存在时返回 409,
避免用空结果覆盖已有分析数据
- scripts/reanalyze-models.mjs:批量回填 CLI(--dry-run / --model-id / --force)
- StorageService.persistBuffer:去重命中但文件不存在时按原路径**自愈补写**
- 契约:rest-api 新增 /api/models/{id}/reanalyze(路由 29 → 30 条)
- 文档:README / tasks/005 / 发布说明补回填章节与升级步骤
实测:真实 21MB VRM(290 骨骼)回填出面数 53552 / 顶点 44662 / humanoid 7/22,
并重建 512×512 缩略图;文件缺失场景返回 409 且不覆盖既有数据。
测试 168 项通过;契约与 30 条路由一致。
105 lines
3.5 KiB
JavaScript
105 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 回填 / 刷新已有模型的分析结果与缩略图。
|
||
*
|
||
* 用途:升级到带 `models.stats` 的版本后,历史模型还没有面数、骨骼树与预览图;
|
||
* 这个脚本逐个调用 `POST /api/models/{id}/reanalyze` 把它们补齐。
|
||
*
|
||
* 用法(在 web-server 目录下,服务需已启动):
|
||
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 全部模型
|
||
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --model-id 14
|
||
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --force # 已有 stats 的也重算
|
||
* node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列出会处理谁
|
||
*
|
||
* 只读列表 + 调 reanalyze 接口,不直接改数据库。
|
||
*/
|
||
const args = process.argv.slice(2);
|
||
const flag = (name, fallback = undefined) => {
|
||
const index = args.indexOf(name);
|
||
return index >= 0 && args[index + 1] && !args[index + 1].startsWith('--')
|
||
? args[index + 1]
|
||
: index >= 0
|
||
? true
|
||
: fallback;
|
||
};
|
||
const has = (name) => args.includes(name);
|
||
|
||
const url = String(flag('--url', 'http://127.0.0.1:3000')).replace(/\/$/, '');
|
||
const onlyId = Number(flag('--model-id', 0)) || 0;
|
||
const force = has('--force');
|
||
const dryRun = has('--dry-run');
|
||
const pageSize = 50;
|
||
|
||
async function api(path, options = {}) {
|
||
const response = await fetch(`${url}${path}`, options);
|
||
const text = await response.text();
|
||
let body = null;
|
||
try {
|
||
body = text ? JSON.parse(text) : null;
|
||
} catch {
|
||
body = text;
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error(`${options.method ?? 'GET'} ${path} → HTTP ${response.status}:${JSON.stringify(body)}`);
|
||
}
|
||
return body;
|
||
}
|
||
|
||
/** 拉取全部模型(分页) */
|
||
async function listAllModels() {
|
||
const all = [];
|
||
for (let page = 1; ; page += 1) {
|
||
const data = await api(`/api/models?page=${page}&pageSize=${pageSize}`);
|
||
all.push(...(data.items ?? []));
|
||
if (all.length >= (data.total ?? 0) || (data.items ?? []).length === 0) break;
|
||
}
|
||
return all;
|
||
}
|
||
|
||
async function main() {
|
||
if (onlyId) {
|
||
const model = await api(`/api/models/${onlyId}`);
|
||
await reanalyze(model);
|
||
return;
|
||
}
|
||
|
||
const models = await listAllModels();
|
||
const todo = force ? models : models.filter((m) => !m.stats);
|
||
console.log(
|
||
`模型总数 ${models.length},${force ? '(--force)全部重算' : '需要回填'} ${todo.length} 个`,
|
||
);
|
||
if (dryRun) {
|
||
for (const m of todo) console.log(` #${m.id} ${m.name}(${m.format})`);
|
||
return;
|
||
}
|
||
|
||
let ok = 0;
|
||
let failed = 0;
|
||
for (const model of todo) {
|
||
try {
|
||
await reanalyze(model);
|
||
ok += 1;
|
||
} catch (error) {
|
||
failed += 1;
|
||
console.error(` ✗ #${model.id} ${model.name}:${error instanceof Error ? error.message : error}`);
|
||
}
|
||
}
|
||
console.log(`完成:成功 ${ok} 个,失败 ${failed} 个`);
|
||
if (failed > 0) process.exit(1);
|
||
}
|
||
|
||
async function reanalyze(model) {
|
||
const result = await api(`/api/models/${model.id}/reanalyze`, { method: 'POST' });
|
||
const stats = result.stats ?? {};
|
||
console.log(
|
||
` ✓ #${result.id} ${result.name}:面数=${stats.triangles ?? '—'} 骨骼=${stats.bones ?? '—'} ` +
|
||
`humanoid=${stats.humanoidMapped ?? '—'}/${stats.humanoidTotal ?? '—'} ` +
|
||
`可驱动=${stats.drivable ?? '—'} 缩略图=${result.thumbnailUrl ? '已生成' : '无'}`,
|
||
);
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`失败:${error instanceof Error ? error.message : String(error)}`);
|
||
process.exit(1);
|
||
});
|