Files
opendance-web/web-server/test/init-env.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

405 lines
13 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 { 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,
);
});