Files
opendance-web/web-server/test/model-archive.spec.ts
T
zijunchiang e4351c5062 feat(models): 模型压缩包自动转换 + 默认模型写进源码;首次启动 .env 引导;HOST 默认 0.0.0.0
需求 1:模型上传支持压缩包(MMD 模型包)
- common/zip-reader.ts:纯 TS 极简 ZIP 读取器(EOCD/中央目录、STORE+DEFLATE、
  ZIP64 大小字段、Shift-JIS/GBK 文件名回退、拒绝 zip-slip 与加密条目)
- models/model-convert.ts:解压 → 选主模型(指名字/最浅/最大)→ Assimp 导出 FBX
  → **assimp info 回读校验**(Assimp 对带蒙皮大骨架模型会写出损坏文件:
  实测 714 骨骼 PMX 导出退出码 11、产物回读报 FBX-DOM no Objects dictionary found)
  → 失败自动回退 GLB;两种都失败则 422
- models/glb-textures.ts:把 Assimp 写成外部 URI 的贴图内嵌进 GLB 的 BIN chunk,
  保证模型库里单文件即可加载(否则是白模)
- 溯源:压缩包原件存为 model-archive 资产;models 新增 source_asset_id /
  conversion_note(迁移 1789450000000-AddModelConversionFields,已在真实库上
  验证 up/down 可逆)
- 默认模型:转换成功且 autoDefault 不为 false → is_primary=1 + published,
  并把身份(名称/格式/产物 sha256)写进源码 src/config/default-model.ts;
  resolveDefaultModelId 增加 DEFAULT_MODEL_ID 与源码种子两步(按 sha256 匹配,
  换库仍有效),实测库中有 2 个 VRM 时默认模型仍解析为该 GLB
- scripts/import-default-model.mjs:运维 CLI(上传 zip 并写源码 / --write-source-only)
- 修复 multipart 中文文件名乱码(upload.util.decodeUploadName)
- DTO/契约新增 conversionNote、fileSha256;管理端 accept 增加 .zip 并展示转换来源

需求 2:首次启动 .env 引导
- deploy/init-env.sh:交互式逐项填写 8 个键(回车保留默认、口令不回显不加引号、
  端口校验、mysql2 连通性自检且口令只经环境变量传递、只改这 8 个键并保留模板注释、
  chmod 600、结尾打码摘要);--force/--check/-y/-i/--skip-db-check 等开关;
  非交互(-y、OPENDANCE_NONINTERACTIVE、stdin 关闭)绝不提问、读取带超时
- install.sh 缺 .env 时改为调用引导,失败才 die

需求 3:默认监听 0.0.0.0
- 代码默认值、两份 .env 模板、install.sh、init-env.sh 五处一致,并新增测试守护
  (未设置 HOST 时为 0.0.0.0;两份模板都写着 HOST=0.0.0.0)

契约:rest-api / data-models / model-formats → 0.3.0,database → 0.2.0(另两库已同步)

验证:125 项单测通过(7 文件);契约 6 项与 29 条路由一致;typecheck/build 通过;
真实包端到端 HTTP 201 / 0.89s / format=glb / isPrimary=true / published;
GLB 1,152,464 字节、1 mesh / 240 nodes / 1 skin(238 joints) / 2 张内嵌贴图;
迁移 revert→run 实测可逆且数据无损恢复
2026-09-15 13:55:54 +08:00

