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 { if (!server || !server.listening) return Promise.resolve(); return new Promise((resolveClose, rejectClose) => { server.close((error) => (error ? rejectClose(error) : resolveClose())); }); } /** 在指定地址监听一个 HTTP server,失败时 reject(例如端口被占用)。 */ function listenServer(server: Server, port: number, host: string): Promise { return new Promise((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 { 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(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////. 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 => { 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); });