fix(models): 修复上传 PMX 返回 500(models.stats 超 TEXT 上限)与 GBK 丢贴图

现象(真实故障):导入示例文件/【奥黛塔】_by_原神.zip 返回 500 Internal server error。

根因一(500):
  该 PMX 643 根骨骼、93,350 面,转换本身成功(GLB 23.7MB),但分析结果里的
  骨骼树按骨骼逐条展开,JSON 实测 70,980 字节,而 models.stats 是 TEXT
  (上限 65,535)。MySQL/MariaDB 严格模式直接拒绝写入(ER_DATA_TOO_LONG /
  1406),而这一步没有兜底 -> 未捕获 -> 500。

根因二(丢贴图):
  tex/髮.png 的 GBK 字节是 F3 8C。文件名解码先试 Shift-JIS,而 WHATWG 的
  shift_jis 把未定义码位映射成私用区不可见字符(不是 U+FFFD),于是被判为
  「解码成功」,永不回退 GBK;贴图名对不上,该模型 18 张贴图只内嵌了 17 张。

修复:
1. models.stats -> MEDIUMTEXT(实体 mediumtext + transformer,保留 JSON 语义;
   不用 simple-json 以免元数据与库列型不一致),新增迁移
   AddModelStatsMediumText1789550000000;
2. 新增 shrinkStatsForStorage() 兜底:即使库未迁移,也只截断骨骼树
   (父在前,保留祖先链),面数/骨骼数/humanoid 覆盖/可驱动性/warnings 全部保留;
   服务层 saveWithStatsFallback() 仅在 1406/DATA_TOO_LONG 时裁剪重试一次,
   其它数据库错误照旧抛出;
3. 压缩包文件名解码把「私用区字符」也算作解错信号,回退 GBK;实测该模型
   贴图由 17/18 变为 18/18 全部内嵌;
