feat(api): cn-app skeleton, toolchain, env loading convention

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 19:14:41 +08:00
parent f77ab5ae4b
commit 7d250ec342
21 changed files with 1655 additions and 27 deletions
@@ -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',
});
});
});
+88
View File
@@ -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);
}
+37
View File
@@ -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('*');
}
}
+11
View File
@@ -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' };
}
}
+26
View File
@@ -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();
+37
View File
@@ -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();
}
}