// ============================================================ // db/config-crud.ts — 配置表 CRUD 服务层 // ============================================================ // 职责: // 1. 封装 monitored_symbols / exchange_config / app_config 三张配置表的增删改查 // 2. 所有方法通过 pg.Pool 执行参数化 SQL(防注入) // 3. 返回类型与 types.ts 严格对应,调用方无需手动断言 // 4. 支持依赖注入:构造函数接收 Pool,便于单元测试 mock // // 使用方式: // import { pool } from "./db"; // import { MonitoredSymbolsRepo, ExchangeConfigRepo, AppConfigRepo } from "./db/config-crud"; // // const symbolsRepo = new MonitoredSymbolsRepo(pool); // const all = await symbolsRepo.listAll(); // ============================================================ import type pg from "pg"; import type { MonitoredSymbolRow, MonitoredSymbolInsert, MonitoredSymbolUpdate, ExchangeConfigRow, ExchangeConfigInsert, AppConfigRow, Exchange, KlineInterval, } from "./types"; import { // monitored_symbols queryAllMonitoredSymbols, queryEnabledSymbols, querySymbolsByExchange, queryMonitoredSymbolById, queryMonitoredSymbolByKey, upsertMonitoredSymbol, updateMonitoredSymbol, disableMonitoredSymbol, deleteMonitoredSymbol, deleteMonitoredSymbolByKey, // exchange_config queryAllExchangeConfigs, queryEnabledExchanges, queryExchangeConfig, queryExchangeConfigById, upsertExchangeConfig, updateExchangeConfig, deleteExchangeConfig, deleteExchangeConfigByExchange, // app_config queryAllAppConfig, queryAppConfig, queryAppConfigById, upsertAppConfig, updateAppConfig, deleteAppConfig, deleteAppConfigById, } from "./queries"; // ============================================================ // 工具类型:将 Promise 解包的一行或 null // ============================================================ /** pg query 返回的第一行,不存在则为 null */ type FirstRow = T | null; // ============================================================ // MonitoredSymbolsRepo — 监控交易对配置 CRUD // ============================================================ export class MonitoredSymbolsRepo { constructor(private readonly pool: pg.Pool) {} // ---------------------------------------------------------- // CREATE / UPSERT // ---------------------------------------------------------- /** * 新增或更新监控标的。 * 唯一键冲突时更新 enabled/priority/label/notes 并刷新 updated_at。 * * @returns 插入或更新后的完整行 */ async upsert( insert: MonitoredSymbolInsert, ): Promise { const { rows } = await this.pool.query( upsertMonitoredSymbol, [ insert.exchange, insert.symbol, insert.interval, insert.enabled ?? true, insert.priority ?? 0, insert.label ?? null, insert.notes ?? null, ], ); return rows[0]!; } // ---------------------------------------------------------- // READ — 单条 // ---------------------------------------------------------- /** 按主键 ID 查询 */ async findById(id: number): Promise> { const { rows } = await this.pool.query( queryMonitoredSymbolById, [id], ); return rows[0] ?? null; } /** * 按唯一业务键 (exchange, symbol, interval) 查询。 * 这是最常用的精确查找方式。 */ async findByKey( exchange: Exchange, symbol: string, interval: KlineInterval, ): Promise> { const { rows } = await this.pool.query( queryMonitoredSymbolByKey, [exchange, symbol, interval], ); return rows[0] ?? null; } // ---------------------------------------------------------- // READ — 列表 // ---------------------------------------------------------- /** 查询所有监控标的(含已禁用),按优先级降序 */ async listAll(): Promise { const { rows } = await this.pool.query( queryAllMonitoredSymbols, ); return rows; } /** 查询所有启用的监控标的(采集服务启动时调用) */ async listEnabled(): Promise { const { rows } = await this.pool.query( queryEnabledSymbols, ); return rows; } /** 查询指定交易所下所有启用的监控标的 */ async listByExchange(exchange: Exchange): Promise { const { rows } = await this.pool.query( querySymbolsByExchange, [exchange], ); return rows; } // ---------------------------------------------------------- // UPDATE // ---------------------------------------------------------- /** * 按 ID 部分更新监控标的。 * 仅更新传入的非 undefined 字段(COALESCE 语义)。 * * @returns 更新后的完整行;ID 不存在则返回 null */ async update( id: number, patch: MonitoredSymbolUpdate, ): Promise> { const { rows } = await this.pool.query( updateMonitoredSymbol, [ id, patch.enabled ?? null, patch.priority ?? null, patch.label ?? null, patch.notes ?? null, ], ); return rows[0] ?? null; } /** * 禁用指定监控标的(软删除)。 * 不会删除记录,仅将 enabled 设为 FALSE。 */ async disable( exchange: Exchange, symbol: string, interval: KlineInterval, ): Promise | null> { const { rows } = await this.pool.query< Pick >(disableMonitoredSymbol, [exchange, symbol, interval]); return rows[0] ?? null; } // ---------------------------------------------------------- // DELETE(硬删除) // ---------------------------------------------------------- /** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */ async deleteById(id: number): Promise { const { rows } = await this.pool.query<{ id: number }>( deleteMonitoredSymbol, [id], ); return rows[0]?.id ?? null; } /** 按唯一键硬删除。返回被删除的 id,不存在则返回 null */ async deleteByKey( exchange: Exchange, symbol: string, interval: KlineInterval, ): Promise { const { rows } = await this.pool.query<{ id: number }>( deleteMonitoredSymbolByKey, [exchange, symbol, interval], ); return rows[0]?.id ?? null; } } // ============================================================ // ExchangeConfigRepo — 交易所连接配置 CRUD // ============================================================ export class ExchangeConfigRepo { constructor(private readonly pool: pg.Pool) {} // ---------------------------------------------------------- // CREATE / UPSERT // ---------------------------------------------------------- /** * 新增或更新交易所配置。 * 唯一键冲突时更新所有连接参数并刷新 updated_at。 * * @returns 插入或更新后的完整行 */ async upsert( insert: ExchangeConfigInsert, ): Promise { const { rows } = await this.pool.query( upsertExchangeConfig, [ insert.exchange, insert.rest_url ?? null, insert.ws_url ?? null, insert.ws_ping_interval_ms ?? 30000, insert.rate_limit_per_sec ?? 20, insert.max_reconnect_attempts ?? 10, insert.reconnect_delay_ms ?? 3000, insert.enabled ?? true, insert.notes ?? null, ], ); return rows[0]!; } // ---------------------------------------------------------- // READ — 单条 // ---------------------------------------------------------- /** 按主键 ID 查询 */ async findById(id: number): Promise> { const { rows } = await this.pool.query( queryExchangeConfigById, [id], ); return rows[0] ?? null; } /** 按交易所标识查询(如 "binance") */ async findByExchange( exchange: Exchange, ): Promise> { const { rows } = await this.pool.query( queryExchangeConfig, [exchange], ); return rows[0] ?? null; } // ---------------------------------------------------------- // READ — 列表 // ---------------------------------------------------------- /** 查询所有交易所配置(含已禁用) */ async listAll(): Promise { const { rows } = await this.pool.query( queryAllExchangeConfigs, ); return rows; } /** 查询所有启用的交易所配置 */ async listEnabled(): Promise { const { rows } = await this.pool.query( queryEnabledExchanges, ); return rows; } // ---------------------------------------------------------- // UPDATE // ---------------------------------------------------------- /** * 按 ID 部分更新交易所配置。 * 仅更新传入的非 undefined 字段(COALESCE 语义)。 * * ⚠️ 风险提示:修改限频参数(rate_limit_per_sec)可能触发交易所封禁 IP。 * 务必确认目标交易所的官方限频规则后再调整。 * * @returns 更新后的完整行;ID 不存在则返回 null */ async update( id: number, patch: Partial>, ): Promise> { const { rows } = await this.pool.query( updateExchangeConfig, [ id, patch.rest_url ?? null, patch.ws_url ?? null, patch.ws_ping_interval_ms ?? null, patch.rate_limit_per_sec ?? null, patch.max_reconnect_attempts ?? null, patch.reconnect_delay_ms ?? null, patch.enabled ?? null, patch.notes ?? null, ], ); return rows[0] ?? null; } // ---------------------------------------------------------- // DELETE(硬删除) // ---------------------------------------------------------- /** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */ async deleteById(id: number): Promise { const { rows } = await this.pool.query<{ id: number }>( deleteExchangeConfig, [id], ); return rows[0]?.id ?? null; } /** 按交易所标识硬删除。返回被删除的 id,不存在则返回 null */ async deleteByExchange(exchange: Exchange): Promise { const { rows } = await this.pool.query<{ id: number }>( deleteExchangeConfigByExchange, [exchange], ); return rows[0]?.id ?? null; } } // ============================================================ // AppConfigRepo — 全局应用配置(KV)CRUD // ============================================================ export class AppConfigRepo { constructor(private readonly pool: pg.Pool) {} // ---------------------------------------------------------- // CREATE / UPSERT // ---------------------------------------------------------- /** * 设置一个配置项(新增或更新)。 * * @param key — 配置键 * @param value — 配置值(字符串,消费方自行解析类型) * @param description — 可选说明 * @returns 插入或更新后的完整行 */ async set( key: string, value: string, description?: string | null, ): Promise { const { rows } = await this.pool.query(upsertAppConfig, [ key, value, description ?? null, ]); return rows[0]!; } // ---------------------------------------------------------- // READ — 单条 // ---------------------------------------------------------- /** 按主键 ID 查询 */ async findById(id: number): Promise> { const { rows } = await this.pool.query( queryAppConfigById, [id], ); return rows[0] ?? null; } /** * 按 key 查询配置项。 * * @returns 配置行;不存在则返回 null */ async get(key: string): Promise> { const { rows } = await this.pool.query(queryAppConfig, [key]); return rows[0] ?? null; } /** * 按 key 获取配置值(字符串)。 * 便捷方法——等价于 (await get(key))?.value ?? defaultValue。 * * @param key — 配置键 * @param defaultValue — 默认值(key 不存在时返回) */ async getValue(key: string, defaultValue = ""): Promise { const row = await this.get(key); return row?.value ?? defaultValue; } /** * 按 key 获取配置值并解析为整数。 * 解析失败时返回 defaultValue。 */ async getIntValue(key: string, defaultValue = 0): Promise { const raw = await this.getValue(key, String(defaultValue)); const parsed = parseInt(raw, 10); return Number.isNaN(parsed) ? defaultValue : parsed; } /** * 按 key 获取配置值并解析为布尔。 * 规则:'true' / '1' → true,其余 → false。 */ async getBoolValue(key: string, defaultValue = false): Promise { const raw = await this.getValue(key, String(defaultValue)); return raw === "true" || raw === "1"; } // ---------------------------------------------------------- // READ — 列表 // ---------------------------------------------------------- /** 查询所有应用配置 */ async listAll(): Promise { const { rows } = await this.pool.query(queryAllAppConfig); return rows; } /** * 批量获取多个 key 的值。 * 一次性查询全表后过滤,避免 N+1 问题。 * * @param keys — 需要获取的 key 列表 * @returns Map */ async getBatch(keys: string[]): Promise> { const all = await this.listAll(); const map = new Map(); const keySet = new Set(keys); for (const row of all) { if (keySet.has(row.key)) { map.set(row.key, row.value); } } // 保证未找到的 key 也有默认值 "" for (const k of keys) { if (!map.has(k)) { map.set(k, ""); } } return map; } // ---------------------------------------------------------- // UPDATE // ---------------------------------------------------------- /** * 按 ID 部分更新应用配置。 * * @returns 更新后的完整行;ID 不存在则返回 null */ async update( id: number, value?: string, description?: string | null, ): Promise> { const { rows } = await this.pool.query(updateAppConfig, [ id, value ?? null, description ?? null, ]); return rows[0] ?? null; } // ---------------------------------------------------------- // DELETE // ---------------------------------------------------------- /** 按 key 删除配置。返回被删除的 id,不存在则返回 null */ async deleteByKey(key: string): Promise { const { rows } = await this.pool.query<{ id: number }>(deleteAppConfig, [ key, ]); return rows[0]?.id ?? null; } /** 按 ID 删除配置。返回被删除的 id,不存在则返回 null */ async deleteById(id: number): Promise { const { rows } = await this.pool.query<{ id: number }>( deleteAppConfigById, [id], ); return rows[0]?.id ?? null; } }