4. 新增 12 项测试(test/model-stats-limit.spec.ts 9 项 + 压缩包编码/目录条目 3 项);
5. 版本 0.2.3,README(.md/.txt)/DSH.md 补修订说明、500 的 FAQ 与 stats 容量约束。
This commit is contained in:
opendance
2026-09-16 03:30:12 +08:00
parent afed67e175
commit 8e8cc0475b
16 changed files with 464 additions and 35 deletions
+67
View File
@@ -667,3 +667,70 @@ export async function inspectModelFile(absPath: string, fileSize?: number): Prom
return analyzeGltfDocument(gltf, glb ? 'glb' : 'gltf', size, warnings);
}
/* --------------------------------------------------- 存储兜底:裁剪超大的 stats */
/** MySQL / MariaDB 的 TEXT 上限(未迁移 MEDIUMTEXT 的老库仍是这个值) */
export const MYSQL_TEXT_LIMIT = 65535;
/** 裁剪后至少保留的骨骼条目数:太少审核界面就看不出结构了 */
const MIN_BONE_TREE_KEPT = 24;
/**
* 把过大的 `ModelStats` 裁到能安全写进 TEXT 的大小(纯函数,永不抛异常)。
*
* 为什么需要:`models.stats` 里最占地方的是骨骼树,按骨骼逐条展开。
* 实测一个 643 骨骼的 PMX 分析结果 70,980 字节 > TEXT 上限 65,535,
* MySQL 严格模式会直接拒绝写入(1406)。迁移已把列扩到 MEDIUMTEXT,
* 这里再兜一层:万一跑在没迁移的库上,也宁可少存骨骼明细,不能让上传 500。
*
* 保留策略:面数 / 骨骼数 / humanoid 覆盖 / 可驱动性 / warnings 一律保留,
* 只裁骨骼树(父在前,从头截断即保留祖先链),并追加一条 warning 说明被裁过。
*/
export function shrinkStatsForStorage(
stats: ModelStats,
maxBytes: number = MYSQL_TEXT_LIMIT,
): ModelStats {
const fits = (candidate: ModelStats): boolean => {
try {
return JSON.stringify(candidate).length <= maxBytes;
} catch {
return false;
}
};
if (fits(stats)) return stats;
const note = `骨骼树过大(原 ${stats.boneTree.length} 条),存储前已截断`;
const withNote = (boneTree: BoneNode[]): ModelStats => ({
...stats,
boneTree,
warnings: stats.warnings.includes(note) ? stats.warnings : [...stats.warnings, note],
});
// 二分找能放下的最大前缀长度(骨骼树父在前,前缀即祖先链完整的子集)
let low = Math.min(MIN_BONE_TREE_KEPT, stats.boneTree.length);
let high = stats.boneTree.length;
let best = withNote(stats.boneTree.slice(0, low));
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const candidate = withNote(stats.boneTree.slice(0, mid));
if (fits(candidate)) {
best = candidate;
low = mid + 1;
} else {
high = mid - 1;
}
}
// 极端情况:连最小骨骼树都放不下 -> 只留汇总字段
if (!fits(best)) {
const summary = withNote([]);
if (fits(summary)) return summary;
return {
...summary,
missingRequiredBones: summary.missingRequiredBones.slice(0, 16),
warnings: [note],
};
}
return best;
}
+42 -3
View File
@@ -29,7 +29,7 @@ import {
convertModelFile,
importModelFile,
} from './model-convert';
import { ModelStats, inspectModelFile } from './model-inspector';
import { ModelStats, inspectModelFile, shrinkStatsForStorage } from './model-inspector';
import { renderModelThumbnail } from './model-thumbnail';
import { CharacterModel } from '../entities/model.entity';
import { AppConfig } from '../config/configuration';
@@ -38,6 +38,21 @@ import { QueryModelDto, UpdateModelDto } from './model.dto';
import { ModelDto, toModelDto } from './model.serializer';
import { inspectVrm } from './vrm-inspector';
/** 判断是否为「数据超长」类数据库错误(MySQL 1406 / MariaDB ER_DATA_TOO_LONG) */
function isDataTooLongError(error: unknown): boolean {
const err = error as {
code?: string;
errno?: number;
driverError?: { code?: string; errno?: number };
message?: string;
};
const code = err.driverError?.code ?? err.code;
const errno = err.driverError?.errno ?? err.errno;
if (errno === 1406) return true;
if (typeof code === 'string' && /DATA_TOO_LONG/i.test(code)) return true;
return typeof err.message === 'string' && /Data too long/i.test(err.message);
}
@Injectable()
export class ModelsService {
private readonly logger = new Logger(ModelsService.name);
@@ -202,7 +217,7 @@ export class ModelsService {
// 上传一律待审核:模型必须过审核才可能被客户端消费
status: PublishStatus.Pending,
});
const saved = await this.models.save(model);
const saved = await this.saveWithStatsFallback(model);
this.logger.log(
`模型已入库待审:#${saved.id} ${name}(${imported.format},${imported.direct ? '直接导入' : '转换'})` +
` 面数=${stats.triangles} 骨骼=${stats.bones} humanoid=${stats.humanoidMapped}/${stats.humanoidTotal}` +
@@ -241,7 +256,7 @@ export class ModelsService {
if (thumbnailAssetId !== null) model.thumbnailAssetId = thumbnailAssetId;
if (stats.vrmVersion) model.vrmVersion = stats.vrmVersion;
const saved = await this.models.save(model);
const saved = await this.saveWithStatsFallback(model);
this.logger.log(
`模型 #${id} 重新分析完成:面数=${stats.triangles} 骨骼=${stats.bones} ` +
`humanoid=${stats.humanoidMapped}/${stats.humanoidTotal} 可驱动=${stats.drivable}` +
@@ -251,6 +266,30 @@ export class ModelsService {
return dto;
}
/**
* 保存模型;若因 `stats` 超长被数据库拒绝,裁剪后重试一次。
*
* 骨骼树上千根的大模型并不罕见:TEXT(64KB) 时代实测 643 骨骼的 PMX 就会
* 触发 1406 报错,而这一步抛出去就是 500。列已迁到 MEDIUMTEXT,
* 这里再兜一层,保证「分析结果太大」永远不会让上传失败。
*/
private async saveWithStatsFallback(model: CharacterModel): Promise<CharacterModel> {
try {
return await this.models.save(model);
} catch (error) {
if (!isDataTooLongError(error)) throw error;
const original = model.stats as unknown as ModelStats | null;
if (!original) throw error;
const shrunk = shrinkStatsForStorage(original);
this.logger.warn(
`模型「${model.name}」分析结果超出数据库字段上限(${JSON.stringify(original).length} 字节),` +
`已裁剪骨骼树至 ${shrunk.boneTree.length} 条后重试`,
);
model.stats = shrunk as unknown as Record<string, unknown>;
return await this.models.save(model);
}
}
/** 分析模型;任何异常都降级为一条 warning,不阻断上传 */
private async analyzeSafely(
absolutePath: string,