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 越界(测试数据已从数据库清理)。
This commit is contained in:
+96
-23
@@ -1,20 +1,47 @@
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from '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 { describeAccess } from './config/access-hint';
|
||||
import { AppConfig, configuration, validateEnv } from './config/configuration';
|
||||
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 problems = validateEnv(configuration());
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) logger.warn(`环境变量问题:${problem}`);
|
||||
// 启动前校验环境变量(生产环境禁止 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);
|
||||
@@ -26,6 +53,11 @@ async function bootstrap(): Promise<void> {
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -53,18 +85,29 @@ async function bootstrap(): Promise<void> {
|
||||
app.useStaticAssets(resolve(config.contractsDir), { prefix: '/contracts/', index: false });
|
||||
}
|
||||
|
||||
// ---- 管理端静态站点 ----
|
||||
// 本服务同时提供管理端页面,因此反向代理只需要把全部请求转发到本端口,
|
||||
// 不需要在代理层再单独配置一个静态站点(IIS / nginx 示例见 deploy/proxy/)。
|
||||
// ---- 管理端静态站点(只在管理端口生效)----
|
||||
// 软件 API 端口(Windows/Godot 客户端、Android App 调用)只提供接口,
|
||||
// 根路径 `/` 与任意前端路径都必须 404,避免把管理页面暴露给客户端调用的端口。
|
||||
const adminDir = resolve(config.adminDir);
|
||||
const adminIndex = join(adminDir, 'index.html');
|
||||
if (existsSync(adminIndex)) {
|
||||
app.useStaticAssets(adminDir, { index: 'index.html', fallthrough: true });
|
||||
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 (
|
||||
@@ -89,26 +132,56 @@ async function bootstrap(): Promise<void> {
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const db = config.db;
|
||||
await app.listen(config.port, config.bindHost);
|
||||
await app.listen(apiPort, config.bindHost);
|
||||
|
||||
const access = describeAccess(config.bindHost, config.port);
|
||||
const externalUrls = access.urls.filter((url) => !url.includes('127.0.0.1'));
|
||||
// 第二个 HTTP server:与管理端口分开监听,复用同一个 express 实例
|
||||
let adminServer: Server | undefined;
|
||||
if (!sharedPort) {
|
||||
adminServer = createServer(app.getHttpAdapter().getInstance());
|
||||
await listenServer(adminServer, adminPort, adminHost);
|
||||
}
|
||||
|
||||
logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.listen}`);
|
||||
if (access.loopbackOnly) {
|
||||
// 让 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(
|
||||
`可访问地址:${access.urls[0]}(只能本机访问;` +
|
||||
`软件 API 地址:${access.api.urls[0]}(只能本机访问;` +
|
||||
'要让外部访问,请在 .env 中设置 HOST=0.0.0.0 后重启)',
|
||||
);
|
||||
} else if (externalUrls.length > 0) {
|
||||
logger.log(`可访问地址:${externalUrls.join('、')}(管理端页面与 API 同一端口)`);
|
||||
}
|
||||
logger.log(`健康检查:${externalUrls[0] ?? access.urls[0]}/health`);
|
||||
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(
|
||||
existsSync(adminIndex)
|
||||
? `管理端站点:${externalUrls[0] ?? access.urls[0]}/(由本服务直接提供)`
|
||||
adminAvailable
|
||||
? `管理端站点:${primaryAdminUrl}/(仅管理端口 ${adminPort} 提供)`
|
||||
: '管理端站点:未部署(仅 API)',
|
||||
);
|
||||
logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`);
|
||||
|
||||
Reference in New Issue
Block a user