Files
trade/data/db/config-crud.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

531 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.
// ============================================================
// 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 — 全局应用配置(KVCRUD
// ============================================================
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;
}
}