feat: 实现房间状态同步功能

- 新增 RoomStatusManager 类,负责管理房间状态快照表的数据库连接池及批量 Upsert 操作。
- 新增 StatusBatchProcessor 类,负责收集和合并房间状态更新,并定期将其写入数据库。
- 新增状态提取器 statusExtractor.js,从 Kafka 消息中提取并构建房间状态更新对象。
- 修改 index.js,初始化 RoomStatusManager 和 StatusBatchProcessor,并在 Kafka 消息处理流程中并行推送状态更新。
- 修改 processor/index.js,更新 processKafkaMessage 函数以支持状态提取和处理。
- 更新 kafkaPayload.js,修正 control_list 的提取逻辑,兼容 Kafka 实际传输中的 loop 字段。
- 添加状态批处理器和状态提取器的单元测试,确保功能的正确性。
- 更新文档 plan-room-status-sync.md,详细描述房间状态同步方案及字段映射。
This commit is contained in:
2026-03-02 11:47:52 +08:00
parent e76d04f526
commit cf61e8dac6
16 changed files with 1154776 additions and 28 deletions

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StatusBatchProcessor } from '../src/db/statusBatchProcessor.js';
// Create a mock RoomStatusManager
const createMockManager = () => ({
upsertBatch: vi.fn().mockResolvedValue(undefined),
testConnection: vi.fn().mockResolvedValue(true),
close: vi.fn().mockResolvedValue(undefined)
});
describe('StatusBatchProcessor', () => {
let mockManager;
let processor;
beforeEach(() => {
mockManager = createMockManager();
processor = new StatusBatchProcessor(mockManager, {
flushInterval: 50000, // Long interval so we control flush manually
maxBufferSize: 100
});
});
const makeUpdate = (overrides = {}) => ({
hotel_id: 1001,
room_id: '8001',
device_id: 'dev_001',
ts_ms: 1700000000000,
sys_lock_status: null,
dev_loops: null,
faulty_device_count: null,
...overrides
});
it('should accept and buffer a status update', () => {
processor.add(makeUpdate());
expect(processor.buffer.size).toBe(1);
});
it('should ignore null/undefined updates', () => {
processor.add(null);
processor.add(undefined);
expect(processor.buffer.size).toBe(0);
});
it('should merge dev_loops for same device (different keys accumulate)', async () => {
processor.add(makeUpdate({
dev_loops: { '001010001': 100 }
}));
processor.add(makeUpdate({
dev_loops: { '001011002': 50 }
}));
expect(processor.buffer.size).toBe(1);
await processor.flush();
expect(mockManager.upsertBatch).toHaveBeenCalledTimes(1);
const rows = mockManager.upsertBatch.mock.calls[0][0];
expect(rows).toHaveLength(1);
expect(rows[0].dev_loops).toEqual({
'001010001': 100,
'001011002': 50
});
});
it('should overwrite dev_loops for same key (newer wins)', async () => {
processor.add(makeUpdate({
dev_loops: { '001010001': 100 }
}));
processor.add(makeUpdate({
dev_loops: { '001010001': 200 }
}));
await processor.flush();
const rows = mockManager.upsertBatch.mock.calls[0][0];
expect(rows[0].dev_loops['001010001']).toBe(200);
});
it('should take latest ts_ms across merges', () => {
processor.add(makeUpdate({ ts_ms: 100 }));
processor.add(makeUpdate({ ts_ms: 300 }));
processor.add(makeUpdate({ ts_ms: 200 }));
const entry = [...processor.buffer.values()][0];
expect(entry.ts_ms).toBe(300);
});
it('should replace faulty_device_count with newer value', () => {
processor.add(makeUpdate({
faulty_device_count: [{ dev_type: 1, error_type: 1, error_data: 1 }]
}));
processor.add(makeUpdate({
faulty_device_count: [{ dev_type: 2, error_type: 2, error_data: 0 }]
}));
const entry = [...processor.buffer.values()][0];
expect(entry.faulty_device_count).toEqual([{ dev_type: 2, error_type: 2, error_data: 0 }]);
});
it('should keep different devices separate in the buffer', () => {
processor.add(makeUpdate({ device_id: 'dev_001' }));
processor.add(makeUpdate({ device_id: 'dev_002' }));
expect(processor.buffer.size).toBe(2);
});
it('should clear buffer after flush', async () => {
processor.add(makeUpdate());
expect(processor.buffer.size).toBe(1);
await processor.flush();
expect(processor.buffer.size).toBe(0);
expect(mockManager.upsertBatch).toHaveBeenCalledTimes(1);
});
it('should NOT throw when upsertBatch fails', async () => {
mockManager.upsertBatch.mockRejectedValue(new Error('DB down'));
processor.add(makeUpdate());
// flush should not throw
await expect(processor.flush()).resolves.not.toThrow();
// Buffer should still be cleared even on error
expect(processor.buffer.size).toBe(0);
});
it('should do nothing when flushing empty buffer', async () => {
await processor.flush();
expect(mockManager.upsertBatch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest';
import { extractStatusUpdate } from '../src/processor/statusExtractor.js';
describe('StatusExtractor', () => {
const base = {
ts_ms: 1700000000000,
hotel_id: 1001,
room_id: '8001',
device_id: 'dev_001',
direction: '上报',
cmd_word: '0x36',
frame_id: 1,
udp_raw: 'test',
sys_lock_status: 0,
device_list: [],
fault_list: [],
control_list: []
};
it('should return null when payload is null/undefined', () => {
expect(extractStatusUpdate(null)).toBeNull();
expect(extractStatusUpdate(undefined)).toBeNull();
});
it('should return null when nothing to update (empty lists, no sys_lock)', () => {
const payload = {
...base,
sys_lock_status: undefined,
device_list: [],
fault_list: [],
control_list: []
};
expect(extractStatusUpdate(payload)).toBeNull();
});
it('should return update when sys_lock_status is present even with empty lists', () => {
const result = extractStatusUpdate({
...base,
sys_lock_status: 1
});
expect(result).not.toBeNull();
expect(result.sys_lock_status).toBe(1);
expect(result.hotel_id).toBe(1001);
expect(result.room_id).toBe('8001');
expect(result.device_id).toBe('dev_001');
});
it('should build dev_loops from device_list with 9-digit padded keys', () => {
const result = extractStatusUpdate({
...base,
device_list: [
{ dev_type: 1, dev_addr: 10, dev_loop: 1, dev_data: 100 },
{ dev_type: 1, dev_addr: 11, dev_loop: 2, dev_data: 0 }
]
});
expect(result).not.toBeNull();
expect(result.dev_loops).toEqual({
'001010001': 100,
'001011002': 0
});
});
it('should build dev_loops from control_list with type_l/type_h values', () => {
const result = extractStatusUpdate({
...base,
control_list: [
{ dev_type: 1, dev_addr: 10, dev_loop: 1, type_l: 0, type_h: 1 }
]
});
expect(result).not.toBeNull();
expect(result.dev_loops).toEqual({
'001010001': { type_l: 0, type_h: 1 }
});
});
it('should merge device_list and control_list into dev_loops', () => {
const result = extractStatusUpdate({
...base,
device_list: [
{ dev_type: 1, dev_addr: 10, dev_loop: 1, dev_data: 100 }
],
control_list: [
{ dev_type: 2, dev_addr: 5, dev_loop: 3, type_l: 1, type_h: 2 }
]
});
expect(result.dev_loops).toEqual({
'001010001': 100,
'002005003': { type_l: 1, type_h: 2 }
});
});
it('should build faulty_device_count from fault_list', () => {
const result = extractStatusUpdate({
...base,
fault_list: [
{ dev_type: 1, dev_addr: 10, dev_loop: 1, error_type: 2, error_data: 5 }
]
});
expect(result).not.toBeNull();
expect(result.faulty_device_count).toEqual([
{ dev_type: 1, dev_addr: 10, dev_loop: 1, error_type: 2, error_data: 5 }
]);
});
it('should return null when identity fields are missing', () => {
expect(extractStatusUpdate({ ...base, hotel_id: null })).toBeNull();
expect(extractStatusUpdate({ ...base, room_id: '' })).toBeNull();
expect(extractStatusUpdate({ ...base, device_id: '' })).toBeNull();
expect(extractStatusUpdate({ ...base, ts_ms: null })).toBeNull();
});
it('should handle large dev_type/addr/loop values with proper zero-padding', () => {
const result = extractStatusUpdate({
...base,
device_list: [
{ dev_type: 241, dev_addr: 255, dev_loop: 999, dev_data: 42 }
]
});
expect(result.dev_loops).toEqual({
'241255999': 42
});
});
});