# 状态机:告警 与 下行命令 > 状态字段全部是 `varchar(16)` 枚举直存,不用数字码,便于人工核对。 > 两条状态机是本模型最容易被评「含糊」的地方,因此把**谁能改、什么条件能改、改不了会怎样**写死在这里。 > > **口径来源**:告警九态=GOV-001 §4.3「告警与命令」检查项;命令七态=`6_J-00_接入接口规格_V1.8.md` §3.4。 > 本文只做展开说明,**不另立字段含义**;与 J-00 冲突时以 J-00 为准。 --- ## 一、告警状态机(九态) ``` ┌────────── 新事件触发 ──────────┐ │ │ ▼ │ ┌───────┐ 推送成功 ┌─────────┐ │ │ NEW │ ──────────► │ PUSHED │ │ └───────┘ └─────────┘ │ │ │ │ │ │ 命中免打扰时段 │ │ └── 判定误报 ──► FALSE │ ▼ │ │ │ ┌────────┐ │ │ │DELAYED │ 静默结束回 NEW │ │ └────────┘ │ │ │ │ │ 已知悉/确认 ────┴────────┬────────────────┘ │ ▼ │ ┌────────────┐ 超时未确认 │ │ CONFIRMED │ ────────► ESCALATED │ └────────────┘ │ │ │ │ │ 处置完成 ──────────────┴─────────────────────┘ │ ▼ │ ┌──────────┐ 关闭 ┌────────┐ │ │ HANDLED │ ──────► │ CLOSED │ │ └──────────┘ └────────┘ │ │ │ 同一设备同一规则再次触发,且本单无人处理 │ ▼ │ ┌────────────┐ │ │ SUPERSEDED │ ◄─────────────────────┘ └────────────┘ ``` ### 状态定义 | 状态 | 中文 | 含义 | 是否算「待处理」 | |---|---|---|---| | `NEW` | 新告警 | 刚生成,尚未推送或推送中 | ✅ | | `PUSHED` | 已推送 | 已送到家属端(SSE/短信/App),等人看 | ✅ | | `DELAYED` | 免打扰 | 落在收件人免打扰时段,不响铃不弹窗,**仍在列表里** | ✅ | | `CONFIRMED` | 已确认 | 有人接了这个单(`confirmed_by/confirmed_at` 有值),还没处置完 | ✅ | | `ESCALATED` | 已升级 | 超时未确认,已按规则升级(加急通知/通知下一位家属) | ✅ | | `HANDLED` | 已处置 | 家属点了「已处理」,但事还没完全关闭 | ❌(家属视角) | | `CLOSED` | 已关闭 | 处置完成并关闭 | ❌ | | `FALSE` | 误报 | 人工判定为误报 | ❌ | | `SUPERSEDED` | 已被替代 | 同一设备同一规则又触发了新的,本单被顶掉 | ❌ | > **`HANDLED` 与 `CLOSED` 的区别**:前者是「我知道这件事了、我处理了」,后者是「这条记录可以结束了」。 > 家属端把两者都当"不用再管",但后台统计「今日处置率」只认 `HANDLED` 及以上。 > > **`PUSHED` 为什么必须独立**:`NEW` 是"系统生成了",`PUSHED` 是"人真的收到了"。 > 合成一个值以后,「推送失败了没」查不出来——告警生成了但没送到人手上,恰恰是最危险的情况。 ### 允许的状态迁移(白名单) | 当前 | 允许去向 | 触发者 | |---|---|---| | `NEW` | `PUSHED` / `DELAYED` / `CONFIRMED` / `HANDLED` / `FALSE` / `SUPERSEDED` | 通知模块/免打扰判定/家属/人工 | | `PUSHED` | `CONFIRMED` / `HANDLED` / `FALSE` / `SUPERSEDED` | 家属/人工 | | `DELAYED` | `NEW`(静默结束唤醒)/ `CONFIRMED` / `HANDLED` / `SUPERSEDED` | 定时任务/家属 | | `CONFIRMED` | `HANDLED` / `ESCALATED` / `FALSE` | 家属/超时升级 | | `ESCALATED` | `CONFIRMED` / `HANDLED` / `FALSE` | 家属/人工 | | `HANDLED` | `CLOSED` | 家属/人工 | | `CLOSED` / `FALSE` / `SUPERSEDED` | **无(终态)** | — | **白名单外的迁移一律 `400`,不是静默忽略。** 前端按钮由 `GET /alarms/{id}/actions` 返回,不靠猜。 ### 自动替代规则(这套模型的重点) 同一 `dev_id + rule_key` 再次触发时,**不是往旧单上叠次数,而是开新单**,旧单按三种情况处理: | 旧单当时的状态 | 处理 | 结果 | |---|---|---| | `NEW` / `PUSHED` / `DELAYED`(无人管) | 标为 `SUPERSEDED`,回填 `superseded_by=新单id`,释放 `active_key` | 家属看到最新一条,点开能看整组历史 | | `CONFIRMED` / `ESCALATED` / `HANDLED`(人处理过) | **保持原状**,只释放 `active_key`,不改状态 | 处理结果不被覆盖 | | `CLOSED` / `FALSE` / `SUPERSEDED`(终态) | 不动 | 历史记录保持 | **执行顺序有讲究**:先释放 `active_key` → 再插新单 → 最后回填 `superseded_by`。 顺序错了会撞唯一键(`active_key` 保证"同一设备同一规则同时只有一条活跃单")。 `active_key = dev_id + ':' + rule_key`,终结时置 NULL。 ### 告警链(`GET /alarms/{id}/chain`) 沿 `superseded_by` 反向递归(MySQL 递归 CTE),返回整组: ``` 1 替代 2 替代 3 处理 4 替代 5 当前 ``` - 点 **5** → 看到 4、5(4 被替代,5 是当前) - 点 **3** → 看到 1、2、3(3 是被人处理过的,链在这里断) - 点**任一被替代的** → 只看到自己 + 一条提示「已被后面的告警替代,不能单独处理」 **被替代的单不可编辑**:`ack/confirm/handle/close/false` 一律 400。 ### 恢复是「附加状态」,不是状态迁移 门关上、漏水已止、烟雾已散、设备来电 —— 这些**不新建告警**,只在原单上写 `cleared_at` 与 `clear_event_id`。 ``` 设备状态:已恢复 · 2026-11-09 20:21:12(设备来电) ``` 状态标签仍是 `NEW`,旁边多一个绿色「已恢复」。 **恢复事件严格按类型配对**:来电只恢复断电,绝不会把一条还没处理的 SOS 标成已恢复。 | 触发事件 | 只能恢复 | |---|---| | `DOOR_CLOSE` | `DOOR_OPEN` | | `WATER_LEAK_CLEAR` | `WATER_LEAK` | | `SMOKE_CLEAR` | `SMOKE` | | `SOS_CLEAR` | `SOS` | | `POWER_ON` / `POWER_RESTORE` | `POWER_OFF` / `POWER_CUT` | **恢复只写附加字段,永远不把告警置为 `CLOSED`** —— 安全告警必须由人关闭,设备状态好转不等于这件事了了。 --- ## 二、下行命令状态机(七态) **英文枚举**:`PENDING / CLAIMED / SENT / ACKED / FAILED / TIMEOUT / CANCELLED`,由业务层维护。 定义见 `6_J-00_接入接口规格_V1.8.md` §3.4,本节只做落地说明。 ``` ┌──────── I-18 拉取 ────────┐ ▼ │ PENDING ────> CLAIMED ──I-05 SENT──> SENT ──ACKED code=0──> ACKED(终态) ▲ │ │ │ ├─ 租约到期(60s) ────┼──> 回 PENDING(reclaim+1) │ │ │ │ ├─ 可延迟 + OFFLINE/TIMEOUT/FAILED │ │ └──> 回 PENDING(retry+1,退避) │ │ │ │ └─ 实时 + 任意失败 └─ timeout_at(24h) 到 ──> FAILED(终态) │ └──> FAILED(终态,当场返回用户) │ ├── retry 超 10 次 或 过 expire_at(72h)──> TIMEOUT(终态) └── 用户撤单/设备解绑 ──────────────────> CANCELLED(终态) ``` | 状态 | 说明 | 进这个状态的时刻 | |---|---|---| | `PENDING` | 待领取 | 创建命令 | | **`CLAIMED`** | **已领取/租约中**(`lease_expire_at` 有效) | IoT 通过 **I-18** 领走 | | `SENT` | 已发出(`timeout_at` 有效) | **I-05** 回报 `SENT`(无回执协议) | | `ACKED` | **设备有回应**,且 `resultCode=0` | **I-05** 回报 `ACKED` + `resultCode=0` | | `FAILED` | 失败(终态) | 实时类任意失败;`SENT` 超 24h 无回执 | | `TIMEOUT` | 已过期(终态) | 重排 10 次 或 过 `expire_at`(创建+72h) | | `CANCELLED` | 已取消(终态) | 用户撤单/设备已解绑 | > **`CLAIMED` 与 `SENT` 必须分开**:「IoT 领走了」和「已经发出去了」是两件事。 > 混成一个值,租约回收就判不出该不该重派,也分不清是中间件失联还是设备没响应。 ### ⚠ `ACKED` 不等于「执行成功」 这是最容易写错、也是评审明确列为否决情形的一条,在此写死: | 字段 | 含义 | |---|---| | `result`(I-05) | `ACKED` = **设备有回应**(收到回执帧)。**不代表设备按意图执行了。** | | `resultCode` | 标准字典值:`0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙。**只有 `0` 才算设备声明成功** | | `rawResultCode` / `rawAck` | 厂家原始码与回执原始字节,原样保存,**不得透传到标准字段** | 所以: - `ACKED + resultCode≠0` → 落 **`FAILED`**,不是 `ACKED`; - 即使 `ACKED + resultCode=0`,也只说明**设备声明已执行**。业务效果要看后续状态快照或事件确认(例如 `set_mode` 之后要核对 `gateway.mode_cache` 是否一致)。 - **库里绝不出现「已生效」这种把回执当结果的值。** ### 状态值迁移(库现值 → 目标七态) `device_command.status` 现在是**中文六值**,`varchar(16)`;目标七态是英文。这是**值域迁移,不是加列**: | 目标 | 库现值 | 迁移动作 | |---|---|---| | `PENDING` | 待下发 | 改值 | | `CLAIMED` | **无** | **新增枚举值**(列宽够,只扩值域) | | `SENT` | 已下发 | 改值 | | `ACKED` | 已生效 | 改值(**语义同时纠正**:从「已生效」改为「设备有回应」) | | `FAILED` | 失败 | 改值 | | `TIMEOUT` | 超时 | 改值 | | `CANCELLED` | 已取消 | 改值 | > **迁移状态(2026-11-11 已闭环)**:V1.0.23 迁移脚本已把库里中文六值刷成英文七态, > 代码读写全部改为英文;`BizDeviceService.commands()` 保留一层"中文旧值 → 英文"的**读时映射**, > 只为兼容尚未升级的老库,跑过 V1.0.23 后这层就是空转。 > 实测:`cmd_chain_test.py` 23 条全过,其中第 12 条专门验"命令记录里没有中文旧值残留"(20 条,中文 0 条)。 ### 租约与两个计数器 | 计数器 | 记什么 | 用途 | |---|---|---| | `retry_count` | **设备侧**失败重排了几次 | 超 10 次 → `TIMEOUT` | | `reclaim_count` | **IoT 拿走却没回话**几次 | 超 5 次 → 说明该 `nodeId` 反复失联,告警转人工 | 混成一个,就分不清是设备坏了还是中间件坏了。 ### 领取代次 `claim_generation`(V1.7.2 新增) 每次 `PENDING → CLAIMED` 时 +1,**I-18 返回、I-05 回带**。 | 步 | 发生什么 | 代次 | |---|---|---| | 1 | 创建 | `0`(尚未领取) | | 2 | 节点 A 领走 | `1` | | 3 | 租约 60 秒到期,没回话,回收成 `PENDING` | 仍是 `1`(回退不 +1,只记录 `reclaim_count`) | | 4 | 节点 B 领走 | `2` | | 5 | 节点 A 的回执**迟到**,带 `claimGeneration=1` | **更新 0 行 → 计 `duplicated`,状态不动** | | 6 | 节点 B 的回执,带 `2` | 更新 1 行 → 生效 | 没有代次的话第 5 步会把 B 的流转覆盖掉,而且不报错——只在报表里表现为"同一条命令走了两条路"。 > 合规边界:回执不带代次**不会被拒**……这条旧口径已作废。 > **V1.8 起 I-05 的 `claimGeneration` 必填,缺了直接拒**(`CLAIM_GENERATION_REQUIRED`)。 ### 领取节点 `claimed_node_id`(V1.8.1 新增,迁移 V1.0.28) 代次只回答"第几轮",**不回答"这一轮是谁"**。所以领取时必须把 `nodeId` 一起落库: ``` UPDATE device_command SET status='CLAIMED', claim_generation = claim_generation + 1, claimed_node_id = ?, -- 这次来领的 IoT 节点 claimed_at = NOW(3), lease_expire_at = ? WHERE id IN (...) AND status='PENDING' ``` I-05 的原子更新条件因此变成**四个守卫**: ```sql WHERE id = ? -- 命令 AND claim_generation = ? -- 第几轮 AND claimed_node_id = ? -- 这一轮是谁(V1.8.1) AND status IN ('CLAIMED','SENT') -- 允许的来源状态 ``` | 回执情况 | 结果 | |---|---| | 节点对、代次对 | 更新 1 行,`accepted` | | 节点对、代次旧(同一节点上一轮) | 更新 0 行,计 `duplicated`(过期回执,不算错) | | 节点错 | `rejected` + `NODE_MISMATCH`,状态不动 | | 历史在途行(迁移前领的,节点为空) | `rejected` + `NODE_UNKNOWN`,不猜、不伪造;租约到期重派后即有值 | > **作用边界**:`nodeId` 由 IoT 自报,**可以伪造,它不是身份认证**。 > "调用方是谁"靠内网来源白名单 + 签名;这一列证明的是**领取事实一致**—— > 这条结果是不是领走这一轮的那个节点回的。 --- ## 三、两条状态机的共同原则 1. **状态只由事件驱动,不允许任意跳转**:没有"把 `CLOSED` 直接改回 `NEW`"这种口子。 2. **改不了就明确拒绝**:白名单外的迁移一律 `400`,不是静默忽略。 3. **动作白名单**:`GET /alarms/{id}/actions` 返回当前状态下允许的动作,前端据此渲染按钮,不靠猜。 4. **审计留痕**:每个状态变更写 `audit_log`(后台操作)或 `alarm_action`(家属操作),**与状态变更同事务**。 **审计写失败必须让整个动作回滚**,不能 `catch {}` 吞掉——否则会出现"告警关了但查不到谁关的", 而且没有任何报错。非事务路径(后台建档等)改成记账 + ERROR 日志,计数进 `/health` 的 `auditFailures`。 5. **回执 ≠ 结果**:`ACKED` 只代表"设备有回应",业务是否生效要看 `resultCode` 和后续核对。 6. **恢复 ≠ 关闭**:设备状态好转只写 `cleared_at`,安全告警必须由人关闭。 --- ## 四、与 J-00 V1.8 的对应关系 | 本文 | J-00 V1.8 位置 | 是否一致 | |---|---|---| | 告警九态 | GOV-001 §4.3 检查项 | ✅ 代码已实现九态 | | 命令七态 | §3.4「业务层的命令状态机」 | ✅ 契约一致;✅ 代码已迁移到英文七态(V1.0.23),23 条用例全过 | | 命令领取 `I-18` | §三 接口表 | ✅ 本文已统一为 I-18(原文档写作 I-20,已废止) | | 命令回执 `I-05` | §三 接口表 | ✅ 本文已统一为 I-05(原文档写作 I-21,已废止) | | `claimGeneration` | §3.2 / §3.3(V1.7.2) | ✅ I-18 返回、I-05 回带;旧代次回执被挡(用例 18) | | `result` 五值 | §3.3 | ✅ `ACKED/SENT/TIMEOUT/DEVICE_OFFLINE/FAILED` | | `resultCode` 标准码 | §3.3 | ✅ `0`–`4`,厂家原码另存 `rawResultCode` | > 本文与 J-00 V1.8 冲突时,**以 J-00 为准**;新增枚举值须先改 J-00,再改本文与代码。