Files
opendance-web/web-server/test/admin-port.spec.ts
T
zijunchiang bba5bcf59e feat(web): 端口分离 + VMD 动作支持;契约升至 0.2.0
需求 1:管理端端口与软件 API 端口分离
- 软件 API 端口 PORT(默认 3000)只提供 /api、/health、/static、/contracts,
  不提供管理页面(未命中路径 404);管理端端口 ADMIN_PORT(默认 3001)
  提供管理页面与全部接口;ADMIN_HOST 可单独限制管理端来源
- main.ts 同进程监听两个端口,用 req.socket.localPort 区分端口行为;
  ADMIN_PORT=PORT 为单端口兼容模式(只创建一个 server,无 EADDRINUSE)
- 端口非法在生产环境中止启动;ADMIN_PORT=PORT 只 warn
- 启动日志分别打印两个端口的访问地址

需求 2:动作数据支持 VMD(MikuMikuDance)
- vmd-parser.ts:二进制解析(30 字节头 + Shift-JIS 模型名 + 111 字节/条骨骼
  关键帧 + morph 段)、MMD 贝塞尔插值、GBK 模型名回退、固定 30fps
- mmd-skeleton.ts:标准 MMD 骨架;静止比例从参考 PMX 骨骼段实测得到
  (足首 1.43 / 膝 6.57 / 髋 11.50 / 首 16.21 / 頭 17.16,腿长 10.09)
- mmd-pose.ts:FK + 两骨骼解析腿部 IK(膝盖朝正面,不可达收敛为伸直)
  + 左手系→右手系 Z 镜像(由真实数据对照实验确定:94.1% vs 5.9%)
- retarget.ts:抽出 worldFramesToKeypointFrames(),BVH 与 VMD 共用重定向
- MotionFormat 增 vmd、扩展名映射增 .vmd;管理端上传支持 .vmd
- scripts/inspect-vmd.mjs:上传前自查工具

契约:rest-api / data-models / model-formats → 0.2.0(另两库已同步逐字节一致)

验证:85 项单测通过(VMD 31 项);契约与 29 条路由一致;typecheck/build 通过;
真实样本经 HTTP 端到端:霜雪千年 2901 帧(IK 腿)、恋爱循环 4604 帧(FK 腿)
均 preprocessState=done、0 NaN、0 越界(测试数据已从数据库清理)。
2026-09-15 08:47:37 +08:00

152 lines
5.2 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 { 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('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);
});
});