feat(ci): 搭建 A0 工程骨架与契约基线 #2
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<CreatedApiApp> {
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({ trustProxy: true, bodyLimit: 1024 * 1024 }),
|
||||||
|
{ bufferLogs: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = app.get<AppConfig>(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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<RequestWithTrace>();
|
||||||
|
const reply = context.getResponse<ReplyLike>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<T> implements PipeTransform<unknown, T> {
|
||||||
|
constructor(private readonly schema: ZodSchema<T>) {}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof envSchema>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
@@ -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<void> {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** 依赖注入 token:避免跨模块直接依赖具体实现 */
|
||||||
|
export const PRISMA = Symbol('PRISMA');
|
||||||
|
export const REDIS = Symbol('REDIS');
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -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<HealthReport> {
|
||||||
|
return this.healthService.check();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<T>(task: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
return new Promise<T>((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<HealthReport> {
|
||||||
|
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<ComponentHealth> {
|
||||||
|
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<ComponentHealth> {
|
||||||
|
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 : '未知错误',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -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<TestApp> {
|
||||||
|
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<NestFastifyApplication>(new FastifyAdapter());
|
||||||
|
app.setGlobalPrefix(API_PREFIX, { exclude: ['health'] });
|
||||||
|
app.useGlobalFilters(new AllExceptionsFilter());
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
return { app, prisma, redis };
|
||||||
|
}
|
||||||
@@ -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/**"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"types": ["node"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @lingxi/ai-sdk 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||||
|
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||||
|
*/
|
||||||
|
export const PACKAGE_NAME = '@lingxi/ai-sdk';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @lingxi/form-engine 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||||
|
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||||
|
*/
|
||||||
|
export const PACKAGE_NAME = '@lingxi/form-engine';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* @lingxi/render 骨架占位:具体能力在后续批次实现(见开发计划 WBS)。
|
||||||
|
* 本包在 A0 阶段仅用于固定仓库结构与依赖方向。
|
||||||
|
*/
|
||||||
|
export const PACKAGE_NAME = '@lingxi/render';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<ErrorCode, number>> = {
|
||||||
|
[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<Record<ErrorCode, string>> = {
|
||||||
|
[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<number> = 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>, 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}」生成序号`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> = 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 };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof apiErrorBodySchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页查询参数: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<typeof cursorPaginationQuerySchema>;
|
||||||
|
|
||||||
|
/** 列表响应统一信封(C-02):{ items, next_cursor },单资源不套 data */
|
||||||
|
export interface ListResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listResponseSchema<T extends z.ZodTypeAny>(
|
||||||
|
itemSchema: T,
|
||||||
|
): z.ZodObject<{ items: z.ZodArray<T>; next_cursor: z.ZodNullable<z.ZodString> }> {
|
||||||
|
return z.object({
|
||||||
|
items: z.array(itemSchema),
|
||||||
|
next_cursor: z.string().nullable(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>(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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"noEmit": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src", "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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in new issue
Block a user