# 数据验证规范 (Validation Specification) ## 1. 消息字段定义 ### 1.1 Kafka 消息结构 来自 Kafka Topic: `blwlog4Nodejs-oldrcu-heartbeat-topic` ```javascript { // 必需字段 (4 个) ts_ms: number, // 时间戳(毫秒),心跳发送时间 hotel_id: string, // 酒店编号,仅数字字符 room_id: string, // 房间编号,非空,允许中英文 device_id: string, // 设备编号,非空 // 可选字段 current_time: string // ISO 格式时间戳(用于调试) } ``` ### 1.2 实际 Kafka 消息示例 ```json { "current_time": "2026-03-11T10:30:45.123Z", "ts_ms": 1234567890, "device_id": "DEV-GATEWAY-02", "hotel_id": "1309", "room_id": "6010" } ``` ```json { "current_time": "2026-03-11T10:30:46.456Z", "ts_ms": 1234567891, "device_id": "RCU-UNIT-A5", "hotel_id": "2045", "room_id": "大会议室" } ``` ```json { "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 安全整数) ```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 字符建议,无硬限制 ```javascript 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 字符建议 ```javascript 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 字符建议 ```javascript 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` ```javascript 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 辅助验证函数 ```javascript 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 统计与监控 ```javascript // 在 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` ```javascript 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 极值测试 ```javascript // 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