feat(api): cn-app skeleton, toolchain, env loading convention
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,6 @@ S3_BUCKET=photo-dev
|
||||
SIGNED_URL_TTL_SECONDS=900
|
||||
AUTH_SESSION_TTL_SECONDS=2592000
|
||||
UPLOAD_MAX_BYTES=52428800
|
||||
PORT=8080
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -286,7 +286,7 @@ export interface OutboxEvent<T = unknown> {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 单测**:类型测试——合法 `OutboxEvent{type:'photo.published'}` 编译通过,非法 type 用 `@ts-expect-error` 验证编译期拒绝;**枚举同步测试**(Task 4 审查遗留):断言 `ErrorCode` 与 `components['schemas']['Error']['code']` 的 enum 双向可赋值。
|
||||
- [x] **Step 2: 单测**:类型测试——合法 `OutboxEvent{type:'photo.published'}` 编译通过,非法 type 用 `@ts-expect-error` 验证编译期拒绝;**枚举同步测试**(Task 4 审查遗留):断言 `ErrorCode` 与 `components['schemas']['Error']['code']` 的 enum 双向可赋值。(完成:8/8 通过,两护栏经变异验证真实生效)
|
||||
|
||||
```bash
|
||||
pnpm --filter @photo/contracts test
|
||||
@@ -294,8 +294,8 @@ pnpm --filter @photo/contracts test
|
||||
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 3: ⏸ 用户确认点:请用户过目 openapi.yaml(接口形态、字段命名、分页方式)后再进入 M2。**
|
||||
- [ ] **Step 4: Commit** `feat(contracts): outbox event contract types`
|
||||
- [x] **Step 3: ⏸ 用户确认点:请用户过目 openapi.yaml(接口形态、字段命名、分页方式)后再进入 M2。**(完成:用户于 2026-09-17 确认"确认,继续 M2")
|
||||
- [x] **Step 4: Commit** `feat(contracts): outbox event contract types`(完成:f77ab5a)
|
||||
|
||||
---
|
||||
|
||||
@@ -306,10 +306,10 @@ Expected: PASS。
|
||||
**Files:**
|
||||
- Create: `services/api/package.json`(name `@photo/api-cn`)、`tsconfig.json`、`nest-cli.json`、`src/main-cn.ts`、`src/app-cn.module.ts`、`src/health.controller.ts`、`src/request-id.middleware.ts`、`src/all-exceptions.filter.ts`、`services/api/jest.config.ts`、`services/api/test/load-env.ts`、`services/api/test/reset.ts`、根 `eslint.config.js`
|
||||
|
||||
- [ ] **Step 1: env 加载约定(全仓库唯一机制,解决 pnpm --filter cwd 在包目录的问题)**:根 `.env` 为唯一 env 文件。开发/迁移脚本经根 `dotenv-cli` 注入(Task 2 scripts 已配置);Jest 经 `services/api/test/load-env.ts`(`dotenv.config({ path: join(__dirname, '../../../.env') })`)作为 `setupFiles` 载入;运行时代码只读 `process.env`。
|
||||
- [ ] **Step 2: 依赖与入口**:deps `@nestjs/common @nestjs/core @nestjs/platform-express nest-pino pino-http`;devDeps `jest ts-jest @types/jest supertest @types/supertest @nestjs/testing`;scripts:`test`(`*.spec.ts` 单测)、`test:e2e`(`test/*.e2e-spec.ts`,前置 compose 依赖)、`typecheck`、`lint`、`start`。`test/reset.ts` 为 e2e 共用隔离工具:每个 e2e spec `beforeAll` 调用——truncate 全部 cn 表 + 删除 MinIO `photo-live/dev` 前缀对象(防止 spec 间唯一约束冲突与脏数据)。入口监听 `PORT`(默认 8080),`app.setGlobalPrefix('v1')`,注册 request-id 中间件(无则生成 cuid,挂 `res.setHeader('x-request-id')`)、全局异常过滤器(映射 `ErrorCodes`,响应 `{code,message,requestId}`,不泄露堆栈)、`/v1/health` 返回 `{status:'ok'}`。
|
||||
- [ ] **Step 3: 根 ESLint flat config**(eslint + typescript-eslint,覆盖 packages/services/apps),各包(contracts、storage、db-cn、api、worker)`lint` 脚本接 `eslint .`,确保 turbo 不静默跳过。ts-jest 保持类型检查模式(非 isolatedModules),使 `@ts-expect-error` 负向断言生效。
|
||||
- [ ] **Step 4: 启动验证**
|
||||
- [x] **Step 1: env 加载约定(全仓库唯一机制,解决 pnpm --filter cwd 在包目录的问题)**:根 `.env` 为唯一 env 文件。开发/迁移脚本经根 `dotenv-cli` 注入(Task 2 scripts 已配置);Jest 经 `services/api/test/load-env.ts`(`dotenv.config({ path: join(__dirname, '../../../.env') })`)作为 `setupFiles` 载入;运行时代码只读 `process.env`。(完成:2026-09-17)
|
||||
- [x] **Step 2: 依赖与入口**:deps `@nestjs/common @nestjs/core @nestjs/platform-express nest-pino pino-http`(实际:`nest-pino` 上游已更名 `nestjs-pino`,取 ^4.6.1 兼容 Nest 10);devDeps `jest ts-jest @types/jest supertest @types/supertest @nestjs/testing`;scripts:`test`(`*.spec.ts` 单测)、`test:e2e`(`test/*.e2e-spec.ts`,前置 compose 依赖)、`typecheck`、`lint`、`start`。`test/reset.ts` 为 e2e 共用隔离工具:每个 e2e spec `beforeAll` 调用——truncate 全部 cn 表 + 删除 MinIO `photo-live/dev` 前缀对象(防止 spec 间唯一约束冲突与脏数据)。入口监听 `PORT`(默认 8080),`app.setGlobalPrefix('v1')`,注册 request-id 中间件(无则生成 cuid,挂 `res.setHeader('x-request-id')`)、全局异常过滤器(映射 `ErrorCodes`,响应 `{code,message,requestId}`,不泄露堆栈)、`/v1/health` 返回 `{status:'ok'}`。
|
||||
- [x] **Step 3: 根 ESLint flat config**(eslint + typescript-eslint,覆盖 packages/services/apps),各包(contracts、storage、db-cn、api、worker)`lint` 脚本接 `eslint .`,确保 turbo 不静默跳过。ts-jest 保持类型检查模式(非 isolatedModules),使 `@ts-expect-error` 负向断言生效。(完成:contracts 与 api 已接入,storage/db-cn/worker 待各自任务建包时接入)
|
||||
- [x] **Step 4: 启动验证**(完成:curl -i 见 x-request-id 响应头;单测 6/6、e2e 3/3、根 lint/typecheck/test/build/e2e 全绿)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -318,7 +318,7 @@ pnpm dev:api &
|
||||
until curl -s localhost:8080/v1/health; do sleep 1; done # 等待启动 → {"status":"ok"}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit** `feat(api): cn-app skeleton, toolchain, env loading convention`
|
||||
- [x] **Step 5: Commit** `feat(api): cn-app skeleton, toolchain, env loading convention`
|
||||
|
||||
### Task 8: Prisma cn schema —— 身份与审计域
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// eslint.config.js —— 根 flat config(覆盖 packages/services/apps 全部 TS)。
|
||||
// 各包 lint 脚本为 `eslint .`,在包目录执行时向上发现本文件,turbo 不会静默跳过。
|
||||
// 仅 typescript-eslint recommended(无类型感知规则,无需 project service),
|
||||
// 避免跨包 tsconfig 差异(contracts NodeNext / api node16)带来的配置负担。
|
||||
// @ts-check
|
||||
const globals = require('globals');
|
||||
const tseslint = require('typescript-eslint');
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/',
|
||||
'**/dist/',
|
||||
'**/coverage/',
|
||||
'**/.turbo/',
|
||||
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
||||
'**/generated/',
|
||||
],
|
||||
},
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['apps/**/*.{ts,tsx}', 'packages/**/*.ts', 'services/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
);
|
||||
+2
-1
@@ -21,6 +21,7 @@
|
||||
"prettier": "^3.3.0",
|
||||
"dotenv-cli": "^7.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"typescript-eslint": "^8.0.0"
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"globals": "^16.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/",
|
||||
"//": "lint script is a placeholder (exit 0): eslint flat config arrives in Task 7. test is a minimal ts-jest scaffold: real type tests arrive in Task 6.",
|
||||
"//": "lint runs `eslint .` against the root flat config (Task 7). test is ts-jest with full type-checking (Task 6 type tests).",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
@@ -21,7 +21,7 @@
|
||||
"build": "tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/generated', 'dist/generated', { recursive: true })\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"lint": "exit 0"
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "^1.0.0",
|
||||
|
||||
Generated
+1149
-16
File diff suppressed because it is too large
Load Diff
@@ -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