// ============================================================ // schema/queries.ts — 类型安全的参数化 SQL 查询 // ============================================================ // 每一条 SQL 都使用 $1, $2... 参数化,防止 SQL 注入。 // 返回类型与 types.ts 中的接口严格对应。 // // 使用方式: // import { pool } from "../db"; // import { queryEnabledSymbols } from "./schema/queries"; // const result = await pool.query(queryEnabledSymbols, ["binance"]); // // result.rows 自动推断为 MonitoredSymbolRow[] // ============================================================ import type { KlineInsert, KlineRow, AggregatedKlineRow, MonitoredSymbolRow, MonitoredSymbolInsert, ExchangeConfigRow, ExchangeConfigInsert, AppConfigRow, Exchange, KlineInterval, StreamKey, } from "./types"; // ============================================================ // K 线查询 — klines // ============================================================ /** * 批量插入 K 线(UPSERT — 冲突时更新 OHLCV) * * 使用 UNNEST 批量写入,单次可插入数千条,性能远优于逐条 INSERT。 * 冲突策略:ON CONFLICT 时更新价格/成交量/闭合状态。 */ export const bulkUpsertKlines = ` INSERT INTO klines ( time, exchange, symbol, interval, open, high, low, close, volume, quote_volume, taker_buy_base_vol, taker_buy_quote_vol, trade_count, is_closed ) SELECT * FROM UNNEST( $1::TIMESTAMPTZ[], -- time[] $2::TEXT[], -- exchange[] $3::TEXT[], -- symbol[] $4::TEXT[], -- interval[] $5::NUMERIC(20,8)[], -- open[] $6::NUMERIC(20,8)[], -- high[] $7::NUMERIC(20,8)[], -- low[] $8::NUMERIC(20,8)[], -- close[] $9::NUMERIC(20,8)[], -- volume[] $10::NUMERIC(20,8)[],-- quote_volume[] $11::NUMERIC(20,8)[],-- taker_buy_base_vol[] $12::NUMERIC(20,8)[],-- taker_buy_quote_vol[] $13::INTEGER[], -- trade_count[] $14::BOOLEAN[] -- is_closed[] ) ON CONFLICT (time, exchange, symbol, interval) DO UPDATE SET open = EXCLUDED.open, high = EXCLUDED.high, low = EXCLUDED.low, close = EXCLUDED.close, volume = EXCLUDED.volume, quote_volume = EXCLUDED.quote_volume, taker_buy_base_vol = EXCLUDED.taker_buy_base_vol, taker_buy_quote_vol = EXCLUDED.taker_buy_quote_vol, trade_count = EXCLUDED.trade_count, is_closed = EXCLUDED.is_closed, updated_at = NOW() `; /** 批量插入的参数类型:每个字段是一个数组 */ export interface BulkKlineParams { time: Date[]; exchange: Exchange[]; symbol: string[]; interval: KlineInterval[]; open: string[]; high: string[]; low: string[]; close: string[]; volume: string[]; quote_volume: string[]; taker_buy_base_vol: string[]; taker_buy_quote_vol: string[]; trade_count: number[]; is_closed: boolean[]; } /** 将 KlineInsert[] 拆解为 BulkKlineParams */ export function packBulkKlines(rows: KlineInsert[]): BulkKlineParams { const len = rows.length; const params: BulkKlineParams = { time: new Array(len), exchange: new Array(len), symbol: new Array(len), interval: new Array(len), open: new Array(len), high: new Array(len), low: new Array(len), close: new Array(len), volume: new Array(len), quote_volume: new Array(len), taker_buy_base_vol: new Array(len), taker_buy_quote_vol: new Array(len), trade_count: new Array(len), is_closed: new Array(len), }; for (let i = 0; i < len; i++) { const r = rows[i]!; params.time[i] = r.time; params.exchange[i] = r.exchange; params.symbol[i] = r.symbol; params.interval[i] = r.interval; params.open[i] = r.open; params.high[i] = r.high; params.low[i] = r.low; params.close[i] = r.close; params.volume[i] = r.volume; params.quote_volume[i] = r.quote_volume ?? "0"; params.taker_buy_base_vol[i] = r.taker_buy_base_vol ?? "0"; params.taker_buy_quote_vol[i] = r.taker_buy_quote_vol ?? "0"; params.trade_count[i] = r.trade_count ?? 0; params.is_closed[i] = r.is_closed ?? true; } return params; } /** * 查询原始 K 线(时间范围) * 返回类型:KlineRow[] */ export const queryKlinesRange = ` SELECT time, exchange, symbol, interval, open, high, low, close, volume, quote_volume, taker_buy_base_vol, taker_buy_quote_vol, trade_count, is_closed, created_at, updated_at FROM klines WHERE exchange = $1 AND symbol = $2 AND interval = $3 AND time >= $4 AND time < $5 ORDER BY time ASC `; /** * 查询最新 N 根 K 线 * 返回类型:KlineRow[] */ export const queryKlinesLatest = ` SELECT time, exchange, symbol, interval, open, high, low, close, volume, quote_volume, taker_buy_base_vol, taker_buy_quote_vol, trade_count, is_closed, created_at, updated_at FROM klines WHERE exchange = $1 AND symbol = $2 AND interval = $3 ORDER BY time DESC LIMIT $4 `; // ============================================================ // 聚合 K 线查询 — klines_5m / 15m / 1h / 1d // ============================================================ /** * 查询聚合 K 线(动态视图名) * * @param viewName — "klines_5m" | "klines_15m" | "klines_1h" | "klines_1d" * * 注意:视图名已通过枚举约束,不存在注入风险(不使用用户输入拼接) */ export function queryAggregatedKlines( viewName: "klines_5m" | "klines_15m" | "klines_1h" | "klines_1d", ) { // 视图名来自代码常量,安全拼接 return ` SELECT time, exchange, symbol, interval, open, high, low, close, volume, quote_volume, taker_buy_base_vol, taker_buy_quote_vol, trade_count FROM ${viewName} WHERE exchange = $1 AND symbol = $2 AND time >= $3 AND time < $4 ORDER BY time ASC `; } // ============================================================ // 监控交易对查询 — monitored_symbols // ============================================================ /** * 查询所有启用的监控标的(采集服务启动时调用) * 返回类型:MonitoredSymbolRow[] */ export const queryEnabledSymbols = ` SELECT id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at FROM monitored_symbols WHERE enabled = TRUE ORDER BY exchange, priority DESC, symbol, interval `; /** * 查询指定交易所的监控标的 * 返回类型:MonitoredSymbolRow[] */ export const querySymbolsByExchange = ` SELECT id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at FROM monitored_symbols WHERE exchange = $1 AND enabled = TRUE ORDER BY priority DESC, symbol, interval `; /** * 插入监控标的(UPSERT) */ export const upsertMonitoredSymbol = ` INSERT INTO monitored_symbols (exchange, symbol, interval, enabled, priority, label, notes) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (exchange, symbol, interval) DO UPDATE SET enabled = EXCLUDED.enabled, priority = EXCLUDED.priority, label = EXCLUDED.label, notes = EXCLUDED.notes, updated_at = NOW() RETURNING id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at `; /** * 禁用监控标的(软删除) */ export const disableMonitoredSymbol = ` UPDATE monitored_symbols SET enabled = FALSE, updated_at = NOW() WHERE exchange = $1 AND symbol = $2 AND interval = $3 RETURNING id, exchange, symbol, interval `; /** * 按 ID 查询单个监控标的 * 返回类型:MonitoredSymbolRow | null */ export const queryMonitoredSymbolById = ` SELECT id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at FROM monitored_symbols WHERE id = $1 `; /** * 查询所有监控标的(含已禁用) * 返回类型:MonitoredSymbolRow[] */ export const queryAllMonitoredSymbols = ` SELECT id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at FROM monitored_symbols ORDER BY exchange, priority DESC, symbol, interval `; /** * 按唯一键 (exchange, symbol, interval) 查询监控标的 * 返回类型:MonitoredSymbolRow | null */ export const queryMonitoredSymbolByKey = ` SELECT id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at FROM monitored_symbols WHERE exchange = $1 AND symbol = $2 AND interval = $3 `; /** * 按 ID 删除监控标的(硬删除) * 返回被删除记录的 id */ export const deleteMonitoredSymbol = ` DELETE FROM monitored_symbols WHERE id = $1 RETURNING id `; /** * 按唯一键删除监控标的(硬删除) * 返回被删除记录的 id */ export const deleteMonitoredSymbolByKey = ` DELETE FROM monitored_symbols WHERE exchange = $1 AND symbol = $2 AND interval = $3 RETURNING id `; /** * 更新监控标的(部分字段) * 仅更新传入的非 NULL 字段,自动更新 updated_at */ export const updateMonitoredSymbol = ` UPDATE monitored_symbols SET enabled = COALESCE($2, enabled), priority = COALESCE($3, priority), label = COALESCE($4, label), notes = COALESCE($5, notes), updated_at = NOW() WHERE id = $1 RETURNING id, exchange, symbol, interval, enabled, priority, label, notes, created_at, updated_at `; // ============================================================ // 交易所配置查询 — exchange_config // ============================================================ /** * 查询所有启用的交易所配置 * 返回类型:ExchangeConfigRow[] */ export const queryEnabledExchanges = ` SELECT id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at FROM exchange_config WHERE enabled = TRUE ORDER BY exchange `; /** * 查询单个交易所配置 * 返回类型:ExchangeConfigRow | null */ export const queryExchangeConfig = ` SELECT id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at FROM exchange_config WHERE exchange = $1 `; /** * 插入/更新交易所配置(UPSERT) */ export const upsertExchangeConfig = ` INSERT INTO exchange_config ( exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (exchange) DO UPDATE SET rest_url = EXCLUDED.rest_url, ws_url = EXCLUDED.ws_url, ws_ping_interval_ms = EXCLUDED.ws_ping_interval_ms, rate_limit_per_sec = EXCLUDED.rate_limit_per_sec, max_reconnect_attempts = EXCLUDED.max_reconnect_attempts, reconnect_delay_ms = EXCLUDED.reconnect_delay_ms, enabled = EXCLUDED.enabled, notes = EXCLUDED.notes, updated_at = NOW() RETURNING id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at `; /** * 按 ID 查询交易所配置 * 返回类型:ExchangeConfigRow | null */ export const queryExchangeConfigById = ` SELECT id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at FROM exchange_config WHERE id = $1 `; /** * 查询所有交易所配置(含已禁用) * 返回类型:ExchangeConfigRow[] */ export const queryAllExchangeConfigs = ` SELECT id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at FROM exchange_config ORDER BY exchange `; /** * 按 ID 删除交易所配置(硬删除) * 返回被删除记录的 id */ export const deleteExchangeConfig = ` DELETE FROM exchange_config WHERE id = $1 RETURNING id `; /** * 按交易所标识删除配置(硬删除) * 返回被删除记录的 id */ export const deleteExchangeConfigByExchange = ` DELETE FROM exchange_config WHERE exchange = $1 RETURNING id `; /** * 更新交易所配置(部分字段) * 仅更新传入的非 NULL 字段,自动更新 updated_at */ export const updateExchangeConfig = ` UPDATE exchange_config SET rest_url = COALESCE($2, rest_url), ws_url = COALESCE($3, ws_url), ws_ping_interval_ms = COALESCE($4, ws_ping_interval_ms), rate_limit_per_sec = COALESCE($5, rate_limit_per_sec), max_reconnect_attempts = COALESCE($6, max_reconnect_attempts), reconnect_delay_ms = COALESCE($7, reconnect_delay_ms), enabled = COALESCE($8, enabled), notes = COALESCE($9, notes), updated_at = NOW() WHERE id = $1 RETURNING id, exchange, rest_url, ws_url, ws_ping_interval_ms, rate_limit_per_sec, max_reconnect_attempts, reconnect_delay_ms, enabled, notes, created_at, updated_at `; // ============================================================ // 全局配置查询 — app_config // ============================================================ /** * 查询所有应用配置 * 返回类型:AppConfigRow[] */ export const queryAllAppConfig = ` SELECT id, key, value, description, updated_at FROM app_config ORDER BY key `; /** * 查询单个配置项 * 返回类型:AppConfigRow | null */ export const queryAppConfig = ` SELECT id, key, value, description, updated_at FROM app_config WHERE key = $1 `; /** * 设置配置项(UPSERT) */ export const upsertAppConfig = ` INSERT INTO app_config (key, value, description) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, description = EXCLUDED.description, updated_at = NOW() RETURNING id, key, value, description, updated_at `; /** * 按 ID 查询应用配置 * 返回类型:AppConfigRow | null */ export const queryAppConfigById = ` SELECT id, key, value, description, updated_at FROM app_config WHERE id = $1 `; /** * 按 key 删除应用配置(硬删除) * 返回被删除记录的 id */ export const deleteAppConfig = ` DELETE FROM app_config WHERE key = $1 RETURNING id `; /** * 按 ID 删除应用配置(硬删除) * 返回被删除记录的 id */ export const deleteAppConfigById = ` DELETE FROM app_config WHERE id = $1 RETURNING id `; /** * 更新应用配置值(部分字段) * 仅更新传入的非 NULL 字段,自动更新 updated_at */ export const updateAppConfig = ` UPDATE app_config SET value = COALESCE($2, value), description = COALESCE($3, description), updated_at = NOW() WHERE id = $1 RETURNING id, key, value, description, updated_at `; // ============================================================ // 复合查询:采集服务启动加载项 // ============================================================ /** * 一次性加载所有启动配置: * 1. 启用的监控标的列表 * 2. 对应交易所的连接配置 * * 返回两表 JOIN 结果,供采集服务初始化 WebSocket 连接池。 */ export const queryStreamSubscriptions = ` SELECT m.exchange, m.symbol, m.interval, m.priority, e.rest_url, e.ws_url, e.ws_ping_interval_ms, e.rate_limit_per_sec, e.max_reconnect_attempts, e.reconnect_delay_ms FROM monitored_symbols m JOIN exchange_config e ON m.exchange = e.exchange WHERE m.enabled = TRUE AND e.enabled = TRUE ORDER BY m.exchange, m.priority DESC, m.symbol, m.interval `;