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 共用同一端口,行为与旧版一致' : '', }; }