feat(api): argon2 password, idempotent admin seed, api-exception infra

Task 12 + Task 7 quality-review carryovers:

Part A - password hashing & controlled initial admin:
- src/auth/password.ts: hashPassword/verifyPassword over @node-rs/argon2
  (napi prebuilt, argon2id default); empty plain rejected at the door
- src/scripts/seed-admin.ts: idempotent admin seed (ADMIN_ACCOUNT/
  ADMIN_PASSWORD env, walk-up root .env load); existing account -> skip,
  never overwrites the password; no audit_logs (system op, no actor)
- pnpm seed script (build + node dist/scripts/seed-admin.js);
  .env.example dev defaults admin/admin-secret-change-me
- tests: test/password.spec.ts (5), test/seed.spec.ts real-DB idempotency (4)

Part B - Task 7 carryovers (all 8):
- ApiException(apiCode, message, httpStatus, details?) recognized first by
  the filter; unmapped-status rule: >=500 -> INTERNAL_ERROR + generic
  message, <500 -> VALIDATION_FAILED keeping original status (warn-logged)
- 5xx/INTERNAL_ERROR responses forced to generic message (leak fix)
- pino redact adds req.headers.cookie / res.headers.set-cookie
- x-request-id echo capped at 128 chars (fresh UUID beyond, no log)
- LOG_LEVEL validated against pino levels at module wiring -> fail fast
- shared/ constants (API_PREFIX), request-with-id, log-level (+spec)
- jest ts-jest reuses tsconfig.json; unit testMatch includes test/*.spec.ts
- build:watch script (no concurrently dep; dev = build:watch + node --watch)

api: 35 unit + 10 e2e tests green; root lint/typecheck/test green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 20:54:30 +08:00
parent 8e870de9d7
commit 368b1d5b47
21 changed files with 724 additions and 62 deletions
+81 -2
View File
@@ -1,15 +1,19 @@
// services/api/src/all-exceptions.filter.spec.ts
// 过滤器信封映射单测:HttpException -> ErrorCodes 映射、未知异常不泄露内部信息。
// 过滤器信封映射单测:ApiException 精确错误码透传、HttpException -> ErrorCodes
// 状态码表映射(含未映射状态兜底规则)、5xx 文案不泄露内部信息。
import {
type ArgumentsHost,
BadRequestException,
ForbiddenException,
HttpException,
HttpStatus,
InternalServerErrorException,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from './api-exception';
import { AllExceptionsFilter } from './all-exceptions.filter';
// 测试不断言日志内容,静音 Nest 控制台日志以保持输出干净。
@@ -17,7 +21,7 @@ Logger.overrideLogger(false);
interface Captured {
status?: number;
body?: { code?: string; message?: string; requestId?: string };
body?: { code?: string; message?: string; requestId?: string; details?: unknown };
}
function runFilter(exception: unknown, requestId: string): Captured {
@@ -74,4 +78,79 @@ describe('AllExceptionsFilter', () => {
requestId: 'req-3',
});
});
describe('ApiException (Task 7 遗留项①)', () => {
it('passes through its exact contract code, status and message', () => {
const exception = new ApiException(
ErrorCodes.AUTH_INVALID_CREDENTIALS,
'account or password is incorrect',
HttpStatus.UNAUTHORIZED,
);
const captured = runFilter(exception, 'req-4');
expect(captured.status).toBe(HttpStatus.UNAUTHORIZED);
expect(captured.body).toEqual({
code: ErrorCodes.AUTH_INVALID_CREDENTIALS,
message: 'account or password is incorrect',
requestId: 'req-4',
});
});
it('includes optional details in the envelope when provided', () => {
const exception = new ApiException(
ErrorCodes.MEMBER_CONFLICT,
'already a member',
HttpStatus.CONFLICT,
{ memberRole: 'collaborator' },
);
const captured = runFilter(exception, 'req-5');
expect(captured.status).toBe(HttpStatus.CONFLICT);
expect(captured.body).toEqual({
code: ErrorCodes.MEMBER_CONFLICT,
message: 'already a member',
requestId: 'req-5',
details: { memberRole: 'collaborator' },
});
});
it('wins over the status table for same-status collisions (401)', () => {
// 状态表将 401 默认映射为 AUTH_TOKEN_EXPIRED;ApiException 必须优先。
const exception = new ApiException(
ErrorCodes.AUTH_INVALID_CREDENTIALS,
'nope',
HttpStatus.UNAUTHORIZED,
);
const captured = runFilter(exception, 'req-6');
expect(captured.body?.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
});
});
describe('unmapped-status fallback (Task 7 遗留项①)', () => {
it('maps unmapped 4xx (409) to VALIDATION_FAILED, keeping the 409 status and message', () => {
const captured = runFilter(new HttpException('conflicting state', 409), 'req-7');
expect(captured.status).toBe(HttpStatus.CONFLICT);
expect(captured.body?.code).toBe(ErrorCodes.VALIDATION_FAILED);
expect(captured.body?.message).toBe('conflicting state');
expect(captured.body?.requestId).toBe('req-7');
});
it('maps unmapped 5xx to INTERNAL_ERROR with generic message', () => {
const captured = runFilter(new HttpException('boom', 503), 'req-8');
expect(captured.status).toBe(503);
expect(captured.body?.code).toBe(ErrorCodes.INTERNAL_ERROR);
expect(captured.body?.message).toBe('Internal server error');
});
});
describe('5xx message leak fix (Task 7 遗留项②)', () => {
it('forces a generic message for InternalServerErrorException with sensitive detail', () => {
const captured = runFilter(
new InternalServerErrorException('DB exploded: mysql://root:hunter2@db:3306/photo'),
'req-9',
);
expect(captured.status).toBe(HttpStatus.INTERNAL_SERVER_ERROR);
expect(captured.body?.code).toBe(ErrorCodes.INTERNAL_ERROR);
expect(captured.body?.message).toBe('Internal server error');
expect(captured.body?.message).not.toContain('mysql://');
});
});
});
+50 -11
View File
@@ -1,8 +1,13 @@
// services/api/src/all-exceptions.filter.ts
// 全局兜底异常过滤器:所有错误响应统一 {code, message, requestId} 信封。
// code 取自 @photo/contracts ErrorCodes;HttpException 按状态码映射默认错误码
// (后续任务可按异常类型精化),未知异常一律 INTERNAL_ERROR + 通用文案,
// 完整错误仅写服务端日志,绝不向客户端泄露堆栈或内部细节。
// 识别顺序(Task 7 遗留项①②):
// 1. ApiException —— 业务异常自带契约错误码,精确透传 code/status/message(/details);
// 2. 其它 HttpException —— 按状态码表映射默认错误码;未映射状态的兜底规则:
// · >=500 -> INTERNAL_ERROR(且响应文案强制通用,杜绝 InternalServerError-
// Exception(message) 把内部细节如连接串泄露给客户端,原文案仅进日志);
// · <500 -> VALIDATION_FAILED(4xx 语义近似"请求有问题"),保留原状态码,
// 并以 warn 日志记录原状态码便于排查(如 409 冲突未显式映射的场景);
// 3. 未知异常 —— INTERNAL_ERROR + 通用文案,完整错误仅写服务端日志。
import {
type ArgumentsHost,
Catch,
@@ -12,15 +17,13 @@ import {
Logger,
} from '@nestjs/common';
import { type ErrorCode, ErrorCodes } from '@photo/contracts';
import { ApiException } from './api-exception';
import type { RequestWithId } from './shared/request-with-id';
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,
@@ -28,6 +31,8 @@ const STATUS_TO_ERROR_CODE: Readonly<Record<number, ErrorCode>> = {
[HttpStatus.NOT_FOUND]: ErrorCodes.ALBUM_NOT_FOUND,
};
const GENERIC_INTERNAL_MESSAGE = 'Internal server error';
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
@@ -37,13 +42,47 @@ export class AllExceptionsFilter implements ExceptionFilter {
const req = host.switchToHttp().getRequest<RequestWithId>();
const requestId = req.id ?? 'unknown';
if (exception instanceof HttpException) {
if (exception instanceof ApiException) {
const status = exception.getStatus();
const code = STATUS_TO_ERROR_CODE[status] ?? ErrorCodes.INTERNAL_ERROR;
const code = exception.apiCode;
const message = extractMessage(exception);
this.logger.warn(
`HttpException ${status} -> ${code} (requestId=${requestId}): ${message}`,
`ApiException ${status} -> ${code} (requestId=${requestId}): ${message}`,
);
const body = exception.details === undefined
? { code, message, requestId }
: { code, message, requestId, details: exception.details };
res.status(status).json(body);
return;
}
if (exception instanceof HttpException) {
const status = exception.getStatus();
const isServerError = status >= HttpStatus.INTERNAL_SERVER_ERROR;
const code =
STATUS_TO_ERROR_CODE[status] ??
(isServerError
? ErrorCodes.INTERNAL_ERROR
: ErrorCodes.VALIDATION_FAILED);
if (!(status in STATUS_TO_ERROR_CODE)) {
this.logger.warn(
`HttpException status ${status} has no explicit error-code mapping, ` +
`falling back to ${code}`,
);
}
const original = extractMessage(exception);
// ② 5xx / INTERNAL_ERROR:客户端只见通用文案,原文案可能含内部细节
//(连接串、堆栈片段),仅写服务端日志。
const message =
isServerError || code === ErrorCodes.INTERNAL_ERROR
? GENERIC_INTERNAL_MESSAGE
: original;
const log = `HttpException ${status} -> ${code} (requestId=${requestId}): ${original}`;
if (isServerError) {
this.logger.error(log, exception.stack);
} else {
this.logger.warn(log);
}
res.status(status).json({ code, message, requestId });
return;
}
@@ -55,7 +94,7 @@ export class AllExceptionsFilter implements ExceptionFilter {
);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: ErrorCodes.INTERNAL_ERROR,
message: 'Internal server error',
message: GENERIC_INTERNAL_MESSAGE,
requestId,
});
}
+28
View File
@@ -0,0 +1,28 @@
// services/api/src/api-exception.ts —— 携带契约错误码的业务异常(Task 7 遗留项①)。
// 状态码表(过滤器内 STATUS_TO_ERROR_CODE)只能表达"一个 HTTP 状态 = 一个错误码",
// 无法表达同状态多错误码的契约空间(如 401 既可能是 AUTH_TOKEN_EXPIRED 也可能是
// AUTH_INVALID_CREDENTIALS,409 既可能是 MEMBER_CONFLICT 也可能是
// UPLOAD_SESSION_CONFLICT)。业务代码抛 ApiException 精确指定 ErrorCode;
// 过滤器优先识别它,状态码表仅作普通 HttpException 的兜底映射。
import { HttpException } from '@nestjs/common';
import type { ErrorCode } from '@photo/contracts';
export class ApiException extends HttpException {
constructor(
readonly apiCode: ErrorCode,
message: string,
httpStatus: number,
readonly details?: unknown,
) {
// 响应体形状 {code, message, details?}:details 缺省时不占位(保持信封干净)。
// 注意 message 须从 getResponse().message 提取(见过滤器 extractMessage),
// HttpException.message 属性在对象响应下不含业务文案。
super(
details === undefined
? { code: apiCode, message }
: { code: apiCode, message, details },
httpStatus,
);
this.name = ApiException.name;
}
}
+14 -3
View File
@@ -12,6 +12,8 @@ import { AllExceptionsFilter } from './all-exceptions.filter';
import { HealthController } from './health.controller';
import { PrismaModule } from './prisma/prisma.module';
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
import { resolveLogLevel } from './shared/log-level';
import type { ServerRequestWithId } from './shared/request-with-id';
@Module({
imports: [
@@ -22,12 +24,21 @@ import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
genReqId: (req: IncomingMessage, res: ServerResponse) =>
resolveRequestId(req, res),
resolveRequestId(req as ServerRequestWithId, res),
redact: {
paths: ['req.headers.authorization', 'res.headers.authorization'],
// 授权头 + Cookie(会话凭据,Task 7 遗留项③):请求/响应两侧都打码,
// 防止日志聚合层留存可重放的凭据。
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers.authorization',
'res.headers.set-cookie',
],
censor: '[REDACTED]',
},
level: process.env.LOG_LEVEL ?? 'info',
// Task 7 遗留项⑤:非法 LOG_LEVEL 在模块装配期(即启动期)快速失败,
// 而不是让 pino 首次写日志时才炸或静默吞掉输出。
level: resolveLogLevel(process.env.LOG_LEVEL),
},
}),
],
+28
View File
@@ -0,0 +1,28 @@
// services/api/src/auth/password.ts —— 密码哈希工具(Task 12)。
// @node-rs/argon2 为 napi 预编译绑定(无原生构建脚本),默认算法即 argon2id
// (抗 GPU 侧信道,口令哈希的现代默认)。哈希自带随机盐,同口令两次结果不同,
// 调用方不得做哈希相等比较——一律走 verifyPassword。
import { hash, verify } from '@node-rs/argon2';
/**
* 哈希明文口令(argon2id,默认参数 m=19456/t=2/p=1,约 19MiB 内存,服务端
* 登录路径的合理默认;如需调参在登录压测后集中调整)。
*
* 空口令直接抛错而不是静默哈希:空口令只会来自漏校验的调用方,属程序错误。
* 调用方(如 auth.service)应先做入参校验(DTO 非空约束),若仍逸出则由
* 全局过滤器按 400 -> VALIDATION_FAILED 兜底。
*/
export function hashPassword(plain: string): Promise<string> {
if (plain === '') {
return Promise.reject(new Error('plain password must not be empty'));
}
return hash(plain);
}
/** 校验明文口令是否与存储的 argon2 哈希匹配(常量时间比较由 argon2 内部保证)。 */
export function verifyPassword(
passwordHash: string,
plain: string,
): Promise<boolean> {
return verify(passwordHash, plain);
}
+2 -1
View File
@@ -6,13 +6,14 @@ import { type INestApplication } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { Logger } from 'nestjs-pino';
import { AppCnModule } from './app-cn.module';
import { API_PREFIX } from './shared/constants';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create<INestApplication>(AppCnModule, {
bufferLogs: true,
});
app.useLogger(app.get(Logger));
app.setGlobalPrefix('v1');
app.setGlobalPrefix(API_PREFIX);
const corsOrigins = (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
.split(',')
.map((origin) => origin.trim())
@@ -0,0 +1,66 @@
// services/api/src/request-id.middleware.spec.ts —— 请求 ID 解析单测:
// 透传上游值 / 缺省生成 UUID / 超长值拒收换新(Task 7 遗留项④)。
import type { ServerResponse } from 'node:http';
import {
MAX_REQUEST_ID_LENGTH,
resolveRequestId,
} from './request-id.middleware';
function fakeReq(header?: string): { headers: Record<string, string>; id?: string } {
return header === undefined ? { headers: {} } : { headers: { 'x-request-id': header } };
}
function fakeRes(): ServerResponse & { captured?: string } {
const res = { headers: {} } as unknown as ServerResponse & { captured?: string };
Object.defineProperty(res, 'setHeader', {
value: (name: string, value: string) => {
res.captured = value;
return res;
},
});
return res;
}
describe('resolveRequestId', () => {
it('echoes a reasonable upstream x-request-id', () => {
const req = fakeReq('upstream-id-1');
const res = fakeRes();
expect(resolveRequestId(req as never, res)).toBe('upstream-id-1');
expect(req.id).toBe('upstream-id-1');
expect(res.captured).toBe('upstream-id-1');
});
it('generates a UUID v4 when the header is absent', () => {
const req = fakeReq();
const id = resolveRequestId(req as never, fakeRes());
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(req.id).toBe(id);
});
it('treats a blank header as absent', () => {
const id = resolveRequestId(fakeReq(' ') as never, fakeRes());
expect(id).not.toBe(' ');
});
it('replaces an over-long upstream id (>128 chars) with a fresh UUID', () => {
const overlong = 'x'.repeat(MAX_REQUEST_ID_LENGTH + 1);
const req = fakeReq(overlong);
const res = fakeRes();
const id = resolveRequestId(req as never, res);
expect(id).not.toBe(overlong);
expect(id.length).toBeLessThanOrEqual(MAX_REQUEST_ID_LENGTH);
expect(req.id).toBe(id);
expect(res.captured).toBe(id);
});
it('still echoes an id of exactly the cap (128 chars)', () => {
const exact = 'y'.repeat(MAX_REQUEST_ID_LENGTH);
const id = resolveRequestId(fakeReq(exact) as never, fakeRes());
expect(id).toBe(exact);
});
it('reuses req.id when already resolved (pino-http genReqId runs first)', () => {
const req = { ...fakeReq('ignored'), id: 'already-set' };
expect(resolveRequestId(req as never, fakeRes())).toBe('already-set');
});
});
+14 -11
View File
@@ -1,36 +1,39 @@
// services/api/src/request-id.middleware.ts
// 请求 ID 约定:优先透传上游 x-request-id(网关/前端可携带),否则生成 UUID v4;
// 挂到 req.id(pino-http 日志字段)并写回响应头 x-request-id,便于全链路排障。
// 长度上限(Task 7 遗留项④):上游值 >128 字符时视为滥用/异常,静默换成新 UUID
//(不透传也不告警——日志与响应头由本函数统一写回,不会出现半截 ID)。
// genReqId 与本中间件共用 resolveRequestId,先后顺序不影响结果。
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ServerResponse } from 'node:http';
import type { NestMiddleware } from '@nestjs/common';
import type { ServerRequestWithId } from './shared/request-with-id';
interface RequestWithId {
id?: string;
}
/** 透传上游 x-request-id 的长度上限(UUID v4 为 36 字符,128 已十分宽裕)。 */
export const MAX_REQUEST_ID_LENGTH = 128;
export function resolveRequestId(
req: IncomingMessage,
req: ServerRequestWithId,
res: ServerResponse,
): string {
const existing = (req as IncomingMessage & RequestWithId).id;
if (existing) {
return existing;
if (req.id) {
return req.id;
}
const header = req.headers['x-request-id'];
const fromHeader = Array.isArray(header) ? header[0] : header;
const id =
typeof fromHeader === 'string' && fromHeader.trim() !== ''
typeof fromHeader === 'string' &&
fromHeader.trim() !== '' &&
fromHeader.length <= MAX_REQUEST_ID_LENGTH
? fromHeader
: randomUUID();
(req as IncomingMessage & RequestWithId).id = id;
req.id = id;
res.setHeader('x-request-id', id);
return id;
}
export class RequestIdMiddleware implements NestMiddleware {
use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
use(req: ServerRequestWithId, res: ServerResponse, next: () => void): void {
resolveRequestId(req, res);
next();
}
+88
View File
@@ -0,0 +1,88 @@
// services/api/src/scripts/seed-admin.ts —— 受控初始管理员种子脚本(Task 12)。
// 运行方式:`pnpm --filter @photo/api-cn seed`(= tsc 构建后 node dist/scripts/
// seed-admin.js)。环境变量 ADMIN_ACCOUNT / ADMIN_PASSWORD(见 .env.example,
// 开发默认 admin / admin-secret-change-me)。
// 幂等语义:账号已存在则跳过且【绝不改写密码】——生产上该账号的密码应由首个
// 登录流程/管理员自行轮换,重跑脚本不得悄悄重置。不写 audit_logs:种子是系统
// 运维操作而非用户动作,actor 不存在,审计语义不成立(计划 Task 12 明确排除)。
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { config } from 'dotenv';
import { PrismaClient } from '@photo/db-cn';
import { hashPassword } from '../auth/password';
export interface SeedAdminResult {
/** true=本次新建;false=账号已存在,跳过(未改写任何字段)。 */
created: boolean;
}
/**
* 种子核心逻辑(可测):存在即跳过,不存在则建 admin 用户。
* displayName 缺省取账号名(users.displayName 非空约束)。
*/
export async function seedAdmin(
prisma: PrismaClient,
account: string,
password: string,
): Promise<SeedAdminResult> {
if (account.trim() === '') {
throw new Error('seed-admin: ADMIN_ACCOUNT must not be empty');
}
const existing = await prisma.users.findUnique({ where: { account } });
if (existing !== null) {
return { created: false };
}
await prisma.users.create({
data: {
account,
passwordHash: await hashPassword(password),
displayName: account,
role: 'admin',
credentialStatus: 'active',
},
});
return { created: true };
}
/** 从脚本所在目录向上找 pnpm-workspace.yaml 定位仓库根并载入根 .env(同 packages/storage/test/load-env.ts 约定;不覆盖已有进程环境变量)。 */
function loadRootEnv(): void {
let dir = __dirname;
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
const parent = dirname(dir);
if (parent === dir) {
throw new Error('seed-admin: pnpm-workspace.yaml not found, cannot locate repo root (.env)');
}
dir = parent;
}
config({ path: join(dir, '.env') });
}
async function main(): Promise<void> {
loadRootEnv();
const account = process.env.ADMIN_ACCOUNT;
const password = process.env.ADMIN_PASSWORD;
if (!account || !password) {
throw new Error(
'seed-admin: ADMIN_ACCOUNT and ADMIN_PASSWORD are required (see .env.example)',
);
}
const prisma = new PrismaClient();
try {
const { created } = await seedAdmin(prisma, account, password);
if (created) {
console.log(`seed-admin: created admin "${account}" (role=admin, credentialStatus=active)`);
} else {
console.log(`seed-admin: admin "${account}" already exists, skipping (password untouched)`);
}
} finally {
await prisma.$disconnect();
}
}
// 直接以 CLI 运行(node dist/scripts/seed-admin.js)时执行 main;被 import(测试)时不执行。
if (require.main === module) {
void main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
}
+3
View File
@@ -0,0 +1,3 @@
// services/api/src/shared/constants.ts —— 跨层共享常量(Task 7 遗留项⑥:
// 此前 'v1' 前缀在 main-cn 与 e2e spec 各写一份,漂移风险集中到此处消除)。
export const API_PREFIX = 'v1';
+17
View File
@@ -0,0 +1,17 @@
// services/api/src/shared/log-level.spec.ts —— LOG_LEVEL 校验单测。
import { resolveLogLevel, VALID_LOG_LEVELS } from './log-level';
describe('resolveLogLevel', () => {
it('defaults to info when unset', () => {
expect(resolveLogLevel(undefined)).toBe('info');
});
it.each([...VALID_LOG_LEVELS])('accepts built-in pino level %s', (level) => {
expect(resolveLogLevel(level)).toBe(level);
});
it('rejects an invalid level with a message listing valid values', () => {
expect(() => resolveLogLevel('verbose')).toThrow(/LOG_LEVEL.*verbose/);
expect(() => resolveLogLevel('')).toThrow(/fatal/);
});
});
+29
View File
@@ -0,0 +1,29 @@
// services/api/src/shared/log-level.ts —— LOG_LEVEL 启动期校验(Task 7 遗留项⑤)。
// 此前非法 LOG_LEVEL 由 pino 在首次写日志时才隐式抛错(或更糟:被当成自定义
// level 静默吞掉全部输出)。改为模块装配(LoggerModule.forRoot 前)即快速失败,
// 错误信息列出全部合法值。合法值即 pino 内建 level 集合(pino.Level 类型)。
import type { Level } from 'pino';
export const VALID_LOG_LEVELS: readonly Level[] = [
'fatal',
'error',
'warn',
'info',
'debug',
'trace',
];
/**
* 解析进程环境 LOG_LEVEL:缺省 'info';非法值抛错(调用点在模块装配期,
* 即应用启动即失败,不带半残日志配置上线)。
*/
export function resolveLogLevel(raw: string | undefined): Level {
const level = raw ?? 'info';
if (!VALID_LOG_LEVELS.includes(level as Level)) {
throw new Error(
`Invalid LOG_LEVEL "${level}". Valid levels: ${VALID_LOG_LEVELS.join(', ')} ` +
'(see .env.example).',
);
}
return level as Level;
}
@@ -0,0 +1,14 @@
// services/api/src/shared/request-with-id.ts —— 请求对象带 req.id 的共享类型
// (Task 7 遗留项⑥):request-id 中间件 / pino-http genReqId 写入,异常过滤器、
// 后续 auth guard(req.user)等读取。此前在过滤器与中间件各声明一份局部接口。
// 注意不继承 IncomingMessage:pino-http 经 module augmentation 给后者挂了
// 非可选的 id: ReqId,扩展会与其冲突;组合用 ServerRequestWithId。
import type { IncomingMessage } from 'node:http';
/** 请求 ID 字段约定:上游 x-request-id 透传或 UUID v4(见 request-id.middleware.ts)。 */
export interface RequestWithId {
id?: string;
}
/** 中间件/genReqId 实际操作的请求对象(node 原始请求 + id 字段)。 */
export type ServerRequestWithId = IncomingMessage & RequestWithId;