e91cad79e6
- 新增 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 初始化脚本
185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
// ============================================================
|
||
// 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));
|
||
}
|