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:
@@ -0,0 +1,561 @@
|
||||
// ============================================================
|
||||
// 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
|
||||
`;
|
||||
Reference in New Issue
Block a user