Files
trade/data/db/queries.ts
T
Rekey e91cad79e6 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 初始化脚本
2026-06-07 20:46:35 +08:00

562 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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
`;