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;'); }); });