Files
Web_BLS_OldRcu_Heartbeat_Se…/bls-oldrcu-heartbeat-backend/spec/validation.md

501 lines
12 KiB
Markdown
Raw Normal View History

# 数据验证规范 (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 表使用 smallintKafka 送字符串避免精度问题 |
| 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