// ============================================================ // schema/validators.ts — Zod 运行时校验 Schema // ============================================================ // 用途: // 1. WebSocket 行情数据到达后校验字段完整性再入库 // 2. 配置文件 / 环境变量加载后类型收窄 // 3. API 输入参数校验 // // 依赖:zod ^4.x(已包含在 data/package.json) // ============================================================ import { z } from "zod"; // ============================================================ // 基础标量 Schema // ============================================================ /** 交易所枚举 */ export const ExchangeSchema = z.enum(["binance", "okx", "bybit"]); export type Exchange = z.infer; /** K 线周期枚举 */ export const KlineIntervalSchema = z.enum([ "1m", "5m", "15m", "1h", "4h", "1d", ]); export type KlineInterval = z.infer; /** 日志级别 */ export const LogLevelSchema = z.enum([ "trace", "debug", "info", "warn", "error", "fatal", ]); /** 交易对格式:大写字母 + 大写字母(如 BTCUSDT),3-12 字符 */ export const SymbolSchema = z .string() .regex(/^[A-Z0-9]{4,14}$/, "交易对格式无效,示例:BTCUSDT"); /** * NUMERIC(20,8) 数值字符串 * pg 驱动默认以字符串返回 NUMERIC 以保留精度 */ export const NumericStringSchema = z .string() .regex(/^-?\d+(\.\d+)?$/, "期望 NUMERIC 字符串"); // ============================================================ // 1. Kline 数据校验 — klines 表 // ============================================================ /** WebSocket 原始 OHLCV 消息校验(单条 K 线,入库前) */ export const KlineRawSchema = z.object({ /** K 线开盘时间(UTC),Unix 毫秒时间戳 */ time: z.number().int().positive(), /** 交易所 */ exchange: ExchangeSchema, /** 交易对 */ symbol: SymbolSchema, /** 周期 */ interval: KlineIntervalSchema, /** 开盘价 */ open: NumericStringSchema, /** 最高价 */ high: NumericStringSchema, /** 最低价 */ low: NumericStringSchema, /** 收盘价 */ close: NumericStringSchema, /** 成交量 */ volume: NumericStringSchema, /** 成交额(可选) */ quote_volume: NumericStringSchema.optional().default("0"), /** 主动买入量(可选) */ taker_buy_base_vol: NumericStringSchema.optional().default("0"), /** 主动买入额(可选) */ taker_buy_quote_vol: NumericStringSchema.optional().default("0"), /** 成交笔数(可选) */ trade_count: z.number().int().nonnegative().optional().default(0), /** K 线是否闭合 */ is_closed: z.boolean().optional().default(true), }); export type KlineRaw = z.infer; /** 批量 K 线消息校验(WebSocket 可能一次推送多根) */ export const KlineBatchSchema = z.array(KlineRawSchema).min(1); export type KlineBatch = z.infer; // ============================================================ // 2. 监控交易对配置校验 — monitored_symbols // ============================================================ /** 插入监控标的 */ export const MonitoredSymbolInsertSchema = z.object({ exchange: ExchangeSchema, symbol: SymbolSchema, interval: KlineIntervalSchema, enabled: z.boolean().optional().default(true), /** 优先级 0-32767(SMALLINT 范围) */ priority: z .number() .int() .min(0) .max(32767) .optional() .default(0), label: z.string().max(200).nullable().optional().default(null), notes: z.string().max(1000).nullable().optional().default(null), }); export type MonitoredSymbolInsert = z.infer< typeof MonitoredSymbolInsertSchema >; /** 更新监控标的 */ export const MonitoredSymbolUpdateSchema = z.object({ enabled: z.boolean().optional(), priority: z.number().int().min(0).max(32767).optional(), label: z.string().max(200).nullable().optional(), notes: z.string().max(1000).nullable().optional(), }); export type MonitoredSymbolUpdate = z.infer< typeof MonitoredSymbolUpdateSchema >; // ============================================================ // 3. 交易所连接配置校验 — exchange_config // ============================================================ /** 交易所连接配置输入 */ export const ExchangeConfigInsertSchema = z.object({ exchange: ExchangeSchema, rest_url: z.string().url().nullable().optional().default(null), ws_url: z.string().url().nullable().optional().default(null), ws_ping_interval_ms: z .number() .int() .min(5000) .max(300000) .optional() .default(30000), rate_limit_per_sec: z.number().positive().max(100).optional().default(20), max_reconnect_attempts: z .number() .int() .min(0) .max(100) .optional() .default(10), reconnect_delay_ms: z .number() .int() .min(100) .max(60000) .optional() .default(3000), enabled: z.boolean().optional().default(true), notes: z.string().max(500).nullable().optional().default(null), }); export type ExchangeConfigInsert = z.infer< typeof ExchangeConfigInsertSchema >; // ============================================================ // 4. 流标识校验 — StreamKey // ============================================================ /** (exchange, symbol, interval) 三元组 */ export const StreamKeySchema = z.object({ exchange: ExchangeSchema, symbol: SymbolSchema, interval: KlineIntervalSchema, }); export type StreamKey = z.infer; // ============================================================ // 5. 环境变量 / 配置校验 // ============================================================ /** .env 环境变量 schema */ export const EnvConfigSchema = z.object({ /** 逗号分隔的交易对列表 */ SYMBOLS: z .string() .optional() .default("BTCUSDT,ETHUSDT"), DB_HOST: z.string().optional().default("localhost"), DB_PORT: z.coerce.number().int().positive().optional().default(5432), DB_NAME: z.string().optional().default("trade"), DB_USER: z.string().optional().default("trader"), DB_PASSWORD: z.string().optional().default("changeme"), REDIS_URL: z.string().url().optional().default("redis://localhost:6379"), REDIS_PUBLISH_ENABLED: z .enum(["true", "false"]) .optional() .default("true"), BATCH_SIZE: z.coerce.number().int().positive().optional().default(500), FLUSH_INTERVAL_MS: z.coerce .number() .int() .positive() .optional() .default(1000), /** WebSocket 断线重连延迟基数(毫秒) */ WS_RECONNECT_DELAY_MS: z.coerce .number() .int() .positive() .optional() .default(3000), /** WebSocket 心跳间隔(毫秒) */ WS_PING_INTERVAL_MS: z.coerce .number() .int() .positive() .optional() .default(30000), /** WebSocket 最大重连次数 */ WS_MAX_RECONNECT_ATTEMPTS: z.coerce .number() .int() .nonnegative() .optional() .default(10), LOG_LEVEL: LogLevelSchema.optional().default("info"), NODE_ENV: z .enum(["development", "production", "test"]) .optional() .default("development"), }); export type EnvConfig = z.infer;