- 新增 HeartbeatBuffer 类,用于收集和去重 Kafka 心跳消息,并定期将数据刷新到数据库。 - 新增 HeartbeatDbManager 类,负责与 PostgreSQL 数据库的交互,支持批量 upsert 操作。 - 新增配置文件 config.js,支持从环境变量加载配置。 - 新增 Kafka 消费者模块,支持从 Kafka 中消费心跳消息。 - 新增 Redis 集成模块,支持将日志和心跳信息推送到 Redis。 - 新增心跳消息解析器,负责解析 Kafka 消息并提取心跳字段。 - 新增日志记录工具,支持不同级别的日志输出。 - 新增指标收集器,跟踪 Kafka 消息处理和数据库操作的指标。 - 新增单元测试,覆盖 HeartbeatBuffer 和 HeartbeatDbManager 的主要功能。 - 新增数据库表结构 SQL 文件,定义 room_status_moment_g5 表的结构。 - 配置 Vite 构建工具,支持 Node.js 环境的构建。
12 KiB
12 KiB
数据验证规范 (Validation Specification)
1. 消息字段定义
1.1 Kafka 消息结构
来自 Kafka Topic: blwlog4Nodejs-oldrcu-heartbeat-topic
{
// 必需字段 (4 个)
ts_ms: number, // 时间戳(毫秒),心跳发送时间
hotel_id: string, // 酒店编号,仅数字字符
room_id: string, // 房间编号,非空,允许中英文
device_id: string, // 设备编号,非空
// 可选字段
current_time: string // ISO 格式时间戳(用于调试)
}
1.2 实际 Kafka 消息示例
{
"current_time": "2026-03-11T10:30:45.123Z",
"ts_ms": 1234567890,
"device_id": "DEV-GATEWAY-02",
"hotel_id": "1309",
"room_id": "6010"
}
{
"current_time": "2026-03-11T10:30:46.456Z",
"ts_ms": 1234567891,
"device_id": "RCU-UNIT-A5",
"hotel_id": "2045",
"room_id": "大会议室"
}
{
"current_time": "2026-03-11T10:30:47.789Z",
"ts_ms": 1234567892,
"device_id": "DEV-SENSOR-03",
"hotel_id": "1071",
"room_id": "A608"
}
2. 字段验证规则
2.1 ts_ms (时间戳)
类型: number
必需: 是
验证函数: Number.isFinite()
允许值范围: 0 ~ 2^53-1 (JavaScript 安全整数)
function validateTs(ts_ms) {
// ✓ 有效
1234567890 // 秒级时间戳(建议)
1234567890000 // 毫秒级时间戳
// ✗ 无效
null
undefined
"1234567890" // 字符串(必须是数字)
NaN
Infinity
-1000 // 负数(通常无效)
1.5 // 浮点数(接受,会截断)
}
if (!Number.isFinite(ts_ms)) {
throw new Error('ts_ms must be a finite number');
}
设计决策: 使用 Number.isFinite() 而非 typeof ts_ms === 'number',可同时拒绝 NaN、Infinity。
2.2 hotel_id (酒店编号)
类型: string
必需: 是
验证函数: isDigitsOnly()
允许字符: 0-9 (纯数字)
长度: 1-10 字符建议,无硬限制
function isDigitsOnly(value) {
if (typeof value !== 'string') return false;
if (value.length === 0) return false;
// 检查每个字符都是 0-9 的数字
return /^\d+$/.test(value);
}
function validateHotelId(hotel_id) {
// ✓ 有效
"2045"
"1309"
"1071"
"123"
// ✗ 无效
2045 // 数字(必须是字符串)
"2045a" // 含有字母
"20 45" // 含有空格
"2045中" // 含有中文
"" // 空字符串
null
undefined
}
if (!isDigitsOnly(hotel_id)) {
throw new Error('hotel_id must be a non-empty string with only digits');
}
设计决策: 尽管数据库表使用 smallint 存储 hotel_id,但 Kafka 消息中以字符串形式传输。原因:Kafka 数据格式灵活,强制使用字符串避免精度丢失。SQL 层使用 ::smallint 显式转换。
2.3 room_id (房间编号)
类型: string
必需: 是
验证函数: isNonBlankString()
允许字符: 任意非空白字符 (包括中文、英文、数字、特殊符号)
长度: 1-255 字符建议
function isNonBlankString(value) {
if (typeof value !== 'string') return false;
// 去除首尾空白,检查是否还有内容
return value.trim().length > 0;
}
function validateRoomId(room_id) {
// ✓ 有效
"6010" // 纯数字
"A608" // 英文 + 数字
"大会议室" // 纯中文
"1325卧室" // 中文 + 数字
"Deluxe Suite #2" // 英文 + 特殊符号
" Room123 " // 前后有空格(trim 后仍有内容)
// ✗ 无效
"" // 空字符串
" " // 全是空白
"\t\n" // 制表符 + 换行
null
undefined
123 // 数字(必须是字符串)
}
if (!isNonBlankString(room_id)) {
throw new Error('room_id must be a non-empty string (non-whitespace)');
}
2.4 device_id (设备编号)
类型: string
必需: 是
验证函数: isNonBlankString()
允许字符: 任意非空白字符
长度: 1-255 字符建议
function validateDeviceId(device_id) {
// ✓ 有效 (同 room_id 规则)
"DEV-GATEWAY-02"
"RCU-UNIT-A5"
"DEV-SENSOR-03"
// ✗ 无效
""
" "
null
undefined
}
if (!isNonBlankString(device_id)) {
throw new Error('device_id must be a non-empty string (non-whitespace)');
}
3. Parser 实现
3.1 主函数 (parseHeartbeat)
位置: src/processor/heartbeatParser.js
export function parseHeartbeat(rawMessage) {
// Step 1: JSON 解析
let json;
try {
json = JSON.parse(rawMessage);
} catch (err) {
// 无效 JSON → 返回 null(被视为垃圾消息)
return null;
}
// Step 2: 验证必需字段和类型
const { ts_ms, hotel_id, room_id, device_id } = json;
// ts_ms: 必需,有限数字
if (!Number.isFinite(ts_ms)) {
return null;
}
// hotel_id: 必需,数字字符串
if (!isDigitsOnly(hotel_id)) {
return null;
}
// room_id: 必需,非空字符串
if (!isNonBlankString(room_id)) {
return null;
}
// device_id: 必需,非空字符串
if (!isNonBlankString(device_id)) {
return null;
}
// Step 3: 返回规范化对象
return {
ts_ms,
hotel_id: hotel_id.trim(), // 可选:trim hotel_id
room_id: room_id.trim(),
device_id: device_id.trim()
};
}
3.2 辅助验证函数
function isDigitsOnly(value) {
if (typeof value !== 'string' || value.length === 0) {
return false;
}
return /^\d+$/.test(value);
}
function isNonBlankString(value) {
if (typeof value !== 'string') {
return false;
}
return value.trim().length > 0;
}
4. 验证失败处理
4.1 错误分类
| 错误类型 | 示例 | 处理 | 偏移量提交 |
|---|---|---|---|
| 无效 JSON | "{bad" |
计数 + 日志 | ✓ 是 |
| 缺失字段 | {ts_ms: null, ...} |
计数 + 日志 | ✓ 是 |
| 类型错误 | {ts_ms: "123", ...} |
计数 + 日志 | ✓ 是 |
| 空值 | {hotel_id: "", ...} |
计数 + 日志 | ✓ 是 |
| 无效格式 | {hotel_id: "20 45", ...} |
计数 + 日志 | ✓ 是 |
处理原则: 所有验证失败的消息被忽略并正常提交偏移量,不重试。
4.2 统计与监控
// 在 Consumer 中跟踪
const stats = {
totalMessages: 0,
validMessages: 0,
invalidMessages: 0
};
const parsed = parseHeartbeat(messageValue);
stats.totalMessages++;
if (parsed === null) {
stats.invalidMessages++;
// 可选:log specific error reason
logger.warn(`Invalid heartbeat: ${messageValue}`);
} else {
stats.validMessages++;
buffer.add(parsed);
}
// 周期性报告(通过 Redis 或控制台)
console.log(`Valid: ${stats.validMessages}/${stats.totalMessages} (${
(stats.validMessages / stats.totalMessages * 100).toFixed(2)
}%)`);
5. 测试用例
5.1 Parser 单元测试
位置: tests/heartbeat_parser.test.js
describe('HeartbeatParser', () => {
// 有效消息
test('should parse valid heartbeat', () => {
const raw = JSON.stringify({
ts_ms: 1234567890,
hotel_id: '2045',
room_id: '6010',
device_id: 'DEV001'
});
const result = parseHeartbeat(raw);
expect(result).not.toBeNull();
expect(result.ts_ms).toBe(1234567890);
});
// 无效 JSON
test('should reject invalid JSON', () => {
expect(parseHeartbeat('{invalid')).toBeNull();
});
// 缺失字段
test('should reject missing ts_ms', () => {
const raw = JSON.stringify({
hotel_id: '2045',
room_id: '6010',
device_id: 'DEV001'
});
expect(parseHeartbeat(raw)).toBeNull();
});
// 类型错误:ts_ms 为字符串
test('should reject string ts_ms', () => {
const raw = JSON.stringify({
ts_ms: '1234567890',
hotel_id: '2045',
room_id: '6010',
device_id: 'DEV001'
});
expect(parseHeartbeat(raw)).toBeNull();
});
// 空值:hotel_id 为空字符串
test('should reject empty hotel_id', () => {
const raw = JSON.stringify({
ts_ms: 1234567890,
hotel_id: '',
room_id: '6010',
device_id: 'DEV001'
});
expect(parseHeartbeat(raw)).toBeNull();
});
// 空值:hotel_id 为空格
test('should reject blank string hotel_id', () => {
const raw = JSON.stringify({
ts_ms: 1234567890,
hotel_id: ' ',
room_id: '6010',
device_id: 'DEV001'
});
expect(parseHeartbeat(raw)).toBeNull();
});
// 非数字 hotel_id
test('should reject non-digit hotel_id', () => {
const raw = JSON.stringify({
ts_ms: 1234567890,
hotel_id: '2045a',
room_id: '6010',
device_id: 'DEV001'
});
expect(parseHeartbeat(raw)).toBeNull();
});
// 有效的中文 room_id
test('should accept Chinese characters in room_id', () => {
const raw = JSON.stringify({
ts_ms: 1234567890,
hotel_id: '2045',
room_id: '大会议室',
device_id: 'DEV001'
});
const result = parseHeartbeat(raw);
expect(result).not.toBeNull();
expect(result.room_id).toBe('大会议室');
});
});
5.2 测试覆盖矩阵
| 场景 | 输入 | 预期输出 | 测试用例 |
|---|---|---|---|
| 有效消息 | 4 个正确字段 | parsed object ✓ | 1 |
| 无效 JSON | {bad |
null ✓ | 1 |
| 缺失 ts_ms | 只有 3 个字段 | null ✓ | 1 |
| ts_ms 为字符串 | "1234567890" |
null ✓ | 1 |
| ts_ms 为 NaN | NaN |
null ✓ | (包含在字符串案例) |
| 空 hotel_id | "" |
null ✓ | 1 |
| 空格 hotel_id | " " |
null ✓ | 1 |
| 非数字 hotel_id | "2045a" |
null ✓ | 1 |
| 中文 room_id | "大会议室" |
parsed object ✓ | 1 |
总计: 8 个测试用例,全部通过 ✓
6. 与数据库的类型映射
6.1 Kafka → JavaScript → PostgreSQL 转换链
Kafka Message
├─ ts_ms: 1234567890 (number in JSON)
├─ hotel_id: "2045" (string in JSON)
├─ room_id: "6010" (string in JSON)
└─ device_id: "DEV001" (string in JSON)
↓
JavaScript Parsed Object
├─ ts_ms: 1234567890 (number)
├─ hotel_id: "2045" (string)
├─ room_id: "6010" (string)
└─ device_id: "DEV001" (string)
↓
Parameterized SQL Statement
INSERT INTO room_status_moment_g5
(hotel_id, room_id, device_id, ts_ms)
VALUES
($1::smallint, $2::text, $3::varchar, $4::bigint)
Parameters: ["2045", "6010", "DEV001", 1234567890]
↓
PostgreSQL Row (G5 Schema)
├─ hotel_id: 2045 (smallint = -32768 ~ 32767)
├─ room_id: '6010' (text)
├─ device_id: 'DEV001' (varchar(255))
├─ ts_ms: 1234567890 (bigint)
└─ status: 1 (smallint)
6.2 类型转换设计决策
| 转换 | 理由 |
|---|---|
| hotel_id: string → ::smallint | G5 表使用 smallint;Kafka 送字符串避免精度问题 |
| room_id: string → text | 支持中文、特殊字符 |
| device_id: string → varchar | 与 G5 schema 兼容 |
| ts_ms: number → bigint | JavaScript number 足以覆盖 64-bit 整数范围 |
7. 边界情况与异常处理
7.1 极值测试
// ts_ms 极值
parseHeartbeat(JSON.stringify({
ts_ms: 0, // ✓ 有效(虽然可能不现实)
hotel_id: "1",
room_id: "1",
device_id: "1"
}));
parseHeartbeat(JSON.stringify({
ts_ms: Number.MAX_SAFE_INTEGER, // ✓ 有效
hotel_id: "1",
room_id: "1",
device_id: "1"
}));
parseHeartbeat(JSON.stringify({
ts_ms: Number.MAX_SAFE_INTEGER + 1, // ✗ 可能失效(精度丢失)
hotel_id: "1",
room_id: "1",
device_id: "1"
}));
// hotel_id 极值
parseHeartbeat(JSON.stringify({
ts_ms: 1234567890,
hotel_id: "32767", // ✓ 最大 smallint
room_id: "1",
device_id: "1"
}));
parseHeartbeat(JSON.stringify({
ts_ms: 1234567890,
hotel_id: "32768", // ✗ 超过 smallint(但 parser 不检查,DB 会拒绝)
room_id: "1",
device_id: "1"
}));
上次修订: 2026-03-11
维护者: BLS OldRCU Heartbeat Team