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:
@@ -0,0 +1,187 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CharacterModel } from '../src/entities/model.entity';
|
||||
import {
|
||||
MYSQL_TEXT_LIMIT,
|
||||
type BoneNode,
|
||||
type ModelStats,
|
||||
shrinkStatsForStorage,
|
||||
} from '../src/models/model-inspector';
|
||||
|
||||
/**
|
||||
* 骨骼树超出数据库字段上限导致的 500。
|
||||
*
|
||||
* 起因(真实故障):上传含 PMX 的压缩包(【奥黛塔】_by_原神.zip,643 根骨骼)
|
||||
* 返回 500。转换本身成功,但分析结果里骨骼树按骨骼逐条展开,JSON 实测
|
||||
* **70,980 字节** > `models.stats` 的 TEXT 上限 65,535,MySQL / MariaDB 严格模式
|
||||
* 直接拒绝写入(ER_DATA_TOO_LONG / 1406),而写入没有兜底。
|
||||
*
|
||||
* 这里锁三件事:
|
||||
* 1. 列必须是 MEDIUMTEXT(实体 + 迁移一致);
|
||||
* 2. 服务层对「数据超长」有裁剪重试兜底;
|
||||
* 3. 裁剪函数本身永不抛、且保留审核必需的汇总字段。
|
||||
*/
|
||||
|
||||
/** 造一个规模接近真实的骨骼树(【奥黛塔】实测 643 根、每根约 110 字节) */
|
||||
function buildBoneTree(count: number): BoneNode[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
index,
|
||||
name: `Bone_${String(index).padStart(4, '0')}_髪_腕捩_ねじ`,
|
||||
depth: Math.min(index % 40, 35),
|
||||
parentName: index === 0 ? null : `Bone_${String(Math.max(0, index - 1)).padStart(4, '0')}_髪_腕捩_ねじ`,
|
||||
childCount: index % 3,
|
||||
inSkin: index % 7 !== 0,
|
||||
humanoid: index < 7 ? ['hips', 'spine', 'chest', 'neck', 'head', 'leftShoulder', 'leftUpperArm'][index] : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildStats(bones: number): ModelStats {
|
||||
return {
|
||||
container: 'glb',
|
||||
vrmVersion: null,
|
||||
fileSize: 23_700_000,
|
||||
meshes: 12,
|
||||
primitives: 18,
|
||||
vertices: 120_000,
|
||||
triangles: 93_350,
|
||||
materials: 9,
|
||||
textures: 17,
|
||||
images: 17,
|
||||
animations: 0,
|
||||
morphTargets: 24,
|
||||
nodes: 700,
|
||||
bones,
|
||||
skins: 1,
|
||||
humanoidMapped: 0,
|
||||
humanoidTotal: 22,
|
||||
missingRequiredBones: ['hips', 'spine', 'chest', 'neck', 'head', 'leftUpperArm'],
|
||||
drivable: false,
|
||||
maxBoneDepth: 35,
|
||||
boneTree: buildBoneTree(bones),
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function findMigrationsDir(): string {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), 'src', 'migrations'),
|
||||
resolve(process.cwd(), '..', 'src', 'migrations'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const files = readdirSync(candidate).filter((f) => f.endsWith('.ts'));
|
||||
if (files.length > 0) return candidate;
|
||||
}
|
||||
throw new Error('找不到 src/migrations');
|
||||
}
|
||||
|
||||
describe('models.stats 字段容量', () => {
|
||||
it('643 根骨骼的分析结果确实超过 TEXT 上限(这就是 500 的原因)', () => {
|
||||
const json = JSON.stringify(buildStats(643));
|
||||
expect(json.length).toBeGreaterThan(MYSQL_TEXT_LIMIT);
|
||||
// 与真实故障量级一致(实测 70,980 字节)
|
||||
expect(json.length).toBeGreaterThan(65_000);
|
||||
expect(json.length).toBeLessThan(120_000);
|
||||
});
|
||||
|
||||
it('实体列型是 mediumtext,且能 JSON 往返(不再被 simple-json 钉死在 text)', () => {
|
||||
const column = getMetadataArgsStorage().columns.find(
|
||||
(item) => item.target === CharacterModel && item.propertyName === 'stats',
|
||||
);
|
||||
expect(column?.options.type).toBe('mediumtext');
|
||||
const transformer = column?.options.transformer as
|
||||
| { to?: (v: unknown) => unknown; from?: (v: unknown) => unknown }
|
||||
| undefined;
|
||||
expect(typeof transformer?.to).toBe('function');
|
||||
expect(typeof transformer?.from).toBe('function');
|
||||
const stats = buildStats(643);
|
||||
const stored = transformer!.to!(stats) as string;
|
||||
expect(typeof stored).toBe('string');
|
||||
expect(JSON.parse(stored).bones).toBe(643);
|
||||
expect((transformer!.from!(stored) as ModelStats).bones).toBe(643);
|
||||
// 脏数据不能让接口 500
|
||||
expect(transformer!.from!('{ 坏 JSON')).toEqual({ parseError: true, raw: '{ 坏 JSON' });
|
||||
});
|
||||
|
||||
it('存在把 stats 扩到 MEDIUMTEXT 的迁移(install.sh 会自动执行)', () => {
|
||||
const dir = findMigrationsDir();
|
||||
const file = readdirSync(dir).find((f) => /AddModelStatsMediumText.*\.ts$/.test(f));
|
||||
expect(file, '缺少 AddModelStatsMediumText 迁移').toBeTruthy();
|
||||
const sql = readFileSync(join(dir, file as string), 'utf8');
|
||||
// TS 源码里反引号是转义的,这里只断言关键字与方向
|
||||
expect(sql).toMatch(/MODIFY/i);
|
||||
expect(sql).toMatch(/mediumtext/i);
|
||||
expect(sql).toMatch(/ALTER TABLE/i);
|
||||
expect(sql).toMatch(/implements MigrationInterface/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shrinkStatsForStorage:写入前的兜底裁剪', () => {
|
||||
it('没超限时原样返回(不做无谓修改)', () => {
|
||||
const stats = buildStats(20);
|
||||
expect(shrinkStatsForStorage(stats)).toBe(stats);
|
||||
});
|
||||
|
||||
it('超限时裁剪骨骼树到能放下,并追加一条说明用的 warning', () => {
|
||||
const stats = buildStats(643);
|
||||
const shrunk = shrinkStatsForStorage(stats);
|
||||
expect(JSON.stringify(shrunk).length).toBeLessThanOrEqual(MYSQL_TEXT_LIMIT);
|
||||
expect(shrunk.boneTree.length).toBeLessThan(stats.boneTree.length);
|
||||
// 审核必需的汇总字段一个都不能少
|
||||
expect(shrunk.triangles).toBe(93_350);
|
||||
expect(shrunk.bones).toBe(643);
|
||||
expect(shrunk.humanoidMapped).toBe(0);
|
||||
expect(shrunk.humanoidTotal).toBe(22);
|
||||
expect(shrunk.drivable).toBe(false);
|
||||
expect(shrunk.missingRequiredBones).toEqual(stats.missingRequiredBones);
|
||||
expect(shrunk.warnings.join()).toContain('已截断');
|
||||
});
|
||||
|
||||
it('保留的骨骼仍是父在前的祖先链(审核界面按 depth 缩进不会错乱)', () => {
|
||||
const stats = buildStats(643);
|
||||
const shrunk = shrinkStatsForStorage(stats);
|
||||
const kept = shrunk.boneTree;
|
||||
expect(kept.length).toBeGreaterThan(0);
|
||||
for (let i = 1; i < kept.length; i += 1) {
|
||||
expect(kept[i].depth).toBeLessThanOrEqual(kept[i - 1].depth + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it('极端情况(连最小骨骼树都放不下)也不会抛,仍返回可存储的结果', () => {
|
||||
const stats = buildStats(400);
|
||||
const shrunk = shrinkStatsForStorage(stats, 512);
|
||||
expect(JSON.stringify(shrunk).length).toBeLessThanOrEqual(1024);
|
||||
expect(shrunk.triangles).toBe(93_350);
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务层兜底:数据超长时裁剪重试而不是 500', () => {
|
||||
const source = readFileSync(
|
||||
[
|
||||
resolve(process.cwd(), 'src', 'models', 'models.service.ts'),
|
||||
resolve(process.cwd(), '..', 'src', 'models', 'models.service.ts'),
|
||||
].find((p) => {
|
||||
try {
|
||||
readFileSync(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}) as string,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
it('create 与 reanalyze 都走 saveWithStatsFallback', () => {
|
||||
const calls = source.match(/saveWithStatsFallback\(model\)/g) ?? [];
|
||||
expect(calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('兜底只用 1406 / DATA_TOO_LONG 触发,其它数据库错误照旧抛出', () => {
|
||||
expect(source).toContain('isDataTooLongError');
|
||||
expect(source).toMatch(/errno === 1406/);
|
||||
expect(source).toMatch(/DATA_TOO_LONG/);
|
||||
expect(source).toContain('if (!isDataTooLongError(error)) throw error;');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user