Files
aomin a7605ef8b8 chore(repo): 导入 M-Z 业务底座 V1.9 基线并建立仓库规范
按周工交付的 SEC-Z-01 清理后源码建立 M-Z 主干,并按 Git 仓库惯例重排目录。

- aomin-gateway/  后端工程 87 文件,原样保留,路径与构建命令未变
- demo/           演示前端与设备模拟器 9 文件(原 02_前端与设备端),非正式三端
- docs/handover/  交付与安全清理材料 6 份,内容未改
- docs/contract/  J-00 规格、OpenAPI、内部五接口、状态机、Mock 样例
- docs/reference/ 指纹与校验清单,含 84 个源码文件的 sha256 清单

目录重排只动位置不动内容:源码树 84 文件哈希与交付一致,逐文件自校验通过;
12 条 Flyway 迁移与交付包内镜像逐字节一致。

未纳入:迁移镜像目录(与工程内一套重复,改留指纹)、target/、证据 raw、
应急恢复脚本、评审用虚构台账数据。

安全:SEC-Z-01 15 处明文已清零,导入前重新扫描无新增命中,本仓库无历史污染。
行尾按交付字节原样保存(.gitattributes 设 * -text),不做归一化以免整树摘要失效。
2026-11-13 17:32:37 +08:00

16 KiB
Raw Permalink Blame History

状态机:告警 与 下行命令

状态字段全部是 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 的原子更新条件因此变成四个守卫:

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,再改本文与代码。