From 23067edb8f5e96850ddba7ea8c4fed2335159662 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Mon, 2 Nov 2026 17:20:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=90=AD=E5=BB=BA=20API=20?= =?UTF-8?q?=E4=B8=8E=20Worker=20=E9=AA=A8=E6=9E=B6=E5=8F=8A=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestJS 11 + Fastify 统一错误体与 trace_id,/health 真实探活 DB/Redis,OpenAPI 3.1 契约含 20 个错误码;shared 包提供错误码、权限点、ULID、分页与通用 schema;worker 完成队列进程骨架。 --- apps/api/package.json | 42 ++++++ apps/api/src/app.module.ts | 51 ++++++++ apps/api/src/bootstrap.ts | 44 +++++++ apps/api/src/common/errors/app-exception.ts | 25 ++++ .../filters/all-exceptions.filter.spec.ts | 46 +++++++ .../common/filters/all-exceptions.filter.ts | 121 ++++++++++++++++++ .../api/src/common/http/error-response.dto.ts | 25 ++++ .../common/pipes/zod-validation.pipe.spec.ts | 39 ++++++ .../src/common/pipes/zod-validation.pipe.ts | 30 +++++ apps/api/src/config/config.module.ts | 17 +++ apps/api/src/config/env.spec.ts | 48 +++++++ apps/api/src/config/env.ts | 68 ++++++++++ apps/api/src/config/load-dotenv.ts | 17 +++ apps/api/src/health.int.spec.ts | 34 +++++ apps/api/src/health.smoke.spec.ts | 41 ++++++ apps/api/src/infra/infra.service.spec.ts | 36 ++++++ apps/api/src/infra/prisma/prisma.module.ts | 19 +++ apps/api/src/infra/prisma/prisma.service.ts | 24 ++++ apps/api/src/infra/redis/redis.module.ts | 19 +++ apps/api/src/infra/redis/redis.service.ts | 44 +++++++ apps/api/src/infra/tokens.ts | 3 + apps/api/src/main.ts | 24 ++++ .../platform/health/health.controller.ts | 25 ++++ .../modules/platform/health/health.module.ts | 10 ++ .../modules/platform/health/health.service.ts | 94 ++++++++++++++ .../modules/platform/health/health.types.ts | 58 +++++++++ .../src/modules/platform/platform.module.ts | 14 ++ apps/api/src/openapi/document.spec.ts | 36 ++++++ apps/api/src/openapi/document.ts | 74 +++++++++++ apps/api/src/scripts/export-openapi.ts | 30 +++++ apps/api/src/testing/test-app.ts | 91 +++++++++++++ apps/api/tsconfig.build.json | 13 ++ apps/api/tsconfig.json | 14 ++ apps/api/vitest.config.ts | 16 +++ apps/api/vitest.int.config.ts | 10 ++ apps/worker/package.json | 23 ++++ apps/worker/src/config.ts | 40 ++++++ apps/worker/src/main.ts | 62 +++++++++ apps/worker/tsconfig.build.json | 12 ++ apps/worker/tsconfig.json | 11 ++ packages/ai-sdk/package.json | 27 ++++ packages/ai-sdk/src/index.ts | 5 + packages/ai-sdk/tsconfig.json | 8 ++ packages/form-engine/package.json | 27 ++++ packages/form-engine/src/index.ts | 5 + packages/form-engine/tsconfig.json | 8 ++ packages/render/package.json | 27 ++++ packages/render/src/index.ts | 5 + packages/render/tsconfig.json | 8 ++ packages/shared/package.json | 34 +++++ packages/shared/src/constants.ts | 16 +++ packages/shared/src/errors/api-error.spec.ts | 35 +++++ packages/shared/src/errors/api-error.ts | 32 +++++ packages/shared/src/errors/codes.spec.ts | 54 ++++++++ packages/shared/src/errors/codes.ts | 106 +++++++++++++++ packages/shared/src/index.ts | 9 ++ packages/shared/src/naming.spec.ts | 47 +++++++ packages/shared/src/naming.ts | 107 ++++++++++++++++ packages/shared/src/permissions.spec.ts | 42 ++++++ packages/shared/src/permissions.ts | 78 +++++++++++ packages/shared/src/schemas/api.spec.ts | 53 ++++++++ packages/shared/src/schemas/api.ts | 39 ++++++ packages/shared/src/schemas/common.spec.ts | 27 ++++ packages/shared/src/schemas/common.ts | 26 ++++ packages/shared/src/time.spec.ts | 31 +++++ packages/shared/src/time.ts | 23 ++++ packages/shared/src/ulid.spec.ts | 36 ++++++ packages/shared/src/ulid.ts | 56 ++++++++ packages/shared/tsconfig.json | 9 ++ packages/shared/vitest.config.ts | 15 +++ 70 files changed, 2445 insertions(+) create mode 100644 apps/api/package.json create mode 100644 apps/api/src/app.module.ts create mode 100644 apps/api/src/bootstrap.ts create mode 100644 apps/api/src/common/errors/app-exception.ts create mode 100644 apps/api/src/common/filters/all-exceptions.filter.spec.ts create mode 100644 apps/api/src/common/filters/all-exceptions.filter.ts create mode 100644 apps/api/src/common/http/error-response.dto.ts create mode 100644 apps/api/src/common/pipes/zod-validation.pipe.spec.ts create mode 100644 apps/api/src/common/pipes/zod-validation.pipe.ts create mode 100644 apps/api/src/config/config.module.ts create mode 100644 apps/api/src/config/env.spec.ts create mode 100644 apps/api/src/config/env.ts create mode 100644 apps/api/src/config/load-dotenv.ts create mode 100644 apps/api/src/health.int.spec.ts create mode 100644 apps/api/src/health.smoke.spec.ts create mode 100644 apps/api/src/infra/infra.service.spec.ts create mode 100644 apps/api/src/infra/prisma/prisma.module.ts create mode 100644 apps/api/src/infra/prisma/prisma.service.ts create mode 100644 apps/api/src/infra/redis/redis.module.ts create mode 100644 apps/api/src/infra/redis/redis.service.ts create mode 100644 apps/api/src/infra/tokens.ts create mode 100644 apps/api/src/main.ts create mode 100644 apps/api/src/modules/platform/health/health.controller.ts create mode 100644 apps/api/src/modules/platform/health/health.module.ts create mode 100644 apps/api/src/modules/platform/health/health.service.ts create mode 100644 apps/api/src/modules/platform/health/health.types.ts create mode 100644 apps/api/src/modules/platform/platform.module.ts create mode 100644 apps/api/src/openapi/document.spec.ts create mode 100644 apps/api/src/openapi/document.ts create mode 100644 apps/api/src/scripts/export-openapi.ts create mode 100644 apps/api/src/testing/test-app.ts create mode 100644 apps/api/tsconfig.build.json create mode 100644 apps/api/tsconfig.json create mode 100644 apps/api/vitest.config.ts create mode 100644 apps/api/vitest.int.config.ts create mode 100644 apps/worker/package.json create mode 100644 apps/worker/src/config.ts create mode 100644 apps/worker/src/main.ts create mode 100644 apps/worker/tsconfig.build.json create mode 100644 apps/worker/tsconfig.json create mode 100644 packages/ai-sdk/package.json create mode 100644 packages/ai-sdk/src/index.ts create mode 100644 packages/ai-sdk/tsconfig.json create mode 100644 packages/form-engine/package.json create mode 100644 packages/form-engine/src/index.ts create mode 100644 packages/form-engine/tsconfig.json create mode 100644 packages/render/package.json create mode 100644 packages/render/src/index.ts create mode 100644 packages/render/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants.ts create mode 100644 packages/shared/src/errors/api-error.spec.ts create mode 100644 packages/shared/src/errors/api-error.ts create mode 100644 packages/shared/src/errors/codes.spec.ts create mode 100644 packages/shared/src/errors/codes.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/naming.spec.ts create mode 100644 packages/shared/src/naming.ts create mode 100644 packages/shared/src/permissions.spec.ts create mode 100644 packages/shared/src/permissions.ts create mode 100644 packages/shared/src/schemas/api.spec.ts create mode 100644 packages/shared/src/schemas/api.ts create mode 100644 packages/shared/src/schemas/common.spec.ts create mode 100644 packages/shared/src/schemas/common.ts create mode 100644 packages/shared/src/time.spec.ts create mode 100644 packages/shared/src/time.ts create mode 100644 packages/shared/src/ulid.spec.ts create mode 100644 packages/shared/src/ulid.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vitest.config.ts diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..7771712 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,42 @@ +{ + "name": "@lingxi/api", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/main.js", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --coverage", + "test:int": "vitest run --config vitest.int.config.ts" + }, + "dependencies": { + "@fastify/cors": "11.3.1", + "@fastify/helmet": "13.2.0", + "@fastify/static": "10.1.8", + "@lingxi/shared": "workspace:*", + "@nestjs/common": "11.2.8", + "@nestjs/core": "11.2.8", + "@nestjs/platform-fastify": "11.2.8", + "@nestjs/swagger": "11.4.7", + "@prisma/client": "6.19.3", + "dotenv": "18.0.9", + "fastify": "5.12.7", + "ioredis": "5.11.1", + "nestjs-pino": "5.3.1", + "pino": "10.4.0", + "pino-http": "11.0.0", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.2", + "zod": "3.25.76" + }, + "devDependencies": { + "@nestjs/testing": "11.2.8", + "@types/node": "24.19.2", + "@vitest/coverage-v8": "3.2.7", + "pino-pretty": "13.2.0", + "tsx": "4.23.15", + "typescript": "5.9.3", + "vitest": "3.2.7" + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..e1d07c2 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,51 @@ +import { REQUEST_ID_HEADER, ulid } from '@lingxi/shared'; +import { Module } from '@nestjs/common'; +import { LoggerModule } from 'nestjs-pino'; + +import { APP_CONFIG, AppConfigModule } from './config/config.module'; +import type { AppConfig } from './config/env'; +import { PrismaModule } from './infra/prisma/prisma.module'; +import { RedisModule } from './infra/redis/redis.module'; +import { PlatformModule } from './modules/platform/platform.module'; + +@Module({ + imports: [ + AppConfigModule, + LoggerModule.forRootAsync({ + imports: [AppConfigModule], + inject: [APP_CONFIG], + useFactory: (config: AppConfig) => ({ + pinoHttp: { + level: config.logLevel, + genReqId: (request, response) => { + const header = request.headers[REQUEST_ID_HEADER]; + const existing = Array.isArray(header) ? header[0] : header; + const traceId = typeof existing === 'string' && existing.length > 0 ? existing : ulid(); + response.setHeader(REQUEST_ID_HEADER, traceId); + return traceId; + }, + customProps: (request) => ({ trace_id: request.id }), + redact: { + paths: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'], + censor: '[redacted]', + }, + autoLogging: { + ignore: (request) => request.url === '/health', + }, + ...(config.nodeEnv === 'development' && process.env.VITEST === undefined + ? { + transport: { + target: 'pino-pretty', + options: { singleLine: true, translateTime: 'SYS:HH:MM:ss.l' }, + }, + } + : {}), + }, + }), + }), + PrismaModule, + RedisModule, + PlatformModule, + ], +}) +export class AppModule {} diff --git a/apps/api/src/bootstrap.ts b/apps/api/src/bootstrap.ts new file mode 100644 index 0000000..6e08e12 --- /dev/null +++ b/apps/api/src/bootstrap.ts @@ -0,0 +1,44 @@ +import helmet from '@fastify/helmet'; +import { API_PREFIX } from '@lingxi/shared'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Logger } from 'nestjs-pino'; + +import { AppModule } from './app.module'; +import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; +import { APP_CONFIG } from './config/config.module'; +import type { AppConfig } from './config/env'; +import { buildOpenApiDocument, setupOpenApi } from './openapi/document'; + +export interface CreatedApiApp { + app: NestFastifyApplication; + config: AppConfig; +} + +/** 创建并配置 API 应用(测试与 OpenAPI 导出复用,不负责监听端口) */ +export async function createApp(): Promise { + const app = await NestFactory.create( + AppModule, + new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }), + { bufferLogs: true }, + ); + + const config = app.get(APP_CONFIG); + app.useLogger(app.get(Logger)); + + app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] }); + app.useGlobalFilters(new AllExceptionsFilter()); + app.enableCors({ + origin: config.webOrigins, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + }); + + // 安全响应头;Swagger UI 需要内联脚本,CSP 在关闭文档页后再开启 + await app.register(helmet, { contentSecurityPolicy: false }); + app.enableShutdownHooks(); + + return { app, config }; +} + +export { buildOpenApiDocument, setupOpenApi }; diff --git a/apps/api/src/common/errors/app-exception.ts b/apps/api/src/common/errors/app-exception.ts new file mode 100644 index 0000000..da96d41 --- /dev/null +++ b/apps/api/src/common/errors/app-exception.ts @@ -0,0 +1,25 @@ +import { ERROR_MESSAGES, type ErrorCode, httpStatusOf } from '@lingxi/shared'; + +export interface AppExceptionOptions { + code: ErrorCode; + message?: string; + details?: unknown; +} + +/** + * 业务异常:由统一异常过滤器序列化为 { code, message, details, trace_id }。 + * 业务代码禁止直接 throw new Error 表达可预期错误。 + */ +export class AppException extends Error { + readonly code: ErrorCode; + readonly status: number; + readonly details?: unknown; + + constructor(options: AppExceptionOptions) { + super(options.message ?? ERROR_MESSAGES[options.code]); + this.name = 'AppException'; + this.code = options.code; + this.status = httpStatusOf(options.code); + this.details = options.details; + } +} diff --git a/apps/api/src/common/filters/all-exceptions.filter.spec.ts b/apps/api/src/common/filters/all-exceptions.filter.spec.ts new file mode 100644 index 0000000..ace9200 --- /dev/null +++ b/apps/api/src/common/filters/all-exceptions.filter.spec.ts @@ -0,0 +1,46 @@ +import { ErrorCode } from '@lingxi/shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, type TestApp } from '../../testing/test-app'; + +describe('统一异常过滤器(冒烟)', () => { + let testApp: TestApp; + + beforeAll(async () => { + testApp = await createTestApp({ includeErrorRoutes: true }); + }); + + afterAll(async () => { + await testApp.app.close(); + }); + + it('AppException 输出业务错误码与 trace_id', async () => { + const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/app-error' }); + expect(response.statusCode).toBe(409); + const body = response.json<{ code: number; message: string; trace_id: string }>(); + expect(body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + expect(body.message).toBe('当前状态不允许该操作'); + expect(body.trace_id.length).toBeGreaterThan(0); + }); + + it('HttpException 404 映射为 40401', async () => { + const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/http-404' }); + expect(response.statusCode).toBe(404); + expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND); + }); + + it('未知异常收敛为 50001,不泄露内部信息', async () => { + const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/__test/boom' }); + expect(response.statusCode).toBe(500); + const body = response.json<{ code: number; message: string; trace_id: string }>(); + expect(body.code).toBe(ErrorCode.INTERNAL_ERROR); + expect(body.message).toBe('服务内部错误'); + expect(body.trace_id.length).toBeGreaterThan(0); + }); + + it('未注册路由返回统一 404 错误体', async () => { + const response = await testApp.app.inject({ method: 'GET', url: '/api/v1/not-exists' }); + expect(response.statusCode).toBe(404); + expect(response.json<{ code: number }>().code).toBe(ErrorCode.NOT_FOUND); + }); +}); diff --git a/apps/api/src/common/filters/all-exceptions.filter.ts b/apps/api/src/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..7af7f1d --- /dev/null +++ b/apps/api/src/common/filters/all-exceptions.filter.ts @@ -0,0 +1,121 @@ +import { type ApiErrorBody, ErrorCode, httpStatusOf, isErrorCode } from '@lingxi/shared'; +import { type ArgumentsHost, Catch, type ExceptionFilter, HttpException } from '@nestjs/common'; + +import { AppException } from '../errors/app-exception'; + +interface RequestWithTrace { + id?: string; + url?: string; + method?: string; + log?: { + error: (payload: unknown, message?: string) => void; + }; +} + +interface ReplyLike { + status: (code: number) => { send: (body: unknown) => void }; +} + +function codeFromHttpStatus(status: number): ErrorCode { + switch (status) { + case 400: + return ErrorCode.PARAM_INVALID; + case 401: + return ErrorCode.UNAUTHENTICATED; + case 403: + return ErrorCode.PERMISSION_DENIED; + case 404: + return ErrorCode.NOT_FOUND; + case 409: + return ErrorCode.STATE_NOT_ALLOWED; + case 422: + return ErrorCode.BUSINESS_RULE_VIOLATION; + case 429: + return ErrorCode.RATE_LIMITED; + case 502: + case 503: + return ErrorCode.DEPENDENCY_UNAVAILABLE; + case 504: + return ErrorCode.TIMEOUT; + default: + return status >= 400 && status < 500 ? ErrorCode.PARAM_INVALID : ErrorCode.INTERNAL_ERROR; + } +} + +function messageFromHttpException(exception: HttpException): string { + const response = exception.getResponse(); + if (typeof response === 'string') { + return response; + } + if ('message' in response) { + const message = response.message; + if (typeof message === 'string') { + return message; + } + if (Array.isArray(message) && message.length > 0 && typeof message[0] === 'string') { + return message.join('; '); + } + } + return exception.message; +} + +/** + * 全局异常过滤器:所有错误统一输出 { code, message, details, trace_id }(工程规范 04)。 + * trace_id 与 Pino 的 request id 同源,响应用户报障时可反查日志。 + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const context = host.switchToHttp(); + const request = context.getRequest(); + const reply = context.getResponse(); + const traceId = request.id ?? ''; + + let status = 500; + let body: ApiErrorBody; + + if (exception instanceof AppException) { + status = exception.status; + body = { + code: exception.code, + message: exception.message, + ...(exception.details === undefined ? {} : { details: exception.details }), + trace_id: traceId, + }; + } else if (exception instanceof HttpException) { + status = exception.getStatus(); + const code = codeFromHttpStatus(status); + body = { + code, + message: status >= 500 ? '服务内部错误' : messageFromHttpException(exception), + trace_id: traceId, + }; + } else { + body = { + code: ErrorCode.INTERNAL_ERROR, + message: '服务内部错误', + trace_id: traceId, + }; + } + + if (status >= 500) { + request.log?.error( + { + err: exception, + trace_id: traceId, + method: request.method, + url: request.url, + code: body.code, + }, + 'request failed', + ); + } + + reply.status(status).send(body); + } +} + +/** 供测试或未来的 OpenAPI 生成复用:确认错误码与 HTTP 状态一致 */ +export function httpStatusForCode(code: number): number { + return isErrorCode(code) ? httpStatusOf(code) : 500; +} diff --git a/apps/api/src/common/http/error-response.dto.ts b/apps/api/src/common/http/error-response.dto.ts new file mode 100644 index 0000000..a4e518c --- /dev/null +++ b/apps/api/src/common/http/error-response.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** OpenAPI 错误响应模型(工程规范 04 统一错误体) */ +export class ErrorResponseDto { + @ApiProperty({ type: Number, description: '错误码(见 ErrorCode 常量)', example: 42202 }) + code!: number; + + @ApiProperty({ type: String, description: '可直接展示给用户的提示', example: '内容触发合规拦截' }) + message!: string; + + @ApiProperty({ + type: Object, + description: '补充信息(字段错误、命中规则等),结构由错误码决定', + required: false, + example: { issues: [{ path: 'name', message: 'Required' }] }, + }) + details?: unknown; + + @ApiProperty({ + type: String, + description: '请求追踪 ID,与日志同源', + example: '01JABCDEF0123456789ABCDEFG', + }) + trace_id!: string; +} diff --git a/apps/api/src/common/pipes/zod-validation.pipe.spec.ts b/apps/api/src/common/pipes/zod-validation.pipe.spec.ts new file mode 100644 index 0000000..1e5e3c7 --- /dev/null +++ b/apps/api/src/common/pipes/zod-validation.pipe.spec.ts @@ -0,0 +1,39 @@ +import { ErrorCode } from '@lingxi/shared'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { AppException } from '../errors/app-exception'; +import { ZodValidationPipe } from './zod-validation.pipe'; + +const schema = z.object({ + name: z.string().min(2), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +describe('ZodValidationPipe', () => { + it('通过校验并返回转换后的值', () => { + const pipe = new ZodValidationPipe(schema); + expect(pipe.transform({ name: '素材', limit: '30' })).toEqual({ name: '素材', limit: 30 }); + }); + + it('缺省值生效', () => { + const pipe = new ZodValidationPipe(schema); + expect(pipe.transform({ name: '素材' })).toEqual({ name: '素材', limit: 20 }); + }); + + it('校验失败抛 40001,details 带字段错误', () => { + const pipe = new ZodValidationPipe(schema); + try { + pipe.transform({ name: 'x' }); + throw new Error('应当抛错'); + } catch (error) { + expect(error).toBeInstanceOf(AppException); + const appError = error instanceof AppException ? error : null; + expect(appError?.code).toBe(ErrorCode.PARAM_INVALID); + expect(appError?.status).toBe(400); + expect(appError?.details).toMatchObject({ + issues: [{ path: 'name' }], + }); + } + }); +}); diff --git a/apps/api/src/common/pipes/zod-validation.pipe.ts b/apps/api/src/common/pipes/zod-validation.pipe.ts new file mode 100644 index 0000000..bac77e2 --- /dev/null +++ b/apps/api/src/common/pipes/zod-validation.pipe.ts @@ -0,0 +1,30 @@ +import { ErrorCode } from '@lingxi/shared'; +import { type PipeTransform } from '@nestjs/common'; +import type { ZodSchema } from 'zod'; + +import { AppException } from '../errors/app-exception'; + +/** + * Zod 校验管道(静态 DTO):body / query / params 共用。 + * 校验失败统一转 40001,并把字段错误放进 details.issues,便于前端定位。 + */ +export class ZodValidationPipe implements PipeTransform { + constructor(private readonly schema: ZodSchema) {} + + transform(value: unknown): T { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: '参数校验失败', + details: { + issues: result.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + })), + }, + }); + } + return result.data; + } +} diff --git a/apps/api/src/config/config.module.ts b/apps/api/src/config/config.module.ts new file mode 100644 index 0000000..23c5120 --- /dev/null +++ b/apps/api/src/config/config.module.ts @@ -0,0 +1,17 @@ +import { Global, Module } from '@nestjs/common'; + +import { type AppConfig, loadEnv } from './env'; + +export const APP_CONFIG = Symbol('APP_CONFIG'); + +@Global() +@Module({ + providers: [ + { + provide: APP_CONFIG, + useFactory: (): AppConfig => loadEnv(), + }, + ], + exports: [APP_CONFIG], +}) +export class AppConfigModule {} diff --git a/apps/api/src/config/env.spec.ts b/apps/api/src/config/env.spec.ts new file mode 100644 index 0000000..6ed3f2c --- /dev/null +++ b/apps/api/src/config/env.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { loadEnv } from './env'; + +const validEnv: NodeJS.ProcessEnv = { + NODE_ENV: 'test', + DATABASE_URL: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test', + REDIS_URL: 'redis://127.0.0.1:16379', + WEB_ORIGIN: 'http://127.0.0.1:5173,http://localhost:5173', +}; + +describe('loadEnv', () => { + it('解析必填项并给可选项默认值', () => { + const config = loadEnv(validEnv); + expect(config.nodeEnv).toBe('test'); + expect(config.isProduction).toBe(false); + expect(config.port).toBe(3000); + expect(config.host).toBe('0.0.0.0'); + expect(config.logLevel).toBe('info'); + expect(config.maxUploadMb).toBe(500); + expect(config.featureVideo).toBe(false); + expect(config.webOrigins).toEqual(['http://127.0.0.1:5173', 'http://localhost:5173']); + }); + + it('字符串数字会被转成 number', () => { + const config = loadEnv({ ...validEnv, PORT: '8080', MAX_UPLOAD_MB: '200' }); + expect(config.port).toBe(8080); + expect(config.maxUploadMb).toBe(200); + }); + + it('缺少 DATABASE_URL 时抛错并带字段名', () => { + expect(() => loadEnv({ ...validEnv, DATABASE_URL: undefined })).toThrow(/DATABASE_URL/); + }); + + it('拒绝非 postgres 连接串', () => { + expect(() => loadEnv({ ...validEnv, DATABASE_URL: 'mysql://localhost/db' })).toThrow( + /DATABASE_URL/, + ); + }); + + it('拒绝非法 LOG_LEVEL', () => { + expect(() => loadEnv({ ...validEnv, LOG_LEVEL: 'verbose' })).toThrow(/LOG_LEVEL/); + }); + + it('FEATURE_VIDEO=true 转为布尔值', () => { + expect(loadEnv({ ...validEnv, FEATURE_VIDEO: 'true' }).featureVideo).toBe(true); + }); +}); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts new file mode 100644 index 0000000..abcb654 --- /dev/null +++ b/apps/api/src/config/env.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + PORT: z.coerce.number().int().min(1).max(65535).default(3000), + HOST: z.string().min(1).default('0.0.0.0'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + DATABASE_URL: z + .string() + .min(1) + .refine( + (value) => value.startsWith('postgresql://') || value.startsWith('postgres://'), + 'DATABASE_URL 必须是 postgresql:// 连接串', + ), + REDIS_URL: z + .string() + .min(1) + .refine( + (value) => value.startsWith('redis://') || value.startsWith('rediss://'), + 'REDIS_URL 必须是 redis:// 连接串', + ), + WEB_ORIGIN: z.string().default('http://127.0.0.1:5173'), + MAX_UPLOAD_MB: z.coerce.number().int().min(1).max(10240).default(500), + FEATURE_VIDEO: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), +}); + +export type RawEnv = z.infer; + +export interface AppConfig { + nodeEnv: RawEnv['NODE_ENV']; + isProduction: boolean; + port: number; + host: string; + logLevel: RawEnv['LOG_LEVEL']; + databaseUrl: string; + redisUrl: string; + webOrigins: string[]; + maxUploadMb: number; + featureVideo: boolean; +} + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppConfig { + const result = envSchema.safeParse(source); + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; '); + throw new Error(`环境变量校验失败:${details}`); + } + const env = result.data; + return { + nodeEnv: env.NODE_ENV, + isProduction: env.NODE_ENV === 'production', + port: env.PORT, + host: env.HOST, + logLevel: env.LOG_LEVEL, + databaseUrl: env.DATABASE_URL, + redisUrl: env.REDIS_URL, + webOrigins: env.WEB_ORIGIN.split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0), + maxUploadMb: env.MAX_UPLOAD_MB, + featureVideo: env.FEATURE_VIDEO, + }; +} diff --git a/apps/api/src/config/load-dotenv.ts b/apps/api/src/config/load-dotenv.ts new file mode 100644 index 0000000..8da0c3f --- /dev/null +++ b/apps/api/src/config/load-dotenv.ts @@ -0,0 +1,17 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { config as loadDotenvFile } from 'dotenv'; + +/** + * 按工程规范 09 的顺序加载本地环境变量:.env.local 优先,.env 兜底。 + * 只做加载,不做校验(校验见 config/env.ts)。 + */ +export function loadDotenv(): void { + for (const file of ['.env.local', '.env']) { + const path = resolve(process.cwd(), file); + if (existsSync(path)) { + loadDotenvFile({ path }); + } + } +} diff --git a/apps/api/src/health.int.spec.ts b/apps/api/src/health.int.spec.ts new file mode 100644 index 0000000..a12db70 --- /dev/null +++ b/apps/api/src/health.int.spec.ts @@ -0,0 +1,34 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from './bootstrap'; +import { loadDotenv } from './config/load-dotenv'; + +/** + * 集成测试:需要 pnpm compose:up 后真实 PG / Redis。 + * 单独运行:pnpm test:int + */ +describe('GET /health(集成)', () => { + let created: CreatedApiApp; + + beforeAll(async () => { + loadDotenv(); + created = await createApp(); + await created.app.init(); + }); + + afterAll(async () => { + await created.app.close(); + }); + + it('真实依赖健康时返回 200', async () => { + const response = await created.app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(200); + const body = response.json<{ + status: string; + checks: { database: { status: string }; redis: { status: string } }; + }>(); + expect(body.status).toBe('ok'); + expect(body.checks.database.status).toBe('up'); + expect(body.checks.redis.status).toBe('up'); + }); +}); diff --git a/apps/api/src/health.smoke.spec.ts b/apps/api/src/health.smoke.spec.ts new file mode 100644 index 0000000..f676ea7 --- /dev/null +++ b/apps/api/src/health.smoke.spec.ts @@ -0,0 +1,41 @@ +import { ErrorCode } from '@lingxi/shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, type TestApp } from './testing/test-app'; + +describe('GET /health(冒烟)', () => { + let testApp: TestApp; + + beforeAll(async () => { + testApp = await createTestApp(); + }); + + afterAll(async () => { + await testApp.app.close(); + }); + + it('依赖健康时返回 200 与检查明细', async () => { + const response = await testApp.app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(200); + const body = response.json<{ + status: string; + checks: { database: { status: string }; redis: { status: string } }; + }>(); + expect(body.status).toBe('ok'); + expect(body.checks.database.status).toBe('up'); + expect(body.checks.redis.status).toBe('up'); + }); +}); + +describe('GET /health(依赖异常)', () => { + it('数据库不可用时返回 503 与统一错误体', async () => { + const testApp = await createTestApp({ databaseUp: false }); + const response = await testApp.app.inject({ method: 'GET', url: '/health' }); + expect(response.statusCode).toBe(503); + const body = response.json<{ code: number; message: string; trace_id: string }>(); + expect(body.code).toBe(ErrorCode.DEPENDENCY_UNAVAILABLE); + expect(body.message).toBe('依赖服务不可用'); + expect(body.trace_id.length).toBeGreaterThan(0); + await testApp.app.close(); + }); +}); diff --git a/apps/api/src/infra/infra.service.spec.ts b/apps/api/src/infra/infra.service.spec.ts new file mode 100644 index 0000000..a91e27b --- /dev/null +++ b/apps/api/src/infra/infra.service.spec.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { testConfig } from '../testing/test-app'; +import { PrismaService } from './prisma/prisma.service'; +import { RedisService } from './redis/redis.service'; + +describe('PrismaService', () => { + it('关闭应用时断开 Prisma 连接', async () => { + const service = new PrismaService(testConfig); + const disconnect = vi.spyOn(service, '$disconnect').mockResolvedValue(); + await service.onModuleDestroy(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); + +describe('RedisService', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('ping 返回 PONG', async () => { + const service = new RedisService(testConfig); + const client = service.getClient(); + vi.spyOn(client, 'connect').mockResolvedValue(undefined); + vi.spyOn(client, 'ping').mockResolvedValue('PONG'); + await expect(service.ping()).resolves.toBe('PONG'); + }); + + it('关闭应用时断开 Redis 连接', () => { + const service = new RedisService(testConfig); + const client = service.getClient(); + const disconnect = vi.spyOn(client, 'disconnect').mockReturnValue(undefined); + service.onModuleDestroy(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/infra/prisma/prisma.module.ts b/apps/api/src/infra/prisma/prisma.module.ts new file mode 100644 index 0000000..a363c4a --- /dev/null +++ b/apps/api/src/infra/prisma/prisma.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; + +import { APP_CONFIG } from '../../config/config.module'; +import type { AppConfig } from '../../config/env'; +import { PRISMA } from '../tokens'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [ + { + provide: PRISMA, + inject: [APP_CONFIG], + useFactory: (config: AppConfig): PrismaService => new PrismaService(config), + }, + ], + exports: [PRISMA], +}) +export class PrismaModule {} diff --git a/apps/api/src/infra/prisma/prisma.service.ts b/apps/api/src/infra/prisma/prisma.service.ts new file mode 100644 index 0000000..caae23d --- /dev/null +++ b/apps/api/src/infra/prisma/prisma.service.ts @@ -0,0 +1,24 @@ +import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +import { APP_CONFIG } from '../../config/config.module'; +import type { AppConfig } from '../../config/env'; + +/** + * Prisma 封装。 + * 不主动 $connect:Prisma 首次查询时才建连接,这样 OpenAPI 导出、单元测试不需要真实数据库; + * /health 会执行 SELECT 1 做真实检查。 + */ +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleDestroy { + constructor(@Inject(APP_CONFIG) config: AppConfig) { + super({ + datasources: { db: { url: config.databaseUrl } }, + log: config.isProduction ? ['error'] : ['warn', 'error'], + }); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/infra/redis/redis.module.ts b/apps/api/src/infra/redis/redis.module.ts new file mode 100644 index 0000000..8790ac5 --- /dev/null +++ b/apps/api/src/infra/redis/redis.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common'; + +import { APP_CONFIG } from '../../config/config.module'; +import type { AppConfig } from '../../config/env'; +import { REDIS } from '../tokens'; +import { RedisService } from './redis.service'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS, + inject: [APP_CONFIG], + useFactory: (config: AppConfig): RedisService => new RedisService(config), + }, + ], + exports: [REDIS], +}) +export class RedisModule {} diff --git a/apps/api/src/infra/redis/redis.service.ts b/apps/api/src/infra/redis/redis.service.ts new file mode 100644 index 0000000..90d88b1 --- /dev/null +++ b/apps/api/src/infra/redis/redis.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, type OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; + +import { APP_CONFIG } from '../../config/config.module'; +import type { AppConfig } from '../../config/env'; + +/** + * Redis 客户端封装。 + * lazyConnect:不在应用启动时连接,避免 OpenAPI 导出 / 单测依赖真实 Redis; + * /health 调 ping 时才会真正连接。 + */ +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly client: Redis; + + constructor(@Inject(APP_CONFIG) config: AppConfig) { + this.client = new Redis(config.redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + }); + this.client.on('error', () => { + // 连接错误由健康检查与请求日志暴露,这里只避免 ioredis 抛未处理异常 + }); + } + + getClient(): Redis { + return this.client; + } + + async ping(): Promise<'PONG'> { + if (this.client.status === 'wait') { + await this.client.connect(); + } + await this.client.ping(); + return 'PONG'; + } + + onModuleDestroy(): void { + if (this.client.status !== 'end') { + this.client.disconnect(); + } + } +} diff --git a/apps/api/src/infra/tokens.ts b/apps/api/src/infra/tokens.ts new file mode 100644 index 0000000..59829e4 --- /dev/null +++ b/apps/api/src/infra/tokens.ts @@ -0,0 +1,3 @@ +/** 依赖注入 token:避免跨模块直接依赖具体实现 */ +export const PRISMA = Symbol('PRISMA'); +export const REDIS = Symbol('REDIS'); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..a34ccb3 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,24 @@ +import 'reflect-metadata'; + +import { Logger } from 'nestjs-pino'; + +import { createApp, setupOpenApi } from './bootstrap'; +import { loadDotenv } from './config/load-dotenv'; + +async function bootstrap(): Promise { + loadDotenv(); + const { app, config } = await createApp(); + await app.init(); + setupOpenApi(app, config); + await app.listen(config.port, config.host); + + const logger = app.get(Logger); + logger.log(`灵犀 API 已启动:http://${config.host}:${config.port}(文档 /api/docs)`); +} + +void bootstrap().catch((error: unknown) => { + process.stderr.write( + `API 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/apps/api/src/modules/platform/health/health.controller.ts b/apps/api/src/modules/platform/health/health.controller.ts new file mode 100644 index 0000000..2fd72ea --- /dev/null +++ b/apps/api/src/modules/platform/health/health.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Inject } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { HealthService } from './health.service'; +import { type HealthReport, HealthResponseDto } from './health.types'; + +@ApiTags('platform') +@Controller('health') +export class HealthController { + constructor(@Inject(HealthService) private readonly healthService: HealthService) {} + + @Get() + @ApiOperation({ + summary: '健康检查', + description: '真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。', + }) + @ApiOkResponse({ type: HealthResponseDto, description: '全部依赖健康' }) + @ApiResponse({ + status: 503, + description: '依赖不可用(错误码 50002),details 内附各依赖检查结果', + }) + async check(): Promise { + return this.healthService.check(); + } +} diff --git a/apps/api/src/modules/platform/health/health.module.ts b/apps/api/src/modules/platform/health/health.module.ts new file mode 100644 index 0000000..b486957 --- /dev/null +++ b/apps/api/src/modules/platform/health/health.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + providers: [HealthService], +}) +export class HealthModule {} diff --git a/apps/api/src/modules/platform/health/health.service.ts b/apps/api/src/modules/platform/health/health.service.ts new file mode 100644 index 0000000..e97e677 --- /dev/null +++ b/apps/api/src/modules/platform/health/health.service.ts @@ -0,0 +1,94 @@ +import { ErrorCode, nowIso } from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; + +import { AppException } from '../../../common/errors/app-exception'; +import { APP_CONFIG } from '../../../config/config.module'; +import type { AppConfig } from '../../../config/env'; +import type { PrismaService } from '../../../infra/prisma/prisma.service'; +import type { RedisService } from '../../../infra/redis/redis.service'; +import { PRISMA } from '../../../infra/tokens'; +import { REDIS } from '../../../infra/tokens'; +import type { ComponentHealth, HealthReport } from './health.types'; + +const API_VERSION = '0.1.0'; +const CHECK_TIMEOUT_MS = 2000; + +function withTimeout(task: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`检查超时(${String(timeoutMs)}ms)`)); + }, timeoutMs); + void task.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} + +/** + * 健康检查:真实检查数据库(SELECT 1)与 Redis(PING),不只看端口(技术架构 §4.1.1)。 + * 任一依赖不可用时返回 503 + 统一错误体,details 里带完整检查快照。 + */ +@Injectable() +export class HealthService { + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(REDIS) private readonly redis: RedisService, + @Inject(APP_CONFIG) private readonly config: AppConfig, + ) {} + + async check(): Promise { + const [database, redis] = await Promise.all([this.checkDatabase(), this.checkRedis()]); + const report: HealthReport = { + status: database.status === 'up' && redis.status === 'up' ? 'ok' : 'degraded', + env: this.config.nodeEnv, + version: API_VERSION, + uptimeSeconds: Math.round(process.uptime()), + checks: { database, redis }, + timestamp: nowIso(), + }; + + if (report.status === 'degraded') { + throw new AppException({ + code: ErrorCode.DEPENDENCY_UNAVAILABLE, + message: '依赖服务不可用', + details: report.checks, + }); + } + return report; + } + + private async checkDatabase(): Promise { + const startedAt = Date.now(); + try { + await withTimeout(this.prisma.$queryRaw`SELECT 1`, CHECK_TIMEOUT_MS); + return { status: 'up', latencyMs: Date.now() - startedAt }; + } catch (error) { + return { + status: 'down', + latencyMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : '未知错误', + }; + } + } + + private async checkRedis(): Promise { + const startedAt = Date.now(); + try { + await withTimeout(this.redis.ping(), CHECK_TIMEOUT_MS); + return { status: 'up', latencyMs: Date.now() - startedAt }; + } catch (error) { + return { + status: 'down', + latencyMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : '未知错误', + }; + } + } +} diff --git a/apps/api/src/modules/platform/health/health.types.ts b/apps/api/src/modules/platform/health/health.types.ts new file mode 100644 index 0000000..2888db5 --- /dev/null +++ b/apps/api/src/modules/platform/health/health.types.ts @@ -0,0 +1,58 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export interface ComponentHealth { + status: 'up' | 'down'; + latencyMs: number; + error?: string; +} + +export interface HealthReport { + status: 'ok' | 'degraded'; + env: string; + version: string; + uptimeSeconds: number; + checks: { + database: ComponentHealth; + redis: ComponentHealth; + }; + timestamp: string; +} + +export class ComponentHealthDto { + @ApiProperty({ type: String, enum: ['up', 'down'], example: 'up' }) + status!: 'up' | 'down'; + + @ApiProperty({ type: Number, description: '检查耗时(毫秒)', example: 3 }) + latencyMs!: number; + + @ApiProperty({ type: String, required: false, description: '失败原因(仅 down 时返回)' }) + error?: string; +} + +export class HealthChecksDto { + @ApiProperty({ type: ComponentHealthDto }) + database!: ComponentHealthDto; + + @ApiProperty({ type: ComponentHealthDto }) + redis!: ComponentHealthDto; +} + +export class HealthResponseDto { + @ApiProperty({ type: String, enum: ['ok', 'degraded'], example: 'ok' }) + status!: 'ok' | 'degraded'; + + @ApiProperty({ type: String, example: 'development' }) + env!: string; + + @ApiProperty({ type: String, example: '0.1.0' }) + version!: string; + + @ApiProperty({ type: Number, description: '进程运行秒数', example: 128 }) + uptimeSeconds!: number; + + @ApiProperty({ type: HealthChecksDto }) + checks!: HealthChecksDto; + + @ApiProperty({ type: String, example: '2026-11-02T08:00:00.000Z' }) + timestamp!: string; +} diff --git a/apps/api/src/modules/platform/platform.module.ts b/apps/api/src/modules/platform/platform.module.ts new file mode 100644 index 0000000..dcc6df6 --- /dev/null +++ b/apps/api/src/modules/platform/platform.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; + +import { HealthModule } from './health/health.module'; + +/** + * platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。 + * A0 只落地健康检查;其余能力按里程碑在本模块内新增。 + */ +@Global() +@Module({ + imports: [HealthModule], + exports: [HealthModule], +}) +export class PlatformModule {} diff --git a/apps/api/src/openapi/document.spec.ts b/apps/api/src/openapi/document.spec.ts new file mode 100644 index 0000000..17609f4 --- /dev/null +++ b/apps/api/src/openapi/document.spec.ts @@ -0,0 +1,36 @@ +import { ErrorCode } from '@lingxi/shared'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, type TestApp,testConfig } from '../testing/test-app'; +import { buildOpenApiDocument } from './document'; + +describe('OpenAPI 产物(冒烟)', () => { + let testApp: TestApp; + + beforeAll(async () => { + testApp = await createTestApp(); + }); + + afterAll(async () => { + await testApp.app.close(); + }); + + it('版本为 3.1 且包含健康检查路由', () => { + const document = buildOpenApiDocument(testApp.app, testConfig); + expect(document.openapi).toBe('3.1.0'); + expect(Object.keys(document.paths).some((path) => path.endsWith('/health'))).toBe(true); + expect(document.info.version).toBe('0.1.0'); + }); + + it('包含全部错误码枚举与统一错误体', () => { + const document = buildOpenApiDocument(testApp.app, testConfig); + const schemas = document.components?.schemas ?? {}; + expect(schemas.ErrorCode).toBeDefined(); + expect(schemas.ErrorResponse).toBeDefined(); + const errorCodeSchema = schemas.ErrorCode; + expect(errorCodeSchema && 'enum' in errorCodeSchema ? errorCodeSchema.enum : []).toHaveLength( + Object.values(ErrorCode).length, + ); + expect('x-error-codes' in document).toBe(true); + }); +}); diff --git a/apps/api/src/openapi/document.ts b/apps/api/src/openapi/document.ts new file mode 100644 index 0000000..3979107 --- /dev/null +++ b/apps/api/src/openapi/document.ts @@ -0,0 +1,74 @@ +import { ErrorCode } from '@lingxi/shared'; +import { type INestApplication } from '@nestjs/common'; +import type { OpenAPIObject } from '@nestjs/swagger'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +import { ErrorResponseDto } from '../common/http/error-response.dto'; +import type { AppConfig } from '../config/env'; + +/** + * 生成 OpenAPI 文档。 + * - 版本固定 3.1(技术架构:OpenAPI 3.1 + oasdiff breaking 检测) + * - 错误码以 ErrorCode schema + x-error-codes 扩展显式写入产物,供前端 codegen 与契约比对 + */ +export function buildOpenApiDocument(app: INestApplication, config: AppConfig): OpenAPIObject { + const builder = new DocumentBuilder() + .setTitle('灵犀 API') + .setDescription( + [ + '信息流广告素材生产平台后端 API。', + '', + '统一约定:', + '- 前缀 /api/v1(健康检查 /health 除外)', + '- 错误体 { code, message, details, trace_id }', + '- 列表响应 { items, next_cursor }', + ].join('\n'), + ) + .setVersion('0.1.0') + .setOpenAPIVersion('3.1.0') + .addServer('/api/v1', 'API v1') + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, builder, { + extraModels: [ErrorResponseDto], + }); + + const components = document.components ?? {}; + const schemas = components.schemas ?? {}; + + schemas.ErrorCode = { + type: 'integer', + description: '业务错误码(只允许使用 @lingxi/shared 中登记的错误码)', + enum: Object.values(ErrorCode), + }; + schemas.ErrorResponse = { + type: 'object', + required: ['code', 'message', 'trace_id'], + properties: { + code: { $ref: '#/components/schemas/ErrorCode' }, + message: { type: 'string' }, + details: { description: '补充信息,结构由错误码决定' }, + trace_id: { type: 'string' }, + }, + }; + + document.components = { ...components, schemas }; + Object.assign(document, { + 'x-error-codes': Object.values(ErrorCode).map((code) => ({ code })), + 'x-api-config': { + env: config.nodeEnv, + featureVideo: config.featureVideo, + }, + }); + + return document; +} + +export function setupOpenApi(app: INestApplication, config: AppConfig): OpenAPIObject { + const document = buildOpenApiDocument(app, config); + SwaggerModule.setup('api/docs', app, document, { + jsonDocumentUrl: 'api/openapi.json', + }); + return document; +} diff --git a/apps/api/src/scripts/export-openapi.ts b/apps/api/src/scripts/export-openapi.ts new file mode 100644 index 0000000..fad3e79 --- /dev/null +++ b/apps/api/src/scripts/export-openapi.ts @@ -0,0 +1,30 @@ +import 'reflect-metadata'; + +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { createApp } from '../bootstrap'; +import { loadDotenv } from '../config/load-dotenv'; +import { buildOpenApiDocument } from '../openapi/document'; + +async function main(): Promise { + loadDotenv(); + const { app, config } = await createApp(); + await app.init(); + + const document = buildOpenApiDocument(app, config); + const outputDir = resolve(process.cwd(), 'openapi'); + const outputFile = resolve(outputDir, 'lingxi.openapi.json'); + await mkdir(outputDir, { recursive: true }); + await writeFile(outputFile, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); + + process.stdout.write(`OpenAPI 已导出:${outputFile}\n`); + await app.close(); +} + +void main().catch((error: unknown) => { + process.stderr.write( + `OpenAPI 导出失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/apps/api/src/testing/test-app.ts b/apps/api/src/testing/test-app.ts new file mode 100644 index 0000000..6895c42 --- /dev/null +++ b/apps/api/src/testing/test-app.ts @@ -0,0 +1,91 @@ +import { API_PREFIX, ErrorCode } from '@lingxi/shared'; +import { Controller, Get, NotFoundException } from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import { Test } from '@nestjs/testing'; +import { type Mock, vi } from 'vitest'; + +import { AppException } from '../common/errors/app-exception'; +import { AllExceptionsFilter } from '../common/filters/all-exceptions.filter'; +import { APP_CONFIG } from '../config/config.module'; +import type { AppConfig } from '../config/env'; +import { PRISMA, REDIS } from '../infra/tokens'; +import { HealthController } from '../modules/platform/health/health.controller'; +import { HealthService } from '../modules/platform/health/health.service'; + +export const testConfig: AppConfig = { + nodeEnv: 'test', + isProduction: false, + port: 3000, + host: '127.0.0.1', + logLevel: 'error', + databaseUrl: 'postgresql://lingxi:lingxi@127.0.0.1:15432/lingxi_test', + redisUrl: 'redis://127.0.0.1:16379', + webOrigins: ['http://127.0.0.1:5173'], + maxUploadMb: 500, + featureVideo: false, +}; + +@Controller('__test') +class ThrowingController { + @Get('app-error') + appError(): never { + throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '当前状态不允许该操作' }); + } + + @Get('http-404') + notFound(): never { + throw new NotFoundException('资源不存在'); + } + + @Get('boom') + boom(): never { + throw new Error('unexpected failure'); + } +} + +export interface TestAppOptions { + databaseUp?: boolean; + redisUp?: boolean; + includeErrorRoutes?: boolean; +} + +export interface TestApp { + app: NestFastifyApplication; + prisma: { $queryRaw: Mock }; + redis: { ping: Mock }; +} + +export async function createTestApp(options: TestAppOptions = {}): Promise { + const prisma = { + $queryRaw: + options.databaseUp === false + ? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:15432')) + : vi.fn().mockResolvedValue([{ '?column?': 1 }]), + }; + const redis = { + ping: + options.redisUp === false + ? vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:16379')) + : vi.fn().mockResolvedValue('PONG'), + }; + + const moduleRef = await Test.createTestingModule({ + controllers: + options.includeErrorRoutes === true + ? [HealthController, ThrowingController] + : [HealthController], + providers: [ + HealthService, + { provide: PRISMA, useValue: prisma }, + { provide: REDIS, useValue: redis }, + { provide: APP_CONFIG, useValue: testConfig }, + ], + }).compile(); + + const app = moduleRef.createNestApplication(new FastifyAdapter()); + app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] }); + app.useGlobalFilters(new AllExceptionsFilter()); + await app.init(); + + return { app, prisma, redis }; +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..cfff73f --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts", "src/**/*.int.spec.ts", "src/scripts/**"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..f4796a1 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "types": ["node"], + "noEmit": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "useDefineForClassFields": false + }, + "include": ["src", "vitest.config.ts", "vitest.int.config.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..5a91445 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + exclude: ['**/*.int.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts', 'src/**/*.int.spec.ts', 'src/main.ts', 'src/scripts/**'], + thresholds: { lines: 60, functions: 60, statements: 60, branches: 60 }, + }, + }, +}); diff --git a/apps/api/vitest.int.config.ts b/apps/api/vitest.int.config.ts new file mode 100644 index 0000000..fd8a853 --- /dev/null +++ b/apps/api/vitest.int.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.int.spec.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..fb216f0 --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,23 @@ +{ + "name": "@lingxi/worker", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "tsx watch src/main.ts", + "build": "tsc -p tsconfig.build.json", + "start": "node dist/main.js", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@lingxi/shared": "workspace:*", + "dotenv": "18.0.9", + "ioredis": "5.11.1", + "pino": "10.4.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/node": "24.19.2", + "tsx": "4.23.15", + "typescript": "5.9.3" + } +} diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts new file mode 100644 index 0000000..9da7a2e --- /dev/null +++ b/apps/worker/src/config.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), + REDIS_URL: z + .string() + .min(1) + .refine( + (value) => value.startsWith('redis://') || value.startsWith('rediss://'), + 'REDIS_URL 必须是 redis:// 连接串', + ), + QUEUE: z.string().min(1).default('default'), + WORKER_CONCURRENCY: z.coerce.number().int().min(1).max(64).default(2), +}); + +export interface WorkerConfig { + nodeEnv: 'development' | 'test' | 'production'; + logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; + redisUrl: string; + queue: string; + concurrency: number; +} + +export function loadWorkerConfig(source: NodeJS.ProcessEnv = process.env): WorkerConfig { + const result = envSchema.safeParse(source); + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; '); + throw new Error(`Worker 环境变量校验失败:${details}`); + } + return { + nodeEnv: result.data.NODE_ENV, + logLevel: result.data.LOG_LEVEL, + redisUrl: result.data.REDIS_URL, + queue: result.data.QUEUE, + concurrency: result.data.WORKER_CONCURRENCY, + }; +} diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts new file mode 100644 index 0000000..064903a --- /dev/null +++ b/apps/worker/src/main.ts @@ -0,0 +1,62 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { config as loadDotenvFile } from 'dotenv'; +import Redis from 'ioredis'; +import pino from 'pino'; + +import { loadWorkerConfig } from './config'; + +function loadDotenv(): void { + for (const file of ['.env.local', '.env']) { + const path = resolve(process.cwd(), file); + if (existsSync(path)) { + loadDotenvFile({ path }); + } + } +} + +async function main(): Promise { + loadDotenv(); + const config = loadWorkerConfig(); + const logger = pino({ level: config.logLevel, base: { service: 'lingxi-worker' } }); + + const redis = new Redis(config.redisUrl, { + lazyConnect: true, + maxRetriesPerRequest: null, + enableOfflineQueue: false, + }); + redis.on('error', (error: Error) => { + logger.error({ err: error }, 'redis connection error'); + }); + + await redis.connect(); + await redis.ping(); + logger.info({ queue: config.queue, concurrency: config.concurrency }, 'worker 已启动,等待任务'); + + // A0 只验证进程与依赖可用;BullMQ 消费者在 M1 批次接入(QUEUE 环境变量决定队列) + const heartbeat = setInterval(() => { + logger.debug({ queue: config.queue }, 'worker heartbeat'); + }, 30_000); + heartbeat.unref(); + + const shutdown = async (signal: string): Promise => { + clearInterval(heartbeat); + logger.info({ signal }, 'worker 正在退出'); + await redis.quit(); + }; + + process.on('SIGINT', () => { + void shutdown('SIGINT'); + }); + process.on('SIGTERM', () => { + void shutdown('SIGTERM'); + }); +} + +void main().catch((error: unknown) => { + process.stderr.write( + `Worker 启动失败:${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/apps/worker/tsconfig.build.json b/apps/worker/tsconfig.build.json new file mode 100644 index 0000000..1ea0982 --- /dev/null +++ b/apps/worker/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..4f25ee2 --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["ES2022"], + "types": ["node"], + "noEmit": true + }, + "include": ["src"] +} diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json new file mode 100644 index 0000000..f02fcb0 --- /dev/null +++ b/packages/ai-sdk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lingxi/ai-sdk", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "tsup": "8.5.1", + "typescript": "5.9.3" + } +} diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts new file mode 100644 index 0000000..8b6b864 --- /dev/null +++ b/packages/ai-sdk/src/index.ts @@ -0,0 +1,5 @@ +/** + * @lingxi/ai-sdk 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。 + * 本包在 A0 阶段仅用于固定仓库结构与依赖方向。 + */ +export const PACKAGE_NAME = '@lingxi/ai-sdk'; diff --git a/packages/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json new file mode 100644 index 0000000..dee81a1 --- /dev/null +++ b/packages/ai-sdk/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/form-engine/package.json b/packages/form-engine/package.json new file mode 100644 index 0000000..1bdd6d2 --- /dev/null +++ b/packages/form-engine/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lingxi/form-engine", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "tsup": "8.5.1", + "typescript": "5.9.3" + } +} diff --git a/packages/form-engine/src/index.ts b/packages/form-engine/src/index.ts new file mode 100644 index 0000000..092f6ba --- /dev/null +++ b/packages/form-engine/src/index.ts @@ -0,0 +1,5 @@ +/** + * @lingxi/form-engine 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。 + * 本包在 A0 阶段仅用于固定仓库结构与依赖方向。 + */ +export const PACKAGE_NAME = '@lingxi/form-engine'; diff --git a/packages/form-engine/tsconfig.json b/packages/form-engine/tsconfig.json new file mode 100644 index 0000000..dee81a1 --- /dev/null +++ b/packages/form-engine/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/render/package.json b/packages/render/package.json new file mode 100644 index 0000000..5d41ef6 --- /dev/null +++ b/packages/render/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lingxi/render", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "tsup": "8.5.1", + "typescript": "5.9.3" + } +} diff --git a/packages/render/src/index.ts b/packages/render/src/index.ts new file mode 100644 index 0000000..7f817c1 --- /dev/null +++ b/packages/render/src/index.ts @@ -0,0 +1,5 @@ +/** + * @lingxi/render 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。 + * 本包在 A0 阶段仅用于固定仓库结构与依赖方向。 + */ +export const PACKAGE_NAME = '@lingxi/render'; diff --git a/packages/render/tsconfig.json b/packages/render/tsconfig.json new file mode 100644 index 0000000..dee81a1 --- /dev/null +++ b/packages/render/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src"] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..2f01424 --- /dev/null +++ b/packages/shared/package.json @@ -0,0 +1,34 @@ +{ + "name": "@lingxi/shared", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm,cjs --dts --clean --sourcemap", + "dev": "tsup src/index.ts --format esm,cjs --dts --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run --coverage" + }, + "dependencies": { + "zod": "3.25.76" + }, + "devDependencies": { + "@vitest/coverage-v8": "3.2.7", + "tsup": "8.5.1", + "typescript": "5.9.3", + "vitest": "3.2.7" + } +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 0000000..be12e52 --- /dev/null +++ b/packages/shared/src/constants.ts @@ -0,0 +1,16 @@ +/** + * 跨端共享常量(A0) + * 约束:packages/shared 不依赖任何业务包(AGENTS §5)。 + */ +export const API_PREFIX = '/api/v1'; + +export const DEFAULT_PAGE_SIZE = 20; +export const MAX_PAGE_SIZE = 100; + +export const ULID_LENGTH = 26; + +/** 任务提交必须携带的幂等键请求头(AGENTS §3.13) */ +export const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key'; + +/** 请求追踪 ID 请求/响应头 */ +export const REQUEST_ID_HEADER = 'x-request-id'; diff --git a/packages/shared/src/errors/api-error.spec.ts b/packages/shared/src/errors/api-error.spec.ts new file mode 100644 index 0000000..b2dfa59 --- /dev/null +++ b/packages/shared/src/errors/api-error.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { ApiError } from './api-error.js'; +import { ErrorCode } from './codes.js'; + +describe('ApiError', () => { + it('使用默认文案并按错误码推导 HTTP 状态', () => { + const error = new ApiError({ code: ErrorCode.NOT_FOUND, traceId: 'trace-001' }); + expect(error.message).toBe('资源不存在或已删除'); + expect(error.status).toBe(404); + expect(error.name).toBe('ApiError'); + expect(error).toBeInstanceOf(Error); + }); + + it('toBody 输出统一错误体(trace_id 为 snake_case)', () => { + const error = new ApiError({ + code: ErrorCode.COMPLIANCE_BLOCKED, + message: '触发极限词:最', + details: { words: ['最'] }, + traceId: 'trace-abc', + }); + expect(error.toBody()).toEqual({ + code: 42202, + message: '触发极限词:最', + details: { words: ['最'] }, + trace_id: 'trace-abc', + }); + }); + + it('details 缺省时不写入错误体', () => { + const body = new ApiError({ code: ErrorCode.INTERNAL_ERROR }).toBody(); + expect(body).not.toHaveProperty('details'); + expect(body.trace_id).toBe(''); + }); +}); diff --git a/packages/shared/src/errors/api-error.ts b/packages/shared/src/errors/api-error.ts new file mode 100644 index 0000000..bf3eca7 --- /dev/null +++ b/packages/shared/src/errors/api-error.ts @@ -0,0 +1,32 @@ +import type { ApiErrorBody } from '../schemas/api.js'; +import { ERROR_MESSAGES, type ErrorCode } from './codes.js'; + +/** + * 前后端共用的 API 错误对象。 + * - 服务端:AllExceptionsFilter 用它把异常序列化成统一错误体; + * - 前端:请求层把错误体还原成 ApiError,保留 trace_id 便于用户报障。 + */ +export class ApiError extends Error { + readonly code: ErrorCode; + readonly status: number; + readonly details?: unknown; + readonly traceId: string; + + constructor(params: { code: ErrorCode; message?: string; details?: unknown; traceId?: string }) { + super(params.message ?? ERROR_MESSAGES[params.code]); + this.name = 'ApiError'; + this.code = params.code; + this.status = Math.floor(params.code / 100); + this.details = params.details; + this.traceId = params.traceId ?? ''; + } + + toBody(): ApiErrorBody { + return { + code: this.code, + message: this.message, + ...(this.details === undefined ? {} : { details: this.details }), + trace_id: this.traceId, + }; + } +} diff --git a/packages/shared/src/errors/codes.spec.ts b/packages/shared/src/errors/codes.spec.ts new file mode 100644 index 0000000..58a0047 --- /dev/null +++ b/packages/shared/src/errors/codes.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { + ERROR_HTTP_STATUS, + ERROR_MESSAGES, + ErrorCode, + httpStatusOf, + isErrorCode, +} from './codes.js'; + +describe('ErrorCode', () => { + it('错误码段与工程规范 04 一致', () => { + expect(ErrorCode.PARAM_INVALID).toBe(40001); + expect(ErrorCode.UNAUTHENTICATED).toBe(40101); + expect(ErrorCode.TOKEN_EXPIRED).toBe(40102); + expect(ErrorCode.PERMISSION_DENIED).toBe(40301); + expect(ErrorCode.OUT_OF_SCOPE).toBe(40302); + expect(ErrorCode.NOT_FOUND).toBe(40401); + expect(ErrorCode.VERSION_CONFLICT).toBe(40901); + expect(ErrorCode.STATE_NOT_ALLOWED).toBe(40903); + expect(ErrorCode.COMPLIANCE_BLOCKED).toBe(42202); + expect(ErrorCode.COST_OVER_BUDGET).toBe(42203); + expect(ErrorCode.RATE_LIMITED).toBe(42901); + expect(ErrorCode.INTERNAL_ERROR).toBe(50001); + expect(ErrorCode.DEPENDENCY_UNAVAILABLE).toBe(50002); + expect(ErrorCode.TIMEOUT).toBe(50003); + }); + + it('每个错误码都有 HTTP 状态与默认提示', () => { + for (const code of Object.values(ErrorCode)) { + expect(ERROR_HTTP_STATUS[code]).toBeGreaterThanOrEqual(400); + expect(ERROR_HTTP_STATUS[code]).toBeLessThan(600); + expect(ERROR_MESSAGES[code].length).toBeGreaterThan(0); + } + }); + + it('依赖不可用映射 503、超时映射 504', () => { + expect(ERROR_HTTP_STATUS[ErrorCode.DEPENDENCY_UNAVAILABLE]).toBe(503); + expect(ERROR_HTTP_STATUS[ErrorCode.TIMEOUT]).toBe(504); + }); + + it('isErrorCode 只认可登记的错误码', () => { + expect(isErrorCode(40001)).toBe(true); + expect(isErrorCode(40099)).toBe(false); + expect(isErrorCode(200)).toBe(false); + }); + + it('httpStatusOf 从百位推导,非法码回退 500', () => { + expect(httpStatusOf(42203)).toBe(422); + expect(httpStatusOf(40101)).toBe(401); + expect(httpStatusOf(123)).toBe(500); + expect(httpStatusOf(70001)).toBe(500); + }); +}); diff --git a/packages/shared/src/errors/codes.ts b/packages/shared/src/errors/codes.ts new file mode 100644 index 0000000..0ffd66a --- /dev/null +++ b/packages/shared/src/errors/codes.ts @@ -0,0 +1,106 @@ +/** + * 统一错误码(docs/工程规范/04-API与路由规范.md §4) + * API-14:新增错误码必须集中登记在这里,禁止业务代码写裸数字。 + */ +export const ErrorCode = { + /** 400xx 参数校验 */ + PARAM_INVALID: 40001, + + /** 401xx 认证 */ + UNAUTHENTICATED: 40101, + TOKEN_EXPIRED: 40102, + SIGNATURE_INVALID: 40103, + + /** 403xx 授权 */ + PERMISSION_DENIED: 40301, + OUT_OF_SCOPE: 40302, + SPECIAL_PERMISSION_REQUIRED: 40303, + SOD_VIOLATION: 40304, + + /** 404xx 不存在 */ + NOT_FOUND: 40401, + + /** 409xx 冲突 */ + VERSION_CONFLICT: 40901, + DUPLICATE_SUBMIT: 40902, + STATE_NOT_ALLOWED: 40903, + + /** 422xx 业务规则 */ + BUSINESS_RULE_VIOLATION: 42201, + COMPLIANCE_BLOCKED: 42202, + COST_OVER_BUDGET: 42203, + + /** 429xx 限流 */ + RATE_LIMITED: 42901, + CONCURRENCY_LIMITED: 42902, + + /** 500xx 系统 */ + INTERNAL_ERROR: 50001, + DEPENDENCY_UNAVAILABLE: 50002, + TIMEOUT: 50003, +} as const; + +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** 错误码 → HTTP 状态码(4xx/5xx 段由百位决定) */ +export const ERROR_HTTP_STATUS: Readonly> = { + [ErrorCode.PARAM_INVALID]: 400, + [ErrorCode.UNAUTHENTICATED]: 401, + [ErrorCode.TOKEN_EXPIRED]: 401, + [ErrorCode.SIGNATURE_INVALID]: 401, + [ErrorCode.PERMISSION_DENIED]: 403, + [ErrorCode.OUT_OF_SCOPE]: 403, + [ErrorCode.SPECIAL_PERMISSION_REQUIRED]: 403, + [ErrorCode.SOD_VIOLATION]: 403, + [ErrorCode.NOT_FOUND]: 404, + [ErrorCode.VERSION_CONFLICT]: 409, + [ErrorCode.DUPLICATE_SUBMIT]: 409, + [ErrorCode.STATE_NOT_ALLOWED]: 409, + [ErrorCode.BUSINESS_RULE_VIOLATION]: 422, + [ErrorCode.COMPLIANCE_BLOCKED]: 422, + [ErrorCode.COST_OVER_BUDGET]: 422, + [ErrorCode.RATE_LIMITED]: 429, + [ErrorCode.CONCURRENCY_LIMITED]: 429, + [ErrorCode.INTERNAL_ERROR]: 500, + [ErrorCode.DEPENDENCY_UNAVAILABLE]: 503, + [ErrorCode.TIMEOUT]: 504, +}; + +/** 默认中文提示(前端兜底展示;服务端可按场景覆盖 message) */ +export const ERROR_MESSAGES: Readonly> = { + [ErrorCode.PARAM_INVALID]: '参数缺失或格式错误', + [ErrorCode.UNAUTHENTICATED]: '请先登录', + [ErrorCode.TOKEN_EXPIRED]: '登录已过期,请重新登录', + [ErrorCode.SIGNATURE_INVALID]: '签名校验失败', + [ErrorCode.PERMISSION_DENIED]: '没有操作权限', + [ErrorCode.OUT_OF_SCOPE]: '数据范围之外', + [ErrorCode.SPECIAL_PERMISSION_REQUIRED]: '缺少特殊权限', + [ErrorCode.SOD_VIOLATION]: '违反职责互斥,不能执行该操作', + [ErrorCode.NOT_FOUND]: '资源不存在或已删除', + [ErrorCode.VERSION_CONFLICT]: '数据已被他人修改,请刷新后重试', + [ErrorCode.DUPLICATE_SUBMIT]: '请勿重复提交', + [ErrorCode.STATE_NOT_ALLOWED]: '当前状态不允许该操作', + [ErrorCode.BUSINESS_RULE_VIOLATION]: '业务校验未通过', + [ErrorCode.COMPLIANCE_BLOCKED]: '内容触发合规拦截', + [ErrorCode.COST_OVER_BUDGET]: '成本超出预算', + [ErrorCode.RATE_LIMITED]: '请求过于频繁,请稍后再试', + [ErrorCode.CONCURRENCY_LIMITED]: '并发任务已达上限', + [ErrorCode.INTERNAL_ERROR]: '服务内部错误', + [ErrorCode.DEPENDENCY_UNAVAILABLE]: '依赖服务不可用', + [ErrorCode.TIMEOUT]: '请求超时', +}; + +const ERROR_CODE_VALUES: ReadonlySet = new Set(Object.values(ErrorCode)); + +export function isErrorCode(value: number): value is ErrorCode { + return ERROR_CODE_VALUES.has(value); +} + +/** 数字错误码对应的 HTTP 状态;优先查表(502/503/504 等细分),非法码回退 500 */ +export function httpStatusOf(code: number): number { + if (isErrorCode(code)) { + return ERROR_HTTP_STATUS[code]; + } + const segment = Math.floor(code / 100); + return segment >= 400 && segment <= 599 ? segment : 500; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..7c5f3ea --- /dev/null +++ b/packages/shared/src/index.ts @@ -0,0 +1,9 @@ +export * from './constants.js'; +export * from './errors/api-error.js'; +export * from './errors/codes.js'; +export * from './naming.js'; +export * from './permissions.js'; +export * from './schemas/api.js'; +export * from './schemas/common.js'; +export * from './time.js'; +export * from './ulid.js'; diff --git a/packages/shared/src/naming.spec.ts b/packages/shared/src/naming.spec.ts new file mode 100644 index 0000000..1f539e9 --- /dev/null +++ b/packages/shared/src/naming.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { dedupeName, sanitizeFilename } from './naming.js'; + +describe('sanitizeFilename', () => { + it('替换路径分隔符与非法字符,保留中文', () => { + expect(sanitizeFilename('素材/标签:01*?.png')).toBe('素材_标签_01__.png'); + }); + + it('去除首尾空格与点,空名回退', () => { + expect(sanitizeFilename(' ..脚本草稿 .. ')).toBe('脚本草稿'); + expect(sanitizeFilename(' ')).toBe('未命名'); + expect(sanitizeFilename('***', { fallback: 'file' })).toBe('file'); + }); + + it('Windows 保留名追加下划线', () => { + expect(sanitizeFilename('CON')).toBe('CON_'); + expect(sanitizeFilename('lpt1')).toBe('lpt1_'); + }); + + it('超长时保留扩展名', () => { + const name = sanitizeFilename(`${'a'.repeat(200)}.png`, { maxLength: 20 }); + expect(name).toHaveLength(20); + expect(name.endsWith('.png')).toBe(true); + }); +}); + +describe('dedupeName(R-01 同名自动加序号)', () => { + it('不冲突时原样返回', () => { + expect(dedupeName('脚本-01', new Set())).toBe('脚本-01'); + }); + + it('冲突时追加两位序号', () => { + const existing = new Set(['素材.png', '素材-02.png']); + expect(dedupeName('素材.png', existing)).toBe('素材-03.png'); + }); + + it('带序号的名字基于原始基名递增', () => { + const existing = new Set(['素材-02.png', '素材-03.png']); + expect(dedupeName('素材-03.png', existing)).toBe('素材-04.png'); + }); + + it('序号耗尽时抛错', () => { + const existing = new Set(['a', ...Array.from({ length: 3 }, (_, i) => `a-0${String(i + 2)}`)]); + expect(() => dedupeName('a', existing, 3)).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts new file mode 100644 index 0000000..0eec356 --- /dev/null +++ b/packages/shared/src/naming.ts @@ -0,0 +1,107 @@ +/** + * 命名工具(A0-SH-01):文件名清洗 + 同名自动加序号(PRD R-01) + * 浏览器 / Node 通用,不依赖 node:path。 + */ + +const ILLEGAL_FILENAME_CHARS = /[\\/:*?"<>|]/g; +const RESERVED_FILENAMES = new Set([ + 'CON', + 'PRN', + 'AUX', + 'NUL', + 'COM1', + 'COM2', + 'COM3', + 'COM4', + 'COM5', + 'COM6', + 'COM7', + 'COM8', + 'COM9', + 'LPT1', + 'LPT2', + 'LPT3', + 'LPT4', + 'LPT5', + 'LPT6', + 'LPT7', + 'LPT8', + 'LPT9', +]); + +const MAX_FILENAME_LENGTH = 120; + +export interface SanitizeFilenameOptions { + maxLength?: number; + replacement?: string; + fallback?: string; +} + +/** 去掉控制字符(\u0000-\u001f、\u007f),保留常规空白 */ +function stripControlChars(input: string): string { + let output = ''; + for (const char of input) { + const code = char.codePointAt(0) ?? 0; + if (code >= 0x20 && code !== 0x7f) { + output += char; + } + } + return output; +} + +/** 清洗文件名:去除非法字符 / 控制字符 / 首尾空格与点,中文原样保留 */ +export function sanitizeFilename(input: string, options: SanitizeFilenameOptions = {}): string { + const maxLength = options.maxLength ?? MAX_FILENAME_LENGTH; + const replacement = options.replacement ?? '_'; + const fallback = options.fallback ?? '未命名'; + + let name = stripControlChars(input.normalize('NFC')) + .replace(ILLEGAL_FILENAME_CHARS, replacement) + .trim(); + name = name.replace(/^[.\s]+|[.\s]+$/g, ''); + + if (name.length === 0 || name.replaceAll(replacement, '').trim().length === 0) { + return fallback; + } + if (RESERVED_FILENAMES.has(name.toUpperCase())) { + name = `${name}_`; + } + if (name.length > maxLength) { + const dotIndex = name.lastIndexOf('.'); + const extension = dotIndex > 0 ? name.slice(dotIndex) : ''; + const base = dotIndex > 0 ? name.slice(0, dotIndex) : name; + const keep = Math.max(1, maxLength - extension.length); + name = `${base.slice(0, keep)}${extension}`; + } + return name; +} + +const SEQUENCE_PATTERN = /^(.*)-(\d{2,})$/; + +function splitExtension(name: string): { stem: string; extension: string } { + const dotIndex = name.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex === name.length - 1) { + return { stem: name, extension: '' }; + } + return { stem: name.slice(0, dotIndex), extension: name.slice(dotIndex) }; +} + +/** + * 同名自动加序号(R-01):从 base-02 开始尝试到 maxAttempts,全部占用则抛错。 + * 已带序号的名字会基于其原始基名继续递增;扩展名保持不变。 + */ +export function dedupeName(name: string, existing: ReadonlySet, maxAttempts = 999): string { + if (!existing.has(name)) { + return name; + } + const { stem, extension } = splitExtension(name); + const matched = SEQUENCE_PATTERN.exec(stem); + const root = matched?.[1] ?? stem; + for (let sequence = 2; sequence <= maxAttempts; sequence += 1) { + const candidate = `${root}-${String(sequence).padStart(2, '0')}${extension}`; + if (!existing.has(candidate)) { + return candidate; + } + } + throw new RangeError(`同名文件过多,无法为「${name}」生成序号`); +} diff --git a/packages/shared/src/permissions.spec.ts b/packages/shared/src/permissions.spec.ts new file mode 100644 index 0000000..7451065 --- /dev/null +++ b/packages/shared/src/permissions.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; + +import { + isPermissionCode, + parsePermission, + Permission, + PERMISSION_CODES, + SPECIAL_PERMISSIONS, +} from './permissions.js'; + +describe('权限点常量', () => { + it('全部符合 模块:动作 命名且模块为小写', () => { + for (const code of PERMISSION_CODES) { + expect(code).toMatch(/^[a-z][a-z_]*:[a-z][a-z_]*$/); + expect(parsePermission(code)).not.toBeNull(); + } + }); + + it('特殊权限必须是已登记的权限点', () => { + for (const code of SPECIAL_PERMISSIONS) { + expect(PERMISSION_CODES).toContain(code); + } + }); + + it('isPermissionCode 只认可登记值', () => { + expect(isPermissionCode(Permission.SCRIPT_READ)).toBe(true); + expect(isPermissionCode('script:delete_all')).toBe(false); + }); +}); + +describe('parsePermission', () => { + it('拆分模块与动作', () => { + expect(parsePermission('material:render')).toEqual({ module: 'material', action: 'render' }); + }); + + it('非法格式返回 null', () => { + expect(parsePermission('scriptread')).toBeNull(); + expect(parsePermission(':read')).toBeNull(); + expect(parsePermission('script:')).toBeNull(); + expect(parsePermission('a:b:c')).toBeNull(); + }); +}); diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts new file mode 100644 index 0000000..1c7cedb --- /dev/null +++ b/packages/shared/src/permissions.ts @@ -0,0 +1,78 @@ +/** + * 权限点常量骨架(A0-SH-01) + * 命名规范:<模块>:<动作>,模块取后端八模块语义(identity / client / catalog / industry / + * script / media / delivery / platform),全小写;动作使用动词或名词短语。 + * A1/A2 接入真实角色-权限表后,这里只增不改;删除/改名需评审(AGENTS §10)。 + */ +export const Permission = { + // 脚本 + SCRIPT_READ: 'script:read', + SCRIPT_WRITE: 'script:write', + SCRIPT_REVIEW: 'script:review', + // 素材 + MATERIAL_READ: 'material:read', + MATERIAL_WRITE: 'material:write', + MATERIAL_RENDER: 'material:render', + MATERIAL_REVIEW: 'material:review', + // 资产 + ASSET_READ: 'asset:read', + ASSET_WRITE: 'asset:write', + // 导出交付 + DELIVERY_READ: 'delivery:read', + DELIVERY_EXPORT: 'delivery:export', + // 客户 + CUSTOMER_READ: 'customer:read', + CUSTOMER_MANAGE: 'customer:manage', + CUSTOMER_FIELD_APPROVE: 'customer_field:approve', + // 组织与角色 + USER_MANAGE: 'user:manage', + ROLE_MANAGE: 'role:manage', + // 平台配置 + INDUSTRY_MANAGE: 'industry:manage', + INDUSTRY_PUBLISH: 'industry:publish', + DICT_MANAGE: 'dict:manage', + FORM_DESIGN: 'form:design', + AI_CONFIGURE: 'ai:configure', + SYSTEM_CONFIGURE: 'system:configure', + // 审计 + AUDIT_READ: 'audit:read', + AUDIT_EXPORT: 'audit:export', +} as const; + +export type PermissionCode = (typeof Permission)[keyof typeof Permission]; + +export const PERMISSION_CODES: readonly PermissionCode[] = Object.values(Permission); + +/** 需要额外单独校验的特殊权限(技术架构 §6.2「关键特殊权限」) */ +export const SPECIAL_PERMISSIONS: readonly PermissionCode[] = [ + Permission.DELIVERY_EXPORT, + Permission.AI_CONFIGURE, + Permission.AUDIT_EXPORT, + Permission.INDUSTRY_PUBLISH, + Permission.CUSTOMER_FIELD_APPROVE, +]; + +const PERMISSION_CODE_SET: ReadonlySet = new Set(PERMISSION_CODES); + +export function isPermissionCode(value: string): value is PermissionCode { + return PERMISSION_CODE_SET.has(value); +} + +export interface ParsedPermission { + module: string; + action: string; +} + +/** 拆分权限点为 { module, action };格式不合法返回 null */ +export function parsePermission(code: string): ParsedPermission | null { + const separator = code.indexOf(':'); + if (separator <= 0 || separator === code.length - 1) { + return null; + } + const module = code.slice(0, separator); + const action = code.slice(separator + 1); + if (module.includes(':') || action.includes(':')) { + return null; + } + return { module, action }; +} diff --git a/packages/shared/src/schemas/api.spec.ts b/packages/shared/src/schemas/api.spec.ts new file mode 100644 index 0000000..0982997 --- /dev/null +++ b/packages/shared/src/schemas/api.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js'; +import { apiErrorBodySchema, cursorPaginationQuerySchema, listResponseSchema } from './api.js'; + +describe('apiErrorBodySchema', () => { + it('接受统一错误体', () => { + const parsed = apiErrorBodySchema.parse({ + code: 40001, + message: '参数缺失', + details: { field: 'name' }, + trace_id: 'trace-1', + }); + expect(parsed.code).toBe(40001); + }); + + it('拒绝缺少 trace_id 的错误体', () => { + expect(() => apiErrorBodySchema.parse({ code: 40001, message: 'x' })).toThrow(); + }); +}); + +describe('cursorPaginationQuerySchema', () => { + it('缺省 limit 为默认页大小', () => { + expect(cursorPaginationQuerySchema.parse({})).toEqual({ limit: DEFAULT_PAGE_SIZE }); + expect(DEFAULT_PAGE_SIZE).toBe(20); + }); + + it('字符串 limit 会被转成数字(query 参数场景)', () => { + expect(cursorPaginationQuerySchema.parse({ limit: '50', cursor: 'abc' })).toEqual({ + limit: 50, + cursor: 'abc', + }); + }); + + it('拒绝越界 limit', () => { + expect(() => cursorPaginationQuerySchema.parse({ limit: 0 })).toThrow(); + expect(() => cursorPaginationQuerySchema.parse({ limit: MAX_PAGE_SIZE + 1 })).toThrow(); + expect(() => cursorPaginationQuerySchema.parse({ limit: 'abc' })).toThrow(); + }); +}); + +describe('listResponseSchema', () => { + it('列表信封为 { items, next_cursor }', () => { + const schema = listResponseSchema(apiErrorBodySchema); + const parsed = schema.parse({ items: [], next_cursor: null }); + expect(parsed).toEqual({ items: [], next_cursor: null }); + }); + + it('next_cursor 可为字符串', () => { + const schema = listResponseSchema(apiErrorBodySchema); + expect(schema.parse({ items: [], next_cursor: 'cursor-2' }).next_cursor).toBe('cursor-2'); + }); +}); diff --git a/packages/shared/src/schemas/api.ts b/packages/shared/src/schemas/api.ts new file mode 100644 index 0000000..58378b6 --- /dev/null +++ b/packages/shared/src/schemas/api.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE } from '../constants.js'; + +/** 统一错误体(工程规范 04 §4):{ code, message, details, trace_id } */ +export const apiErrorBodySchema = z.object({ + code: z.number().int().positive(), + message: z.string().min(1), + details: z.unknown().optional(), + trace_id: z.string().min(1), +}); + +export type ApiErrorBody = z.infer; + +/** + * 游标分页查询参数:query 用 snake_case(C-01) + * cursor:上一页返回的 next_cursor;limit:默认 20、上限 100 + */ +export const cursorPaginationQuerySchema = z.object({ + cursor: z.string().min(1).optional(), + limit: z.coerce.number().int().min(1).max(MAX_PAGE_SIZE).default(DEFAULT_PAGE_SIZE), +}); + +export type CursorPaginationQuery = z.infer; + +/** 列表响应统一信封(C-02):{ items, next_cursor },单资源不套 data */ +export interface ListResponse { + items: T[]; + next_cursor: string | null; +} + +export function listResponseSchema( + itemSchema: T, +): z.ZodObject<{ items: z.ZodArray; next_cursor: z.ZodNullable }> { + return z.object({ + items: z.array(itemSchema), + next_cursor: z.string().nullable(), + }); +} diff --git a/packages/shared/src/schemas/common.spec.ts b/packages/shared/src/schemas/common.spec.ts new file mode 100644 index 0000000..2f95e03 --- /dev/null +++ b/packages/shared/src/schemas/common.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { ulid } from '../ulid.js'; +import { idempotencyKeySchema, isoDateTimeSchema, traceIdSchema, ulidSchema } from './common.js'; + +describe('common schemas', () => { + it('ulidSchema 接受应用侧生成的 ULID', () => { + expect(ulidSchema.parse(ulid())).toHaveLength(26); + expect(() => ulidSchema.parse('not-a-ulid')).toThrow(); + }); + + it('isoDateTimeSchema 要求带时区', () => { + expect(isoDateTimeSchema.parse('2026-11-02T08:00:00.000Z')).toBeTruthy(); + expect(() => isoDateTimeSchema.parse('2026-11-02 08:00:00')).toThrow(); + }); + + it('traceIdSchema 限制长度', () => { + expect(traceIdSchema.parse('trace-12345678')).toBe('trace-12345678'); + expect(() => traceIdSchema.parse('short')).toThrow(); + }); + + it('idempotencyKeySchema 只接受可见 ASCII', () => { + expect(idempotencyKeySchema.parse('idem-key-0001')).toBe('idem-key-0001'); + expect(() => idempotencyKeySchema.parse('幂等键-0001')).toThrow(); + expect(() => idempotencyKeySchema.parse('short')).toThrow(); + }); +}); diff --git a/packages/shared/src/schemas/common.ts b/packages/shared/src/schemas/common.ts new file mode 100644 index 0000000..29883ca --- /dev/null +++ b/packages/shared/src/schemas/common.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ULID_PATTERN } from '../ulid.js'; + +/** 26 位 ULID 主键 */ +export const ulidSchema = z.string().regex(ULID_PATTERN, '必须是 26 位大写 ULID'); + +export const idSchema = ulidSchema; + +/** ISO 8601 时间(必须带时区,A0 统一用 UTC Z) */ +export const isoDateTimeSchema = z.string().datetime({ offset: true }); + +export const sortOrderSchema = z.enum(['asc', 'desc']); + +/** 请求追踪 ID(与 Pino requestId 同源) */ +export const traceIdSchema = z.string().min(8).max(64); + +/** 任务提交幂等键:8–128 位可见 ASCII */ +export const idempotencyKeySchema = z + .string() + .min(8) + .max(128) + .regex(/^[\x21-\x7e]+$/, '幂等键必须是可见 ASCII 字符'); + +/** 客户 ID:ULID */ +export const customerIdSchema = ulidSchema; diff --git a/packages/shared/src/time.spec.ts b/packages/shared/src/time.spec.ts new file mode 100644 index 0000000..eaae5f0 --- /dev/null +++ b/packages/shared/src/time.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { addSeconds, isIsoDateString, nowIso, toIsoString } from './time.js'; + +describe('time utils', () => { + it('nowIso 返回 UTC ISO 字符串', () => { + expect(isIsoDateString(nowIso())).toBe(true); + }); + + it('toIsoString 支持 Date / 时间戳 / 字符串', () => { + const date = new Date('2026-11-02T08:00:00.000Z'); + expect(toIsoString(date)).toBe('2026-11-02T08:00:00.000Z'); + expect(toIsoString(date.getTime())).toBe('2026-11-02T08:00:00.000Z'); + expect(toIsoString('2026-11-02T08:00:00.000Z')).toBe('2026-11-02T08:00:00.000Z'); + }); + + it('非法时间抛错', () => { + expect(() => toIsoString('not-a-date')).toThrow(TypeError); + }); + + it('addSeconds 不修改原对象', () => { + const date = new Date('2026-11-02T08:00:00.000Z'); + expect(addSeconds(date, 90).toISOString()).toBe('2026-11-02T08:01:30.000Z'); + expect(date.toISOString()).toBe('2026-11-02T08:00:00.000Z'); + }); + + it('isIsoDateString 拒绝非 UTC 格式', () => { + expect(isIsoDateString('2026-11-02T08:00:00+08:00')).toBe(false); + expect(isIsoDateString('2026-13-02T08:00:00.000Z')).toBe(false); + }); +}); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts new file mode 100644 index 0000000..23e049c --- /dev/null +++ b/packages/shared/src/time.ts @@ -0,0 +1,23 @@ +/** 时间工具:统一 ISO 8601(UTC)字符串,跨端序列化口径一致 */ + +export function nowIso(): string { + return new Date().toISOString(); +} + +export function toIsoString(value: Date | number | string): string { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) { + throw new TypeError(`无法解析时间:${String(value)}`); + } + return date.toISOString(); +} + +export function addSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1000); +} + +const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/; + +export function isIsoDateString(value: string): boolean { + return ISO_PATTERN.test(value) && !Number.isNaN(Date.parse(value)); +} diff --git a/packages/shared/src/ulid.spec.ts b/packages/shared/src/ulid.spec.ts new file mode 100644 index 0000000..63a2165 --- /dev/null +++ b/packages/shared/src/ulid.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { isUlid, ulid, ULID_PATTERN, ulidToDate, ulidToTimestamp } from './ulid.js'; + +describe('ulid', () => { + it('生成 26 位 Crockford Base32 且不含 I/L/O/U', () => { + const id = ulid(); + expect(id).toHaveLength(26); + expect(ULID_PATTERN.test(id)).toBe(true); + expect(id).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); + }); + + it('时间戳可从 ID 还原', () => { + const timestamp = 1_800_000_000_000; + const id = ulid(timestamp); + expect(ulidToTimestamp(id)).toBe(timestamp); + expect(ulidToDate(id).getTime()).toBe(timestamp); + }); + + it('同一毫秒内生成不重复', () => { + const ids = new Set(Array.from({ length: 1000 }, () => ulid(1_800_000_000_000))); + expect(ids.size).toBe(1000); + }); + + it('isUlid 拒绝非法输入', () => { + expect(isUlid(ulid())).toBe(true); + expect(isUlid('01ARZ3NDEKTSV4RRFFQ69G5FA')).toBe(false); + expect(isUlid('01ARZ3NDEKTSV4RRFFQ69G5FAV'.toLowerCase())).toBe(false); + expect(isUlid('O1ARZ3NDEKTSV4RRFFQ69G5FAV')).toBe(false); + }); + + it('解析非法 ULID 抛错', () => { + expect(() => ulidToTimestamp('bad')).toThrow(TypeError); + expect(() => ulid(-1)).toThrow(RangeError); + }); +}); diff --git a/packages/shared/src/ulid.ts b/packages/shared/src/ulid.ts new file mode 100644 index 0000000..c8a3bd2 --- /dev/null +++ b/packages/shared/src/ulid.ts @@ -0,0 +1,56 @@ +/** + * ULID 生成与解析(Crockford Base32,26 字符 = 48bit 时间 + 80bit 随机) + * 主键统一由应用侧生成 char(26) ULID(工程规范 05)。 + */ +import { ULID_LENGTH } from './constants.js'; + +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +const ENCODING_LEN = 32; + +export const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; + +function randomBytes(length: number): Uint8Array { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + return bytes; +} + +/** 生成 ULID;同一毫秒内依靠 80bit 随机数避免碰撞 */ +export function ulid(timestamp: number = Date.now()): string { + if (!Number.isInteger(timestamp) || timestamp < 0 || timestamp > 0xffffffffffff) { + throw new RangeError('ULID 时间戳必须是 48bit 非负整数'); + } + let time = timestamp; + const timeChars: string[] = new Array(10); + for (let i = 9; i >= 0; i -= 1) { + timeChars[i] = ENCODING[time % ENCODING_LEN] ?? '0'; + time = Math.floor(time / ENCODING_LEN); + } + const randomChars = Array.from(randomBytes(16), (byte) => ENCODING[byte & 31] ?? '0'); + return `${timeChars.join('')}${randomChars.join('')}`; +} + +export function isUlid(value: string): boolean { + return value.length === ULID_LENGTH && ULID_PATTERN.test(value); +} + +/** 解析 ULID 前 10 位得到毫秒时间戳 */ +export function ulidToTimestamp(id: string): number { + if (!isUlid(id)) { + throw new TypeError(`不是合法 ULID:${id}`); + } + let timestamp = 0; + for (let i = 0; i < 10; i += 1) { + const char = id[i] ?? ''; + const value = ENCODING.indexOf(char); + if (value < 0) { + throw new TypeError(`ULID 含非法字符:${char}`); + } + timestamp = timestamp * ENCODING_LEN + value; + } + return timestamp; +} + +export function ulidToDate(id: string): Date { + return new Date(ulidToTimestamp(id)); +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..a50024e --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "types": [] + }, + "include": ["src", "vitest.config.ts"] +} diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..6310cef --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov', 'json-summary'], + include: ['src/**/*.ts'], + exclude: ['src/**/*.spec.ts', 'src/index.ts', 'src/**/index.ts'], + thresholds: { lines: 85, functions: 85, statements: 85, branches: 80 }, + }, + }, +});