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:
Rekey
2026-06-07 20:46:35 +08:00
parent 10e13ae8da
commit e91cad79e6
18 changed files with 8560 additions and 5 deletions
+561
View File
@@ -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
`;