feat: 新增 G4 热表独立双写能力

- 新增配置项以支持旧/新明细表的独立写入开关及目标表名。
- 重构 DatabaseManager,抽象通用批量 COPY 写入内核,支持不同目标表的复用。
- 新增双明细写入编排器,支持旧/新表独立执行、重试及 fallback。
- 调整 HeartbeatProcessor.processBatch(),确保 room_status 独立执行。
- 错误表仅记录新表写入失败,旧表失败不再写入错误表。
- 重新定义消费暂停策略,基于当前启用的关键 sink 判断。
- 补充按 sink 维度的统计项与启动日志。

新增 G4 热表相关的数据库规范与处理逻辑,确保系统在双写模式下的稳定性与可扩展性。
This commit is contained in:
2026-03-09 15:49:12 +08:00
parent f59000f5ef
commit 43fa7505e5
21 changed files with 2546 additions and 154 deletions

View File

@@ -0,0 +1,76 @@
## Context
当前系统只有一个明细写入目标 `heartbeat.heartbeat_events`。需要新增 `heartbeat.heartbeat_events_g4_hot` 作为第二个独立写入目标,两路完全解耦,支持平滑迁移。
约束:
- 同一 PostgreSQL 实例/数据库,共享同一连接池
- 数据来源Kafka、解包、校验、转换逻辑完全不变
- `room_status` 始终独立执行,不依赖任何明细写入
- 错误表仅服务新表写入失败
## Goals / Non-Goals
- Goals:
- 旧/新明细表可通过启动配置独立开关
- 新表复用现有批量 COPY 写入内核
- 两路写入互不影响错误隔离、重试隔离、fallback 隔离)
- `room_status` 始终执行,不受明细开关影响
- 错误表仅记录新表失败
- 支持"旧开新关 → 双开 → 关旧留新"迁移路径
- Non-Goals:
- 不引入第二套 PostgreSQL 连接配置(同库新表)
- 不改变 Kafka 消费链路、消息格式、数据转换
- 不做运行时热开关(重启生效即可)
- 不在本次改造中删除旧表相关代码
## Decisions
### Decision 1: 共享连接池,不新建第二个 Pool
- **What**: 旧表和新表都使用同一个 `pg.Pool`
- **Why**: 两个目标表在同一 PostgreSQL 实例/数据库下,共享连接池避免资源浪费
- **Alternatives**: 为新表创建独立连接池 → 复杂度高,同库下无必要
### Decision 2: 抽象通用写入内核而非复制代码
- **What**: 把现有 `insertHeartbeatEvents()` 中与表名绑定的逻辑抽象为 `_insertEventsToTarget(events, targetConfig)`
- **Why**: 避免维护两份几乎相同的 COPY/INSERT 代码;新表和旧表的列清单不同但写入流程一致
- **Alternatives**: 复制一份改表名 → 双维护成本高
### Decision 3: 编排层聚合结果Processor 不感知双写细节
- **What**: `DatabaseManager.insertHeartbeatEventsDual()` 负责编排两路写入并聚合结果;`HeartbeatProcessor` 只消费聚合结果
- **Why**: 保持 Processor 职责单一,不引入 DB 路由逻辑
### Decision 4: room_status 独立于明细写入
- **What**: `upsertRoomStatus()` 始终在 `processBatch()` 中独立执行,不依赖旧/新明细写入成功
- **Why**: 用户明确要求即使两路明细都关闭,仍需写 `room_status`
### Decision 5: 错误表仅服务新表
- **What**: `insertHeartbeatEventsErrors()` 仅接收新表 (`g4Hot`) 写入失败的记录
- **Why**: 旧表即将下线,错误追踪聚焦新表;旧表失败仅记日志/统计
### Decision 6: 消费暂停基于"启用中的关键 sink"
- **What**: 只有当前启用的写入路径全部发生连接级不可恢复故障时,才暂停 Kafka 消费
- **Why**: 避免新表结构/权限问题误停全局消费
## Risks / Trade-offs
### Risk 1: 新表列映射不完整
- 新表字段比旧表更宽,旧数据可能缺少部分新表列(如 `svc_*`, `*_1`, `*_2`, `*_residual`, `guid`
- **Mitigation**: 旧数据确认可完整映射到新表必填字段与唯一键;新增列允许 NULL 或有默认值
### Risk 2: 同库双写增加连接池压力
- 单批数据写两次,连接池使用量翻倍
- **Mitigation**: 两路顺序执行而非并发,复用同一连接池;必要时可调大 `maxConnections`
### Risk 3: 新表分区函数可能与旧表不同
- `heartbeat.ensure_partitions` 是否也涵盖新表的分区维护
- **Mitigation**: 实现时需确认分区保障函数对新表的覆盖,或为新表配置独立的分区保障调用
## Migration Plan
1. 部署代码,保持 `DB_LEGACY_HEARTBEAT_ENABLED=true``DB_G4_HOT_HEARTBEAT_ENABLED=false`
2. 验证旧模式下行为不变
3. 开启 `DB_G4_HOT_HEARTBEAT_ENABLED=true`,双写观测
4. 确认新表写入稳定后,关闭 `DB_LEGACY_HEARTBEAT_ENABLED=false`
5. 过渡观察期后清理旧表相关配置
## Open Questions
- 新表 `heartbeat.heartbeat_events_g4_hot` 的分区预创建是否由同一个 `heartbeat.ensure_partitions` 函数覆盖?还是需要独立的分区保障函数?
- 新表的 `guid` 列是否需要在写入时由应用层生成?还是有数据库默认值?

View File

@@ -0,0 +1,25 @@
# Change: 新增 G4 热表独立双写能力
## Why
当前系统仅写入旧明细表 `heartbeat.heartbeat_events`。需要新增对 `heartbeat.heartbeat_events_g4_hot` 的独立写入能力,两路写入完全解耦、可分别开关,支持后续平滑关闭旧表、仅保留新表。
## What Changes
- 新增配置项:旧/新明细表写入开关、目标表名、`room_status` 独立开关
- 重构 `DatabaseManager`:抽象通用批量 COPY 写入内核,支持面向不同目标表的复用
- 新增双明细写入编排器:旧/新表各自独立执行、独立重试、独立 fallback
- 调整 `HeartbeatProcessor.processBatch()``room_status` 从"依赖旧表成功"改为"始终独立执行"
- 错误表 `heartbeat_events_errors` 仅记录新表写入失败,旧表失败不再写错误表
- 重新定义消费暂停策略:基于"当前启用的关键 sink"判断,非全局一刀切
- 补充按 sink 维度的统计项与启动日志
## Impact
- Affected specs: `db`, `processor`
- Affected code:
- `src/config/config.js` — 新增配置项
- `src/db/databaseManager.js` — 核心重构
- `src/processor/heartbeatProcessor.js` — 主流程调整
- `src/index.js` — 启动日志
- `src/stats/statsManager.js` — 统计项
- `src/config/config.example.js` — 配置示例
- No breaking changes to external APIs or Kafka consumer behavior
- Data source and transformation logic remain unchanged

View File

@@ -0,0 +1,129 @@
## ADDED Requirements
### Requirement: G4 热表独立写入能力
系统 SHALL 支持向 `heartbeat.heartbeat_events_g4_hot` 分区表执行批量 COPY 写入写入内核COPY 流式写入、失败逐条 INSERT 降级SHALL 与旧表 `heartbeat.heartbeat_events` 共享同一套通用实现。
#### Scenario: G4 热表批量 COPY 写入
- **WHEN** `g4HotHeartbeatEnabled=true` 且有一批心跳数据待写入
- **THEN** 系统应使用批量 COPY 将数据写入 `heartbeat.heartbeat_events_g4_hot`
- **AND** 写入流程应与旧表共享同一通用写入内核
#### Scenario: G4 热表分区由外部维护
- **WHEN** 写入 `heartbeat.heartbeat_events_g4_hot`
- **THEN** 系统不应在运行时为该表执行分区预创建、缺分区补建或其他分区维护逻辑
- **AND** 分区应由外部脚本或外部调度系统维护
#### Scenario: G4 热表 COPY 失败降级
- **WHEN** G4 热表批量 COPY 写入失败且重试次数耗尽
- **THEN** 系统应降级为逐条 INSERT 写入
- **AND** 单条失败不影响同批次其他记录
### Requirement: G4 热表 guid 写入规则
系统 SHALL 在写入 `heartbeat.heartbeat_events_g4_hot` 时为每条记录提供 `guid` 字段值,格式为无连接符的小写 GUID。
#### Scenario: 缺失 guid 时自动生成
- **WHEN** 待写入记录未提供 `guid`
- **THEN** 系统应为该记录生成一个新的 GUID
- **AND** 生成结果应为 32 位十六进制小写字符串
- **AND** 结果中不应包含 `-`
#### Scenario: 已提供 guid 时规范化
- **WHEN** 待写入记录已提供 `guid`
- **THEN** 系统应将该值转换为小写
- **AND** 应去除其中的 `-`
- **AND** 规范化后的值应写入 `heartbeat.heartbeat_events_g4_hot`
### Requirement: G4 热表 power 辅助字段当前阶段写空
系统 SHALL 在当前阶段将 `power_carbon_on``power_carbon_off``power_person_exist``power_person_left` 固定写为 `null`,待后续计算逻辑接入后再启用实际赋值。
#### Scenario: 当前阶段统一写 null
- **WHEN** 系统写入 `heartbeat.heartbeat_events_g4_hot`
- **THEN** `power_carbon_on` 应写入 `null`
- **AND** `power_carbon_off` 应写入 `null`
- **AND** `power_person_exist` 应写入 `null`
- **AND** `power_person_left` 应写入 `null`
- **AND** 不应从来源数据的 `extra` 或其他字段提取这 4 个值
### Requirement: 双明细独立编排
系统 SHALL 提供双明细写入编排能力,按启动配置分别控制旧表与 G4 热表的写入,两路写入结果完全独立。
#### Scenario: 仅旧表开启
- **WHEN** `legacyHeartbeatEnabled=true``g4HotHeartbeatEnabled=false`
- **THEN** 系统应仅写入旧表 `heartbeat.heartbeat_events`
- **AND** 不应尝试写入 G4 热表
#### Scenario: 仅新表开启
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=true`
- **THEN** 系统应仅写入 G4 热表 `heartbeat.heartbeat_events_g4_hot`
- **AND** 不应尝试写入旧表
#### Scenario: 双写模式
- **WHEN** `legacyHeartbeatEnabled=true``g4HotHeartbeatEnabled=true`
- **THEN** 系统应分别写入旧表与 G4 热表
- **AND** 旧表写入失败不影响 G4 热表写入
- **AND** G4 热表写入失败不影响旧表写入
#### Scenario: 双关模式
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=false`
- **THEN** 系统应跳过所有明细写入
- **AND** 不应报错或阻止消费
### Requirement: 写入目标启动配置
系统 SHALL 通过启动配置环境变量分别控制旧表、G4 热表和 room_status 的写入开关与目标表名。
#### Scenario: 配置加载
- **WHEN** 系统启动时
- **THEN** 应从环境变量读取 `DB_LEGACY_HEARTBEAT_ENABLED`(默认 true`DB_G4_HOT_HEARTBEAT_ENABLED`(默认 false`DB_ROOM_STATUS_ENABLED`(默认 true
- **AND** 应从环境变量读取 `DB_LEGACY_TABLE`(默认 `heartbeat.heartbeat_events`)、`DB_G4_HOT_TABLE`(默认 `heartbeat.heartbeat_events_g4_hot`
#### Scenario: 启动日志输出配置摘要
- **WHEN** 系统启动完成时
- **THEN** 应在日志中输出旧/新明细写入开关状态、目标表名和 room_status 开关状态
### Requirement: 错误表仅服务 G4 热表
系统 SHALL 将 `heartbeat.heartbeat_events_errors` 仅用于记录 G4 热表写入失败的记录,旧表写入失败不写入错误表。
#### Scenario: G4 热表写入失败记录错误
- **WHEN** G4 热表写入产生失败记录
- **THEN** 系统应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 错误表字段和结构不变
#### Scenario: 旧表写入失败不记录错误表
- **WHEN** 旧表写入产生失败记录
- **THEN** 系统不应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 应仅记录日志/统计
## MODIFIED Requirements
### Requirement: 心跳数据写入
系统 MUST 能够将处理后的心跳数据写入 PostgreSQL 数据库,支持多目标表独立写入。
#### Scenario: 写入单条心跳数据
- **WHEN** 接收到单条处理后的心跳数据时
- **THEN** 系统应该将数据写入已启用的目标表
- **AND** 返回写入结果
#### Scenario: 批量写入心跳数据
- **WHEN** 接收到批量处理后的心跳数据时
- **THEN** 系统应该对每个已启用的目标表使用批量 COPY 写入机制
- **AND** 各目标表写入结果独立
- **AND** 提高写入效率
### Requirement: 数据库连接管理
系统 MUST 能够建立和维护与 PostgreSQL 数据库的连接。
#### Scenario: 成功连接数据库
- **WHEN** 系统启动时
- **THEN** 应该成功连接到配置的PostgreSQL数据库
- **AND** 应该监控连接状态
#### Scenario: 数据库连接断开重连
- **WHEN** 数据库连接断开时
- **THEN** 系统应该自动尝试重连
- **AND** 重连失败时应该记录错误日志
#### Scenario: 消费暂停判定
- **WHEN** 当前启用的明细写入目标全部因连接级不可恢复故障不可写时
- **THEN** 系统应暂停 Kafka 消费
- **WHEN** 仅某一路非关键 sink 失败(如新表结构错误)
- **THEN** 系统不应暂停全局消费

View File

@@ -0,0 +1,52 @@
## MODIFIED Requirements
### Requirement: 心跳数据转换
系统 MUST 在批次处理中始终独立执行 room_status 同步,不依赖任何明细写入的成功与否。
#### Scenario: room_status 始终独立执行
- **WHEN** 一批心跳数据完成验证与转换
- **THEN** 系统应始终调用 room_status 的 upsert 同步逻辑(当 `roomStatusEnabled=true`
- **AND** 不依赖旧表明细写入是否开启
- **AND** 不依赖新表明细写入是否开启
- **AND** 不依赖旧/新表写入是否成功
- **AND** 同步失败不应阻塞主处理流程
#### Scenario: 明细双关仍写 room_status
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=false``roomStatusEnabled=true`
- **THEN** 系统仍应对当前批次数据执行 room_status upsert
- **AND** Kafka 消费不应因明细双关而暂停
### Requirement: 批量写库容错
系统 MUST 在批量写库时确保单条失败不影响同批次其他记录的写入,且区分旧/新目标的失败处理路径。
#### Scenario: 单条数据写库失败不影响同批次
- **WHEN** 批量写库中存在某条记录违反约束或写入失败
- **THEN** 系统应继续写入同批次其他合法记录
- **AND** 失败记录应按错误日志规则写入 Redis 项目控制台
#### Scenario: 新表失败记录写入错误表
- **WHEN** G4 热表批量写入中存在失败记录
- **THEN** 系统应将失败记录写入 `heartbeat.heartbeat_events_errors`
#### Scenario: 旧表失败记录不写入错误表
- **WHEN** 旧表批量写入中存在失败记录
- **THEN** 系统不应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 应仅记录日志与统计
## ADDED Requirements
### Requirement: 按 sink 维度的统计与监控
系统 SHALL 按写入目标维度分别统计成功数、失败数与降级事件。
#### Scenario: legacy 与 g4Hot 分别统计
- **WHEN** 系统完成一批次的双写编排
- **THEN** 应分别统计 legacy 写入成功数、失败数
- **AND** 应分别统计 g4Hot 写入成功数、失败数
- **AND** 统计项应在 Redis 控制台输出或 stats 汇总中可见
#### Scenario: 启动时输出双写配置摘要
- **WHEN** 服务完成数据库连接并进入启动阶段
- **THEN** 系统应输出双写配置摘要
- **AND** 摘要中应包含 legacy 开关与目标表
- **AND** 摘要中应包含 g4Hot 开关与目标表
- **AND** 摘要中应包含 room_status 开关

View File

@@ -0,0 +1,28 @@
## 1. Configuration
- [x] 1.1 在 `src/config/config.js` 新增旧/新明细写入开关、目标表名、`room_status` 独立开关配置
- [x] 1.2 在 `src/config/config.example.js` 同步新增对应示例配置
## 2. Database Layer Refactor
- [x] 2.1 抽象通用批量 COPY 写入内核 `_insertEventsToTarget(events, targetConfig)`,支持接收目标表名、列清单、日志前缀等参数
- [x] 2.2 将旧表 `insertHeartbeatEvents()` 迁移为调用通用内核,保证旧逻辑行为不变
- [x] 2.3 新增新表 `heartbeat.heartbeat_events_g4_hot` 的目标配置与列映射
- [x] 2.4 新增双明细独立编排方法 `insertHeartbeatEventsDual(events)`,按配置分别调用旧/新 writer聚合返回结构化结果
## 3. Processor Flow Adjustment
- [x] 3.1 调整 `HeartbeatProcessor.processBatch()` 调用链:改用 `insertHeartbeatEventsDual()` 替代原 `insertHeartbeatEvents()`
- [x] 3.2 将 `upsertRoomStatus()` 从"依赖旧表成功"改为"独立执行"——无论旧/新明细开关状态如何,只要 `roomStatusEnabled=true` 就始终执行
- [x] 3.3 调整错误表调用:仅将新表 (`g4Hot`) 失败记录送入 `insertHeartbeatEventsErrors()`,旧表失败不写错误表
## 4. Connection & Pause Strategy
- [x] 4.1 重构消费暂停判定:基于"当前启用的 sink 是否全部连接级不可写",而非单一旧表状态
- [x] 4.2 区分连接错误与表级错误,避免新表结构问题误触发全局暂停
## 5. Observability
- [x] 5.1 在 `src/index.js` 启动时输出双写配置摘要(旧/新开关状态、目标表名、`room_status` 开关)
- [x] 5.2 在 `src/stats/statsManager.js` 新增按 sink 维度的统计项legacy/g4Hot 成功数、失败数、room_status 成功/失败数)
## 6. Testing
- [x] 6.1 补充 `databaseManager` 双写编排单元测试(仅旧、仅新、双开、双关、交叉失败等场景)
- [x] 6.2 补充 `room_status` 独立性断言测试
- [x] 6.3 补充错误表仅接新表失败的断言测试
- [x] 6.4 补充/扩展数据库 smoke 验证脚本,覆盖新表写入、分区、索引验证

View File

@@ -102,6 +102,99 @@
- **AND** 调用后应重试当前批次写入
- **AND** 系统不应在运行时创建或替换数据库 schema 对象
### Requirement: G4 热表独立写入能力
系统 MUST 支持向 `heartbeat.heartbeat_events_g4_hot` 分区表执行批量 COPY 写入,并与旧表 `heartbeat.heartbeat_events` 共享同一套通用写入内核。
#### Scenario: G4 热表批量 COPY 写入
- **WHEN** `g4HotHeartbeatEnabled=true` 且有一批心跳数据待写入
- **THEN** 系统应使用批量 COPY 将数据写入 `heartbeat.heartbeat_events_g4_hot`
- **AND** 写入流程应与旧表共享同一通用写入内核
#### Scenario: G4 热表分区由外部维护
- **WHEN** 写入 `heartbeat.heartbeat_events_g4_hot`
- **THEN** 系统不应在运行时为该表执行分区预创建、缺分区补建或其他分区维护逻辑
- **AND** 分区应由外部脚本或外部调度系统维护
#### Scenario: G4 热表 COPY 失败降级
- **WHEN** G4 热表批量 COPY 写入失败且重试次数耗尽
- **THEN** 系统应降级为逐条 INSERT 写入
- **AND** 单条失败不影响同批次其他记录
### Requirement: G4 热表 guid 写入规则
系统 MUST 在写入 `heartbeat.heartbeat_events_g4_hot` 时为每条记录提供 `guid` 字段值,格式为无连接符的小写 GUID。
#### Scenario: 缺失 guid 时自动生成
- **WHEN** 待写入记录未提供 `guid`
- **THEN** 系统应为该记录生成一个新的 GUID
- **AND** 生成结果应为 32 位十六进制小写字符串
- **AND** 结果中不应包含 `-`
#### Scenario: 已提供 guid 时规范化
- **WHEN** 待写入记录已提供 `guid`
- **THEN** 系统应将该值转换为小写
- **AND** 应去除其中的 `-`
- **AND** 规范化后的值应写入 `heartbeat.heartbeat_events_g4_hot`
### Requirement: G4 热表 power 辅助字段当前阶段写空
系统 MUST 在当前阶段将 `power_carbon_on``power_carbon_off``power_person_exist``power_person_left` 固定写为 `null`,待后续计算逻辑接入后再启用实际赋值。
#### Scenario: 当前阶段统一写 null
- **WHEN** 系统写入 `heartbeat.heartbeat_events_g4_hot`
- **THEN** `power_carbon_on` 应写入 `null`
- **AND** `power_carbon_off` 应写入 `null`
- **AND** `power_person_exist` 应写入 `null`
- **AND** `power_person_left` 应写入 `null`
- **AND** 不应从来源数据的 `extra` 或其他字段提取这 4 个值
### Requirement: 双明细独立编排
系统 MUST 提供双明细写入编排能力,按启动配置分别控制旧表与 G4 热表的写入,两路写入结果完全独立。
#### Scenario: 仅旧表开启
- **WHEN** `legacyHeartbeatEnabled=true``g4HotHeartbeatEnabled=false`
- **THEN** 系统应仅写入旧表 `heartbeat.heartbeat_events`
- **AND** 不应尝试写入 G4 热表
#### Scenario: 仅新表开启
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=true`
- **THEN** 系统应仅写入 G4 热表 `heartbeat.heartbeat_events_g4_hot`
- **AND** 不应尝试写入旧表
#### Scenario: 双写模式
- **WHEN** `legacyHeartbeatEnabled=true``g4HotHeartbeatEnabled=true`
- **THEN** 系统应分别写入旧表与 G4 热表
- **AND** 旧表写入失败不影响 G4 热表写入
- **AND** G4 热表写入失败不影响旧表写入
#### Scenario: 双关模式
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=false`
- **THEN** 系统应跳过所有明细写入
- **AND** 不应报错或阻止消费
### Requirement: 写入目标启动配置
系统 MUST 通过启动配置环境变量分别控制旧表、G4 热表和 room_status 的写入开关与目标表名。
#### Scenario: 配置加载
- **WHEN** 系统启动时
- **THEN** 应从环境变量读取 `DB_LEGACY_HEARTBEAT_ENABLED`(默认 true`DB_G4_HOT_HEARTBEAT_ENABLED`(默认 false`DB_ROOM_STATUS_ENABLED`(默认 true
- **AND** 应从环境变量读取 `DB_LEGACY_TABLE`(默认 `heartbeat.heartbeat_events`)、`DB_G4_HOT_TABLE`(默认 `heartbeat.heartbeat_events_g4_hot`
#### Scenario: 启动日志输出配置摘要
- **WHEN** 系统启动完成时
- **THEN** 应在日志中输出旧/新明细写入开关状态、目标表名和 room_status 开关状态
### Requirement: 错误表仅服务 G4 热表
系统 MUST 将 `heartbeat.heartbeat_events_errors` 仅用于记录 G4 热表写入失败的记录,旧表写入失败不写入错误表。
#### Scenario: G4 热表写入失败记录错误
- **WHEN** G4 热表写入产生失败记录
- **THEN** 系统应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 错误表字段和结构不变
#### Scenario: 旧表写入失败不记录错误表
- **WHEN** 旧表写入产生失败记录
- **THEN** 系统不应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 应仅记录日志/统计
### Requirement: 建库与分区维护能力必须以外部脚本提供
系统 MUST 在仓库根目录 `SQL_Script/` 提供可被外部程序调用的建库/分区维护脚本。

View File

@@ -94,6 +94,50 @@
- **THEN** 系统应继续写入同批次其他合法记录
- **AND** 失败记录应按错误日志规则写入 Redis 项目控制台
### Requirement: room_status 独立执行
系统 MUST 在批次处理中始终独立执行 room_status 同步,不依赖任何明细写入的成功与否。
#### Scenario: room_status 始终独立执行
- **WHEN** 一批心跳数据完成验证与转换
- **THEN** 系统应始终调用 room_status 的 upsert 同步逻辑(当 `roomStatusEnabled=true`
- **AND** 不依赖旧表明细写入是否开启
- **AND** 不依赖新表明细写入是否开启
- **AND** 不依赖旧/新表写入是否成功
- **AND** 同步失败不应阻塞主处理流程
#### Scenario: 明细双关仍写 room_status
- **WHEN** `legacyHeartbeatEnabled=false``g4HotHeartbeatEnabled=false``roomStatusEnabled=true`
- **THEN** 系统仍应对当前批次数据执行 room_status upsert
- **AND** Kafka 消费不应因明细双关而暂停
### Requirement: 区分旧新目标失败处理
系统 MUST 在批量写库时区分旧表与 G4 热表的失败处理路径。
#### Scenario: 新表失败记录写入错误表
- **WHEN** G4 热表批量写入中存在失败记录
- **THEN** 系统应将失败记录写入 `heartbeat.heartbeat_events_errors`
#### Scenario: 旧表失败记录不写入错误表
- **WHEN** 旧表批量写入中存在失败记录
- **THEN** 系统不应将失败记录写入 `heartbeat.heartbeat_events_errors`
- **AND** 应仅记录日志与统计
### Requirement: 按 sink 维度的统计与监控
系统 MUST 按写入目标维度分别统计成功数、失败数与降级事件。
#### Scenario: legacy 与 g4Hot 分别统计
- **WHEN** 系统完成一批次的双写编排
- **THEN** 应分别统计 legacy 写入成功数、失败数
- **AND** 应分别统计 g4Hot 写入成功数、失败数
- **AND** 统计项应在 Redis 控制台输出或 stats 汇总中可见
#### Scenario: 启动时输出双写配置摘要
- **WHEN** 服务完成数据库连接并进入启动阶段
- **THEN** 系统应输出双写配置摘要
- **AND** 摘要中应包含 legacy 开关与目标表
- **AND** 摘要中应包含 g4Hot 开关与目标表
- **AND** 摘要中应包含 room_status 开关
## ADDED Requirements
### Requirement: 数组字段聚合为列数组
系统 SHALL <20>?`electricity[]` <20>?`air_conditioner[]` 按原始顺序聚合为数据库写入结构的列数组<E695B0>?