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:
134
bls-rcu-action-backend/tests/status_batch_processor.test.js
Normal file
134
bls-rcu-action-backend/tests/status_batch_processor.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
128
bls-rcu-action-backend/tests/status_extractor.test.js
Normal file
128
bls-rcu-action-backend/tests/status_extractor.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user