From 76725e5ce37ce09f2b506bc189b6703ba88c55a2 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Tue, 15 Sep 2026 02:04:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(log):=20=E5=90=AF=E5=8A=A8=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=89=93=E5=8D=B0=E7=9C=9F=E5=AE=9E=E5=8F=AF=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E5=9C=B0=E5=9D=80=EF=BC=8C=E5=9B=9E=E7=8E=AF=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E6=97=B6=E6=98=BE=E5=BC=8F=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绑定 0.0.0.0 时日志直接拼 http://0.0.0.0:3000——浏览器打不开, 排障时只会看到"已启动"却不知道访问哪个 IP。 - 新增 src/config/access-hint.ts:describeAccess() 把绑定地址翻译成 监听描述 + 可访问地址(回环 / 具体网卡 / 通配取真实网卡 IPv4); 独立成模块是为了可测——main.ts 顶层会执行 bootstrap(),不能被 import - main.ts:启动日志改为「监听 …(全部网卡,外部可访问)」+ 真实访问地址; HOST=127.0.0.1 时用 warn 明确提示"只能本机访问"并给出修复方法 - 新增 test/access-log.spec.ts(4 项)覆盖三种绑定模式, 断言通配模式绝不出现 0.0.0.0 形式的 URL 实测三种模式日志输出正确;单测 35/35、契约与 29 条路由校验通过。 --- web-server/src/config/access-hint.ts | 39 +++++++++++++++++++++++++++ web-server/src/main.ts | 18 ++++++++++--- web-server/test/access-log.spec.ts | 40 ++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 web-server/src/config/access-hint.ts create mode 100644 web-server/test/access-log.spec.ts diff --git a/web-server/src/config/access-hint.ts b/web-server/src/config/access-hint.ts new file mode 100644 index 0000000..3ccd270 --- /dev/null +++ b/web-server/src/config/access-hint.ts @@ -0,0 +1,39 @@ +import { networkInterfaces } from 'node:os'; + +/** 服务实际监听地址与外界可用的访问地址。 */ +export interface AccessHint { + /** 供日志展示的监听描述,例如 `0.0.0.0:3000(全部网卡,外部可访问)`。 */ + listen: string; + /** 可访问地址,第一个恒为回环地址。 */ + urls: string[]; + /** 是否只监听回环(此时外部一定访问不到)。 */ + loopbackOnly: boolean; +} + +/** + * 把绑定地址翻译成"人能看懂、能直接用"的访问地址。 + * + * 绑定 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 }; +} diff --git a/web-server/src/main.ts b/web-server/src/main.ts index 8208ee7..c3ceb24 100644 --- a/web-server/src/main.ts +++ b/web-server/src/main.ts @@ -5,6 +5,7 @@ import { NextFunction, Request, Response } from 'express'; 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'; async function bootstrap(): Promise { @@ -90,13 +91,24 @@ async function bootstrap(): Promise { const db = config.db; await app.listen(config.port, config.bindHost); - logger.log(`OpenDance Studio Web 服务端已启动:http://${config.bindHost}:${config.port}`); - logger.log(`健康检查:http://${config.bindHost}:${config.port}/health`); + const access = describeAccess(config.bindHost, config.port); + const externalUrls = access.urls.filter((url) => !url.includes('127.0.0.1')); + + logger.log(`OpenDance Studio Web 服务端已启动,监听 ${access.listen}`); + if (access.loopbackOnly) { + logger.warn( + `可访问地址:${access.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`); logger.log(`数据库:${db.host}:${db.port}/${db.name}(现有实例,本服务不管理 MySQL)`); logger.log(`文件存储:${storageDir}`); logger.log( existsSync(adminIndex) - ? `管理端站点:http://${config.bindHost}:${config.port}/(由本服务直接提供)` + ? `管理端站点:${externalUrls[0] ?? access.urls[0]}/(由本服务直接提供)` : '管理端站点:未部署(仅 API)', ); logger.log(`契约版本:${JSON.stringify(config.contractVersions)}`); diff --git a/web-server/test/access-log.spec.ts b/web-server/test/access-log.spec.ts new file mode 100644 index 0000000..2b4e9de --- /dev/null +++ b/web-server/test/access-log.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { describeAccess } from '../src/config/access-hint'; + +/** + * 启动日志里"该访问哪个地址"必须与真实绑定一致。 + * 之前的写法直接把 bindHost 拼进 URL,绑定 0.0.0.0 时会打印 + * http://0.0.0.0:3000——浏览器打不开,排障时会被误导。 + */ +describe('启动日志的访问地址推导', () => { + it('绑定回环时只给回环地址,并明确标注仅本机可访问', () => { + for (const host of ['127.0.0.1', 'localhost', '::1']) { + const { listen, urls } = describeAccess(host, 3000); + expect(listen).toContain(host); + expect(listen).toContain('仅本机可访问'); + expect(urls).toEqual([`http://${host}:3000`]); + } + }); + + it('绑定通配地址时给出回环 + 真实网卡地址,且绝不出现 0.0.0.0', () => { + for (const host of ['0.0.0.0', '::', '*', '']) { + const { listen, urls } = describeAccess(host, 3000); + expect(listen).toContain('全部网卡'); + expect(urls[0]).toBe('http://127.0.0.1:3000'); + expect(urls.every((url) => !url.includes('0.0.0.0'))).toBe(true); + // 至少要有回环地址可用 + expect(urls.length).toBeGreaterThanOrEqual(1); + } + }); + + it('绑定具体网卡时只给该地址,并提示范围', () => { + const { listen, urls } = describeAccess('192.168.150.20', 8080); + expect(listen).toBe('192.168.150.20:8080(仅该地址可访问)'); + expect(urls).toEqual(['http://192.168.150.20:8080']); + }); + + it('端口号被完整带进每个地址', () => { + expect(describeAccess('0.0.0.0', 3123).urls.every((url) => url.endsWith(':3123'))).toBe(true); + expect(describeAccess('10.0.0.1', 80).urls).toEqual(['http://10.0.0.1:80']); + }); +});