feat: 更新心跳管理和解析模块,支持 IP 字段;调整批量插入逻辑以处理新字段;增加相应测试用例
This commit is contained in:
@@ -5,7 +5,7 @@ const { Pool } = pg;
|
||||
|
||||
const SMALLINT_MIN = -32768;
|
||||
const SMALLINT_MAX = 32767;
|
||||
const PARAMS_PER_ROW = 4;
|
||||
const PARAMS_PER_ROW = 5;
|
||||
const PG_MAX_BIND_PARAMS = 65535;
|
||||
const MAX_ROWS_PER_STATEMENT = Math.floor(PG_MAX_BIND_PARAMS / PARAMS_PER_ROW);
|
||||
|
||||
@@ -48,7 +48,7 @@ export class HeartbeatDbManager {
|
||||
/**
|
||||
* Batch upsert heartbeat rows.
|
||||
* 为避免 PostgreSQL bind 参数上限,按 upsertChunkSize 分片执行。
|
||||
* @param {Array<{ts_ms: number, hotel_id: string, room_id: string, device_id: string}>} rows
|
||||
* @param {Array<{ts_ms: number, hotel_id: string, room_id: string, device_id: string, ip?: string}>} rows
|
||||
*/
|
||||
async upsertBatch(rows) {
|
||||
if (!rows || rows.length === 0) return;
|
||||
@@ -66,14 +66,20 @@ export class HeartbeatDbManager {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const offset = i * PARAMS_PER_ROW;
|
||||
values.push(normalizeHotelId(row.hotel_id), row.room_id, row.device_id, row.ts_ms);
|
||||
values.push(
|
||||
normalizeHotelId(row.hotel_id),
|
||||
row.room_id,
|
||||
row.device_id,
|
||||
row.ts_ms,
|
||||
typeof row.ip === 'string' && row.ip.trim() ? row.ip.trim() : null
|
||||
);
|
||||
placeholders.push(
|
||||
`($${offset + 1}::smallint, $${offset + 2}, $${offset + 3}, $${offset + 4}, 1)`
|
||||
`($${offset + 1}::smallint, $${offset + 2}, $${offset + 3}, $${offset + 4}, $${offset + 5}, 1)`
|
||||
);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${this.fullTableName} (hotel_id, room_id, device_id, ts_ms, online_status)
|
||||
INSERT INTO ${this.fullTableName} (hotel_id, room_id, device_id, ts_ms, ip, online_status)
|
||||
VALUES ${placeholders.join(', ')}
|
||||
ON CONFLICT (hotel_id, room_id)
|
||||
DO UPDATE SET
|
||||
@@ -82,6 +88,7 @@ export class HeartbeatDbManager {
|
||||
WHEN EXCLUDED.ts_ms >= ${this.fullTableName}.ts_ms THEN EXCLUDED.device_id
|
||||
ELSE ${this.fullTableName}.device_id
|
||||
END,
|
||||
ip = COALESCE(EXCLUDED.ip, ${this.fullTableName}.ip),
|
||||
online_status = 1
|
||||
`;
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const isDigitsOnly = (value) => {
|
||||
/**
|
||||
* 解析 Kafka 消息并提取心跳字段
|
||||
* @param {string} raw - JSON string from Kafka
|
||||
* @returns {{ ts_ms: number, hotel_id: string, room_id: string, device_id: string } | null}
|
||||
* @returns {{ ts_ms: number, hotel_id: string, room_id: string, device_id: string, ip?: string } | null}
|
||||
*/
|
||||
export const parseHeartbeat = (raw) => {
|
||||
const parsed = JSON.parse(raw);
|
||||
@@ -39,7 +39,7 @@ export const parseHeartbeat = (raw) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { ts_ms: tsMs, hotel_id: hotelId, room_id: roomId, device_id: deviceId } = parsed;
|
||||
const { ts_ms: tsMs, hotel_id: hotelId, room_id: roomId, device_id: deviceId, ip } = parsed;
|
||||
|
||||
if (!Number.isFinite(tsMs) || !isDigitsOnly(hotelId)) {
|
||||
return null;
|
||||
@@ -49,10 +49,16 @@ export const parseHeartbeat = (raw) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
const heartbeat = {
|
||||
ts_ms: tsMs,
|
||||
hotel_id: hotelId,
|
||||
room_id: roomId,
|
||||
device_id: deviceId
|
||||
};
|
||||
|
||||
if (typeof ip === 'string' && ip.trim()) {
|
||||
heartbeat.ip = ip.trim();
|
||||
}
|
||||
|
||||
return heartbeat;
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('HeartbeatDbManager', () => {
|
||||
});
|
||||
|
||||
await manager.upsertBatch([
|
||||
{ hotel_id: '1', room_id: '101', device_id: 'dev-a', ts_ms: 1000 }
|
||||
{ hotel_id: '1', room_id: '101', device_id: 'dev-a', ts_ms: 1000, ip: '10.0.0.1' }
|
||||
]);
|
||||
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
@@ -44,8 +44,9 @@ describe('HeartbeatDbManager', () => {
|
||||
expect(sql).toContain('online_status = 1');
|
||||
expect(sql).toContain('ts_ms = GREATEST(EXCLUDED.ts_ms, room_status.room_status_moment_g5.ts_ms)');
|
||||
expect(sql).toContain('WHEN EXCLUDED.ts_ms >= room_status.room_status_moment_g5.ts_ms THEN EXCLUDED.device_id');
|
||||
expect(sql).toContain('ip = COALESCE(EXCLUDED.ip, room_status.room_status_moment_g5.ip)');
|
||||
expect(sql).not.toContain('WHERE EXCLUDED.ts_ms >= room_status.room_status_moment_g5.ts_ms');
|
||||
expect(values).toEqual([1, '101', 'dev-a', 1000]);
|
||||
expect(values).toEqual([1, '101', 'dev-a', 1000, '10.0.0.1']);
|
||||
});
|
||||
|
||||
it('should write hotel_id as 0 when it is outside the smallint range', async () => {
|
||||
@@ -68,7 +69,7 @@ describe('HeartbeatDbManager', () => {
|
||||
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
const [, values] = queryMock.mock.calls[0];
|
||||
expect(values).toEqual([0, '101', 'dev-a', 1000]);
|
||||
expect(values).toEqual([0, '101', 'dev-a', 1000, null]);
|
||||
});
|
||||
|
||||
it('should split large batches into multiple upsert queries', async () => {
|
||||
@@ -87,16 +88,16 @@ describe('HeartbeatDbManager', () => {
|
||||
});
|
||||
|
||||
await manager.upsertBatch([
|
||||
{ hotel_id: '1', room_id: '101', device_id: 'dev-a', ts_ms: 1000 },
|
||||
{ hotel_id: '1', room_id: '102', device_id: 'dev-b', ts_ms: 1001 },
|
||||
{ hotel_id: '1', room_id: '103', device_id: 'dev-c', ts_ms: 1002 },
|
||||
{ hotel_id: '1', room_id: '104', device_id: 'dev-d', ts_ms: 1003 },
|
||||
{ hotel_id: '1', room_id: '105', device_id: 'dev-e', ts_ms: 1004 }
|
||||
{ hotel_id: '1', room_id: '101', device_id: 'dev-a', ts_ms: 1000, ip: '10.0.0.1' },
|
||||
{ hotel_id: '1', room_id: '102', device_id: 'dev-b', ts_ms: 1001, ip: '10.0.0.2' },
|
||||
{ hotel_id: '1', room_id: '103', device_id: 'dev-c', ts_ms: 1002, ip: '10.0.0.3' },
|
||||
{ hotel_id: '1', room_id: '104', device_id: 'dev-d', ts_ms: 1003, ip: '10.0.0.4' },
|
||||
{ hotel_id: '1', room_id: '105', device_id: 'dev-e', ts_ms: 1004, ip: '10.0.0.5' }
|
||||
]);
|
||||
|
||||
expect(queryMock).toHaveBeenCalledTimes(3);
|
||||
expect(queryMock.mock.calls[0][1]).toEqual([1, '101', 'dev-a', 1000, 1, '102', 'dev-b', 1001]);
|
||||
expect(queryMock.mock.calls[1][1]).toEqual([1, '103', 'dev-c', 1002, 1, '104', 'dev-d', 1003]);
|
||||
expect(queryMock.mock.calls[2][1]).toEqual([1, '105', 'dev-e', 1004]);
|
||||
expect(queryMock.mock.calls[0][1]).toEqual([1, '101', 'dev-a', 1000, '10.0.0.1', 1, '102', 'dev-b', 1001, '10.0.0.2']);
|
||||
expect(queryMock.mock.calls[1][1]).toEqual([1, '103', 'dev-c', 1002, '10.0.0.3', 1, '104', 'dev-d', 1003, '10.0.0.4']);
|
||||
expect(queryMock.mock.calls[2][1]).toEqual([1, '105', 'dev-e', 1004, '10.0.0.5']);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,24 @@ import { parseHeartbeat } from '../src/processor/heartbeatParser.js';
|
||||
|
||||
describe('parseHeartbeat', () => {
|
||||
it('should parse valid heartbeat message', () => {
|
||||
const raw = JSON.stringify({
|
||||
ts_ms: 1710000000000,
|
||||
hotel_id: '2045',
|
||||
room_id: '101',
|
||||
device_id: 'abc123',
|
||||
ip: '10.1.2.3'
|
||||
});
|
||||
const result = parseHeartbeat(raw);
|
||||
expect(result).toEqual({
|
||||
ts_ms: 1710000000000,
|
||||
hotel_id: '2045',
|
||||
room_id: '101',
|
||||
device_id: 'abc123',
|
||||
ip: '10.1.2.3'
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow heartbeat without ip field', () => {
|
||||
const raw = JSON.stringify({
|
||||
ts_ms: 1710000000000,
|
||||
hotel_id: '2045',
|
||||
|
||||
Reference in New Issue
Block a user