Files
opendance-web/web-server/src/config/access-hint.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

107 lines
4.1 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 { networkInterfaces } from 'node:os';
/** 服务实际监听地址与外界可用的访问地址。 */
export interface AccessHint {
/** 供日志展示的监听描述,例如 `0.0.0.0:3000(全部网卡,外部可访问)`。 */
listen: string;
/** 可访问地址,第一个恒为回环地址。 */
urls: string[];
/** 是否只监听回环(此时外部一定访问不到)。 */
loopbackOnly: boolean;
}
/** 软件 API 端口对外提供的接口路径范围,用于启动日志。 */
const API_SCOPE = '/api、/health、/static、/contracts';
/** 双端口模式(软件 API 端口 + 管理端端口)的访问提示。 */
export interface DualAccessHint {
/** 软件 API 端口(Windows/Godot 客户端、Android App 调用)。 */
api: AccessHint;
/** 管理端端口(浏览器访问)。 */
admin: AccessHint;
/** 是否为单端口兼容模式(ADMIN_PORT === PORT)。 */
sharedPort: boolean;
/** 是否只监听回环(两个端口都只在本机可访问时才为 true)。 */
loopbackOnly: boolean;
/** 启动日志第一行:软件 API 端口。 */
apiLine: string;
/** 启动日志第二行:管理端端口。 */
adminLine: string;
/** 单端口兼容模式下的提示文案;未分端口时为空串。 */
sharedNotice: string;
}
/** describeDualAccess 的入参。 */
export interface DualAccessOptions {
apiHost: string;
apiPort: number;
adminHost: string;
adminPort: number;
}
/**
* 把绑定地址翻译成"人能看懂、能直接用"的访问地址。
*
* 绑定 0.0.0.0 时,日志里写 http://0.0.0.0:3000 是没有意义的(浏览器打不开),
* 必须换成真实网卡地址,否则排障时会被误导——"服务已启动"但没人知道该访问哪个 IP。
*/
export function describeAccess(bindHost: string, port: number): AccessHint {
const wildcard = ['0.0.0.0', '::', '*', ''].includes(bindHost);
if (!wildcard) {
const loopbackOnly = ['127.0.0.1', 'localhost', '::1'].includes(bindHost);
return {
listen: `${bindHost}:${port}(${loopbackOnly ? '仅本机可访问' : '仅该地址可访问'})`,
urls: [`http://${bindHost}:${port}`],
loopbackOnly,
};
}
const urls = [`http://127.0.0.1:${port}`];
for (const addresses of Object.values(networkInterfaces())) {
for (const address of addresses ?? []) {
if (address.family === 'IPv4' && !address.internal) {
urls.push(`http://${address.address}:${port}`);
}
}
}
return { listen: `0.0.0.0:${port}(全部网卡,外部可访问)`, urls, loopbackOnly: false };
}
/** 取一个"外部设备也能访问"的地址优先展示;没有真实网卡时回落到回环地址。 */
export function preferredUrl(hint: AccessHint): string {
const external = hint.urls.find((url) => !/127\.0\.0\.1|localhost|\[::1\]/.test(url));
return external ?? hint.urls[0];
}
/**
* 描述"软件 API 端口 + 管理端端口"两个端口,供启动日志直接打印。
*
* 客户端(Windows/Godot、Android)与管理端(浏览器)的职责不同,
* 必须分别告诉使用者该访问哪个地址、该地址提供什么,避免把管理页面
* 挂到客户端调用的端口上。
*/
export function describeDualAccess(options: DualAccessOptions): DualAccessHint {
const api = describeAccess(options.apiHost, options.apiPort);
const admin = describeAccess(options.adminHost, options.adminPort);
const sharedPort = options.adminPort === options.apiPort;
const apiLine = `软件 API 端口:${preferredUrl(api)}(${API_SCOPE})`;
const adminLine = sharedPort
? `管理端端口: ${preferredUrl(admin)}(未分端口,与软件 API 同一端口;` +
'浏览器访问,含管理页面与全部接口)'
: `管理端端口: ${preferredUrl(admin)}(浏览器访问,含管理页面与全部接口)`;
return {
api,
admin,
sharedPort,
loopbackOnly: api.loopbackOnly || admin.loopbackOnly,
apiLine,
adminLine,
sharedNotice: sharedPort
? `ADMIN_PORT 与 PORT 相同(${options.apiPort}),未分端口:` +
'管理页面与软件 API 共用同一端口,行为与旧版一致'
: '',
};
}