Files
opendance-web/web-server/src/main.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

195 lines
7.5 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 { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import express, { NextFunction, Request, Response } from 'express';
import { createServer, Server } from 'node:http';
import { existsSync, mkdirSync } from 'node:fs';
import { extname, join, resolve } from 'node:path';
import { AppModule } from './app.module';
import { describeDualAccess, preferredUrl } from './config/access-hint';
import {
AppConfig,
configuration,
isAdminPortRequest,
validateEnv,
} from './config/configuration';
/** 关闭一个 HTTP server;未监听时直接返回。 */
function closeServer(server: Server | undefined): Promise<void> {
if (!server || !server.listening) return Promise.resolve();
return new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error ? rejectClose(error) : resolveClose()));
});
}
/** 在指定地址监听一个 HTTP server,失败时 reject(例如端口被占用)。 */
function listenServer(server: Server, port: number, host: string): Promise<void> {
return new Promise<void>((resolveListen, rejectListen) => {
const onError = (error: Error) => rejectListen(error);
server.once('error', onError);
server.listen(port, host, () => {
server.off('error', onError);
resolveListen();
});
});
}
async function bootstrap(): Promise<void> {
const logger = new Logger('Bootstrap');
// 启动前校验环境变量(生产环境禁止 DB_SYNCHRONIZE=true、端口必须合法等)
const { errors, warnings } = validateEnv(configuration());
for (const warning of warnings) logger.warn(`环境变量提示:${warning}`);
if (errors.length > 0) {
for (const error of errors) logger.warn(`环境变量问题:${error}`);
if ((process.env.NODE_ENV ?? 'development') === 'production') {
logger.error('生产环境配置校验失败,启动中止');
process.exit(1);
}
}
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log'],
});
const config = app.get(AppConfig);
const apiPort = config.port;
const adminPort = config.adminPort;
const adminHost = config.adminHost;
// 单端口兼容模式:ADMIN_PORT === PORT 时只创建一个 HTTP server,避免 EADDRINUSE
const sharedPort = adminPort === apiPort;
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
transformOptions: { enableImplicitConversion: false },
}),
);
// Windows 客户端与管理端跨源访问
app.enableCors({
origin: true,
credentials: false,
exposedHeaders: ['Content-Disposition'],
});
// 上传文件静态访问:/static/<kind>/<yyyy>/<mm>/<sha256>.<ext>
const storageDir = resolve(config.storageDir);
if (!existsSync(storageDir)) mkdirSync(storageDir, { recursive: true });
app.useStaticAssets(storageDir, { prefix: '/static/', index: false, fallthrough: true });
// 契约快照静态访问(若存在):/contracts/rest-api.yaml 等
if (config.contractsDir && existsSync(config.contractsDir)) {
app.useStaticAssets(resolve(config.contractsDir), { prefix: '/contracts/', index: false });
}
// ---- 管理端静态站点(只在管理端口生效)----
// 软件 API 端口(Windows/Godot 客户端、Android App 调用)只提供接口,
// 根路径 `/` 与任意前端路径都必须 404,避免把管理页面暴露给客户端调用的端口。
const adminDir = resolve(config.adminDir);
const adminIndex = join(adminDir, 'index.html');
const adminAvailable = existsSync(adminIndex);
if (adminAvailable) {
// 请求是否从管理端口进入:用实际建立连接的本地端口判断(express 实例由两个 server 共用)
const fromAdminPort = (req: Request): boolean =>
isAdminPortRequest(req.socket.localPort, apiPort, adminPort);
// 管理端构建产物(/assets/**、/index.html 等)
const adminStatic = express.static(adminDir, { index: 'index.html', fallthrough: true });
app.use((req: Request, res: Response, next: NextFunction) => {
if (!fromAdminPort(req)) return next();
adminStatic(req, res, next);
});
// SPA 深链接回退:/songs、/models 这类前端路由在磁盘上没有对应文件,
// 需要返回 index.html 交给前端路由处理。
// 注意必须放行后端路径,否则会吞掉 API 请求。
app.use((req: Request, res: Response, next: NextFunction) => {
if (!fromAdminPort(req)) return next();
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const path = req.path;
if (
path.startsWith('/api/') ||
path === '/health' ||
path.startsWith('/static/') ||
path.startsWith('/contracts/')
) {
return next();
}
// 带扩展名的请求视为静态资源,未命中就让它 404,不要回退成 HTML
if (extname(path) !== '') return next();
res.sendFile(adminIndex);
});
} else {
logger.warn(
`未找到管理端构建产物(${adminIndex}),本服务仅提供 API。` +
'如需管理界面,请部署 admin/ 目录并设置 ADMIN_DIR。',
);
}
app.enableShutdownHooks();
const db = config.db;
await app.listen(apiPort, config.bindHost);
// 第二个 HTTP server:与管理端口分开监听,复用同一个 express 实例
let adminServer: Server | undefined;
if (!sharedPort) {
adminServer = createServer(app.getHttpAdapter().getInstance());
await listenServer(adminServer, adminPort, adminHost);
}
// 让 app.close() 一并关闭管理端 server,保证优雅退出
const originalClose = app.close.bind(app);
app.close = async (): Promise<void> => {
await Promise.all([closeServer(adminServer), originalClose()]);
};
const access = describeDualAccess({
apiHost: config.bindHost,
apiPort,
adminHost,
adminPort,
});
const primaryApiUrl = preferredUrl(access.api);
const primaryAdminUrl = preferredUrl(access.admin);
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.api.listen}`);
// 明确打印两行:哪个端口给客户端调接口,哪个端口给浏览器看管理页面
logger.log(access.apiLine);
if (!sharedPort) logger.log(`管理端监听:${access.admin.listen}`);
logger.log(access.adminLine);
if (sharedPort) logger.warn(access.sharedNotice);
if (access.api.loopbackOnly) {
logger.warn(
`软件 API 地址:${access.api.urls[0]}(只能本机访问;` +
'要让外部访问,请在 .env 中设置 HOST=0.0.0.0 后重启)',
);
}
if (access.admin.loopbackOnly && !access.api.loopbackOnly) {
logger.warn(
`管理端地址:${access.admin.urls[0]}(只能本机访问;` +
'如需外部浏览器访问,请在 .env 中设置 ADMIN_HOST=0.0.0.0)',
);
}
logger.log(`健康检查:${primaryApiUrl}/health`);
logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`);
logger.log(`文件存储:${storageDir}`);
logger.log(
adminAvailable
? `管理端站点:${primaryAdminUrl}/(仅管理端口 ${adminPort} 提供)`
: '管理端站点:未部署(仅 API)',
);
logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);
}
void bootstrap().catch((error) => {
// eslint-disable-next-line no-console
console.error('服务启动失败:', error);
process.exit(1);
});