145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import 'reflect-metadata';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { NestFactory } from '@nestjs/core';
|
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import helmet from 'helmet';
|
|
|
|
import { AppModule } from './app.module.js';
|
|
import { ApiErrorFilter } from './common/api-error.filter.js';
|
|
import { APP_CONFIG, type AppConfig } from './config/app-config.module.js';
|
|
import { AppLogger } from './logging/app-logger.js';
|
|
|
|
type RequestWithId = Request & {
|
|
requestId?: string;
|
|
};
|
|
|
|
export async function createApplication() {
|
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
|
bodyParser: false,
|
|
// 自定义日志器安装前保留 Nest 启动日志,避免配置错误没有可见诊断。
|
|
logger: ['error', 'warn', 'log'],
|
|
});
|
|
const config = app.get<AppConfig>(APP_CONFIG);
|
|
const logger = new AppLogger(config.LOG_LEVEL);
|
|
app.useLogger(logger);
|
|
|
|
if (config.trustedProxyCidrs.length > 0) {
|
|
// 仅在部署明确声明代理网段时信任转发头,避免伪造客户端 IP。
|
|
app.getHttpAdapter()
|
|
.getInstance()
|
|
.set('trust proxy', config.trustedProxyCidrs);
|
|
}
|
|
|
|
app.enableCors({
|
|
origin: config.WEB_ORIGIN,
|
|
credentials: true,
|
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
|
allowedHeaders: [
|
|
'Authorization',
|
|
'Content-Type',
|
|
'Idempotency-Key',
|
|
'If-Match',
|
|
'X-Atlas-Platform-Id',
|
|
'X-Request-Id',
|
|
],
|
|
exposedHeaders: ['X-Request-Id'],
|
|
});
|
|
// Helmet、body 限制、请求 ID 与全局错误过滤器必须在业务路由前注册。
|
|
app.use(helmet({ contentSecurityPolicy: false }));
|
|
app.useBodyParser('json', { limit: config.API_BODY_LIMIT_BYTES });
|
|
app.useBodyParser('urlencoded', {
|
|
limit: config.API_BODY_LIMIT_BYTES,
|
|
extended: true,
|
|
});
|
|
app.use(
|
|
(request: RequestWithId, response: Response, next: NextFunction) => {
|
|
const typedRequest = request;
|
|
const typedResponse = response;
|
|
const suppliedId = typedRequest.headers['x-request-id'];
|
|
// 合法调用方提供的 request ID 可贯穿上游链路;其他情况由服务端生成。
|
|
const requestId =
|
|
typeof suppliedId === 'string' &&
|
|
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
|
|
? suppliedId
|
|
: `req_${randomUUID()}`;
|
|
const startedAt = performance.now();
|
|
typedRequest.requestId = requestId;
|
|
typedResponse.setHeader('X-Request-Id', requestId);
|
|
typedResponse.on('finish', () => {
|
|
logger.log('http_request_completed', {
|
|
request_id: requestId,
|
|
method: typedRequest.method,
|
|
path: typedRequest.originalUrl,
|
|
status_code: typedResponse.statusCode,
|
|
duration_ms: Math.round(performance.now() - startedAt),
|
|
});
|
|
});
|
|
next();
|
|
},
|
|
);
|
|
app.useGlobalFilters(new ApiErrorFilter(logger));
|
|
app.setGlobalPrefix('api', { exclude: ['health', 'ready'] });
|
|
app.enableShutdownHooks();
|
|
|
|
if (config.swaggerEnabled) {
|
|
// Swagger 仅描述运行时已注册路由;冻结接口契约仍以 docs/api 为准。
|
|
const swaggerConfig = new DocumentBuilder()
|
|
.setTitle('Atlas Seller Kit API')
|
|
.setDescription('Atlas 卖家工作台 HTTP API 文档')
|
|
.setVersion('0.1.0')
|
|
.addBearerAuth(
|
|
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
'access-token',
|
|
)
|
|
.build();
|
|
const swaggerDocument = SwaggerModule.createDocument(
|
|
app,
|
|
swaggerConfig,
|
|
);
|
|
SwaggerModule.setup('api-docs', app, swaggerDocument, {
|
|
swaggerOptions: { persistAuthorization: true },
|
|
});
|
|
}
|
|
|
|
return { app, config, logger };
|
|
}
|
|
|
|
export async function bootstrap() {
|
|
const { app, config, logger } = await createApplication();
|
|
await app.listen(config.API_PORT);
|
|
logger.log('api_started', {
|
|
port: config.API_PORT,
|
|
swagger_enabled: config.swaggerEnabled,
|
|
});
|
|
}
|
|
|
|
// 测试复用应用工厂时不自动监听固定端口,只有直接运行入口才启动服务。
|
|
if (
|
|
process.argv[1] &&
|
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
) {
|
|
void bootstrap().catch((error: unknown) => {
|
|
const details =
|
|
error instanceof Error
|
|
? {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
}
|
|
: error;
|
|
console.error(
|
|
JSON.stringify({
|
|
timestamp: new Date().toISOString(),
|
|
level: 'error',
|
|
message: 'api_startup_failed',
|
|
error: details,
|
|
}),
|
|
);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|