Files
Web_BLS_OldRcu_Heartbeat_Se…/bls-oldrcu-heartbeat-backend/spec/validation.md
XuJiacheng e45d14b720 feat: 实现心跳消息处理模块
- 新增 HeartbeatBuffer 类,用于收集和去重 Kafka 心跳消息,并定期将数据刷新到数据库。
- 新增 HeartbeatDbManager 类,负责与 PostgreSQL 数据库的交互,支持批量 upsert 操作。
- 新增配置文件 config.js,支持从环境变量加载配置。
- 新增 Kafka 消费者模块,支持从 Kafka 中消费心跳消息。
- 新增 Redis 集成模块,支持将日志和心跳信息推送到 Redis。
- 新增心跳消息解析器,负责解析 Kafka 消息并提取心跳字段。
- 新增日志记录工具,支持不同级别的日志输出。
- 新增指标收集器,跟踪 Kafka 消息处理和数据库操作的指标。
- 新增单元测试,覆盖 HeartbeatBuffer 和 HeartbeatDbManager 的主要功能。
- 新增数据库表结构 SQL 文件,定义 room_status_moment_g5 表的结构。
- 配置 Vite 构建工具,支持 Node.js 环境的构建。
2026-03-12 14:11:02 +08:00

12 KiB
Raw Blame History

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