feat(api): cn-app skeleton, toolchain, env loading convention
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// services/api/src/all-exceptions.filter.spec.ts
|
||||
// 过滤器信封映射单测:HttpException -> ErrorCodes 映射、未知异常不泄露内部信息。
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
|
||||
// 测试不断言日志内容,静音 Nest 控制台日志以保持输出干净。
|
||||
Logger.overrideLogger(false);
|
||||
|
||||
interface Captured {
|
||||
status?: number;
|
||||
body?: { code?: string; message?: string; requestId?: string };
|
||||
}
|
||||
|
||||
function runFilter(exception: unknown, requestId: string): Captured {
|
||||
const captured: Captured = {};
|
||||
const res = {
|
||||
status: (code: number) => {
|
||||
captured.status = code;
|
||||
return {
|
||||
json: (body: Captured['body']) => {
|
||||
captured.body = body;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const host = {
|
||||
switchToHttp: () => ({
|
||||
getResponse: () => res,
|
||||
getRequest: () => ({ id: requestId }),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
new AllExceptionsFilter().catch(exception, host);
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe('AllExceptionsFilter', () => {
|
||||
it.each([
|
||||
[new BadRequestException('title is required'), HttpStatus.BAD_REQUEST, ErrorCodes.VALIDATION_FAILED],
|
||||
[new UnauthorizedException(), HttpStatus.UNAUTHORIZED, ErrorCodes.AUTH_TOKEN_EXPIRED],
|
||||
[new ForbiddenException(), HttpStatus.FORBIDDEN, ErrorCodes.AUTH_FORBIDDEN],
|
||||
[new NotFoundException('album'), HttpStatus.NOT_FOUND, ErrorCodes.ALBUM_NOT_FOUND],
|
||||
])('maps HttpException %p to status %i / code %s', (exception, status, code) => {
|
||||
const captured = runFilter(exception, 'req-1');
|
||||
expect(captured.status).toBe(status);
|
||||
expect(captured.body?.code).toBe(code);
|
||||
expect(captured.body?.requestId).toBe('req-1');
|
||||
});
|
||||
|
||||
it('keeps class-validator style array messages readable', () => {
|
||||
const exception = new BadRequestException(['fieldA must be a string', 'fieldB must be positive']);
|
||||
const captured = runFilter(exception, 'req-2');
|
||||
expect(captured.status).toBe(HttpStatus.BAD_REQUEST);
|
||||
expect(captured.body?.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
expect(captured.body?.message).toBe(
|
||||
'fieldA must be a string, fieldB must be positive',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns INTERNAL_ERROR with generic message for unknown errors (no stack leak)', () => {
|
||||
const captured = runFilter(new Error('secret db failure'), 'req-3');
|
||||
expect(captured.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
expect(captured.body).toEqual({
|
||||
code: ErrorCodes.INTERNAL_ERROR,
|
||||
message: 'Internal server error',
|
||||
requestId: 'req-3',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// services/api/src/all-exceptions.filter.ts
|
||||
// 全局兜底异常过滤器:所有错误响应统一 {code, message, requestId} 信封。
|
||||
// code 取自 @photo/contracts ErrorCodes;HttpException 按状态码映射默认错误码
|
||||
// (后续任务可按异常类型精化),未知异常一律 INTERNAL_ERROR + 通用文案,
|
||||
// 完整错误仅写服务端日志,绝不向客户端泄露堆栈或内部细节。
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { type ErrorCode, ErrorCodes } from '@photo/contracts';
|
||||
|
||||
interface JsonRespondable {
|
||||
status(code: number): { json(body: unknown): void };
|
||||
}
|
||||
|
||||
interface RequestWithId {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
|
||||
[HttpStatus.BAD_REQUEST]: ErrorCodes.VALIDATION_FAILED,
|
||||
[HttpStatus.UNAUTHORIZED]: ErrorCodes.AUTH_TOKEN_EXPIRED,
|
||||
[HttpStatus.FORBIDDEN]: ErrorCodes.AUTH_FORBIDDEN,
|
||||
[HttpStatus.NOT_FOUND]: ErrorCodes.ALBUM_NOT_FOUND,
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const res = host.switchToHttp().getResponse<JsonRespondable>();
|
||||
const req = host.switchToHttp().getRequest<RequestWithId>();
|
||||
const requestId = req.id ?? 'unknown';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
const status = exception.getStatus();
|
||||
const code = STATUS_TO_ERROR_CODE[status] ?? ErrorCodes.INTERNAL_ERROR;
|
||||
const message = extractMessage(exception);
|
||||
this.logger.warn(
|
||||
`HttpException ${status} -> ${code} (requestId=${requestId}): ${message}`,
|
||||
);
|
||||
res.status(status).json({ code, message, requestId });
|
||||
return;
|
||||
}
|
||||
|
||||
const stack = exception instanceof Error ? exception.stack : undefined;
|
||||
this.logger.error(
|
||||
`Unhandled exception (requestId=${requestId}): ${stringifyException(exception)}`,
|
||||
stack,
|
||||
);
|
||||
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
code: ErrorCodes.INTERNAL_ERROR,
|
||||
message: 'Internal server error',
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessage(exception: HttpException): string {
|
||||
const body: unknown = exception.getResponse();
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
if (body !== null && typeof body === 'object' && 'message' in body) {
|
||||
const message = (body as { message: unknown }).message;
|
||||
if (typeof message === 'string' && message !== '') {
|
||||
return message;
|
||||
}
|
||||
if (Array.isArray(message)) {
|
||||
const parts = message.filter((part): part is string => typeof part === 'string');
|
||||
if (parts.length > 0) {
|
||||
return parts.join(', ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return exception.message;
|
||||
}
|
||||
|
||||
function stringifyException(exception: unknown): string {
|
||||
return exception instanceof Error
|
||||
? `${exception.name}: ${exception.message}`
|
||||
: String(exception);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// services/api/src/app-cn.module.ts
|
||||
// 根模块:日志(nestjs-pino / pino-http)、request-id 中间件、全局异常过滤器、
|
||||
// /v1/health。功能模块(auth/albums/uploads/photos/public)自 Task 12+ 起逐个加入。
|
||||
// 过滤器经 APP_FILTER、中间件经 NestModule 在模块层注册——e2e 直接 import 本模块
|
||||
// 即可获得与生产一致的管线(全局前缀除外,由入口设置)。
|
||||
import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common';
|
||||
import { APP_FILTER } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { HealthController } from './health.controller';
|
||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
|
||||
genReqId: (req: IncomingMessage, res: ServerResponse) =>
|
||||
resolveRequestId(req, res),
|
||||
redact: {
|
||||
paths: ['req.headers.authorization', 'res.headers.authorization'],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }],
|
||||
})
|
||||
export class AppCnModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// services/api/src/health.controller.ts
|
||||
// GET /v1/health —— 存活探针(P1a 仅此一个端点,后续任务逐步扩展)。
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check(): { status: 'ok' } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// services/api/src/main-cn.ts
|
||||
// cn-app 部署入口(P1a 仅此一个 API 入口)。构建产物 dist/main-cn.js 由根 dev:api /
|
||||
// 生产启动命令运行;env 一律来自进程环境(根 dotenv-cli 注入),此处不读文件。
|
||||
import 'reflect-metadata';
|
||||
import { type INestApplication } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AppCnModule } from './app-cn.module';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<INestApplication>(AppCnModule, {
|
||||
bufferLogs: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
app.setGlobalPrefix('v1');
|
||||
const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin !== '');
|
||||
app.enableCors({ origin: corsOrigins });
|
||||
const port = Number.parseInt(process.env.PORT ?? '', 10) || 8080;
|
||||
await app.listen(port);
|
||||
app.get(Logger).log(`api-cn listening on port ${port}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,37 @@
|
||||
// services/api/src/request-id.middleware.ts
|
||||
// 请求 ID 约定:优先透传上游 x-request-id(网关/前端可携带),否则生成 UUID v4;
|
||||
// 挂到 req.id(pino-http 日志字段)并写回响应头 x-request-id,便于全链路排障。
|
||||
// genReqId 与本中间件共用 resolveRequestId,先后顺序不影响结果。
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { NestMiddleware } from '@nestjs/common';
|
||||
|
||||
interface RequestWithId {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function resolveRequestId(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): string {
|
||||
const existing = (req as IncomingMessage & RequestWithId).id;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const header = req.headers['x-request-id'];
|
||||
const fromHeader = Array.isArray(header) ? header[0] : header;
|
||||
const id =
|
||||
typeof fromHeader === 'string' && fromHeader.trim() !== ''
|
||||
? fromHeader
|
||||
: randomUUID();
|
||||
(req as IncomingMessage & RequestWithId).id = id;
|
||||
res.setHeader('x-request-id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export class RequestIdMiddleware implements NestMiddleware {
|
||||
use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
|
||||
resolveRequestId(req, res);
|
||||
next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user