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,530 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user