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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user