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
+39 -3
View File
@@ -22,6 +22,11 @@ interface FakeEntry {
encrypted?: boolean;
/** 未知压缩方式 */
badMethod?: number;
/**
* 直接指定文件名的原始字节,且**不置 UTF-8 标志位**(模拟 GBK / Shift-JIS 压缩包)。
* 中文 MMD 压缩包很常见:有的条目是 UTF-8、有的是 GBK,且都没打标志位。
*/
rawName?: Buffer;
}
/** 手写一个 ZIP(测试用;不依赖任何第三方库) */
@@ -31,7 +36,9 @@ function buildZip(entries: FakeEntry[]): Buffer {
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.path, 'utf8');
const name = entry.rawName ?? Buffer.from(entry.path, 'utf8');
// 给了 rawName 就当作「未声明 UTF-8」的老压缩包
const nameFlags = entry.rawName ? (entry.encrypted ? 0x0001 : 0) : (entry.encrypted ? 0x0001 : 0) | 0x0800;
const method = entry.badMethod ?? entry.method ?? 8;
const raw = entry.content;
const data = method === 8 ? deflateRawSync(raw) : raw;
@@ -40,7 +47,7 @@ function buildZip(entries: FakeEntry[]): Buffer {
const local = Buffer.alloc(30 + name.length);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(20, 4);
local.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 6);
local.writeUInt16LE(nameFlags, 6);
local.writeUInt16LE(method, 8);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(data.length, 18);
@@ -53,7 +60,7 @@ function buildZip(entries: FakeEntry[]): Buffer {
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8);
central.writeUInt16LE(nameFlags, 8);
central.writeUInt16LE(method, 10);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(data.length, 20);
@@ -220,6 +227,35 @@ describe('ZIP 读取器', () => {
expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx');
});
it('GBK 压缩包:Shift-JIS 解出私用区乱码时回退 GBK(发/髮 这类字不再丢字符)', () => {
// b'tex/\xf3\x8c.png' 是 GBK 的「tex/髮.png」;Shift-JIS 会把它解成一个
// 私用区不可见字符(不是 U+FFFD),旧实现会直接采信,导致贴图名对不上而丢图。
const zip = buildZip([
{ path: 'raw', content: Buffer.from('PMX '.repeat(50)), rawName: Buffer.from([0x74, 0x65, 0x78, 0x2f, 0xf3, 0x8c, 0x2e, 0x70, 0x6e, 0x67]) },
]);
const entries = readZipEntries(zip);
expect(entries.map((e) => e.path)).toEqual(['tex/髮.png']);
});
it('Shift-JIS 名字仍然优先按 Shift-JIS 解(日文 MMD 压缩包)', () => {
// b'\x83\x41\x83\x43.pmx' 是 Shift-JIS 的「アイ.pmx」(半角片假名判断的姊妹用例)
const zip = buildZip([
{ path: 'raw', content: Buffer.from('PMX '.repeat(50)), rawName: Buffer.from([0x83, 0x41, 0x83, 0x43, 0x2e, 0x70, 0x6d, 0x78]) },
]);
const entries = readZipEntries(zip);
expect(entries[0].path).toBe('アイ.pmx');
});
it('目录条目不会当成文件(避免解压时把目录当文件写)', () => {
const zip = buildZip([
{ path: 'spa/', content: Buffer.alloc(0), method: 0 },
{ path: 'spa/a.png', content: Buffer.from('PNG') },
{ path: '奥黛塔.pmx', content: Buffer.from('PMX '.repeat(50)) },
]);
const entries = readZipEntries(zip);
expect(entries.map((e) => e.path)).toEqual(['spa/a.png', '奥黛塔.pmx']);
});
it('拒绝上跳路径(zip-slip)', () => {
const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]);
expect(() => readZipEntries(zip)).toThrow(/上跳路径/);