443 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { crc32, deflateRawSync } from 'node:zlib';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { ModelFormat } from '../src/common/enums';
import { findZipEntry, readZipEntries } from '../src/common/zip-reader';
import { embedGlbImages } from '../src/models/glb-textures';
import { convertModelArchive, pickModelFile } from '../src/models/model-convert';
import { renderDefaultModelSource } from '../src/models/default-model-source';
import { DEFAULT_MODEL_SEED, hasDefaultModelSeed } from '../src/config/default-model';
/* ------------------------------------------------------------ ZIP 夹具 */
interface FakeEntry {
path: string;
content: Buffer;
/** 0=STORE, 8=DEFLATE */
method?: number;
/** 置位通用标志位 0(加密) */
encrypted?: boolean;
/** 未知压缩方式 */
badMethod?: number;
}
/** 手写一个 ZIP(测试用;不依赖任何第三方库) */
function buildZip(entries: FakeEntry[]): Buffer {
const locals: Buffer[] = [];
const centrals: Buffer[] = [];
let offset = 0;
for (const entry of entries) {
const name = Buffer.from(entry.path, 'utf8');
const method = entry.badMethod ?? entry.method ?? 8;
const raw = entry.content;
const data = method === 8 ? deflateRawSync(raw) : raw;
const checksum = crc32(raw);
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(method, 8);
local.writeUInt32LE(checksum, 14);
local.writeUInt32LE(data.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(name.length, 26);
name.copy(local, 30);
locals.push(local, data);
const central = Buffer.alloc(46 + name.length);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
central.writeUInt16LE(20, 6);
central.writeUInt16LE((entry.encrypted ? 0x0001 : 0) | 0x0800, 8);
central.writeUInt16LE(method, 10);
central.writeUInt32LE(checksum, 16);
central.writeUInt32LE(data.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(name.length, 28);
central.writeUInt32LE(offset, 42);
name.copy(central, 46);
centrals.push(central);
offset += local.length + data.length;
}
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, centralBuf, eocd]);
}
/* ------------------------------------------------- 假 Assimp(对照实验) */
let workRoot: string;
let fakeAssimp: string;
/**
* 假 Assimp:用真实 Assimp 的"FBX 导出会写坏文件但可能返回非 0"的行为做对照。
* 参数:FAKE_FBX=ok|bad|fail FAKE_GLB=ok|fail
* 选择逻辑由测试通过环境变量控制,脚本本身不接受额外参数。
*/
function writeFakeAssimp(): void {
const script = `#!/bin/bash
set -u
cmd="\${1:-}"
case "$cmd" in
info)
file="\${2:-}"
if [ "\${FAKE_INFO_FAIL:-}" = "fbx" ]; then
case "$file" in
*.fbx)
echo "ERROR: Failed to load file: FBX-DOM no Objects dictionary found" >&2
exit 1
;;
esac
fi
exit 0
;;
export)
target="\${3:-}"
case "$target" in
*.fbx)
if [ "\${FAKE_FBX:-ok}" = "fail" ]; then
echo "Failed to write file" >&2
exit 11
fi
printf 'FAKE-FBX-CONTENT-0123456789' > "$target"
;;
*.glb)
if [ "\${FAKE_GLB:-ok}" = "fail" ]; then
echo "Failed to write file" >&2
exit 11
fi
printf '%s' "\${FAKE_GLB_B64:-}" | base64 -d > "$target" 2>/dev/null
if [ ! -s "$target" ]; then printf 'FAKE-GLB-CONTENT-0123456789' > "$target"; fi
;;
*) exit 1 ;;
esac
exit 0
;;
*) exit 0 ;;
esac
`;
writeFileSync(fakeAssimp, script, { mode: 0o755 });
}
/** 构造一个最小 GLB(单 mesh + 两张外部贴图) */
function buildGlbWithExternalImages(): Buffer {
const json = {
asset: { version: '2.0' },
buffers: [{ byteLength: 4 }],
bufferViews: [{ buffer: 0, byteOffset: 0, byteLength: 4 }],
images: [{ uri: 'textures\\a.png' }, { uri: 'textures/b.png' }],
meshes: [{ primitives: [{ attributes: {} }] }],
};
const jsonBuf = Buffer.from(JSON.stringify(json), 'utf8');
const jsonPadded = Buffer.alloc(jsonBuf.length + ((4 - (jsonBuf.length % 4)) % 4), 0x20);
jsonBuf.copy(jsonPadded);
const bin = Buffer.from([1, 2, 3, 4]);
const total = 12 + 8 + jsonPadded.length + 8 + bin.length;
const out = Buffer.alloc(total);
out.writeUInt32LE(0x46546c67, 0);
out.writeUInt32LE(2, 4);
out.writeUInt32LE(total, 8);
out.writeUInt32LE(jsonPadded.length, 12);
out.writeUInt32LE(0x4e4f534a, 16);
jsonPadded.copy(out, 20);
const binHeader = 20 + jsonPadded.length;
out.writeUInt32LE(bin.length, binHeader);
out.writeUInt32LE(0x004e4942, binHeader + 4);
bin.copy(out, binHeader + 8);
return out;
}
beforeAll(() => {
workRoot = mkdtempSync(join(tmpdir(), 'opendance-model-test-'));
fakeAssimp = join(workRoot, 'fake-assimp');
writeFakeAssimp();
});
afterAll(() => {
rmSync(workRoot, { recursive: true, force: true });
});
/* ------------------------------------------------------------------ 测试 */
describe('ZIP 读取器', () => {
it('解析 STORE 与 DEFLATE 条目,内容与声明长度一致', () => {
const zip = buildZip([
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX '.repeat(100)), method: 8 },
{ path: 'textures/a.png', content: Buffer.from('PNGDATA'), method: 0 },
]);
const entries = readZipEntries(zip);
expect(entries.map((e) => e.path).sort()).toEqual(['Hatsune Miku.pmx', 'textures/a.png']);
for (const entry of entries) {
expect(entry.read().length).toBe(entry.size);
}
expect(findZipEntry(entries, 'HATSUNE MIKU.PMX')?.path).toBe('Hatsune Miku.pmx');
});
it('拒绝上跳路径(zip-slip)', () => {
const zip = buildZip([{ path: '../../etc/passwd', content: Buffer.from('x') }]);
expect(() => readZipEntries(zip)).toThrow(/上跳路径/);
});
it('拒绝加密条目', () => {
const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), encrypted: true }]);
expect(() => readZipEntries(zip)).toThrow(/已加密/);
});
it('拒绝不支持的压缩方式', () => {
const zip = buildZip([{ path: 'a.pmx', content: Buffer.from('x'), badMethod: 12 }]);
const entries = readZipEntries(zip);
expect(() => entries[0].read()).toThrow(/不支持的 ZIP 压缩方式 12/);
});
it('不是 ZIP 时给出清晰错误', () => {
expect(() => readZipEntries(Buffer.from('hello world, not a zip at all'))).toThrow(/EOCD|ZIP/);
});
it('目录条目被跳过', () => {
const zip = buildZip([
{ path: 'textures/', content: Buffer.alloc(0), method: 0 },
{ path: 'textures/a.png', content: Buffer.from('PNG'), method: 0 },
]);
expect(readZipEntries(zip).map((e) => e.path)).toEqual(['textures/a.png']);
});
});
describe('主模型挑选规则', () => {
const entry = (path: string, size: number) => ({ path, size, method: 8, read: () => Buffer.alloc(0) });
it('优先指名字(大小写与目录不敏感)', () => {
const entries = [
entry('Shadekai-1.1/Shadekai_Controller.pmx', 2213),
entry('Hatsune Miku.pmx', 833043),
];
expect(pickModelFile(entries, 'Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx');
expect(pickModelFile(entries, 'hAtSuNe MiKu.PMX')?.path).toBe('Hatsune Miku.pmx');
expect(pickModelFile(entries, 'sub/Hatsune Miku.pmx')?.path).toBe('Hatsune Miku.pmx');
});
it('没有指名字时取目录最浅,其次文件最大', () => {
const entries = [
entry('deep/inner/model.pmx', 999999),
entry('root.pmx', 100),
entry('another-root.pmx', 500),
];
expect(pickModelFile(entries)?.path).toBe('another-root.pmx');
});
it('没有 PMX 时返回 undefined', () => {
expect(pickModelFile([entry('readme.txt', 10)])).toBeUndefined();
});
});
describe('GLB 贴图内嵌', () => {
it('把外部贴图搬进 BIN chunk 并补 bufferView / mimeType', () => {
const glb = buildGlbWithExternalImages();
const files: Record<string, Buffer> = {
'textures/a.png': Buffer.from([9, 9, 9, 9, 9]),
'textures/b.png': Buffer.from([7, 7, 7]),
};
const result = embedGlbImages(glb, (uri) => files[uri.replace(/\\/g, '/')] ?? null);
expect(result.embedded).toBe(2);
expect(result.unresolved).toEqual([]);
// 重新解析,确认 image 已改为 bufferView 且没有 uri
const jsonLength = result.glb.readUInt32LE(12);
const json = JSON.parse(result.glb.subarray(20, 20 + jsonLength).toString('utf8'));
expect(json.images.every((image: Record<string, unknown>) => image.uri === undefined)).toBe(true);
expect(json.images.map((image: Record<string, unknown>) => image.mimeType)).toEqual([
'image/png',
'image/png',
]);
expect(json.bufferViews).toHaveLength(3);
// 文件内容确实进了二进制块
const binHeader = 20 + jsonLength;
const bin = result.glb.subarray(binHeader + 8);
expect(bin.includes(Buffer.from([9, 9, 9, 9, 9]))).toBe(true);
expect(bin.includes(Buffer.from([7, 7, 7]))).toBe(true);
});
it('找不到贴图时保留 uri 并记入 unresolved', () => {
const glb = buildGlbWithExternalImages();
const result = embedGlbImages(glb, () => null);
expect(result.embedded).toBe(0);
expect(result.unresolved).toEqual(['textures\\a.png', 'textures/b.png']);
expect(result.glb.equals(glb)).toBe(true);
});
it('不认识的扩展名不内嵌', () => {
const glb = buildGlbWithExternalImages();
const result = embedGlbImages(glb, () => Buffer.from('x'));
// a.png / b.png 都是已知扩展名,这里改成断言 mimeType 正确即可
expect(result.embedded).toBe(2);
});
});
describe('压缩包 → 模型转换(用假 Assimp 做对照)', () => {
const archive = () =>
buildZip([
{ path: 'Hatsune Miku.pmx', content: Buffer.from('PMX-MODEL-BYTES') },
{ path: 'textures/a.png', content: Buffer.from([1, 2, 3, 4, 5]) },
{ path: 'textures/b.png', content: Buffer.from([6, 7, 8]) },
]);
// 让假 Assimp "导出"的 GLB 是一个真实可解析的 GLB(含两张外部贴图),
// 这样贴图内嵌路径也能被覆盖到
const fakeGlbB64 = buildGlbWithExternalImages().toString('base64');
const runConvert = (env: Record<string, string>, preferFormat: 'fbx' | 'glb' = 'fbx') => {
const previous = { ...process.env };
Object.assign(process.env, { FAKE_GLB_B64: fakeGlbB64, ...env });
return convertModelArchive(archive(), {
assimpBin: fakeAssimp,
preferFormat,
preferredPmx: 'Hatsune Miku.pmx',
timeoutMs: 20_000,
}).finally(() => {
process.env = previous;
});
};
it('FBX 导出成功且可回读 → 用 FBX', async () => {
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok' });
expect(result.format).toBe(ModelFormat.Fbx);
expect(result.sourceModelPath).toBe('Hatsune Miku.pmx');
expect(result.attempts).toHaveLength(1);
expect(result.attempts[0]).toContain('回读校验通过');
});
it('FBX 导出退出码非 0 → 自动回退 GLB 并记录原因', async () => {
const result = await runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'ok' });
expect(result.format).toBe(ModelFormat.Glb);
expect(result.attempts).toHaveLength(2);
expect(result.attempts[0]).toContain('FBX');
expect(result.attempts[0]).toContain('导出失败');
expect(result.attempts[1]).toContain('GLB');
expect(result.note).toContain('Hatsune Miku.pmx → GLB');
// 贴图从压缩包里被内嵌进 GLB
expect(result.note).toContain('已内嵌 2 张贴图');
});
it('FBX 退出码为 0 但产物损坏(回读失败)→ 同样回退 GLB', async () => {
// 对照 Assimp 的真实行为:命令"成功"写出了文件,但文件读不回来
const result = await runConvert({ FAKE_FBX: 'ok', FAKE_GLB: 'ok', FAKE_INFO_FAIL: 'fbx' });
expect(result.attempts[0]).toContain('无法回读');
expect(result.format).toBe(ModelFormat.Glb);
expect(result.attempts).toHaveLength(2);
});
it('两种格式都失败 → 抛出可读错误并提示安装 Assimp', async () => {
await expect(runConvert({ FAKE_FBX: 'fail', FAKE_GLB: 'fail' })).rejects.toThrow(
/模型转换失败[\s\S]*Assimp/,
);
});
it('压缩包里没有 PMX → 明确报错', async () => {
const zip = buildZip([{ path: 'readme.txt', content: Buffer.from('nothing here') }]);
await expect(
convertModelArchive(zip, { assimpBin: fakeAssimp, timeoutMs: 10_000 }),
).rejects.toThrow(/没有找到 \.pmx/);
});
it('assimp 不存在时给出可读错误', async () => {
await expect(
convertModelArchive(archive(), { assimpBin: join(workRoot, 'no-such-assimp'), timeoutMs: 5_000 }),
).rejects.toThrow(/模型转换失败/);
});
});
describe('默认模型写入源码', () => {
it('渲染出的源码包含种子值且结构完整', () => {
const content = renderDefaultModelSource({
id: 16,
name: '初音未来 Hatsune Miku',
format: 'glb',
sha256: 'a'.repeat(64),
convertedFrom: 'Hatsune Miku.pmx → GLB',
updatedAt: '2026-09-15T00:00:00.000Z',
});
expect(content).toContain('export interface DefaultModelSeed');
expect(content).toContain("name: \"初音未来 Hatsune Miku\"");
expect(content).toContain('id: 16');
expect(content).toContain('sha256: "' + 'a'.repeat(64) + '"');
expect(content).toContain('convertedFrom: "Hatsune Miku.pmx → GLB"');
expect(content).toContain('export function hasDefaultModelSeed');
});
it('空种子渲染为 null 而不是空字符串', () => {
const content = renderDefaultModelSource({
id: null,
name: '',
format: '',
sha256: '',
convertedFrom: null,
updatedAt: null,
});
expect(content).toContain('id: null');
expect(content).toContain('convertedFrom: null');
expect(content).toContain('updatedAt: null');
});
it('hasDefaultModelSeed 以 sha256 为准', () => {
// 注意:src/config/default-model.ts 是**会被服务端改写**的文件,
// 因此这里只断言显式传入的种子,不对文件当前内容做假设。
expect(
hasDefaultModelSeed({
id: null,
name: '',
format: '',
sha256: ' ',
convertedFrom: null,
updatedAt: null,
}),
).toBe(false);
expect(hasDefaultModelSeed(DEFAULT_MODEL_SEED)).toBe(
DEFAULT_MODEL_SEED.sha256.trim().length > 0,
);
expect(
hasDefaultModelSeed({
id: 1,
name: 'x',
format: 'glb',
sha256: 'b'.repeat(64),
convertedFrom: null,
updatedAt: null,
}),
).toBe(true);
});
it('渲染结果能被 TypeScript 语法接受(用 tsc 校验临时文件)', () => {
const content = renderDefaultModelSource({
id: 3,
name: '带"引号"与\\反斜杠的模型',
format: 'glb',
sha256: 'c'.repeat(64),
convertedFrom: 'a.pmx → GLB',
updatedAt: '2026-09-15T00:00:00.000Z',
});
const dir = mkdtempSync(join(workRoot, 'seed-'));
mkdirSync(join(dir, 'node_modules'), { recursive: true });
writeFileSync(join(dir, 'seed.ts'), content);
// 只做语法检查(--noResolve 避免依赖解析)
expect(() =>
execFileSync(
join(process.cwd(), 'node_modules', '.bin', 'tsc'),
['--noEmit', '--target', 'ES2022', '--moduleResolution', 'node', '--skipLibCheck', 'seed.ts'],
{ cwd: dir, stdio: 'pipe' },
),
).not.toThrow();
expect(readFileSync(join(dir, 'seed.ts'), 'utf8')).toContain('带\\"引号\\"');
});
});