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:
+184
@@ -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 连接 URL(ioredis 可直接使用) */
|
||||
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));
|
||||
}
|
||||
Reference in New Issue
Block a user