feat(api): 搭建 API 与 Worker 骨架及契约基线
NestJS 11 + Fastify 统一错误体与 trace_id,/health 真实探活 DB/Redis,OpenAPI 3.1 契约含 20 个错误码;shared 包提供错误码、权限点、ULID、分页与通用 schema;worker 完成队列进程骨架。
This commit is contained in:
1 parent
09dbb27b66
commit
23067edb8f
70 files changed
+2445
No files matched your search
@@ -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;
|
||||
Reference in new issue
Block a user