Files
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

171 lines
5.9 KiB
TypeScript
Raw Permalink 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 { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
configuration,
isAdminPortRequest,
isSamePort,
parsePort,
resolveAdminPort,
validateEnv,
} from '../src/config/configuration';
/**
* 管理端口与软件 API 端口必须分开:
* PORT 只提供接口,ADMIN_PORT(默认 PORT + 1)提供管理页面 + 全部接口,
* 同时允许 ADMIN_PORT === PORT 的单端口兼容模式。
*/
const PORT_KEYS = ['PORT', 'ADMIN_PORT', 'HOST', 'ADMIN_HOST'] as const;
let savedEnv: Record<string, string | undefined>;
beforeEach(() => {
savedEnv = {};
for (const key of PORT_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of PORT_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
}
});
/** 构造一份除端口外都合法的环境,便于单独验证端口校验。 */
function validEnv(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
nodeEnv: 'development',
port: 3000,
adminPort: 3001,
db: { host: '127.0.0.1', user: 'u', name: 'n', synchronize: false },
...overrides,
};
}
describe('端口解析与默认值', () => {
it('parsePort:未设置或空白用 fallback,设置了就取数字', () => {
expect(parsePort(undefined, 3000)).toBe(3000);
expect(parsePort('', 3000)).toBe(3000);
expect(parsePort(' ', 3000)).toBe(3000);
expect(parsePort('3111', 3000)).toBe(3111);
expect(Number.isNaN(parsePort('abc', 3000))).toBe(true);
});
it('resolveAdminPort:未设置 ADMIN_PORT 时默认 PORT + 1', () => {
expect(resolveAdminPort(3000, undefined)).toBe(3001);
expect(resolveAdminPort(3000, '')).toBe(3001);
expect(resolveAdminPort(3111, ' ')).toBe(3112);
expect(resolveAdminPort(3000, '4000')).toBe(4000);
});
it('configuration:默认端口 3000 / 管理端口 3001,管理端监听地址默认取 HOST', () => {
const env = configuration();
expect(env.port).toBe(3000);
expect(env.adminPort).toBe(3001);
expect(env.bindHost).toBe('0.0.0.0');
expect(env.adminHost).toBe('0.0.0.0');
});
it('configuration:PORT 改动后 ADMIN_PORT 跟着变成 PORT + 1', () => {
process.env.PORT = '3111';
const env = configuration();
expect(env.port).toBe(3111);
expect(env.adminPort).toBe(3112);
});
it('configuration:显式 ADMIN_PORT 优先于默认值', () => {
process.env.PORT = '3111';
process.env.ADMIN_PORT = '4111';
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');
process.env.ADMIN_HOST = '0.0.0.0';
expect(configuration().adminHost).toBe('0.0.0.0');
expect(configuration().bindHost).toBe('127.0.0.1');
});
});
describe('端口配置校验', () => {
it('合法端口不报任何问题', () => {
const { errors, warnings } = validateEnv(validEnv());
expect(errors).toEqual([]);
expect(warnings).toEqual([]);
});
it('PORT 非法(0、越界、非整数、NaN)都要报问题', () => {
for (const port of [0, 65536, 3000.5, Number.NaN]) {
const { errors } = validateEnv(validEnv({ port, adminPort: 3001 }));
expect(errors.some((error) => error.includes('PORT'))).toBe(true);
}
});
it('ADMIN_PORT 非法也要报问题', () => {
const { errors } = validateEnv(validEnv({ adminPort: 70000 }));
expect(errors.some((error) => error.includes('ADMIN_PORT'))).toBe(true);
});
it('ADMIN_PORT === PORT 只给 warn 级提示,不算错误(生产环境也不阻断)', () => {
const { errors, warnings } = validateEnv(
validEnv({ nodeEnv: 'production', port: 3000, adminPort: 3000 }),
);
expect(errors).toEqual([]);
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('未分端口');
});
it('原有的数据库校验保持不变', () => {
const { errors } = validateEnv({
nodeEnv: 'production',
port: 3000,
adminPort: 3000,
db: { host: '', user: '', name: '', synchronize: true },
});
expect(errors).toContain('DB_HOST 未设置');
expect(errors).toContain('DB_USER 未设置');
expect(errors).toContain('DB_NAME 未设置');
expect(errors.some((error) => error.includes('DB_SYNCHRONIZE'))).toBe(true);
});
});
describe('按请求端口区分管理端行为', () => {
it('isSamePort 只在两个端口相等且合法时为 true', () => {
expect(isSamePort(3000, 3000)).toBe(true);
expect(isSamePort(3000, 3001)).toBe(false);
expect(isSamePort(Number.NaN, Number.NaN)).toBe(false);
});
it('分端口时:管理端口是管理端,API 端口不是', () => {
expect(isAdminPortRequest(3112, 3111, 3112)).toBe(true);
expect(isAdminPortRequest(3111, 3111, 3112)).toBe(false);
expect(isAdminPortRequest(undefined, 3111, 3112)).toBe(false);
});
it('ADMIN_PORT === PORT 的兼容模式下所有请求都按管理端处理', () => {
expect(isAdminPortRequest(3111, 3111, 3111)).toBe(true);
expect(isAdminPortRequest(undefined, 3111, 3111)).toBe(true);
});
});