feat: 添加 G5 独立写入功能
- 新增 G5 数据库连接配置与可关闭的写入开关 - 在现有 legacy/G4 写入成功路径后,追加独立的 G5 写入流程 - G5 使用与 G4 相同的数据结构映射,但不写入 guid,由数据库自生成 int4 guid - room_status 新增 G5 独立 upsert 写入路径,并保留旧表与 G5 表的独立开关 - 新增 G5 写入统计与启动摘要输出 - 更新 StatsCounters 和 StatsReporter 以支持 G5 统计 - 增加测试覆盖,确保 G5 写入逻辑与 room_status 的独立执行 - 新增 G5 相关数据库表结构 SQL 文件
This commit is contained in:
@@ -184,6 +184,14 @@ class DatabaseManager {
|
||||
// ---- 新表 G4 Hot 列定义 ----
|
||||
|
||||
_getG4HotColumns() {
|
||||
return this._getExpandedHotColumns({ includeGuid: true });
|
||||
}
|
||||
|
||||
_getG5Columns() {
|
||||
return this._getExpandedHotColumns({ includeGuid: false });
|
||||
}
|
||||
|
||||
_getExpandedHotColumns({ includeGuid }) {
|
||||
const base = [
|
||||
'ts_ms', 'write_ts_ms', 'hotel_id', 'room_id', 'device_id', 'ip',
|
||||
'power_state', 'guest_type', 'cardless_state', 'service_mask',
|
||||
@@ -213,8 +221,9 @@ class DatabaseManager {
|
||||
'sum_energy_1', 'sum_energy_2', 'sum_energy_residual',
|
||||
];
|
||||
const power = ['power_carbon_on', 'power_carbon_off', 'power_person_exist', 'power_person_left'];
|
||||
const tail = ['guid'];
|
||||
return [...base, ...svc, ...airUnpacked, ...elecUnpacked, ...power, ...tail];
|
||||
return includeGuid
|
||||
? [...base, ...svc, ...airUnpacked, ...elecUnpacked, ...power, 'guid']
|
||||
: [...base, ...svc, ...airUnpacked, ...elecUnpacked, ...power];
|
||||
}
|
||||
|
||||
_unpackArrElement(arr, idx) {
|
||||
@@ -228,6 +237,14 @@ class DatabaseManager {
|
||||
}
|
||||
|
||||
_g4HotToRowValues(e) {
|
||||
return this._expandedHotToRowValues(e, { includeGuid: true, nullifyArrayColumns: false, nullifyG5BaseColumns: false });
|
||||
}
|
||||
|
||||
_g5ToRowValues(e) {
|
||||
return this._expandedHotToRowValues(e, { includeGuid: false, nullifyArrayColumns: true, nullifyG5BaseColumns: true });
|
||||
}
|
||||
|
||||
_expandedHotToRowValues(e, { includeGuid, nullifyArrayColumns, nullifyG5BaseColumns }) {
|
||||
const values = [
|
||||
e.ts_ms,
|
||||
e.write_ts_ms ?? Date.now(),
|
||||
@@ -238,7 +255,7 @@ class DatabaseManager {
|
||||
e.power_state,
|
||||
e.guest_type,
|
||||
e.cardless_state,
|
||||
e.service_mask,
|
||||
nullifyG5BaseColumns ? null : e.service_mask,
|
||||
e.pms_state,
|
||||
e.carbon_state,
|
||||
e.device_count,
|
||||
@@ -246,21 +263,21 @@ class DatabaseManager {
|
||||
e.insert_card ?? null,
|
||||
(e.bright_g === -1 || e.bright_g === '-1') ? null : (e.bright_g ?? null),
|
||||
e.version ?? null,
|
||||
Array.isArray(e.elec_address) ? e.elec_address : null,
|
||||
Array.isArray(e.air_address) ? e.air_address : null,
|
||||
Array.isArray(e.voltage) ? e.voltage : null,
|
||||
Array.isArray(e.ampere) ? e.ampere : null,
|
||||
Array.isArray(e.power) ? e.power : null,
|
||||
Array.isArray(e.phase) ? e.phase : null,
|
||||
Array.isArray(e.energy) ? e.energy : null,
|
||||
Array.isArray(e.sum_energy) ? e.sum_energy : null,
|
||||
Array.isArray(e.state) ? e.state : null,
|
||||
Array.isArray(e.model) ? e.model : null,
|
||||
Array.isArray(e.speed) ? e.speed : null,
|
||||
Array.isArray(e.set_temp) ? e.set_temp : null,
|
||||
Array.isArray(e.now_temp) ? e.now_temp : null,
|
||||
Array.isArray(e.solenoid_valve) ? e.solenoid_valve : null,
|
||||
e.extra ?? null,
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.elec_address) ? e.elec_address : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.air_address) ? e.air_address : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.voltage) ? e.voltage : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.ampere) ? e.ampere : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.power) ? e.power : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.phase) ? e.phase : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.energy) ? e.energy : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.sum_energy) ? e.sum_energy : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.state) ? e.state : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.model) ? e.model : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.speed) ? e.speed : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.set_temp) ? e.set_temp : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.now_temp) ? e.now_temp : null),
|
||||
nullifyArrayColumns ? null : (Array.isArray(e.solenoid_valve) ? e.solenoid_valve : null),
|
||||
nullifyG5BaseColumns ? null : (e.extra ?? null),
|
||||
];
|
||||
|
||||
// svc_01 .. svc_64 布尔展开
|
||||
@@ -305,7 +322,9 @@ class DatabaseManager {
|
||||
values.push(null);
|
||||
values.push(null);
|
||||
|
||||
values.push(this._normalizeGuid(e.guid));
|
||||
if (includeGuid) {
|
||||
values.push(this._normalizeGuid(e.guid));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
@@ -456,6 +475,24 @@ class DatabaseManager {
|
||||
};
|
||||
}
|
||||
|
||||
async insertHeartbeatEventsG5(events) {
|
||||
if (!Array.isArray(events)) events = [events];
|
||||
if (events.length === 0) {
|
||||
return { enabled: false, success: true, insertedCount: 0, failedRecords: [], error: null, isConnectionError: false, batchError: null };
|
||||
}
|
||||
|
||||
const result = await this._insertEventsToTarget(events, {
|
||||
tableName: this.config.g5Table ?? 'heartbeat.heartbeat_events_g5',
|
||||
columns: this._getG5Columns(),
|
||||
toRowValues: (e) => this._g5ToRowValues(e),
|
||||
ensurePartitions: false,
|
||||
logPrefix: '[g5]',
|
||||
missingPartitionTable: null,
|
||||
});
|
||||
|
||||
return { ...result, enabled: true };
|
||||
}
|
||||
|
||||
// v2 明细表写入(向后兼容封装,仅旧表,抛出连接错误)
|
||||
async insertHeartbeatEvents(events) {
|
||||
if (!Array.isArray(events)) events = [events];
|
||||
@@ -683,34 +720,8 @@ class DatabaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 同步更新 room_status.room_status_moment 表
|
||||
// 使用 INSERT ... ON CONFLICT ... DO UPDATE 实现 upsert
|
||||
async upsertRoomStatus(events) {
|
||||
if (!Array.isArray(events)) {
|
||||
events = [events];
|
||||
}
|
||||
if (events.length === 0) return { insertedCount: 0, updatedCount: 0 };
|
||||
|
||||
// 批次内去重:按 (hotel_id, room_id, device_id) 分组,只保留 ts_ms 最大的一条
|
||||
// 原因:PostgreSQL ON CONFLICT 不允许同一语句中多次更新同一行
|
||||
const uniqueEventsMap = new Map();
|
||||
for (const e of events) {
|
||||
if (!e.hotel_id || !e.room_id || !e.device_id) continue;
|
||||
const key = `${e.hotel_id}_${e.room_id}_${e.device_id}`;
|
||||
const existing = uniqueEventsMap.get(key);
|
||||
// 如果没有记录,或者当前记录时间更新,则覆盖
|
||||
if (!existing || (BigInt(e.ts_ms || 0) > BigInt(existing.ts_ms || 0))) {
|
||||
uniqueEventsMap.set(key, e);
|
||||
}
|
||||
}
|
||||
const uniqueEvents = Array.from(uniqueEventsMap.values());
|
||||
if (uniqueEvents.length === 0) return { insertedCount: 0, updatedCount: 0 };
|
||||
|
||||
// 字段映射:心跳字段 -> room_status 字段
|
||||
// 注意:只更新心跳包里有的字段
|
||||
const columns = [
|
||||
_getRoomStatusBaseColumns() {
|
||||
return [
|
||||
'ts_ms',
|
||||
'hotel_id',
|
||||
'room_id',
|
||||
@@ -723,7 +734,7 @@ class DatabaseManager {
|
||||
'insert_card',
|
||||
'carbon_state',
|
||||
'bright_g',
|
||||
'agreement_ver', // map from version
|
||||
'agreement_ver',
|
||||
'air_address',
|
||||
'air_state',
|
||||
'air_model',
|
||||
@@ -739,77 +750,103 @@ class DatabaseManager {
|
||||
'elec_energy',
|
||||
'elec_sum_energy',
|
||||
];
|
||||
}
|
||||
|
||||
const toRowValues = (e) => [
|
||||
e.ts_ms,
|
||||
e.hotel_id,
|
||||
e.room_id,
|
||||
e.device_id,
|
||||
e.ip,
|
||||
e.pms_state, // pms_status
|
||||
e.power_state,
|
||||
e.cardless_state,
|
||||
e.service_mask,
|
||||
e.insert_card ?? null,
|
||||
e.carbon_state,
|
||||
e.bright_g === -1 ? null : (e.bright_g ?? null),
|
||||
e.version ?? null, // agreement_ver
|
||||
Array.isArray(e.air_address) ? e.air_address : null,
|
||||
Array.isArray(e.state) ? e.state : null, // air_state
|
||||
Array.isArray(e.model) ? e.model : null, // air_model
|
||||
Array.isArray(e.speed) ? e.speed : null, // air_speed
|
||||
Array.isArray(e.set_temp) ? e.set_temp : null, // air_set_temp
|
||||
Array.isArray(e.now_temp) ? e.now_temp : null, // air_now_temp
|
||||
Array.isArray(e.solenoid_valve) ? e.solenoid_valve : null, // air_solenoid_valve
|
||||
Array.isArray(e.elec_address) ? e.elec_address : null,
|
||||
Array.isArray(e.voltage) ? e.voltage : null, // elec_voltage
|
||||
Array.isArray(e.ampere) ? e.ampere : null, // elec_ampere
|
||||
Array.isArray(e.power) ? e.power : null, // elec_power
|
||||
Array.isArray(e.phase) ? e.phase : null, // elec_phase
|
||||
Array.isArray(e.energy) ? e.energy : null, // elec_energy
|
||||
Array.isArray(e.sum_energy) ? e.sum_energy : null, // elec_sum_energy
|
||||
_roomStatusToRowValues(event) {
|
||||
return [
|
||||
event.ts_ms,
|
||||
event.hotel_id,
|
||||
event.room_id,
|
||||
event.device_id,
|
||||
event.ip,
|
||||
event.pms_state,
|
||||
event.power_state,
|
||||
event.cardless_state,
|
||||
event.service_mask,
|
||||
event.insert_card ?? null,
|
||||
event.carbon_state,
|
||||
event.bright_g === -1 ? null : (event.bright_g ?? null),
|
||||
event.version === null || event.version === undefined ? null : String(event.version),
|
||||
Array.isArray(event.air_address) ? event.air_address : null,
|
||||
Array.isArray(event.state) ? event.state : null,
|
||||
Array.isArray(event.model) ? event.model : null,
|
||||
Array.isArray(event.speed) ? event.speed : null,
|
||||
Array.isArray(event.set_temp) ? event.set_temp : null,
|
||||
Array.isArray(event.now_temp) ? event.now_temp : null,
|
||||
Array.isArray(event.solenoid_valve) ? event.solenoid_valve : null,
|
||||
Array.isArray(event.elec_address) ? event.elec_address : null,
|
||||
Array.isArray(event.voltage) ? event.voltage : null,
|
||||
Array.isArray(event.ampere) ? event.ampere : null,
|
||||
Array.isArray(event.power) ? event.power : null,
|
||||
Array.isArray(event.phase) ? event.phase : null,
|
||||
Array.isArray(event.energy) ? event.energy : null,
|
||||
Array.isArray(event.sum_energy) ? event.sum_energy : null,
|
||||
];
|
||||
}
|
||||
|
||||
// 构建 UPDATE SET 子句(排除主键和 guid)
|
||||
// 使用 EXCLUDED.col 引用新值
|
||||
// 使用 IS DISTINCT FROM 避免无意义更新
|
||||
const updateColumns = columns.filter(c => !['hotel_id', 'room_id', 'device_id'].includes(c));
|
||||
const updateSet = updateColumns.map(col => `${col} = EXCLUDED.${col}`).join(', ');
|
||||
_buildRoomStatusUpsertQuery(events, target) {
|
||||
const { tableName, conflictColumns, includeGuid, tableRef } = target;
|
||||
const columns = this._getRoomStatusBaseColumns();
|
||||
const uniqueEventsMap = new Map();
|
||||
|
||||
// 构建 WHERE 子句:仅当至少一个字段发生变化,且时间戳未回退时才更新
|
||||
// 注意:room_status.room_status_moment.ts_ms 是 bigint,EXCLUDED.ts_ms 也是 bigint
|
||||
const whereConditions = updateColumns.map(col => `room_status.room_status_moment.${col} IS DISTINCT FROM EXCLUDED.${col}`).join(' OR ');
|
||||
for (const event of events) {
|
||||
const keyValues = conflictColumns.map((column) => event?.[column]);
|
||||
if (keyValues.some((value) => value === undefined || value === null || value === '')) continue;
|
||||
const key = keyValues.join('_');
|
||||
const existing = uniqueEventsMap.get(key);
|
||||
if (!existing || BigInt(event.ts_ms || 0) > BigInt(existing.ts_ms || 0)) {
|
||||
uniqueEventsMap.set(key, event);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueEvents = Array.from(uniqueEventsMap.values());
|
||||
if (uniqueEvents.length === 0) {
|
||||
return { sql: null, values: [], uniqueEvents: [] };
|
||||
}
|
||||
|
||||
const allColumns = includeGuid ? [...columns, 'guid'] : [...columns];
|
||||
const updateColumns = columns.filter((column) => !['ts_ms', ...conflictColumns].includes(column));
|
||||
const updateSet = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', ');
|
||||
const whereConditions = updateColumns.map((column) => `${tableRef}.${column} IS DISTINCT FROM EXCLUDED.${column}`).join(' OR ');
|
||||
|
||||
// 生成批量插入 SQL
|
||||
// 注意:ON CONFLICT (hotel_id, room_id, device_id) 依赖于唯一索引 idx_room_status_unique_device
|
||||
const values = [];
|
||||
const placeholders = uniqueEvents.map((e, idx) => {
|
||||
const rowVals = toRowValues(e);
|
||||
values.push(...rowVals);
|
||||
// 额外插入 gen_random_uuid() 作为 guid
|
||||
const p = rowVals.map((_, i) => `$${idx * rowVals.length + i + 1}`).join(', ');
|
||||
return `(${p}, gen_random_uuid())`;
|
||||
const placeholders = uniqueEvents.map((event, eventIndex) => {
|
||||
const rowValues = this._roomStatusToRowValues(event);
|
||||
values.push(...rowValues);
|
||||
const start = eventIndex * rowValues.length;
|
||||
const params = rowValues.map((_, valueIndex) => `$${start + valueIndex + 1}`).join(', ');
|
||||
return includeGuid ? `(${params}, gen_random_uuid())` : `(${params})`;
|
||||
}).join(', ');
|
||||
|
||||
const allCols = [...columns, 'guid'].join(', ');
|
||||
|
||||
const sql = `
|
||||
INSERT INTO room_status.room_status_moment (${allCols})
|
||||
INSERT INTO ${tableName} (${allColumns.join(', ')})
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT (hotel_id, room_id, device_id)
|
||||
ON CONFLICT (${conflictColumns.join(', ')})
|
||||
DO UPDATE SET
|
||||
${updateSet}
|
||||
WHERE
|
||||
room_status.room_status_moment.ts_ms <= EXCLUDED.ts_ms
|
||||
WHERE
|
||||
${tableRef}.ts_ms <= EXCLUDED.ts_ms
|
||||
AND (${whereConditions})
|
||||
`;
|
||||
|
||||
return { sql, values, uniqueEvents };
|
||||
}
|
||||
|
||||
async _upsertRoomStatusToTarget(events, target) {
|
||||
if (!Array.isArray(events)) {
|
||||
events = [events];
|
||||
}
|
||||
if (events.length === 0) return { rowCount: 0 };
|
||||
|
||||
const { sql, values, uniqueEvents } = this._buildRoomStatusUpsertQuery(events, target);
|
||||
if (!sql || uniqueEvents.length === 0) return { rowCount: 0 };
|
||||
|
||||
try {
|
||||
const res = await this.pool.query(sql, values);
|
||||
return { rowCount: res.rowCount }; // 包括插入和更新的行数
|
||||
return { rowCount: res.rowCount };
|
||||
} catch (error) {
|
||||
if (this.isRoomStatusMissingPartitionError(error)) {
|
||||
const hotelIds = [...new Set(uniqueEvents.map(e => e.hotel_id).filter(id => id != null))];
|
||||
if (target.autoCreatePartitions && this.isRoomStatusMissingPartitionError(error)) {
|
||||
const hotelIds = [...new Set(uniqueEvents.map((event) => event.hotel_id).filter((id) => id != null))];
|
||||
if (hotelIds.length > 0) {
|
||||
console.log(`[db] 检测到 room_status 分区缺失,尝试自动创建分区,hotelIds: ${hotelIds.join(', ')}`);
|
||||
await this.ensureRoomStatusPartitions(hotelIds);
|
||||
@@ -817,18 +854,43 @@ class DatabaseManager {
|
||||
const res = await this.pool.query(sql, values);
|
||||
return { rowCount: res.rowCount };
|
||||
} catch (retryError) {
|
||||
console.warn('[db] upsertRoomStatus retry failed:', retryError.message);
|
||||
console.warn(`[db] ${target.logPrefix} retry failed:`, retryError.message);
|
||||
return { error: retryError };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不抛出错误,只记录日志,避免影响主流程(Heartbeat History 写入已成功)
|
||||
console.warn('[db] upsertRoomStatus failed:', error.message);
|
||||
console.warn(`[db] ${target.logPrefix} failed:`, error.message);
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 同步更新 room_status.room_status_moment 表
|
||||
// 使用 INSERT ... ON CONFLICT ... DO UPDATE 实现 upsert
|
||||
async upsertRoomStatus(events) {
|
||||
return this._upsertRoomStatusToTarget(events, {
|
||||
tableName: this.config.roomStatusTable ?? 'room_status.room_status_moment',
|
||||
conflictColumns: ['hotel_id', 'room_id', 'device_id'],
|
||||
includeGuid: true,
|
||||
autoCreatePartitions: true,
|
||||
tableRef: this.config.roomStatusTable ?? 'room_status.room_status_moment',
|
||||
logPrefix: 'upsertRoomStatus',
|
||||
});
|
||||
}
|
||||
|
||||
async upsertRoomStatusG5(events) {
|
||||
return this._upsertRoomStatusToTarget(events, {
|
||||
tableName: this.config.roomStatusTable ?? 'room_status.room_status_moment_g5',
|
||||
conflictColumns: ['hotel_id', 'room_id'],
|
||||
includeGuid: false,
|
||||
autoCreatePartitions: false,
|
||||
tableRef: this.config.roomStatusTable ?? 'room_status.room_status_moment_g5',
|
||||
logPrefix: 'upsertRoomStatusG5',
|
||||
});
|
||||
}
|
||||
|
||||
isRoomStatusMissingPartitionError(error) {
|
||||
const msg = String(error?.message ?? '');
|
||||
// 错误码 23514 (check_violation) 通常在插入分区表且无对应分区时触发
|
||||
|
||||
Reference in New Issue
Block a user