501 lines
12 KiB
Markdown
501 lines
12 KiB
Markdown
|
|
# 数据验证规范 (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
|