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

+16
View File
@@ -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('');
});
});
+32
View File
@@ -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,
};
}
}
+54
View File
@@ -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);
});
});
+106
View File
@@ -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;
}
+9
View File
@@ -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';
+47
View File
@@ -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);
});
});
+107
View File
@@ -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}」生成序号`);
}
+42
View File
@@ -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();
});
});
+78
View File
@@ -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 };
}
+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;
+31
View File
@@ -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);
});
});
+23
View File
@@ -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));
}
+36
View File
@@ -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);
});
});
+56
View File
@@ -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));
}