feat: 外置数据库初始化与分区管理功能

- 删除主服务中的数据库初始化与分区管理逻辑,降低复杂度。
- 新增 SQL 脚本用于数据库初始化和分区管理,集中在 SQL_Script 目录。
- 移除环境变量 ENABLE_DATABASE_INITIALIZATION,简化配置。
- 更新 package.json,新增数据库初始化和分区管理的 npm 脚本。
- 删除不再使用的初始化和分区管理相关文件。
- 提供统一的命令行接口,支持外部调用数据库初始化和分区创建。
This commit is contained in:
2026-03-04 14:54:27 +08:00
parent 677db35cdf
commit f61a63d8c1
18 changed files with 483 additions and 397 deletions

58
SQL_Script/README.md Normal file
View File

@@ -0,0 +1,58 @@
# SQL_Script
用于**独立于业务服务**的数据库初始化与分区管理。
> 目标:主服务 `bls-rcu-action-backend` 只负责消费 Kafka 与写库,不再承担任何建库/建表/建分区职责。
## 文件说明
- `init_rcu_action.sql`
- 初始化 `rcu_action.rcu_action_events` 主表与索引
- `init_room_status.sql`
- 初始化 `room_status.room_status_moment` 主表与索引
- `partition_rcu_action.sql`
- `rcu_action` 按天 RANGE 分区 SQL 模板
- `partition_room_status.sql`
- `room_status``hotel_id` LIST 分区 SQL 模板
- `db_manager.js`
- Node 调用入口CLI + 可 import
## 环境变量
与主服务统一:
- `DB_HOST` / `POSTGRES_HOST`
- `DB_PORT` / `POSTGRES_PORT`
- `DB_USER` / `POSTGRES_USER`
- `DB_PASSWORD` / `POSTGRES_PASSWORD`
- `DB_DATABASE` / `POSTGRES_DATABASE`
- `DB_SSL=true|false`
- `DB_ADMIN_DATABASE`(可选,默认 `postgres`
## 命令行调用
`bls-rcu-action-backend` 目录执行:
- `npm run db:init:all`
- 创建数据库(若不存在)+ 初始化两套主表
- `npm run db:init:rcu-action`
- `npm run db:init:room-status`
- `npm run db:partition:rcu-action`
- 默认预建未来 30 天分区
- `npm run db:partition:room-status -- 1001`
- 为 hotel_id=1001 建分区
## 其他程序直接 import
```js
import {
initAll,
ensureDatabase,
ensureRcuPartitions,
ensureRoomStatusPartition
} from '../SQL_Script/db_manager.js';
await initAll();
await ensureRcuPartitions(45);
await ensureRoomStatusPartition(1001);
```

159
SQL_Script/db_manager.js Normal file
View File

@@ -0,0 +1,159 @@
import fs from 'fs';
import path from 'path';
import pg from 'pg';
import { fileURLToPath } from 'url';
const { Client } = pg;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const scriptDir = __dirname;
const parseNumber = (value, defaultValue) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
};
const dbConfig = {
host: process.env.DB_HOST || process.env.POSTGRES_HOST || 'localhost',
port: parseNumber(process.env.DB_PORT || process.env.POSTGRES_PORT, 5432),
user: process.env.DB_USER || process.env.POSTGRES_USER || 'postgres',
password: process.env.DB_PASSWORD || process.env.POSTGRES_PASSWORD || '',
database: process.env.DB_DATABASE || process.env.POSTGRES_DATABASE || 'bls_rcu_action',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : undefined
};
const withClient = async (runner) => {
const client = new Client(dbConfig);
await client.connect();
try {
await runner(client);
} finally {
await client.end();
}
};
const executeSqlFile = async (client, fileName) => {
const filePath = path.join(scriptDir, fileName);
const sql = fs.readFileSync(filePath, 'utf8');
await client.query(sql);
};
export const ensureDatabase = async () => {
const adminClient = new Client({
...dbConfig,
database: process.env.DB_ADMIN_DATABASE || 'postgres'
});
await adminClient.connect();
try {
const targetDb = dbConfig.database;
const check = await adminClient.query('SELECT 1 FROM pg_database WHERE datname = $1', [targetDb]);
if (check.rowCount === 0) {
await adminClient.query(`CREATE DATABASE "${targetDb}"`);
console.log(`[SQL_Script] created database: ${targetDb}`);
} else {
console.log(`[SQL_Script] database exists: ${targetDb}`);
}
} finally {
await adminClient.end();
}
};
const toPartitionSuffix = (date) => {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yyyy}${mm}${dd}`;
};
const getDayRange = (date) => {
const start = new Date(date);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { startMs: start.getTime(), endMs: end.getTime() };
};
export const ensureRcuPartitions = async (daysAhead = 30) => {
const tpl = fs.readFileSync(path.join(scriptDir, 'partition_rcu_action.sql'), 'utf8');
await withClient(async (client) => {
const now = new Date();
for (let i = 0; i < daysAhead; i++) {
const d = new Date(now);
d.setDate(now.getDate() + i);
const suffix = toPartitionSuffix(d);
const partitionName = `rcu_action.rcu_action_events_${suffix}`;
const { startMs, endMs } = getDayRange(d);
const sql = tpl
.replaceAll('{partition_name}', partitionName)
.replaceAll('{start_ms}', String(startMs))
.replaceAll('{end_ms}', String(endMs));
await client.query(sql);
}
});
console.log(`[SQL_Script] ensured rcu_action partitions for ${daysAhead} days`);
};
export const ensureRoomStatusPartition = async (hotelId) => {
if (!Number.isFinite(Number(hotelId))) {
throw new Error('hotelId is required and must be a number');
}
const tpl = fs.readFileSync(path.join(scriptDir, 'partition_room_status.sql'), 'utf8');
const sql = tpl.replaceAll('{hotel_id}', String(hotelId));
await withClient(async (client) => {
await client.query(sql);
});
console.log(`[SQL_Script] ensured room_status partition for hotel_id=${hotelId}`);
};
export const initAll = async () => {
await ensureDatabase();
await withClient(async (client) => {
await executeSqlFile(client, 'init_rcu_action.sql');
await executeSqlFile(client, 'init_room_status.sql');
});
console.log('[SQL_Script] initialized schemas and tables');
};
const run = async () => {
const cmd = process.argv[2];
if (!cmd) {
throw new Error('missing command: init-all | init-rcu | init-room-status | partition-rcu [days] | partition-room-status <hotelId>');
}
switch (cmd) {
case 'init-all':
await initAll();
break;
case 'init-rcu':
await withClient((client) => executeSqlFile(client, 'init_rcu_action.sql'));
console.log('[SQL_Script] initialized rcu_action schema/table');
break;
case 'init-room-status':
await withClient((client) => executeSqlFile(client, 'init_room_status.sql'));
console.log('[SQL_Script] initialized room_status schema/table');
break;
case 'partition-rcu': {
const days = parseNumber(process.argv[3], 30);
await ensureRcuPartitions(days);
break;
}
case 'partition-room-status': {
const hotelId = process.argv[3];
await ensureRoomStatusPartition(hotelId);
break;
}
default:
throw new Error(`unsupported command: ${cmd}`);
}
};
run().catch((err) => {
console.error('[SQL_Script] failed:', err?.message || err);
process.exit(1);
});

View File

@@ -0,0 +1,47 @@
-- SQL_Script/init_rcu_action.sql
-- RCU Action 主业务表初始化(不包含 CREATE DATABASE
CREATE SCHEMA IF NOT EXISTS rcu_action;
CREATE TABLE IF NOT EXISTS rcu_action.rcu_action_events (
guid VARCHAR(32) NOT NULL,
ts_ms BIGINT NOT NULL,
write_ts_ms BIGINT NOT NULL,
hotel_id INTEGER NOT NULL,
room_id VARCHAR(32) NOT NULL,
device_id VARCHAR(32) NOT NULL,
direction VARCHAR(10) NOT NULL,
cmd_word VARCHAR(10) NOT NULL,
frame_id INTEGER NOT NULL,
udp_raw TEXT NOT NULL,
action_type VARCHAR(20) NOT NULL,
sys_lock_status SMALLINT,
report_count SMALLINT,
dev_type SMALLINT,
dev_addr SMALLINT,
dev_loop INTEGER,
dev_data INTEGER,
fault_count SMALLINT,
error_type SMALLINT,
error_data SMALLINT,
type_l SMALLINT,
type_h SMALLINT,
details JSONB,
extra JSONB,
loop_name VARCHAR(255),
PRIMARY KEY (ts_ms, guid)
) PARTITION BY RANGE (ts_ms);
ALTER TABLE rcu_action.rcu_action_events
ADD COLUMN IF NOT EXISTS device_id VARCHAR(32) NOT NULL DEFAULT '';
ALTER TABLE rcu_action.rcu_action_events
ADD COLUMN IF NOT EXISTS loop_name VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_rcu_action_hotel_id ON rcu_action.rcu_action_events (hotel_id);
CREATE INDEX IF NOT EXISTS idx_rcu_action_room_id ON rcu_action.rcu_action_events (room_id);
CREATE INDEX IF NOT EXISTS idx_rcu_action_device_id ON rcu_action.rcu_action_events (device_id);
CREATE INDEX IF NOT EXISTS idx_rcu_action_direction ON rcu_action.rcu_action_events (direction);
CREATE INDEX IF NOT EXISTS idx_rcu_action_cmd_word ON rcu_action.rcu_action_events (cmd_word);
CREATE INDEX IF NOT EXISTS idx_rcu_action_action_type ON rcu_action.rcu_action_events (action_type);
CREATE INDEX IF NOT EXISTS idx_rcu_action_query_main ON rcu_action.rcu_action_events (hotel_id, room_id, ts_ms DESC);

View File

@@ -0,0 +1,66 @@
-- SQL_Script/init_room_status.sql
-- Room Status 快照表初始化(不包含 CREATE DATABASE
CREATE SCHEMA IF NOT EXISTS room_status;
CREATE TABLE IF NOT EXISTS room_status.room_status_moment (
guid UUID NOT NULL,
ts_ms INT8 NOT NULL DEFAULT (EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000)::BIGINT,
hotel_id INT2 NOT NULL,
room_id TEXT NOT NULL,
device_id TEXT NOT NULL,
sys_lock_status INT2,
online_status INT2,
launcher_version TEXT,
app_version TEXT,
config_version TEXT,
register_ts_ms INT8,
upgrade_ts_ms INT8,
config_ts_ms INT8,
ip TEXT,
pms_status INT2,
power_state INT2,
cardless_state INT2,
service_mask INT8,
insert_card INT2,
bright_g INT2,
agreement_ver TEXT,
air_address TEXT[],
air_state INT2[],
air_model INT2[],
air_speed INT2[],
air_set_temp INT2[],
air_now_temp INT2[],
air_solenoid_valve INT2[],
elec_address TEXT[],
elec_voltage DOUBLE PRECISION[],
elec_ampere DOUBLE PRECISION[],
elec_power DOUBLE PRECISION[],
elec_phase DOUBLE PRECISION[],
elec_energy DOUBLE PRECISION[],
elec_sum_energy DOUBLE PRECISION[],
carbon_state INT2,
dev_loops JSONB,
energy_carbon_sum DOUBLE PRECISION,
energy_nocard_sum DOUBLE PRECISION,
external_device JSONB DEFAULT '{}',
faulty_device_count JSONB DEFAULT '{}',
PRIMARY KEY (hotel_id, room_id, device_id, guid)
) PARTITION BY LIST (hotel_id);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_hotel_room ON room_status.room_status_moment (hotel_id, room_id);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_device_id ON room_status.room_status_moment (device_id);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_sys_lock ON room_status.room_status_moment (sys_lock_status);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_online ON room_status.room_status_moment (online_status);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_pms ON room_status.room_status_moment (pms_status);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_power ON room_status.room_status_moment (power_state);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_cardless ON room_status.room_status_moment (cardless_state);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_insert_card ON room_status.room_status_moment (insert_card);
CREATE INDEX IF NOT EXISTS idx_room_status_moment_carbon ON room_status.room_status_moment (carbon_state);
CREATE UNIQUE INDEX IF NOT EXISTS idx_room_status_unique_device ON room_status.room_status_moment (hotel_id, room_id, device_id);

View File

@@ -0,0 +1,14 @@
-- SQL_Script/partition_rcu_action.sql
-- 说明:此文件提供“按天分区”的 SQL 模板。
-- 其他程序可用参数替换后执行:{partition_name} {start_ms} {end_ms}
-- 示例:
-- CREATE TABLE IF NOT EXISTS rcu_action.rcu_action_events_20260304
-- PARTITION OF rcu_action.rcu_action_events
-- FOR VALUES FROM (1741046400000) TO (1741132800000)
-- TABLESPACE ts_hot;
CREATE TABLE IF NOT EXISTS {partition_name}
PARTITION OF rcu_action.rcu_action_events
FOR VALUES FROM ({start_ms}) TO ({end_ms})
TABLESPACE ts_hot;

View File

@@ -0,0 +1,12 @@
-- SQL_Script/partition_room_status.sql
-- 说明:此文件提供 room_status 按 hotel_id LIST 分区 SQL 模板。
-- 其他程序可用参数替换后执行:{hotel_id}
-- 示例:
-- CREATE TABLE IF NOT EXISTS room_status.room_status_moment_h1001
-- PARTITION OF room_status.room_status_moment
-- FOR VALUES IN (1001);
CREATE TABLE IF NOT EXISTS room_status.room_status_moment_h{hotel_id}
PARTITION OF room_status.room_status_moment
FOR VALUES IN ({hotel_id});