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
+39
View File
@@ -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;
+11
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"entryFile": "main-cn"
}
+37
View File
@@ -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',
});
});
});
+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();
}
}
+47
View File
@@ -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');
});
});
+7
View File
@@ -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') });
+17
View File
@@ -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',
);
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.e2e-spec.ts"]
}
+19
View File
@@ -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"]
}