Files
doodle-store/apps/server/src/app.setup.ts
T

113 lines
5.2 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { ValidationPipe } from '@nestjs/common';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { NextFunction, Response } from 'express';
import helmet from 'helmet';
import { API_PREFIX, type ServerConfig } from './config/server-config';
import { AppLogger } from './logging/app-logger';
import type { HttpRequest } from './common/http-request';
import { ApiErrorFilter } from './common/api-error.filter';
/** 在业务路由初始化前安装 HTTP 基础设施;生产和测试复用相同处理顺序。 */
export function configureApplication(
app: NestExpressApplication,
config: ServerConfig,
logger: AppLogger,
): void {
app.useLogger(logger);
const express = app.getHttpAdapter().getInstance();
express.disable('x-powered-by');
// 仅信任部署明确声明的代理地址,默认不采信客户端伪造的转发头。
if (config.trustedProxyCidrs.length)
express.set('trust proxy', config.trustedProxyCidrs);
// 解析 body 前安装,格式错误和超限响应也携带 requestId。
app.use((request: HttpRequest, response: Response, next: NextFunction) => {
const suppliedId = request.headers['x-request-id'];
// 合法上游 ID 可以贯穿调用链;其余输入替换为服务端生成的标识。
const requestId =
typeof suppliedId === 'string' &&
/^[A-Za-z0-9_-]{8,128}$/.test(suppliedId)
? suppliedId
: `req_${randomUUID()}`;
const startedAt = performance.now();
request.requestId = requestId;
response.setHeader('X-Request-Id', requestId);
// 登录和本人接口的成功/失败均禁止缓存,避免凭证或个人数据进入公共缓存。
if (/^\/api\/v1\/(auth|me)(?:\/|$)/.test(request.path))
response.setHeader('Cache-Control', 'no-store');
// 响应完成后记录状态与耗时;只记录 path,排除 URL 查询中的登录 code/token。
response.once('finish', () =>
logger.log('http_request_completed', {
request_id: requestId,
method: request.method,
path: request.path,
status_code: response.statusCode,
duration_ms: Math.round(performance.now() - startedAt),
}),
);
next();
});
// 开启 Swagger 时兼容其页面脚本;关闭文档时恢复 Helmet 默认 CSP。
app.use(
helmet(config.swaggerEnabled ? { contentSecurityPolicy: false } : {}),
);
// 小程序/服务端请求通常不带 Origin;浏览器请求按精确白名单开放。
// 使用 Bearer token,不启用 Cookie 凭证;CORS 不代替服务端鉴权。
app.enableCors({
origin: (origin, callback) =>
callback(null, !origin || config.corsOrigins.includes(origin)),
credentials: false,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Request-Id'],
exposedHeaders: ['X-Request-Id', 'Retry-After'],
});
// 应用工厂已禁用 Nest 默认解析器,此处统一限制 JSON 和表单请求大小。
// 表单不解析嵌套对象,上传文件另由后续 assets 模块处理。
app.useBodyParser('json', { limit: config.bodyLimitBytes });
app.useBodyParser('urlencoded', {
limit: config.bodyLimitBytes,
extended: false,
});
// DTO 拒绝未声明字段;不隐式转换类型,也不将原始参数值放进校验错误。
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
validationError: { target: false, value: false },
transformOptions: { enableImplicitConversion: false },
}),
);
// 统一错误格式;成功响应继续由控制器遵循公共契约,避免重复包装。
app.useGlobalFilters(new ApiErrorFilter(logger));
app.setGlobalPrefix(API_PREFIX);
app.enableShutdownHooks();
// 文档只包含当前实际注册的路由。它直接挂载在 Express 上,不经过 JWT 守卫。
if (config.swaggerEnabled) {
const options = new DocumentBuilder()
.setTitle('Doodle Store API')
.setDescription(
'统一业务 API;已接入微信静默登录、本人资料、账户鉴权与分类查询/管理',
)
.setVersion('0.1.0')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
// 默认声明 Bearer 鉴权,公开操作在各自 Swagger 元数据中显式覆盖。
.addSecurityRequirements('access-token')
.build();
SwaggerModule.setup(
'api-docs',
app,
SwaggerModule.createDocument(app, options),
{
// 刷新页面后不保留 token,避免共享浏览器残留授权凭证。
swaggerOptions: { persistAuthorization: false },
jsonDocumentUrl: 'api-docs-json',
},
);
}
}