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 初始化脚本
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
// ============================================================
|
||
// db/config-crud.ts — 配置表 CRUD 服务层
|
||
// ============================================================
|
||
// 职责:
|
||
// 1. 封装 monitored_symbols / exchange_config / app_config 三张配置表的增删改查
|
||
// 2. 所有方法通过 pg.Pool 执行参数化 SQL(防注入)
|
||
// 3. 返回类型与 types.ts 严格对应,调用方无需手动断言
|
||
// 4. 支持依赖注入:构造函数接收 Pool,便于单元测试 mock
|
||
//
|
||
// 使用方式:
|
||
// import { pool } from "./db";
|
||
// import { MonitoredSymbolsRepo, ExchangeConfigRepo, AppConfigRepo } from "./db/config-crud";
|
||
//
|
||
// const symbolsRepo = new MonitoredSymbolsRepo(pool);
|
||
// const all = await symbolsRepo.listAll();
|
||
// ============================================================
|
||
|
||
import type pg from "pg";
|
||
import type {
|
||
MonitoredSymbolRow,
|
||
MonitoredSymbolInsert,
|
||
MonitoredSymbolUpdate,
|
||
ExchangeConfigRow,
|
||
ExchangeConfigInsert,
|
||
AppConfigRow,
|
||
Exchange,
|
||
KlineInterval,
|
||
} from "./types";
|
||
import {
|
||
// monitored_symbols
|
||
queryAllMonitoredSymbols,
|
||
queryEnabledSymbols,
|
||
querySymbolsByExchange,
|
||
queryMonitoredSymbolById,
|
||
queryMonitoredSymbolByKey,
|
||
upsertMonitoredSymbol,
|
||
updateMonitoredSymbol,
|
||
disableMonitoredSymbol,
|
||
deleteMonitoredSymbol,
|
||
deleteMonitoredSymbolByKey,
|
||
// exchange_config
|
||
queryAllExchangeConfigs,
|
||
queryEnabledExchanges,
|
||
queryExchangeConfig,
|
||
queryExchangeConfigById,
|
||
upsertExchangeConfig,
|
||
updateExchangeConfig,
|
||
deleteExchangeConfig,
|
||
deleteExchangeConfigByExchange,
|
||
// app_config
|
||
queryAllAppConfig,
|
||
queryAppConfig,
|
||
queryAppConfigById,
|
||
upsertAppConfig,
|
||
updateAppConfig,
|
||
deleteAppConfig,
|
||
deleteAppConfigById,
|
||
} from "./queries";
|
||
|
||
// ============================================================
|
||
// 工具类型:将 Promise 解包的一行或 null
|
||
// ============================================================
|
||
|
||
/** pg query 返回的第一行,不存在则为 null */
|
||
type FirstRow<T> = T | null;
|
||
|
||
// ============================================================
|
||
// MonitoredSymbolsRepo — 监控交易对配置 CRUD
|
||
// ============================================================
|
||
|
||
export class MonitoredSymbolsRepo {
|
||
constructor(private readonly pool: pg.Pool) {}
|
||
|
||
// ----------------------------------------------------------
|
||
// CREATE / UPSERT
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 新增或更新监控标的。
|
||
* 唯一键冲突时更新 enabled/priority/label/notes 并刷新 updated_at。
|
||
*
|
||
* @returns 插入或更新后的完整行
|
||
*/
|
||
async upsert(
|
||
insert: MonitoredSymbolInsert,
|
||
): Promise<MonitoredSymbolRow> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
upsertMonitoredSymbol,
|
||
[
|
||
insert.exchange,
|
||
insert.symbol,
|
||
insert.interval,
|
||
insert.enabled ?? true,
|
||
insert.priority ?? 0,
|
||
insert.label ?? null,
|
||
insert.notes ?? null,
|
||
],
|
||
);
|
||
return rows[0]!;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 单条
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按主键 ID 查询 */
|
||
async findById(id: number): Promise<FirstRow<MonitoredSymbolRow>> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
queryMonitoredSymbolById,
|
||
[id],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 按唯一业务键 (exchange, symbol, interval) 查询。
|
||
* 这是最常用的精确查找方式。
|
||
*/
|
||
async findByKey(
|
||
exchange: Exchange,
|
||
symbol: string,
|
||
interval: KlineInterval,
|
||
): Promise<FirstRow<MonitoredSymbolRow>> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
queryMonitoredSymbolByKey,
|
||
[exchange, symbol, interval],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 列表
|
||
// ----------------------------------------------------------
|
||
|
||
/** 查询所有监控标的(含已禁用),按优先级降序 */
|
||
async listAll(): Promise<MonitoredSymbolRow[]> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
queryAllMonitoredSymbols,
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
/** 查询所有启用的监控标的(采集服务启动时调用) */
|
||
async listEnabled(): Promise<MonitoredSymbolRow[]> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
queryEnabledSymbols,
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
/** 查询指定交易所下所有启用的监控标的 */
|
||
async listByExchange(exchange: Exchange): Promise<MonitoredSymbolRow[]> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
querySymbolsByExchange,
|
||
[exchange],
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// UPDATE
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 按 ID 部分更新监控标的。
|
||
* 仅更新传入的非 undefined 字段(COALESCE 语义)。
|
||
*
|
||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||
*/
|
||
async update(
|
||
id: number,
|
||
patch: MonitoredSymbolUpdate,
|
||
): Promise<FirstRow<MonitoredSymbolRow>> {
|
||
const { rows } = await this.pool.query<MonitoredSymbolRow>(
|
||
updateMonitoredSymbol,
|
||
[
|
||
id,
|
||
patch.enabled ?? null,
|
||
patch.priority ?? null,
|
||
patch.label ?? null,
|
||
patch.notes ?? null,
|
||
],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 禁用指定监控标的(软删除)。
|
||
* 不会删除记录,仅将 enabled 设为 FALSE。
|
||
*/
|
||
async disable(
|
||
exchange: Exchange,
|
||
symbol: string,
|
||
interval: KlineInterval,
|
||
): Promise<Pick<MonitoredSymbolRow, "id" | "exchange" | "symbol" | "interval"> | null> {
|
||
const { rows } = await this.pool.query<
|
||
Pick<MonitoredSymbolRow, "id" | "exchange" | "symbol" | "interval">
|
||
>(disableMonitoredSymbol, [exchange, symbol, interval]);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DELETE(硬删除)
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */
|
||
async deleteById(id: number): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(
|
||
deleteMonitoredSymbol,
|
||
[id],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
|
||
/** 按唯一键硬删除。返回被删除的 id,不存在则返回 null */
|
||
async deleteByKey(
|
||
exchange: Exchange,
|
||
symbol: string,
|
||
interval: KlineInterval,
|
||
): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(
|
||
deleteMonitoredSymbolByKey,
|
||
[exchange, symbol, interval],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// ExchangeConfigRepo — 交易所连接配置 CRUD
|
||
// ============================================================
|
||
|
||
export class ExchangeConfigRepo {
|
||
constructor(private readonly pool: pg.Pool) {}
|
||
|
||
// ----------------------------------------------------------
|
||
// CREATE / UPSERT
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 新增或更新交易所配置。
|
||
* 唯一键冲突时更新所有连接参数并刷新 updated_at。
|
||
*
|
||
* @returns 插入或更新后的完整行
|
||
*/
|
||
async upsert(
|
||
insert: ExchangeConfigInsert,
|
||
): Promise<ExchangeConfigRow> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
upsertExchangeConfig,
|
||
[
|
||
insert.exchange,
|
||
insert.rest_url ?? null,
|
||
insert.ws_url ?? null,
|
||
insert.ws_ping_interval_ms ?? 30000,
|
||
insert.rate_limit_per_sec ?? 20,
|
||
insert.max_reconnect_attempts ?? 10,
|
||
insert.reconnect_delay_ms ?? 3000,
|
||
insert.enabled ?? true,
|
||
insert.notes ?? null,
|
||
],
|
||
);
|
||
return rows[0]!;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 单条
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按主键 ID 查询 */
|
||
async findById(id: number): Promise<FirstRow<ExchangeConfigRow>> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
queryExchangeConfigById,
|
||
[id],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/** 按交易所标识查询(如 "binance") */
|
||
async findByExchange(
|
||
exchange: Exchange,
|
||
): Promise<FirstRow<ExchangeConfigRow>> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
queryExchangeConfig,
|
||
[exchange],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 列表
|
||
// ----------------------------------------------------------
|
||
|
||
/** 查询所有交易所配置(含已禁用) */
|
||
async listAll(): Promise<ExchangeConfigRow[]> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
queryAllExchangeConfigs,
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
/** 查询所有启用的交易所配置 */
|
||
async listEnabled(): Promise<ExchangeConfigRow[]> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
queryEnabledExchanges,
|
||
);
|
||
return rows;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// UPDATE
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 按 ID 部分更新交易所配置。
|
||
* 仅更新传入的非 undefined 字段(COALESCE 语义)。
|
||
*
|
||
* ⚠️ 风险提示:修改限频参数(rate_limit_per_sec)可能触发交易所封禁 IP。
|
||
* 务必确认目标交易所的官方限频规则后再调整。
|
||
*
|
||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||
*/
|
||
async update(
|
||
id: number,
|
||
patch: Partial<Omit<ExchangeConfigRow, "id" | "created_at" | "updated_at">>,
|
||
): Promise<FirstRow<ExchangeConfigRow>> {
|
||
const { rows } = await this.pool.query<ExchangeConfigRow>(
|
||
updateExchangeConfig,
|
||
[
|
||
id,
|
||
patch.rest_url ?? null,
|
||
patch.ws_url ?? null,
|
||
patch.ws_ping_interval_ms ?? null,
|
||
patch.rate_limit_per_sec ?? null,
|
||
patch.max_reconnect_attempts ?? null,
|
||
patch.reconnect_delay_ms ?? null,
|
||
patch.enabled ?? null,
|
||
patch.notes ?? null,
|
||
],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DELETE(硬删除)
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按 ID 硬删除。返回被删除的 id,不存在则返回 null */
|
||
async deleteById(id: number): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(
|
||
deleteExchangeConfig,
|
||
[id],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
|
||
/** 按交易所标识硬删除。返回被删除的 id,不存在则返回 null */
|
||
async deleteByExchange(exchange: Exchange): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(
|
||
deleteExchangeConfigByExchange,
|
||
[exchange],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// AppConfigRepo — 全局应用配置(KV)CRUD
|
||
// ============================================================
|
||
|
||
export class AppConfigRepo {
|
||
constructor(private readonly pool: pg.Pool) {}
|
||
|
||
// ----------------------------------------------------------
|
||
// CREATE / UPSERT
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 设置一个配置项(新增或更新)。
|
||
*
|
||
* @param key — 配置键
|
||
* @param value — 配置值(字符串,消费方自行解析类型)
|
||
* @param description — 可选说明
|
||
* @returns 插入或更新后的完整行
|
||
*/
|
||
async set(
|
||
key: string,
|
||
value: string,
|
||
description?: string | null,
|
||
): Promise<AppConfigRow> {
|
||
const { rows } = await this.pool.query<AppConfigRow>(upsertAppConfig, [
|
||
key,
|
||
value,
|
||
description ?? null,
|
||
]);
|
||
return rows[0]!;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 单条
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按主键 ID 查询 */
|
||
async findById(id: number): Promise<FirstRow<AppConfigRow>> {
|
||
const { rows } = await this.pool.query<AppConfigRow>(
|
||
queryAppConfigById,
|
||
[id],
|
||
);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 按 key 查询配置项。
|
||
*
|
||
* @returns 配置行;不存在则返回 null
|
||
*/
|
||
async get(key: string): Promise<FirstRow<AppConfigRow>> {
|
||
const { rows } = await this.pool.query<AppConfigRow>(queryAppConfig, [key]);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* 按 key 获取配置值(字符串)。
|
||
* 便捷方法——等价于 (await get(key))?.value ?? defaultValue。
|
||
*
|
||
* @param key — 配置键
|
||
* @param defaultValue — 默认值(key 不存在时返回)
|
||
*/
|
||
async getValue(key: string, defaultValue = ""): Promise<string> {
|
||
const row = await this.get(key);
|
||
return row?.value ?? defaultValue;
|
||
}
|
||
|
||
/**
|
||
* 按 key 获取配置值并解析为整数。
|
||
* 解析失败时返回 defaultValue。
|
||
*/
|
||
async getIntValue(key: string, defaultValue = 0): Promise<number> {
|
||
const raw = await this.getValue(key, String(defaultValue));
|
||
const parsed = parseInt(raw, 10);
|
||
return Number.isNaN(parsed) ? defaultValue : parsed;
|
||
}
|
||
|
||
/**
|
||
* 按 key 获取配置值并解析为布尔。
|
||
* 规则:'true' / '1' → true,其余 → false。
|
||
*/
|
||
async getBoolValue(key: string, defaultValue = false): Promise<boolean> {
|
||
const raw = await this.getValue(key, String(defaultValue));
|
||
return raw === "true" || raw === "1";
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// READ — 列表
|
||
// ----------------------------------------------------------
|
||
|
||
/** 查询所有应用配置 */
|
||
async listAll(): Promise<AppConfigRow[]> {
|
||
const { rows } = await this.pool.query<AppConfigRow>(queryAllAppConfig);
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* 批量获取多个 key 的值。
|
||
* 一次性查询全表后过滤,避免 N+1 问题。
|
||
*
|
||
* @param keys — 需要获取的 key 列表
|
||
* @returns Map<key, value>
|
||
*/
|
||
async getBatch(keys: string[]): Promise<Map<string, string>> {
|
||
const all = await this.listAll();
|
||
const map = new Map<string, string>();
|
||
const keySet = new Set(keys);
|
||
for (const row of all) {
|
||
if (keySet.has(row.key)) {
|
||
map.set(row.key, row.value);
|
||
}
|
||
}
|
||
// 保证未找到的 key 也有默认值 ""
|
||
for (const k of keys) {
|
||
if (!map.has(k)) {
|
||
map.set(k, "");
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// UPDATE
|
||
// ----------------------------------------------------------
|
||
|
||
/**
|
||
* 按 ID 部分更新应用配置。
|
||
*
|
||
* @returns 更新后的完整行;ID 不存在则返回 null
|
||
*/
|
||
async update(
|
||
id: number,
|
||
value?: string,
|
||
description?: string | null,
|
||
): Promise<FirstRow<AppConfigRow>> {
|
||
const { rows } = await this.pool.query<AppConfigRow>(updateAppConfig, [
|
||
id,
|
||
value ?? null,
|
||
description ?? null,
|
||
]);
|
||
return rows[0] ?? null;
|
||
}
|
||
|
||
// ----------------------------------------------------------
|
||
// DELETE
|
||
// ----------------------------------------------------------
|
||
|
||
/** 按 key 删除配置。返回被删除的 id,不存在则返回 null */
|
||
async deleteByKey(key: string): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(deleteAppConfig, [
|
||
key,
|
||
]);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
|
||
/** 按 ID 删除配置。返回被删除的 id,不存在则返回 null */
|
||
async deleteById(id: number): Promise<number | null> {
|
||
const { rows } = await this.pool.query<{ id: number }>(
|
||
deleteAppConfigById,
|
||
[id],
|
||
);
|
||
return rows[0]?.id ?? null;
|
||
}
|
||
}
|