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,39 @@
|
||||
// services/api/jest.config.ts —— 单测(*.spec.ts,排除 e2e)。
|
||||
// ts-jest 保持类型检查模式(非 isolatedModules),@ts-expect-error 负向断言可生效。
|
||||
// env 约定:setupFiles 经 test/load-env.ts 载入仓库根 .env(全仓库唯一 env 文件)。
|
||||
// ESM 互操作:@photo/contracts 为 ESM 包(NodeNext + .js 后缀导入),测试经
|
||||
// moduleNameMapper 直接指向其 TS 源码,由 ts-jest 以 CJS 编译(同
|
||||
// packages/contracts/jest.config.cjs 的 .js -> $1 映射模式);运行时(node dist)
|
||||
// 则经 require(esm) 消费其 dist(Node >= 22.12 支持)。
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
setupFiles: ['./test/load-env.ts'],
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
module: 'commonjs',
|
||||
moduleResolution: 'node10',
|
||||
target: 'es2022',
|
||||
lib: ['es2022'],
|
||||
strict: true,
|
||||
esModuleInterop: true,
|
||||
skipLibCheck: true,
|
||||
types: ['node', 'jest'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
testMatch: ['<rootDir>/src/**/*.spec.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,11 @@
|
||||
// services/api/jest.e2e.config.ts —— e2e(test/*.e2e-spec.ts),复用单测配置,
|
||||
// 仅切换 testMatch;隔离工具见 test/reset.ts(每个 spec 的 beforeAll 调用)。
|
||||
import type { Config } from 'jest';
|
||||
import baseConfig from './jest.config';
|
||||
|
||||
const config: Config = {
|
||||
...baseConfig,
|
||||
testMatch: ['<rootDir>/test/**/*.e2e-spec.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"entryFile": "main-cn"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@photo/api-cn",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CN NestJS API service (P1a skeleton: bootstrap, /v1/health, request-id, error envelope)",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "tsc -p tsconfig.build.json && node dist/main-cn.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config jest.e2e.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.6",
|
||||
"@nestjs/core": "^10.4.6",
|
||||
"@nestjs/platform-express": "^10.4.6",
|
||||
"@photo/contracts": "workspace:*",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"pino": "^10.0.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^10.4.6",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"dotenv": "^16.4.5",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// services/api/test/health.e2e-spec.ts
|
||||
// e2e 冒烟:验证 e2e 工装(Nest testing module + supertest + 根 .env 加载)可用。
|
||||
// 不监听端口:app.init() 后直接对 app.getHttpServer() 发请求。
|
||||
// 全局前缀与 main-cn.ts 保持一致('v1'),由 spec 显式设置。
|
||||
import { type INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { AppCnModule } from '../src/app-cn.module';
|
||||
import { resetDb } from './reset';
|
||||
|
||||
describe('GET /v1/health (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
await resetDb();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppCnModule],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('v1');
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('responds 200 {"status":"ok"}', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/v1/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ status: 'ok' });
|
||||
});
|
||||
|
||||
it('sets x-request-id response header (generated when absent)', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/v1/health');
|
||||
const requestId = res.headers['x-request-id'];
|
||||
expect(typeof requestId).toBe('string');
|
||||
expect(requestId.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('echoes incoming x-request-id header', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/v1/health')
|
||||
.set('x-request-id', 'e2e-fixed-request-id');
|
||||
expect(res.headers['x-request-id']).toBe('e2e-fixed-request-id');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
// services/api/test/load-env.ts
|
||||
// 全仓库唯一 env 加载机制(Jest 侧):仓库根 .env。开发/迁移脚本经根 dotenv-cli
|
||||
// 注入;运行时代码只读 process.env。worker/storage 包的 test/load-env.ts 复制同约定。
|
||||
import { join } from 'node:path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config({ path: join(__dirname, '../../../.env') });
|
||||
@@ -0,0 +1,17 @@
|
||||
// services/api/test/reset.ts
|
||||
// e2e 共用隔离工具:每个 e2e spec 的 beforeAll 调用 resetDb(),防止 spec 间
|
||||
// 唯一约束冲突与脏数据。
|
||||
// TODO(Task 8): 用 Prisma 实现 RESET_TABLES 中全部表的 truncate-if-exists
|
||||
// (Task 8-10 逐步登记表名,届时空列表不再成立)。
|
||||
// TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。
|
||||
export const RESET_TABLES: readonly string[] = [];
|
||||
|
||||
export async function resetDb(): Promise<void> {
|
||||
if (RESET_TABLES.length === 0) {
|
||||
// 尚未登记任何表(Task 8 前数据库无表):干净地跳过。
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'resetDb: RESET_TABLES is non-empty but truncation is not implemented until Task 8',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user