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 实测可逆且数据无损恢复
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
configuration,
|
||||
@@ -79,6 +81,23 @@ describe('端口解析与默认值', () => {
|
||||
expect(configuration().adminPort).toBe(4111);
|
||||
});
|
||||
|
||||
it('需求约定:未设置 HOST 时默认监听 0.0.0.0,而不是 127.0.0.1', () => {
|
||||
delete process.env.HOST;
|
||||
delete process.env.ADMIN_HOST;
|
||||
const env = configuration();
|
||||
expect(env.bindHost).toBe('0.0.0.0');
|
||||
expect(env.adminHost).toBe('0.0.0.0');
|
||||
expect(env.bindHost).not.toBe('127.0.0.1');
|
||||
});
|
||||
|
||||
it('两份 .env 模板都写着 HOST=0.0.0.0(防止默认值被改回去)', () => {
|
||||
for (const file of [join(__dirname, '..', '..', 'deploy', '.env.example'), join(__dirname, '..', '.env.example')]) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
expect(content, file).toMatch(/^HOST=0\.0\.0\.0$/m);
|
||||
expect(content, file).not.toMatch(/^HOST=127\.0\.0\.1$/m);
|
||||
}
|
||||
});
|
||||
|
||||
it('configuration:ADMIN_HOST 显式设置时不再跟随 HOST', () => {
|
||||
process.env.HOST = '127.0.0.1';
|
||||
expect(configuration().adminHost).toBe('127.0.0.1');
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* deploy/init-env.sh —— Web 端首次启动的 .env 引导流程。
|
||||
*
|
||||
* 这些用例全部在临时目录里驱动真实脚本:
|
||||
* * 非交互场景必须“要么 0、要么非 0”,绝不允许挂住(spawnSync 带 timeout,
|
||||
* 一旦超时 r.error 会被置为 ETIMEDOUT,用例据此断言)。
|
||||
* * 不访问真实数据库:统一注入 OPENDANCE_SKIP_DB_CHECK=1。
|
||||
*/
|
||||
function findScript(): string {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), '..', 'deploy', 'init-env.sh'), // 从 web-server/ 运行(vitest 默认)
|
||||
resolve(process.cwd(), 'deploy', 'init-env.sh'), // 从仓库根运行
|
||||
resolve(process.cwd(), '..', '..', 'deploy', 'init-env.sh'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`找不到 deploy/init-env.sh,候选路径:\n${candidates.join('\n')}`);
|
||||
}
|
||||
|
||||
const SCRIPT = findScript();
|
||||
|
||||
/** 测试模板:含注释与“其它字段”,用于验证写回时不丢注释。 */
|
||||
const EXAMPLE = `# ============================================================
|
||||
# 测试用模板:这些注释必须被保留
|
||||
# ============================================================
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
# 管理端端口注释
|
||||
ADMIN_PORT=3001
|
||||
HOST=0.0.0.0
|
||||
# 数据库
|
||||
DB_HOST=<existing-mysql-host>
|
||||
DB_PORT=3306
|
||||
DB_USER=opendance_app
|
||||
DB_PASS=<部署方提供>
|
||||
DB_NAME=opendance
|
||||
DB_SYNCHRONIZE=false
|
||||
UPLOAD_MAX_BYTES=536870912
|
||||
`;
|
||||
|
||||
/** 一份“齐备”的 .env 内容。 */
|
||||
const COMPLETE_ENV =
|
||||
[
|
||||
'DB_HOST=10.0.0.5',
|
||||
'DB_PORT=3306',
|
||||
'DB_USER=u1',
|
||||
'DB_PASS=p1',
|
||||
'DB_NAME=d1',
|
||||
'PORT=3000',
|
||||
'ADMIN_PORT=3001',
|
||||
'HOST=0.0.0.0',
|
||||
].join('\n') + '\n';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeDir(withExample = true): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'opendance-init-env-'));
|
||||
tempDirs.push(dir);
|
||||
if (withExample) writeFileSync(join(dir, '.env.example'), EXAMPLE, 'utf8');
|
||||
return dir;
|
||||
}
|
||||
|
||||
function envPath(dir: string): string {
|
||||
return join(dir, '.env');
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
function run(
|
||||
args: string[],
|
||||
opts: { input?: string; env?: Record<string, string>; timeoutMs?: number } = {},
|
||||
): RunResult {
|
||||
const result = spawnSync('bash', [SCRIPT, ...args], {
|
||||
input: opts.input ?? '',
|
||||
encoding: 'utf8',
|
||||
timeout: opts.timeoutMs ?? 8000,
|
||||
env: { ...process.env, OPENDANCE_SKIP_DB_CHECK: '1', ...opts.env },
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
function outputOf(result: RunResult): string {
|
||||
return `${result.stdout}${result.stderr}`;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('init-env.sh:非交互模式绝不阻塞', () => {
|
||||
it(
|
||||
'缺必填 → 非 0 退出并列出字段名,且不阻塞',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// 人为缺 DB_USER / DB_PASS
|
||||
writeFileSync(envPath(dir), 'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n');
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined(); // 超时被杀则 error=ETIMEDOUT
|
||||
expect(result.status).not.toBe(0);
|
||||
const out = outputOf(result);
|
||||
expect(out).toContain('DB_USER');
|
||||
expect(out).toContain('DB_PASS');
|
||||
// 不修改文件
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(
|
||||
'DB_HOST=1.2.3.4\nDB_PORT=3306\nDB_NAME=opendance\n',
|
||||
);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'OPENDANCE_NONINTERACTIVE=1 与 --yes 同样跳过提问',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), 'DB_HOST=h\nDB_NAME=n\n'); // 缺 DB_USER / DB_PASS
|
||||
|
||||
const byEnv = run(['--dir', dir], {
|
||||
input: 'should-not-be-read\n',
|
||||
env: { OPENDANCE_NONINTERACTIVE: '1' },
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(byEnv.error).toBeUndefined();
|
||||
expect(byEnv.status).not.toBe(0);
|
||||
expect(outputOf(byEnv)).not.toContain('请输入');
|
||||
|
||||
const byFlag = run(['--yes', '--dir', dir], { timeoutMs: 5000 });
|
||||
expect(byFlag.error).toBeUndefined();
|
||||
expect(byFlag.status).not.toBe(0);
|
||||
expect(outputOf(byFlag)).not.toContain('请输入');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'全部齐备 → 0 退出且完全不修改文件',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(dir), 'utf8');
|
||||
const modeBefore = statSync(envPath(dir)).mode;
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
|
||||
expect(statSync(envPath(dir)).mode).toBe(modeBefore);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'.env 不存在 → 从 .env.example 建立基础,但仍因占位符未填而报错',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const result = run(['--non-interactive', '--dir', dir]);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
const out = outputOf(result);
|
||||
expect(out).toContain('已从模板创建');
|
||||
expect(out).toContain('DB_HOST'); // <existing-mysql-host>
|
||||
expect(out).toContain('DB_PASS'); // <部署方提供>
|
||||
// 文件确实被创建(作为用户后续填写的基础)
|
||||
expect(existsSync(envPath(dir))).toBe(true);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:交互式引导(printf ... | bash)', () => {
|
||||
it(
|
||||
'逐项回答后写回正确值,注释保留,ADMIN_PORT 默认 = PORT + 1',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// 顺序:DB_HOST DB_PORT DB_USER DB_PASS DB_NAME PORT ADMIN_PORT HOST
|
||||
const input =
|
||||
['db.example.com', '3307', 'dance_user', 'p@ss*word%', 'dance_db', '4100', '', ''].join(
|
||||
'\n',
|
||||
) + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
expect(content).toMatch(/^DB_HOST=db\.example\.com$/m);
|
||||
expect(content).toMatch(/^DB_PORT=3307$/m);
|
||||
expect(content).toMatch(/^DB_USER=dance_user$/m);
|
||||
expect(content).toMatch(/^DB_PASS=p@ss\*word%$/m);
|
||||
expect(content).toMatch(/^DB_NAME=dance_db$/m);
|
||||
expect(content).toMatch(/^PORT=4100$/m);
|
||||
expect(content).toMatch(/^ADMIN_PORT=4101$/m); // 默认 = PORT + 1
|
||||
expect(content).toMatch(/^HOST=0\.0\.0\.0$/m);
|
||||
|
||||
// 原有注释与其它字段必须保留
|
||||
expect(content).toContain('# 测试用模板:这些注释必须被保留');
|
||||
expect(content).toContain('# 管理端端口注释');
|
||||
expect(content).toContain('NODE_ENV=production');
|
||||
expect(content).toContain('DB_SYNCHRONIZE=false');
|
||||
expect(content).toContain('UPLOAD_MAX_BYTES=536870912');
|
||||
|
||||
// 口令落盘为 600
|
||||
expect(statSync(envPath(dir)).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'交互输入中途 EOF → 立即非 0 退出,不挂住',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const result = run(['--dir', dir], { input: 'only-a-host\n', timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined(); // 未被 timeout 杀掉
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(outputOf(result)).toContain('stdin');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'重复运行(--force + 同样输入)结果幂等',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
const input =
|
||||
['h1', '3306', 'u1', 'pw1', 'd1', '3000', '3001', '0.0.0.0'].join('\n') + '\n';
|
||||
|
||||
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
|
||||
const first = readFileSync(envPath(dir), 'utf8');
|
||||
expect(run(['--force', '--dir', dir], { input, timeoutMs: 10000 }).status).toBe(0);
|
||||
const second = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
expect(second).toBe(first);
|
||||
},
|
||||
20000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:字段校验', () => {
|
||||
it.each([
|
||||
['DB_PORT', '70000'],
|
||||
['PORT', 'abc'],
|
||||
['ADMIN_PORT', '70000'],
|
||||
])(
|
||||
'端口非法 %s=%s → 非 0 退出并报出字段名',
|
||||
(key, bad) => {
|
||||
const dir = makeDir();
|
||||
const lines = COMPLETE_ENV.trimEnd()
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith(`${key}=`));
|
||||
lines.push(`${key}=${bad}`);
|
||||
writeFileSync(envPath(dir), lines.join('\n') + '\n');
|
||||
|
||||
const result = run(['--non-interactive', '--dir', dir], { timeoutMs: 5000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(outputOf(result)).toContain(key);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'交互下端口非法会重新询问,最终接受合法值',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
// DB_PORT 连续给两个非法值,脚本应重新询问
|
||||
const input =
|
||||
['h', 'abc', '70000', '3306', 'u', 'p', 'd', '', '', ''].join('\n') + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toMatch(/^DB_PORT=3306$/m);
|
||||
expect(outputOf(result)).toContain('必须');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it.each([['6*yGoR9lHz510nl*'], ['a%b*c-d_e.f']])(
|
||||
'口令含特殊字符 %s:不加引号写盘且 set -a; . .env 能读回同一个值',
|
||||
(password) => {
|
||||
const dir = makeDir();
|
||||
const input = ['h', '3306', 'u', password, 'd', '', '', ''].join('\n') + '\n';
|
||||
|
||||
const result = run(['--dir', dir], { input, timeoutMs: 10000 });
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toContain(`DB_PASS=${password}`);
|
||||
// 不得给值加引号(否则 shell 语义可能变化)
|
||||
expect(content).not.toMatch(/^DB_PASS=["']/m);
|
||||
|
||||
// 真实地用 shell source 一次,确认读回同一个值
|
||||
const sourced = spawnSync(
|
||||
'bash',
|
||||
['-c', 'set -a; . "$1"; printf "%s" "$DB_PASS"', '_', envPath(dir)],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
expect(sourced.status).toBe(0);
|
||||
expect(sourced.stdout).toBe(password);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init-env.sh:--force / --check / 幂等', () => {
|
||||
it(
|
||||
'--force 在已有 .env 时重新生成,并提示 ADMIN_PORT === PORT 的单端口模式',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV); // 旧配置
|
||||
|
||||
const input =
|
||||
['newhost', '3406', 'newuser', 'newpass', 'newdb', '5100', '5100', '127.0.0.1'].join('\n') +
|
||||
'\n';
|
||||
const result = run(['--force', '--dir', dir], { input, timeoutMs: 10000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
const content = readFileSync(envPath(dir), 'utf8');
|
||||
expect(content).toMatch(/^DB_HOST=newhost$/m);
|
||||
expect(content).toMatch(/^DB_PORT=3406$/m);
|
||||
expect(content).toMatch(/^DB_USER=newuser$/m);
|
||||
expect(content).toMatch(/^DB_PASS=newpass$/m);
|
||||
expect(content).toMatch(/^DB_NAME=newdb$/m);
|
||||
expect(content).toMatch(/^PORT=5100$/m);
|
||||
expect(content).toMatch(/^ADMIN_PORT=5100$/m);
|
||||
expect(content).toMatch(/^HOST=127\.0\.0\.1$/m);
|
||||
|
||||
expect(outputOf(result)).toContain('单端口');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'已有完整 .env 且未加 --force:跳过询问、不修改文件(幂等)',
|
||||
() => {
|
||||
const dir = makeDir();
|
||||
writeFileSync(envPath(dir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(dir), 'utf8');
|
||||
|
||||
// 即使管道里塞了数据也应当忽略(因为完整且未 --force)
|
||||
const result = run(['--dir', dir], { input: '\n\n\n\n\n\n\n\n', timeoutMs: 8000 });
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status).toBe(0);
|
||||
expect(outputOf(result)).toContain('跳过询问');
|
||||
expect(readFileSync(envPath(dir), 'utf8')).toBe(before);
|
||||
},
|
||||
15000,
|
||||
);
|
||||
|
||||
it(
|
||||
'--check:合法则 0 且不改文件;缺字段则非 0',
|
||||
() => {
|
||||
const okDir = makeDir();
|
||||
writeFileSync(envPath(okDir), COMPLETE_ENV);
|
||||
const before = readFileSync(envPath(okDir), 'utf8');
|
||||
|
||||
const okResult = run(['--check', '--dir', okDir], { timeoutMs: 5000 });
|
||||
expect(okResult.error).toBeUndefined();
|
||||
expect(okResult.status).toBe(0);
|
||||
expect(readFileSync(envPath(okDir), 'utf8')).toBe(before);
|
||||
|
||||
const badDir = makeDir();
|
||||
writeFileSync(envPath(badDir), 'DB_HOST=h\n');
|
||||
const badResult = run(['--check', '--dir', badDir], { timeoutMs: 5000 });
|
||||
expect(badResult.error).toBeUndefined();
|
||||
expect(badResult.status).not.toBe(0);
|
||||
expect(outputOf(badResult)).toContain('DB_USER');
|
||||
expect(outputOf(badResult)).toContain('DB_PASS');
|
||||
},
|
||||
15000,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
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('带\\"引号\\"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user