feat(processor): 同步心跳数据到 room_status 表

- 在 HeartbeatProcessor 中新增异步同步逻辑,在历史表写入成功后尝试更新 room_status 表
- 实现 DatabaseManager.upsertRoomStatus 方法,支持批量更新和自动分区创建
- 添加批次内去重逻辑,避免 PostgreSQL ON CONFLICT 冲突
- 新增相关文档:同步方案、测试报告和提案说明
This commit is contained in:
2026-02-06 15:15:03 +08:00
parent b72cdde8bf
commit e44cf10a82
5 changed files with 372 additions and 0 deletions

View File

@@ -609,6 +609,183 @@ 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 = [
'ts_ms',
'hotel_id',
'room_id',
'device_id',
'ip',
'pms_status',
'power_state',
'cardless_state',
'service_mask',
'insert_card',
'carbon_state',
'bright_g',
'agreement_ver', // map from version
'air_address',
'air_state',
'air_model',
'air_speed',
'air_set_temp',
'air_now_temp',
'air_solenoid_valve',
'elec_address',
'elec_voltage',
'elec_ampere',
'elec_power',
'elec_phase',
'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
];
// 构建 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(', ');
// 构建 WHERE 子句:仅当至少一个字段发生变化,且时间戳未回退时才更新
// 注意room_status.room_status_moment.ts_ms 是 bigintEXCLUDED.ts_ms 也是 bigint
const whereConditions = updateColumns.map(col => `room_status.room_status_moment.${col} IS DISTINCT FROM EXCLUDED.${col}`).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())`;
}).join(', ');
const allCols = [...columns, 'guid'].join(', ');
const sql = `
INSERT INTO room_status.room_status_moment (${allCols})
VALUES ${placeholders}
ON CONFLICT (hotel_id, room_id, device_id)
DO UPDATE SET
${updateSet}
WHERE
room_status.room_status_moment.ts_ms <= EXCLUDED.ts_ms
AND (${whereConditions})
`;
try {
const res = await this.pool.query(sql, values);
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 (hotelIds.length > 0) {
console.log(`[db] 检测到 room_status 分区缺失尝试自动创建分区hotelIds: ${hotelIds.join(', ')}`);
await this.ensureRoomStatusPartitions(hotelIds);
try {
const res = await this.pool.query(sql, values);
return { rowCount: res.rowCount };
} catch (retryError) {
console.warn('[db] upsertRoomStatus retry failed:', retryError.message);
return { error: retryError };
}
}
}
// 不抛出错误只记录日志避免影响主流程Heartbeat History 写入已成功)
console.warn('[db] upsertRoomStatus failed:', error.message);
return { error };
}
}
isRoomStatusMissingPartitionError(error) {
const msg = String(error?.message ?? '');
// 错误码 23514 (check_violation) 通常在插入分区表且无对应分区时触发
// 或者直接匹配错误信息 "no partition of relation"
return msg.includes('no partition of relation') && msg.includes('room_status_moment');
}
async ensureRoomStatusPartitions(hotelIds) {
for (const hotelId of hotelIds) {
await this.createRoomStatusPartition(hotelId);
}
}
async createRoomStatusPartition(hotelId) {
// 简单的整数合法性检查
if (!Number.isInteger(Number(hotelId))) return;
const tableName = `room_status_moment_h${hotelId}`;
const sql = `
CREATE TABLE IF NOT EXISTS room_status.${tableName}
PARTITION OF room_status.room_status_moment
FOR VALUES IN (${hotelId});
`;
try {
await this.pool.query(sql);
console.log(`[db] 成功创建 room_status 分区: ${tableName}`);
} catch (err) {
// 并发创建时可能会报错,如果已存在则忽略
if (String(err?.message).includes('already exists')) return;
console.error(`[db] 创建 room_status 分区失败 ${tableName}:`, err);
}
}
async getLatestHeartbeat(componentId) {
try {
const query = {

View File

@@ -214,6 +214,22 @@ class HeartbeatProcessor {
const result = await this.databaseManager.insertHeartbeatEvents(batchData);
insertedCount = Number(result?.insertedCount ?? result ?? 0);
failedRecords = Array.isArray(result?.failedRecords) ? result.failedRecords : [];
// 同步到 room_status 表 (Best Effort)
// 只有当历史表写入成功insertedCount > 0才尝试同步
// 过滤掉写入失败的记录(如果有)
if (insertedCount > 0) {
const successData = failedRecords.length > 0
? batchData.filter(d => !failedRecords.some(f => f.record === d))
: batchData;
if (successData.length > 0) {
this.databaseManager.upsertRoomStatus(successData).catch(err => {
console.warn('异步同步 room_status 失败 (忽略):', err);
});
}
}
} else {
const result = await this.databaseManager.insertHeartbeatData(batchData);
insertedCount = Number(result?.insertedCount ?? result ?? 0);