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 初始化脚本
562 lines
16 KiB
TypeScript
562 lines
16 KiB
TypeScript
// ============================================================
|
||
// 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
|
||
`;
|