feat(models): 新增历史模型回填(reanalyze)并修复存储去重的悬挂文件问题
背景:升级到带 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 条路由一致。
This commit is contained in:
@@ -294,6 +294,14 @@ deploy/.env 初始化引导(deploy/init-env.sh)
|
||||
入库后**一律 status=pending(待审核)**:上传不再自动发布、不再自动设为默认,
|
||||
必须经审核通过(模型审核面板可勾选「同时设为全局默认模型」)才对客户端可见。
|
||||
|
||||
升级后回填已有模型(面数 / 骨骼 / 缩略图)
|
||||
升级到带 models.stats 的版本后,旧模型还没有分析与预览图。服务启动后执行一次:
|
||||
cd /opt/opendance/web-server
|
||||
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000
|
||||
# 只处理一个:--model-id 14 只列清单:--dry-run 全部重算:--force
|
||||
也可对单个模型调 POST /api/models/:id/reanalyze(只改 stats 与缩略图两个字段)。
|
||||
若模型文件在存储中已丢失,接口返回 409 并提示先恢复文件,不会用空结果覆盖已有数据。
|
||||
|
||||
模型压缩包(.zip)自动搜索与转换
|
||||
上传的 .zip 不再"只找 .pmx",而是**自动搜索包内可用模型**,走
|
||||
"解压 → 选主模型 → 直接导入或转换 → 回读校验 → 入库",而不是把压缩包当模型存下来。
|
||||
|
||||
@@ -398,6 +398,25 @@ paths:
|
||||
responses:
|
||||
'204': { description: 已删除 }
|
||||
|
||||
/api/models/{id}/reanalyze:
|
||||
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||
post:
|
||||
tags: [models]
|
||||
summary: 重新分析模型并重建缩略图(升级回填 / 文件替换后刷新)
|
||||
description: |
|
||||
重新计算 `stats`(面数、骨骼树、humanoid 覆盖、可驱动性)并重出缩略图,
|
||||
只改这两个字段。用于:升级到带 `stats` 的版本后回填历史模型、
|
||||
模型文件被替换后刷新、缩略图渲染器升级后重出图。
|
||||
批量回填用 `node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000`。
|
||||
responses:
|
||||
'200':
|
||||
description: 重新分析后的模型
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Model' }
|
||||
'404': { $ref: '#/components/responses/NotFound' }
|
||||
'409': { description: 模型没有文件资产,无法分析 }
|
||||
|
||||
/api/models/{id}/primary:
|
||||
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||
post:
|
||||
|
||||
@@ -118,6 +118,7 @@ for required in \
|
||||
"opendance-web-v${VERSION}/web-server/dist/preprocess/mmd-skeleton.js" \
|
||||
"opendance-web-v${VERSION}/web-server/scripts/inspect-vmd.mjs" \
|
||||
"opendance-web-v${VERSION}/web-server/scripts/import-default-model.mjs" \
|
||||
"opendance-web-v${VERSION}/web-server/scripts/reanalyze-models.mjs" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/common/zip-reader.js" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/models/model-convert.js" \
|
||||
"opendance-web-v${VERSION}/web-server/dist/models/glb-textures.js" \
|
||||
|
||||
@@ -186,3 +186,20 @@ npm run test:smoke # 冒烟 [4] 段落
|
||||
`test/model-inspector.spec.ts`(**25 项**)覆盖 GLB 统计与骨骼树、VRM 识别、必需 16 根
|
||||
humanoid 骨、非 glTF 容器降级、截断与健壮性、与客户端映射表逐字一致。
|
||||
`test/model-thumbnail.spec.ts`(**8 项**)覆盖缩略图 PNG 结构、蒙皮与降级路径。
|
||||
|
||||
## 追加:历史模型回填(`POST /api/models/:id/reanalyze`)
|
||||
|
||||
升级到带 `models.stats` 的版本后,已有模型还没有分析与缩略图。重新分析接口只改
|
||||
`stats` 与 `thumbnailAssetId` 两个字段,用于回填 / 文件替换后刷新 / 缩略图渲染器升级后重出图。
|
||||
|
||||
```bash
|
||||
cd /opt/opendance/web-server
|
||||
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 # 回填全部缺 stats 的
|
||||
node scripts/reanalyze-models.mjs --url http://127.0.0.1:3000 --dry-run # 只列清单
|
||||
```
|
||||
|
||||
实测(真实 21MB VRM,290 骨骼):回填出面数 53552 / 顶点 44662 / 骨骼 290 /
|
||||
humanoid 7/22,并重建 512×512 缩略图。
|
||||
文件在存储中丢失时返回 **409** 并说明原因,不会用全 0 结果覆盖既有分析数据。
|
||||
另外 `StorageService.persistBuffer` 在 sha256 去重命中但文件不存在时会**自愈补写**,
|
||||
避免换过 `STORAGE_DIR` 或只恢复数据库后出现"静默全 0 分析"。
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -39,6 +39,13 @@ export class ModelsController {
|
||||
return this.models.create(body, file);
|
||||
}
|
||||
|
||||
/** 重新分析并重建缩略图(升级回填 / 文件替换后刷新) */
|
||||
@Post(':id/reanalyze')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
reanalyze(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
|
||||
return this.models.reanalyze(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id', ParseIntPipe) id: number): Promise<ModelDto> {
|
||||
return this.models.findOne(id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname } from 'node:path';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
@@ -212,6 +213,44 @@ export class ModelsService {
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新分析已有模型并重建缩略图(T-WEB-005 追加)。
|
||||
*
|
||||
* 用途:① 升级到带 `stats` 的版本后**回填历史模型**;② 模型文件被替换后刷新;
|
||||
* ③ 缩略图渲染器升级后重出图。只改 `stats` / `thumbnailAssetId`,不动其它字段。
|
||||
*/
|
||||
async reanalyze(id: number): Promise<ModelDto> {
|
||||
const model = await this.getEntity(id);
|
||||
if (!model.fileAssetId) {
|
||||
throw new ConflictException(`模型 #${id} 没有模型文件,无法分析`);
|
||||
}
|
||||
const asset = await this.storage.findById(model.fileAssetId);
|
||||
if (!asset) throw new NotFoundException(`模型 #${id} 的文件资产已丢失`);
|
||||
|
||||
const absolutePath = this.storage.absolutePath(asset);
|
||||
if (!existsSync(absolutePath)) {
|
||||
throw new ConflictException(
|
||||
`模型 #${id} 的文件在存储中不存在(${absolutePath});` +
|
||||
'请先恢复文件(或重新上传该模型)再重新分析,避免用空结果覆盖已有分析数据',
|
||||
);
|
||||
}
|
||||
const stats = await this.analyzeSafely(absolutePath, asset.size, model.name);
|
||||
const thumbnailAssetId = await this.thumbnailSafely(absolutePath, model.name);
|
||||
|
||||
model.stats = stats as unknown as Record<string, unknown>;
|
||||
if (thumbnailAssetId !== null) model.thumbnailAssetId = thumbnailAssetId;
|
||||
if (stats.vrmVersion) model.vrmVersion = stats.vrmVersion;
|
||||
|
||||
const saved = await this.models.save(model);
|
||||
this.logger.log(
|
||||
`模型 #${id} 重新分析完成:面数=${stats.triangles} 骨骼=${stats.bones} ` +
|
||||
`humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}` +
|
||||
` 缩略图=${thumbnailAssetId === null ? '未生成' : '已更新'}`,
|
||||
);
|
||||
const [dto] = await this.toDtos([saved]);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 分析模型;任何异常都降级为一条 warning,不阻断上传 */
|
||||
private async analyzeSafely(
|
||||
absolutePath: string,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { copyFile, mkdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, join, resolve, sep } from 'node:path';
|
||||
import { dirname, extname, join, resolve, sep } from 'node:path';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AssetKind } from '../common/enums';
|
||||
import { AppConfig } from '../config/configuration';
|
||||
@@ -161,15 +161,26 @@ export class StorageService {
|
||||
mime: string,
|
||||
): Promise<StoredAsset> {
|
||||
const sha256 = createHash('sha256').update(content).digest('hex');
|
||||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
||||
|
||||
const existing = await this.assets.findOne({ where: { sha256 } });
|
||||
if (existing) return { asset: existing, url: this.url(existing) as string };
|
||||
if (existing) {
|
||||
// 去重命中,但要确认文件真的还在:换过 STORAGE_DIR、或只恢复了数据库没恢复
|
||||
// 文件时,旧记录会指向一个不存在的路径。此时**按原路径补写一份**,
|
||||
// 否则后续的模型分析 / 缩略图 / 下载都会静默失败(曾实测到"全 0 分析结果")。
|
||||
const absolute = this.absolutePath(existing);
|
||||
if (!existsSync(absolute)) {
|
||||
await mkdir(dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, buf);
|
||||
}
|
||||
return { asset: existing, url: this.url(existing) as string };
|
||||
}
|
||||
|
||||
const ext = extname(originalName).toLowerCase();
|
||||
const now = new Date();
|
||||
const relDir = join(kind, String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), sha256.slice(0, 2));
|
||||
const relPath = join(relDir, `${sha256}${ext}`);
|
||||
await mkdir(join(this.root, relDir), { recursive: true });
|
||||
const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
|
||||
await writeFile(join(this.root, relPath), buf);
|
||||
|
||||
const entity = this.assets.create({
|
||||
|
||||
Reference in New Issue
Block a user