feat(data): 实现配置表 CRUD 与 Schema 初始化拆分

- 新增 data/db/ 数据库访问层:pool 管理、类型定义、Zod 校验、参数化 SQL 查询
- 新增 data/db/config-crud.ts:MonitoredSymbolsRepo / ExchangeConfigRepo / AppConfigRepo 三个 CRUD 服务类
- 新增 data/config.ts:中心化配置模块,零依赖 .env 解析 + Zod 校验
- 新增 data/schema/:klines.sql + config.sql 参考 DDL
- 新增 data/exchanges/:交易所类型定义与 Binance WebSocket 封装
- 新增 data/run/:交易所连接启动入口
- 重构 data/init-db/:001_init.sql 仅保留 TimescaleDB + klines,配置表拆分至 002_config.sql
- 更新 docker-compose.yml:挂载 init-db 初始化脚本
This commit is contained in:
Rekey
2026-06-07 20:46:35 +08:00
parent 10e13ae8da
commit e91cad79e6
18 changed files with 8560 additions and 5 deletions
+184
View File
@@ -0,0 +1,184 @@
// ============================================================
// config.ts — 中心化配置模块(带 Zod 运行时校验)
// ============================================================
// 职责:
// 1. 从 .env 文件加载环境变量(零依赖,手动解析)
// 2. 使用 EnvConfigSchema 校验并类型收窄
// 3. 导出按职责分组的强类型配置对象(pgsql / redis / batch / ws / logging / symbols
//
// 使用方式:
// import { pgsql, redis, batch, ws, logging, symbols } from "./config";
// const pool = new pg.Pool(pgsql);
// const redisClient = new Redis(redis.url);
// ============================================================
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { EnvConfigSchema, type EnvConfig } from "./db/validators";
// ============================================================
// 1. 加载 .env 文件(零依赖实现)
// ============================================================
/**
* 手动解析 .env 文件为 key-value 对。
* 规则:
* - 忽略空行和 # 开头的注释行
* - 首个 = 分割 key=value
* - 去除首尾空白(不处理引号,值原样保留)
* - 跳过不含 = 的行
*
* 不依赖 dotenv 包,保持依赖精简。
*/
function parseEnvFile(filePath: string): Record<string, string> {
const result: Record<string, string> = {};
try {
const content = readFileSync(filePath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
// 跳过空行和注释
if (trimmed === "" || trimmed.startsWith("#")) {
continue;
}
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) {
continue;
}
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (key !== "") {
result[key] = value;
}
}
} catch {
// .env 文件不存在时不报错(生产环境变量由容器注入)
}
return result;
}
/**
* 将解析结果注入 process.env(已存在的变量不覆盖)。
* 这使得后续 Zod 的 `z.coerce` 可以从 process.env 读取。
*/
function loadEnvFile(): void {
// __dirname 在 ESM 中不可用,手动计算
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = resolve(__dirname, ".env");
const parsed = parseEnvFile(envPath);
for (const [key, value] of Object.entries(parsed)) {
// 不覆盖已由系统设置的环境变量(Docker / systemd 注入优先)
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
// 模块加载时立即执行
loadEnvFile();
// ============================================================
// 2. Zod 校验 & 类型收窄
// ============================================================
/**
* 经 Zod 校验后的环境变量配置。
* 所有字段均有默认值兜底,即使 .env 缺失也能正常运行。
*/
const rawConfig: EnvConfig = EnvConfigSchema.parse(process.env);
// ============================================================
// 3. 按职责分组的导出配置对象
// ============================================================
/** PostgreSQL / TimescaleDB 连接配置 */
export const pgsql = {
host: rawConfig.DB_HOST,
port: rawConfig.DB_PORT,
database: rawConfig.DB_NAME,
user: rawConfig.DB_USER,
password: rawConfig.DB_PASSWORD,
/** pg.Pool 连接上限,避免连接数暴涨 */
max: 20,
/** 连接空闲超时(毫秒) */
idleTimeoutMillis: 30000,
/** 连接获取超时(毫秒) */
connectionTimeoutMillis: 5000,
} as const;
/** Redis 连接与发布配置 */
export const redis = {
/** Redis 连接 URLioredis 可直接使用) */
url: rawConfig.REDIS_URL,
/** 是否启用 Pub/Sub 发布行情数据(开发环境可关闭以节省资源) */
publishEnabled: rawConfig.REDIS_PUBLISH_ENABLED === "true",
/** 频道前缀,避免多环境 key 冲突 */
channelPrefix: "trade",
/** 重连策略:指数退避基数(毫秒) */
retryDelayBaseMs: 1000,
/** 最大重试次数 */
maxRetries: 10,
} as const;
/** K 线批量写入配置 */
export const batch = {
/** 缓冲区条数阈值(达到后自动刷新) */
size: rawConfig.BATCH_SIZE,
/** 最大缓冲时间(毫秒),超时后即使未达阈值也刷新 */
flushIntervalMs: rawConfig.FLUSH_INTERVAL_MS,
} as const;
/** WebSocket 连接配置(全局默认值,交易所级别可覆盖) */
export const ws = {
/** 断线重连延迟基数(毫秒),指数退避:基数 × 2^attempts */
reconnectDelayMs: rawConfig.WS_RECONNECT_DELAY_MS,
/** 心跳间隔(毫秒) */
pingIntervalMs: rawConfig.WS_PING_INTERVAL_MS,
/** 最大重连次数(超过后标记 error 状态) */
maxReconnectAttempts: rawConfig.WS_MAX_RECONNECT_ATTEMPTS,
} as const;
/** 日志配置 */
export const logging = {
/** 日志级别:trace / debug / info / warn / error / fatal */
level: rawConfig.LOG_LEVEL,
/** 运行环境(production 时 pino 输出 JSON 便于日志采集) */
nodeEnv: rawConfig.NODE_ENV,
/** 是否启用 pino-pretty(开发环境友好输出) */
pretty: rawConfig.NODE_ENV === "development",
} as const;
/** 默认订阅的交易对列表(逗号分隔 → string[]) */
export const symbols: string[] = rawConfig.SYMBOLS.split(",")
.map((s) => s.trim())
.filter(Boolean);
// ============================================================
// 4. 工具:运行时打印配置概要(不含敏感信息)
// ============================================================
/** 打印脱敏后的配置概要,便于启动排查 */
export function printConfigSummary(): void {
const summary = {
pgsql: {
host: pgsql.host,
port: pgsql.port,
database: pgsql.database,
user: pgsql.user,
password: "***",
},
redis: {
url: redis.url.replace(/\/\/.*@/, "//***@"), // 隐藏密码
publishEnabled: redis.publishEnabled,
},
batch,
ws,
logging,
symbols,
};
console.log("[config] 配置概要:", JSON.stringify(summary, null, 2));
}