feat(data): 实现 Binance WebSocket 适配器与架构重构
- 新增 exchanges/ 模块:MarketDataFeed 统一接口、BaseExchangeAdapter 抽象基类、 BinanceAdapter 完整实现(WebSocket + REST) - WebSocket 层基于 binance 官方 SDK 的 WebsocketClient,自动多路复用与断线重连 - REST 层使用 MainClient(Spot),实现 fetchKlines 自动分页补拉 + fetchMarkets 元数据解析 - 数据标准化:Ticker/Trade/OrderBook/Kline 类型定义与 Binance 原生格式互转 - 引入 RxJS Subject 作为统一事件流管道,按 eventType 运行时路由分发 - 重构 config/:YAML 驱动配置加载 + 零依赖运行时校验(fail-fast) - 重构 db/:TypeORM DataSource 配置 + TimescaleDB K 线 Hypertable 实体 - 新增 utils/logger.ts:Pino 结构化日志(开发环境 pino-pretty 彩色输出) - 新增 env.yaml 作为 TS/Python 共享的统一环境配置源 - 删除旧版手写 SQL schema 与散落配置文件,收敛到 TypeORM 实体管理 - 安装 rxjs@7.8.2 依赖
This commit is contained in:
@@ -1,530 +0,0 @@
|
||||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DataSource } from "typeorm";
|
||||
import { pgsql } from "../config";
|
||||
import * as entities from "./entities";
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: "postgres",
|
||||
host: pgsql.host,
|
||||
port: pgsql.port,
|
||||
database: pgsql.database,
|
||||
username: pgsql.user,
|
||||
password: pgsql.password,
|
||||
// 实体注册:关系实体通过 entities/index.ts 统一导出
|
||||
// TimescaleDB K 线实体后续通过 @timescaledb/typeorm 装饰器注册
|
||||
entities: [
|
||||
...Object.values(entities),
|
||||
],
|
||||
// 生产环境禁用 synchronize,使用 Migration
|
||||
synchronize: true,
|
||||
migrations: [__dirname + "/migrations/*.{ts,js}"],
|
||||
// 连接池
|
||||
extra: {
|
||||
max: pgsql.max, // 最大连接数 20
|
||||
idleTimeoutMillis: pgsql.idleTimeoutMillis, // 空闲超时 30s
|
||||
connectionTimeoutMillis: pgsql.connectionTimeoutMillis, // 连接超时 5s
|
||||
},
|
||||
logging: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
await AppDataSource.initialize();
|
||||
@@ -0,0 +1,32 @@
|
||||
// ============================================================
|
||||
// common.entity.ts — 实体公共基类
|
||||
// ============================================================
|
||||
// 所有关系实体(TypeORM 管理域)继承此类,统一:
|
||||
// - id: UUID 主键
|
||||
// - created_at: 记录创建时间(自动填充)
|
||||
// - updated_at: 最后更新时间(自动填充)
|
||||
//
|
||||
// TimescaleDB K 线实体(@timescaledb/typeorm 管理域)不继承此类,
|
||||
// 因为它们需要 @TimeColumn() 等特定装饰器。
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
BaseEntity,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
export abstract class CommonBaseEntity extends BaseEntity {
|
||||
/** UUID 主键(非自增整数,便于分布式场景) */
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id!: string;
|
||||
|
||||
/** 记录创建时间 */
|
||||
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 最后更新时间(每次 UPDATE 自动刷新) */
|
||||
@UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// ============================================================
|
||||
// exchange.entity.ts — 交易所配置实体
|
||||
// ============================================================
|
||||
// 映射到 PostgreSQL exchanges 表,存储已接入的交易所元信息。
|
||||
// 由 TypeORM 管理(关系数据),不与 TimescaleDB 耦合。
|
||||
//
|
||||
// 继承 CommonBaseEntity:id (UUID) / created_at / updated_at
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
OneToMany,
|
||||
} from "typeorm";
|
||||
import { CommonBaseEntity } from "./common.entity";
|
||||
|
||||
@Entity("exchanges")
|
||||
export class Exchange extends CommonBaseEntity {
|
||||
/** 交易所唯一标识(如 binance / okx / bybit) */
|
||||
@Column("varchar", { length: 50, unique: true })
|
||||
name!: string;
|
||||
|
||||
/** 交易所显示名称(如 Binance / OKX / Bybit) */
|
||||
@Column("varchar", { length: 100 })
|
||||
label!: string;
|
||||
|
||||
/** 是否启用该交易所的数据采集 */
|
||||
@Column("boolean", { default: true })
|
||||
enabled!: boolean;
|
||||
|
||||
/** 交易所特定配置(JSON:费率、最小下单量、API 限频等) */
|
||||
@Column("jsonb", { nullable: true })
|
||||
config?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* 该交易所下的所有交易对。
|
||||
* 使用字符串引用避免循环依赖(TradingPair 也引用 Exchange)。
|
||||
*/
|
||||
@OneToMany("TradingPair", "exchange")
|
||||
tradingPairs!: unknown[];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// ============================================================
|
||||
// entities/index.ts — 实体统一导出
|
||||
// ============================================================
|
||||
// TypeORM DataSource 通过 import * as entities from "../entities"
|
||||
// 自动注册所有实体,无需手动逐个添加。
|
||||
// ============================================================
|
||||
|
||||
export { CommonBaseEntity } from "./common.entity";
|
||||
export { Exchange } from "./exchange.entity";
|
||||
export { TradingPair } from "./trading-pair.entity";
|
||||
export { Kline } from "./kline.entity";
|
||||
export type { KlineInterval } from "./kline.entity";
|
||||
@@ -0,0 +1,138 @@
|
||||
// ============================================================
|
||||
// kline.entity.ts — TimescaleDB K 线 Hypertable 实体
|
||||
// ============================================================
|
||||
// 映射到 PostgreSQL klines 表(TimescaleDB hypertable)。
|
||||
// 不继承 CommonBaseEntity — 使用 @timescaledb/typeorm 的
|
||||
// @Hypertable / @TimeColumn 装饰器管理 TimescaleDB 特性。
|
||||
//
|
||||
// 关键 TimescaleDB 特性(由 @Hypertable 装饰器自动配置):
|
||||
// - 自动按 time 列做时间分区(by_range)
|
||||
// - 列式压缩(compress),7 天后自动执行
|
||||
// - 通过 ContinuousAggregate 生成高周期 K 线视图
|
||||
//
|
||||
// 注意:@timescaledb/typeorm v0.0.1 为实验版本,
|
||||
// 不支持空间分区(partitioning_column)。
|
||||
// 若需要空间分区,可通过 db/init-db/ 下的 SQL 脚本手动添加。
|
||||
// ============================================================
|
||||
|
||||
import { Hypertable, TimeColumn } from "@timescaledb/typeorm";
|
||||
import {
|
||||
Entity,
|
||||
PrimaryColumn,
|
||||
Column,
|
||||
Index,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from "typeorm";
|
||||
|
||||
/** K 线周期枚举 */
|
||||
export type KlineInterval =
|
||||
| "1m"
|
||||
| "5m"
|
||||
| "15m"
|
||||
| "30m"
|
||||
| "1h"
|
||||
| "4h"
|
||||
| "1d"
|
||||
| "1w";
|
||||
|
||||
/**
|
||||
* 1 分钟 K 线 Hypertable
|
||||
*
|
||||
* 存储交易所推送的 OHLCV 数据。写入使用 UPSERT
|
||||
* (ON CONFLICT DO UPDATE),已存在的 K 线只更新
|
||||
* high/low/close/volume 增量。
|
||||
*
|
||||
* 高周期 K 线(5m+)通过 TimescaleDB 连续聚合视图
|
||||
* 从 1m 表自动生成,无需单独建表。
|
||||
*/
|
||||
@Hypertable({
|
||||
compression: {
|
||||
compress: true,
|
||||
compress_orderby: "time DESC",
|
||||
compress_segmentby: "exchange, symbol, interval",
|
||||
policy: {
|
||||
schedule_interval: "365 days", // 365 天后自动压缩
|
||||
},
|
||||
},
|
||||
})
|
||||
@Index(["exchange", "symbol", "interval", "time"], { unique: true })
|
||||
@Entity("klines")
|
||||
export class Kline {
|
||||
/** K 线开盘时间(UTC)— @timescaledb/typeorm 自动标记为时间分区列 */
|
||||
@TimeColumn()
|
||||
@PrimaryColumn("timestamptz")
|
||||
time!: Date;
|
||||
|
||||
/** 交易所标识(binance / okx / bybit) */
|
||||
@Column("text")
|
||||
exchange!: string;
|
||||
|
||||
/** 交易对符号(如 BTCUSDT) */
|
||||
@Column("text")
|
||||
symbol!: string;
|
||||
|
||||
/** K 线周期(1m) */
|
||||
@Column("text")
|
||||
interval!: KlineInterval;
|
||||
|
||||
// ============================================================
|
||||
// OHLCV 价格数据(NUMERIC(20,8) 精度,与交易所对齐)
|
||||
// ============================================================
|
||||
|
||||
/** 开盘价 */
|
||||
@Column("numeric", { precision: 20, scale: 8 })
|
||||
open!: number;
|
||||
|
||||
/** 最高价 */
|
||||
@Column("numeric", { precision: 20, scale: 8 })
|
||||
high!: number;
|
||||
|
||||
/** 最低价 */
|
||||
@Column("numeric", { precision: 20, scale: 8 })
|
||||
low!: number;
|
||||
|
||||
/** 收盘价 */
|
||||
@Column("numeric", { precision: 20, scale: 8 })
|
||||
close!: number;
|
||||
|
||||
/** 成交量(base 币种) */
|
||||
@Column("numeric", { precision: 20, scale: 8 })
|
||||
volume!: number;
|
||||
|
||||
// ============================================================
|
||||
// 扩展字段(Binance 等交易所提供)
|
||||
// ============================================================
|
||||
|
||||
/** 成交额(quote 币种) */
|
||||
@Column("numeric", { precision: 20, scale: 8, nullable: true })
|
||||
quote_volume?: number;
|
||||
|
||||
/** 主动买入成交量(base 币种) */
|
||||
@Column("numeric", { precision: 20, scale: 8, nullable: true })
|
||||
taker_buy_base_vol?: number;
|
||||
|
||||
/** 主动买入成交额(quote 币种) */
|
||||
@Column("numeric", { precision: 20, scale: 8, nullable: true })
|
||||
taker_buy_quote_vol?: number;
|
||||
|
||||
/** 成交笔数 */
|
||||
@Column("integer", { nullable: true })
|
||||
trade_count?: number;
|
||||
|
||||
/** K 线是否已关闭(true = 该周期 K 线不再变化) */
|
||||
@Column("boolean", { default: true })
|
||||
is_closed!: boolean;
|
||||
|
||||
// ============================================================
|
||||
// 审计字段
|
||||
// ============================================================
|
||||
|
||||
/** 记录首次写入时间 */
|
||||
@CreateDateColumn({ type: "timestamptz", name: "created_at" })
|
||||
createdAt!: Date;
|
||||
|
||||
/** 记录最后更新时间 */
|
||||
@UpdateDateColumn({ type: "timestamptz", name: "updated_at" })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// ============================================================
|
||||
// trading-pair.entity.ts — 交易对配置实体
|
||||
// ============================================================
|
||||
// 映射到 PostgreSQL trading_pairs 表,存储各交易所的交易对元信息。
|
||||
// 数据模块启动时从该表读取 active=true 的交易对列表,
|
||||
// 决定 WebSocket 订阅范围和 K 线合成范围。
|
||||
//
|
||||
// 继承 CommonBaseEntity:id (UUID) / created_at / updated_at
|
||||
//
|
||||
// 与 TimescaleDB klines 表的关系:
|
||||
// klines.symbol → trading_pairs.symbol(逻辑外键,不做 DB 级约束)
|
||||
// klines.exchange → exchanges.name(逻辑外键)
|
||||
// ============================================================
|
||||
|
||||
import {
|
||||
Entity,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from "typeorm";
|
||||
import { Exchange } from "./exchange.entity";
|
||||
import { CommonBaseEntity } from "./common.entity";
|
||||
|
||||
@Entity("trading_pairs")
|
||||
@Index(["exchange", "symbol"], { unique: true }) // 同一交易所下 symbol 唯一
|
||||
@Index(["active"]) // 按激活状态快速筛选
|
||||
export class TradingPair extends CommonBaseEntity {
|
||||
/** 所属交易所 */
|
||||
@ManyToOne(() => Exchange, { nullable: false })
|
||||
@JoinColumn({ name: "exchange_id" })
|
||||
exchange!: Exchange;
|
||||
|
||||
/** 交易对符号(如 BTCUSDT / ETHUSDT) */
|
||||
@Column("varchar", { length: 20 })
|
||||
symbol!: string;
|
||||
|
||||
/** 基础币种(如 BTC) */
|
||||
@Column("varchar", { length: 10 })
|
||||
base_asset!: string;
|
||||
|
||||
/** 计价币种(如 USDT) */
|
||||
@Column("varchar", { length: 10 })
|
||||
quote_asset!: string;
|
||||
|
||||
/** 价格精度(小数位数) */
|
||||
@Column("integer", { default: 10 })
|
||||
price_precision!: number;
|
||||
|
||||
/** 数量精度(小数位数) */
|
||||
@Column("integer", { default: 10 })
|
||||
quantity_precision!: number;
|
||||
|
||||
/** 最小下单量 */
|
||||
@Column("numeric", { precision: 32, scale: 8, nullable: true })
|
||||
min_qty?: number;
|
||||
|
||||
/** 下单步长(数量增量) */
|
||||
@Column("numeric", { precision: 32, scale: 8, nullable: true })
|
||||
step_size?: number;
|
||||
|
||||
/** 最小名义价值(USDT) */
|
||||
@Column("numeric", { precision: 32, scale: 8, nullable: true })
|
||||
min_notional?: number;
|
||||
|
||||
/** 是否激活数据订阅(false 时不采集该交易对行情) */
|
||||
@Column("boolean", { default: true })
|
||||
active!: boolean;
|
||||
|
||||
/** 是否启用 K 线合成(false 时仅采集原始行情,不合成) */
|
||||
@Column("boolean", { default: true })
|
||||
kline_synthesis_enabled!: boolean;
|
||||
|
||||
/** K 线合成周期列表(逗号分隔,如 "1m,5m,15m,1h,4h,1d") */
|
||||
@Column("varchar", { length: 100, default: "1m,5m,15m,1h,4h,1d" })
|
||||
kline_intervals!: string;
|
||||
|
||||
/**
|
||||
* 历史 K 线最后补全时间(UTC)。
|
||||
* 记录最近一次 REST 补拉 K 线的结束时间戳,
|
||||
* 下次启动时从此时间点继续补全,避免重复拉取。
|
||||
*
|
||||
* 默认值为 Unix 0(1970-01-01T00:00:00.000Z),
|
||||
* 新交易对从 epoch 起始时间开始全量补拉,
|
||||
* 补全后更新为实际拉取到的最后时间。
|
||||
*/
|
||||
@Column("timestamptz", { default: () => "to_timestamp(0)" })
|
||||
last_backfill_time!: Date;
|
||||
|
||||
/** 备注 */
|
||||
@Column("text", { nullable: true })
|
||||
notes?: string;
|
||||
|
||||
// ============================================================
|
||||
// 工具方法
|
||||
// ============================================================
|
||||
|
||||
/** 解析 kline_intervals 为周期数组 */
|
||||
getIntervals(): string[] {
|
||||
return this.kline_intervals
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// ============================================================
|
||||
// db/index.ts — 统一导出
|
||||
// ============================================================
|
||||
// 使用方式:
|
||||
// import { KlineRow, KlineRawSchema, bulkUpsertKlines } from "./db";
|
||||
// import { MonitoredSymbolsRepo } from "./db";
|
||||
// ============================================================
|
||||
|
||||
// 类型定义
|
||||
export type {
|
||||
Exchange,
|
||||
KlineInterval,
|
||||
LogLevel,
|
||||
KlineRow,
|
||||
KlineInsert,
|
||||
AggregatedKlineRow,
|
||||
MonitoredSymbolRow,
|
||||
MonitoredSymbolInsert,
|
||||
MonitoredSymbolUpdate,
|
||||
ExchangeConfigRow,
|
||||
ExchangeConfigInsert,
|
||||
AppConfigRow,
|
||||
AppConfigKey,
|
||||
StreamKey,
|
||||
StreamSubscription,
|
||||
} from "./types";
|
||||
|
||||
// Zod 运行时校验
|
||||
export {
|
||||
ExchangeSchema,
|
||||
KlineIntervalSchema,
|
||||
LogLevelSchema,
|
||||
SymbolSchema,
|
||||
NumericStringSchema,
|
||||
KlineRawSchema,
|
||||
KlineBatchSchema,
|
||||
MonitoredSymbolInsertSchema,
|
||||
MonitoredSymbolUpdateSchema,
|
||||
ExchangeConfigInsertSchema,
|
||||
StreamKeySchema,
|
||||
EnvConfigSchema,
|
||||
} from "./validators";
|
||||
|
||||
export type {
|
||||
KlineRaw,
|
||||
KlineBatch,
|
||||
MonitoredSymbolInsert as MonitoredSymbolInsertValidated,
|
||||
MonitoredSymbolUpdate as MonitoredSymbolUpdateValidated,
|
||||
ExchangeConfigInsert as ExchangeConfigInsertValidated,
|
||||
StreamKey as StreamKeyValidated,
|
||||
EnvConfig,
|
||||
} from "./validators";
|
||||
|
||||
// 参数化 SQL 查询
|
||||
export {
|
||||
bulkUpsertKlines,
|
||||
packBulkKlines,
|
||||
queryKlinesRange,
|
||||
queryKlinesLatest,
|
||||
queryAggregatedKlines,
|
||||
// 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,
|
||||
// 复合查询
|
||||
queryStreamSubscriptions,
|
||||
} from "./queries";
|
||||
|
||||
export type { BulkKlineParams } from "./queries";
|
||||
|
||||
// ============================================================
|
||||
// Config CRUD 服务层(推荐使用)
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
MonitoredSymbolsRepo,
|
||||
ExchangeConfigRepo,
|
||||
AppConfigRepo,
|
||||
} from "./config-crud";
|
||||
|
||||
// ============================================================
|
||||
// PostgreSQL 连接池 & 工具
|
||||
// ============================================================
|
||||
|
||||
export {
|
||||
pool,
|
||||
healthCheck,
|
||||
timescaleVersion,
|
||||
withTransaction,
|
||||
closePool,
|
||||
registerShutdownHandlers,
|
||||
initSchemaFromFile,
|
||||
initSchema,
|
||||
isSchemaInitialized,
|
||||
} from "./pg";
|
||||
|
||||
export { default as defaultPool } from "./pg";
|
||||
-364
@@ -1,364 +0,0 @@
|
||||
// ============================================================
|
||||
// db/pg.ts — PostgreSQL / TimescaleDB 连接池管理
|
||||
// ============================================================
|
||||
// 职责:
|
||||
// 1. 基于 config.ts 的 pgsql 配置创建 pg.Pool 单例
|
||||
// 2. 连接生命周期事件监听(connect / acquire / remove / error)
|
||||
// 3. 提供健康检查(healthCheck)
|
||||
// 4. 提供事务辅助函数(withTransaction)
|
||||
// 5. 进程退出时优雅关闭(SIGTERM / SIGINT)
|
||||
//
|
||||
// 使用方式:
|
||||
// import { pool } from "./db"; // 通过 index.ts 统一导出
|
||||
// import { healthCheck } from "./db";
|
||||
// const { rows } = await pool.query("SELECT NOW()");
|
||||
// ============================================================
|
||||
|
||||
import pg from "pg";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pgsql } from "../config";
|
||||
|
||||
// ============================================================
|
||||
// 1. 连接池创建(单例)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* pg.Pool 单例。
|
||||
* 配置来源于 config.ts → pgsql,已包含连接数上限、超时等参数。
|
||||
*
|
||||
* pg.Pool 内部使用懒连接——首次 query 时才建立连接,
|
||||
* 因此模块加载时不会立即连接数据库。
|
||||
*/
|
||||
export const pool = new pg.Pool({
|
||||
host: pgsql.host,
|
||||
port: pgsql.port,
|
||||
database: pgsql.database,
|
||||
user: pgsql.user,
|
||||
password: pgsql.password,
|
||||
max: pgsql.max,
|
||||
idleTimeoutMillis: pgsql.idleTimeoutMillis,
|
||||
connectionTimeoutMillis: pgsql.connectionTimeoutMillis,
|
||||
// TimescaleDB 特有的超时设置:分析查询可能较慢
|
||||
statement_timeout: 30000, // 单条 SQL 最大执行 30s
|
||||
// application_name 便于在 pg_stat_activity 中识别
|
||||
application_name: "trade-data",
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 2. 连接池事件监听(可观测性)
|
||||
// ============================================================
|
||||
|
||||
/** 新客户端连接建立 */
|
||||
pool.on("connect", (client) => {
|
||||
// 为每个连接设置 TimescaleDB 优化参数
|
||||
// 跳过 WAL 日志可加速批量写入(仅在可接受丢失最近几秒数据的场景)
|
||||
// client.query("SET timescaledb.enable_skip_scan = ON");
|
||||
console.log(`[pg] 新连接建立 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
|
||||
});
|
||||
|
||||
/** 从池中获取连接 */
|
||||
pool.on("acquire", () => {
|
||||
// 连接池耗尽时会频繁触发,可在此记录高负载信号
|
||||
});
|
||||
|
||||
/** 连接归还池 */
|
||||
pool.on("remove", () => {
|
||||
console.log(`[pg] 连接关闭 (total: ${pool.totalCount}, idle: ${pool.idleCount})`);
|
||||
});
|
||||
|
||||
/**
|
||||
* 空闲客户端出错(如网络中断、PG 重启)。
|
||||
* pg.Pool 会自动移除问题连接并创建新连接,此处仅记录日志。
|
||||
*/
|
||||
pool.on("error", (err: Error) => {
|
||||
console.error(`[pg] 连接池错误: ${err.message}`);
|
||||
// 不退出进程——连接池会自动恢复
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 3. 健康检查
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 数据库连通性检查。
|
||||
* 执行轻量查询 `SELECT 1`,超时 5 秒。
|
||||
*
|
||||
* @returns true 表示数据库可达
|
||||
*/
|
||||
export async function healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT 1");
|
||||
return true;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度健康检查:验证 TimescaleDB 扩展是否已安装。
|
||||
* 仅在首次连接或定期巡检时调用。
|
||||
*
|
||||
* @returns TimescaleDB 版本字符串,未安装则返回 null
|
||||
*/
|
||||
export async function timescaleVersion(): Promise<string | null> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ extversion: string }>(
|
||||
"SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'",
|
||||
);
|
||||
return rows[0]?.extversion ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 事务辅助
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 在单连接上执行事务。
|
||||
* 自动 BEGIN / COMMIT / ROLLBACK,连接用完即释放。
|
||||
*
|
||||
* @param fn - 事务体,接收 pg.PoolClient,返回 Promise<T>
|
||||
* @returns fn 的返回值
|
||||
* @throws 事务内任何异常都会触发 ROLLBACK 并向上抛出
|
||||
*
|
||||
* @example
|
||||
* const result = await withTransaction(async (client) => {
|
||||
* await client.query("INSERT INTO ...");
|
||||
* await client.query("UPDATE ...");
|
||||
* return { success: true };
|
||||
* });
|
||||
*/
|
||||
export async function withTransaction<T>(
|
||||
fn: (client: pg.PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await fn(client);
|
||||
await client.query("COMMIT");
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 优雅关闭
|
||||
// ============================================================
|
||||
|
||||
/** 是否正在关闭(防止重复调用) */
|
||||
let shuttingDown = false;
|
||||
|
||||
/**
|
||||
* 关闭连接池。
|
||||
* 先等待所有进行中的查询完成(drain),再断开所有连接。
|
||||
*
|
||||
* 应在进程退出前调用:SIGTERM / SIGINT 处理器中。
|
||||
*/
|
||||
export async function closePool(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
|
||||
console.log("[pg] 正在关闭连接池...");
|
||||
// pool.end() 等待所有活跃查询完成后关闭
|
||||
await pool.end();
|
||||
console.log("[pg] 连接池已关闭");
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册进程信号处理器——优雅关闭。
|
||||
* 在应用入口调用一次即可。
|
||||
*/
|
||||
export function registerShutdownHandlers(): void {
|
||||
const shutdown = async (signal: string) => {
|
||||
console.log(`[pg] 收到 ${signal} 信号,开始优雅关闭...`);
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 6. Schema 初始化(基于 data/schema/*.sql)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将 SQL 文本按语句拆分为独立命令。
|
||||
* 规则:
|
||||
* - 按 `;` 分割
|
||||
* - 跳过空行和纯注释行
|
||||
* - 单个语句去除首尾空白后若为空则跳过
|
||||
*
|
||||
* 注意:此方法假设 SQL 中 `;` 仅作为语句分隔符,
|
||||
* 不含存储过程/函数体内的 `;`(schema/*.sql 满足此条件)。
|
||||
*/
|
||||
function splitSQLStatements(sql: string): string[] {
|
||||
const statements: string[] = [];
|
||||
for (const raw of sql.split(";")) {
|
||||
const trimmed = raw.trim();
|
||||
// 跳过空语句
|
||||
if (trimmed === "") {
|
||||
continue;
|
||||
}
|
||||
// 跳过仅包含注释的行(已在上层过滤,此处兜底)
|
||||
statements.push(trimmed);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单个 .sql 文件初始化 Schema。
|
||||
* 读取文件 → 拆分语句 → 逐条执行(同一连接,保证顺序)。
|
||||
*
|
||||
* 所有 SQL 均使用 IF NOT EXISTS / ON CONFLICT DO NOTHING,
|
||||
* 因此重复执行安全幂等。
|
||||
*
|
||||
* @param filePath - SQL 文件的绝对路径
|
||||
* @returns 成功执行的语句数
|
||||
*/
|
||||
export async function initSchemaFromFile(filePath: string): Promise<number> {
|
||||
const sql = readFileSync(filePath, "utf-8");
|
||||
|
||||
// 预处理:移除纯注释行(以 -- 开头),减少无效语句
|
||||
const lines = sql
|
||||
.split("\n")
|
||||
.filter((line: string) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed !== "" && !trimmed.startsWith("--");
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const statements = splitSQLStatements(lines);
|
||||
if (statements.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let executed = 0;
|
||||
// 使用单连接执行所有语句,保证 DDL 顺序(如先建表再建索引)
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
for (const stmt of statements) {
|
||||
await client.query(stmt);
|
||||
executed++;
|
||||
}
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
return executed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 data/schema/ 目录初始化所有表结构。
|
||||
*
|
||||
* 执行顺序(按文件名排序):
|
||||
* 1. klines.sql — K 线主表(hypertable)、索引、压缩、连续聚合
|
||||
* 2. config.sql — 配置表(monitored_symbols / exchange_config / app_config)+ 预置数据
|
||||
*
|
||||
* Docker Compose 首次启动时,001_init.sql 已被自动执行。
|
||||
* 此函数作为补充,确保以下场景:
|
||||
* - 裸 PostgreSQL 安装(非 Docker 部署)
|
||||
* - 版本升级时需要新增表/索引
|
||||
* - 开发环境快速重置 Schema
|
||||
*
|
||||
* @param schemaDir - schema 目录路径,默认 ../schema(相对于本文件)
|
||||
* @returns 各文件执行结果摘要
|
||||
*/
|
||||
export async function initSchema(schemaDir?: string): Promise<{
|
||||
files: string[];
|
||||
totalStatements: number;
|
||||
errors: string[];
|
||||
}> {
|
||||
// 计算 schema 目录绝对路径(ESM 中 __dirname 不可用)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const dir = schemaDir ?? resolve(__dirname, "..", "schema");
|
||||
|
||||
const result = { files: [] as string[], totalStatements: 0, errors: [] as string[] };
|
||||
|
||||
// 读取目录,按文件名排序保证执行顺序(klines.sql 先于 config.sql)
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
.filter((f: string) => f.endsWith(".sql"))
|
||||
.sort(); // 字母序
|
||||
} catch {
|
||||
result.errors.push(`无法读取 schema 目录: ${dir}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 手动排序:klines.sql 必须在 config.sql 之前(外键/引用依赖)
|
||||
const klinesFirst = ["klines.sql", "config.sql"];
|
||||
entries.sort((a, b) => {
|
||||
const ia = klinesFirst.indexOf(a);
|
||||
const ib = klinesFirst.indexOf(b);
|
||||
if (ia !== -1 && ib !== -1) return ia - ib;
|
||||
if (ia !== -1) return -1;
|
||||
if (ib !== -1) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = resolve(dir, entry);
|
||||
try {
|
||||
const count = await initSchemaFromFile(filePath);
|
||||
result.files.push(`${entry} (${count} 条)`);
|
||||
result.totalStatements += count;
|
||||
} catch (err) {
|
||||
const msg = `${entry}: ${(err as Error).message}`;
|
||||
result.errors.push(msg);
|
||||
console.error(`[pg] Schema 初始化失败 — ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length === 0) {
|
||||
console.log(
|
||||
`[pg] Schema 初始化完成 — ${result.files.length} 个文件, ${result.totalStatements} 条语句`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速判断核心表是否已存在(轻量级检查,不做全量 Schema 验证)。
|
||||
* 仅在执行 initSchema() 前做一次探测,已存在则跳过。
|
||||
*
|
||||
* @returns true 表示 klines 和 monitored_symbols 表均已存在
|
||||
*/
|
||||
export async function isSchemaInitialized(): Promise<boolean> {
|
||||
try {
|
||||
const { rows } = await pool.query<{ count: string }>(`
|
||||
SELECT COUNT(*)::TEXT AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN ('klines', 'monitored_symbols', 'exchange_config', 'app_config')
|
||||
`);
|
||||
// 4 张核心表全部存在
|
||||
return parseInt(rows[0]?.count ?? "0", 10) >= 4;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 7. 默认导出(便于 import pool from "./db/pg")
|
||||
// ============================================================
|
||||
|
||||
export default pool;
|
||||
@@ -1,561 +0,0 @@
|
||||
// ============================================================
|
||||
// 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
|
||||
`;
|
||||
@@ -1,249 +0,0 @@
|
||||
// ============================================================
|
||||
// schema/types.ts — PostgreSQL 表对应的 TypeScript 类型定义
|
||||
// ============================================================
|
||||
// 与 data/schema/*.sql 中的表结构一一对应
|
||||
//
|
||||
// 类型约定:
|
||||
// NUMERIC(20,8) → string (保留精度,避免 IEEE 754 浮点误差)
|
||||
// TIMESTAMPTZ → Date (pg 默认解析为 Date)
|
||||
// SERIAL / INT → number
|
||||
// SMALLINT → number
|
||||
// REAL → number
|
||||
// BOOLEAN → boolean
|
||||
// ============================================================
|
||||
|
||||
// ============================================================
|
||||
// 联合类型:约束 TEXT 字段的合法值
|
||||
// ============================================================
|
||||
|
||||
/** 支持的交易所标识 */
|
||||
export type Exchange = "binance" | "okx" | "bybit";
|
||||
|
||||
/** K 线周期(原始 1m + 聚合派生) */
|
||||
export type KlineInterval = "1m" | "5m" | "15m" | "1h" | "4h" | "1d";
|
||||
|
||||
/** 日志级别 */
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
|
||||
// ============================================================
|
||||
// 1. K 线主表 — klines
|
||||
// ============================================================
|
||||
|
||||
/** klines 表完整行类型 */
|
||||
export interface KlineRow {
|
||||
/** K 线开盘时间(UTC) */
|
||||
time: Date;
|
||||
/** 交易所 */
|
||||
exchange: Exchange;
|
||||
/** 交易对,如 BTCUSDT */
|
||||
symbol: string;
|
||||
/** K 线周期 */
|
||||
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;
|
||||
/** K 线是否已闭合 */
|
||||
is_closed: boolean;
|
||||
/** 记录创建时间 */
|
||||
created_at: Date;
|
||||
/** 记录更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** klines 表插入类型(省略自动生成的元数据列) */
|
||||
export interface KlineInsert {
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. 连续聚合视图 — klines_5m / klines_15m / klines_1h / klines_1d
|
||||
// ============================================================
|
||||
|
||||
/** 聚合 K 线通用类型(OHLCV,无扩展字段) */
|
||||
export interface AggregatedKlineRow {
|
||||
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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 监控交易对配置 — monitored_symbols
|
||||
// ============================================================
|
||||
|
||||
/** monitored_symbols 表完整行类型 */
|
||||
export interface MonitoredSymbolRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 交易所 */
|
||||
exchange: Exchange;
|
||||
/** 交易对 */
|
||||
symbol: string;
|
||||
/** K 线周期 */
|
||||
interval: KlineInterval;
|
||||
/** 是否启用采集 */
|
||||
enabled: boolean;
|
||||
/** 优先级(0-32767,越大越优先) */
|
||||
priority: number;
|
||||
/** 人类可读标签 */
|
||||
label: string | null;
|
||||
/** 备注 */
|
||||
notes: string | null;
|
||||
/** 创建时间 */
|
||||
created_at: Date;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** monitored_symbols 插入类型 */
|
||||
export interface MonitoredSymbolInsert {
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
label?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
/** monitored_symbols 更新类型(所有字段可选) */
|
||||
export interface MonitoredSymbolUpdate {
|
||||
enabled?: boolean;
|
||||
priority?: number;
|
||||
label?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 4. 交易所连接配置 — exchange_config
|
||||
// ============================================================
|
||||
|
||||
/** exchange_config 表完整行类型 */
|
||||
export interface ExchangeConfigRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 交易所标识(唯一) */
|
||||
exchange: Exchange;
|
||||
/** REST API 基础 URL(null = 使用 SDK 默认值) */
|
||||
rest_url: string | null;
|
||||
/** WebSocket 基础 URL(null = 使用 SDK 默认值) */
|
||||
ws_url: string | null;
|
||||
/** 心跳间隔(毫秒) */
|
||||
ws_ping_interval_ms: number;
|
||||
/** 每秒最大请求数 */
|
||||
rate_limit_per_sec: number;
|
||||
/** 最大重连次数 */
|
||||
max_reconnect_attempts: number;
|
||||
/** 重连延迟基数(毫秒) */
|
||||
reconnect_delay_ms: number;
|
||||
/** 是否启用该交易所 */
|
||||
enabled: boolean;
|
||||
/** 备注 */
|
||||
notes: string | null;
|
||||
/** 创建时间 */
|
||||
created_at: Date;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** exchange_config 插入类型 */
|
||||
export interface ExchangeConfigInsert {
|
||||
exchange: Exchange;
|
||||
rest_url?: string | null;
|
||||
ws_url?: string | null;
|
||||
ws_ping_interval_ms?: number;
|
||||
rate_limit_per_sec?: number;
|
||||
max_reconnect_attempts?: number;
|
||||
reconnect_delay_ms?: number;
|
||||
enabled?: boolean;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 5. 全局应用配置 — app_config
|
||||
// ============================================================
|
||||
|
||||
/** app_config 表完整行类型 */
|
||||
export interface AppConfigRow {
|
||||
/** 自增主键 */
|
||||
id: number;
|
||||
/** 配置键 */
|
||||
key: string;
|
||||
/** 配置值(统一存储为字符串) */
|
||||
value: string;
|
||||
/** 说明 */
|
||||
description: string | null;
|
||||
/** 更新时间 */
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** 已知的 app_config 键名 */
|
||||
export type AppConfigKey =
|
||||
| "batch_size"
|
||||
| "flush_interval_ms"
|
||||
| "log_level"
|
||||
| "redis_publish_enabled";
|
||||
|
||||
// ============================================================
|
||||
// 6. 业务聚合类型
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 唯一标识一个 K 线流
|
||||
* 对应 klines / monitored_symbols 的 (exchange, symbol, interval) 组合
|
||||
*/
|
||||
export interface StreamKey {
|
||||
exchange: Exchange;
|
||||
symbol: string;
|
||||
interval: KlineInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集服务启动时加载的完整订阅配置
|
||||
* = monitored_symbols JOIN exchange_config
|
||||
*/
|
||||
export interface StreamSubscription {
|
||||
/** 流标识 */
|
||||
streamKey: StreamKey;
|
||||
/** 优先级 */
|
||||
priority: number;
|
||||
/** 连接配置 */
|
||||
exchangeConfig: ExchangeConfigRow;
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
// ============================================================
|
||||
// schema/validators.ts — Zod 运行时校验 Schema
|
||||
// ============================================================
|
||||
// 用途:
|
||||
// 1. WebSocket 行情数据到达后校验字段完整性再入库
|
||||
// 2. 配置文件 / 环境变量加载后类型收窄
|
||||
// 3. API 输入参数校验
|
||||
//
|
||||
// 依赖:zod ^4.x(已包含在 data/package.json)
|
||||
// ============================================================
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ============================================================
|
||||
// 基础标量 Schema
|
||||
// ============================================================
|
||||
|
||||
/** 交易所枚举 */
|
||||
export const ExchangeSchema = z.enum(["binance", "okx", "bybit"]);
|
||||
export type Exchange = z.infer<typeof ExchangeSchema>;
|
||||
|
||||
/** K 线周期枚举 */
|
||||
export const KlineIntervalSchema = z.enum([
|
||||
"1m",
|
||||
"5m",
|
||||
"15m",
|
||||
"1h",
|
||||
"4h",
|
||||
"1d",
|
||||
]);
|
||||
export type KlineInterval = z.infer<typeof KlineIntervalSchema>;
|
||||
|
||||
/** 日志级别 */
|
||||
export const LogLevelSchema = z.enum([
|
||||
"trace",
|
||||
"debug",
|
||||
"info",
|
||||
"warn",
|
||||
"error",
|
||||
"fatal",
|
||||
]);
|
||||
|
||||
/** 交易对格式:大写字母 + 大写字母(如 BTCUSDT),3-12 字符 */
|
||||
export const SymbolSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Z0-9]{4,14}$/, "交易对格式无效,示例:BTCUSDT");
|
||||
|
||||
/**
|
||||
* NUMERIC(20,8) 数值字符串
|
||||
* pg 驱动默认以字符串返回 NUMERIC 以保留精度
|
||||
*/
|
||||
export const NumericStringSchema = z
|
||||
.string()
|
||||
.regex(/^-?\d+(\.\d+)?$/, "期望 NUMERIC 字符串");
|
||||
|
||||
// ============================================================
|
||||
// 1. Kline 数据校验 — klines 表
|
||||
// ============================================================
|
||||
|
||||
/** WebSocket 原始 OHLCV 消息校验(单条 K 线,入库前) */
|
||||
export const KlineRawSchema = z.object({
|
||||
/** K 线开盘时间(UTC),Unix 毫秒时间戳 */
|
||||
time: z.number().int().positive(),
|
||||
/** 交易所 */
|
||||
exchange: ExchangeSchema,
|
||||
/** 交易对 */
|
||||
symbol: SymbolSchema,
|
||||
/** 周期 */
|
||||
interval: KlineIntervalSchema,
|
||||
/** 开盘价 */
|
||||
open: NumericStringSchema,
|
||||
/** 最高价 */
|
||||
high: NumericStringSchema,
|
||||
/** 最低价 */
|
||||
low: NumericStringSchema,
|
||||
/** 收盘价 */
|
||||
close: NumericStringSchema,
|
||||
/** 成交量 */
|
||||
volume: NumericStringSchema,
|
||||
/** 成交额(可选) */
|
||||
quote_volume: NumericStringSchema.optional().default("0"),
|
||||
/** 主动买入量(可选) */
|
||||
taker_buy_base_vol: NumericStringSchema.optional().default("0"),
|
||||
/** 主动买入额(可选) */
|
||||
taker_buy_quote_vol: NumericStringSchema.optional().default("0"),
|
||||
/** 成交笔数(可选) */
|
||||
trade_count: z.number().int().nonnegative().optional().default(0),
|
||||
/** K 线是否闭合 */
|
||||
is_closed: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export type KlineRaw = z.infer<typeof KlineRawSchema>;
|
||||
|
||||
/** 批量 K 线消息校验(WebSocket 可能一次推送多根) */
|
||||
export const KlineBatchSchema = z.array(KlineRawSchema).min(1);
|
||||
|
||||
export type KlineBatch = z.infer<typeof KlineBatchSchema>;
|
||||
|
||||
// ============================================================
|
||||
// 2. 监控交易对配置校验 — monitored_symbols
|
||||
// ============================================================
|
||||
|
||||
/** 插入监控标的 */
|
||||
export const MonitoredSymbolInsertSchema = z.object({
|
||||
exchange: ExchangeSchema,
|
||||
symbol: SymbolSchema,
|
||||
interval: KlineIntervalSchema,
|
||||
enabled: z.boolean().optional().default(true),
|
||||
/** 优先级 0-32767(SMALLINT 范围) */
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(32767)
|
||||
.optional()
|
||||
.default(0),
|
||||
label: z.string().max(200).nullable().optional().default(null),
|
||||
notes: z.string().max(1000).nullable().optional().default(null),
|
||||
});
|
||||
|
||||
export type MonitoredSymbolInsert = z.infer<
|
||||
typeof MonitoredSymbolInsertSchema
|
||||
>;
|
||||
|
||||
/** 更新监控标的 */
|
||||
export const MonitoredSymbolUpdateSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
priority: z.number().int().min(0).max(32767).optional(),
|
||||
label: z.string().max(200).nullable().optional(),
|
||||
notes: z.string().max(1000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type MonitoredSymbolUpdate = z.infer<
|
||||
typeof MonitoredSymbolUpdateSchema
|
||||
>;
|
||||
|
||||
// ============================================================
|
||||
// 3. 交易所连接配置校验 — exchange_config
|
||||
// ============================================================
|
||||
|
||||
/** 交易所连接配置输入 */
|
||||
export const ExchangeConfigInsertSchema = z.object({
|
||||
exchange: ExchangeSchema,
|
||||
rest_url: z.string().url().nullable().optional().default(null),
|
||||
ws_url: z.string().url().nullable().optional().default(null),
|
||||
ws_ping_interval_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(5000)
|
||||
.max(300000)
|
||||
.optional()
|
||||
.default(30000),
|
||||
rate_limit_per_sec: z.number().positive().max(100).optional().default(20),
|
||||
max_reconnect_attempts: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.optional()
|
||||
.default(10),
|
||||
reconnect_delay_ms: z
|
||||
.number()
|
||||
.int()
|
||||
.min(100)
|
||||
.max(60000)
|
||||
.optional()
|
||||
.default(3000),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
notes: z.string().max(500).nullable().optional().default(null),
|
||||
});
|
||||
|
||||
export type ExchangeConfigInsert = z.infer<
|
||||
typeof ExchangeConfigInsertSchema
|
||||
>;
|
||||
|
||||
// ============================================================
|
||||
// 4. 流标识校验 — StreamKey
|
||||
// ============================================================
|
||||
|
||||
/** (exchange, symbol, interval) 三元组 */
|
||||
export const StreamKeySchema = z.object({
|
||||
exchange: ExchangeSchema,
|
||||
symbol: SymbolSchema,
|
||||
interval: KlineIntervalSchema,
|
||||
});
|
||||
|
||||
export type StreamKey = z.infer<typeof StreamKeySchema>;
|
||||
|
||||
// ============================================================
|
||||
// 5. 环境变量 / 配置校验
|
||||
// ============================================================
|
||||
|
||||
/** .env 环境变量 schema */
|
||||
export const EnvConfigSchema = z.object({
|
||||
/** 逗号分隔的交易对列表 */
|
||||
SYMBOLS: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("BTCUSDT,ETHUSDT"),
|
||||
DB_HOST: z.string().optional().default("localhost"),
|
||||
DB_PORT: z.coerce.number().int().positive().optional().default(5432),
|
||||
DB_NAME: z.string().optional().default("trade"),
|
||||
DB_USER: z.string().optional().default("trader"),
|
||||
DB_PASSWORD: z.string().optional().default("changeme"),
|
||||
REDIS_URL: z.string().url().optional().default("redis://localhost:6379"),
|
||||
REDIS_PUBLISH_ENABLED: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.default("true"),
|
||||
BATCH_SIZE: z.coerce.number().int().positive().optional().default(500),
|
||||
FLUSH_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(1000),
|
||||
/** WebSocket 断线重连延迟基数(毫秒) */
|
||||
WS_RECONNECT_DELAY_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(3000),
|
||||
/** WebSocket 心跳间隔(毫秒) */
|
||||
WS_PING_INTERVAL_MS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.default(30000),
|
||||
/** WebSocket 最大重连次数 */
|
||||
WS_MAX_RECONNECT_ATTEMPTS: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.default(10),
|
||||
LOG_LEVEL: LogLevelSchema.optional().default("info"),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.optional()
|
||||
.default("development"),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof EnvConfigSchema>;
|
||||
Reference in New Issue
Block a user