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:
@@ -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(/上跳路径/);
|
||||
|
||||
Reference in New Issue
Block a user