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:
Stev_Wang committed 2026-11-02 17:20:46 +08:00
1 parent 09dbb27b66
commit 23067edb8f
70 files changed
+2445

No files matched your search

+53
View File
@@ -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');
});
});
+39
View File
@@ -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();
});
});
+26
View File
@@ -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;