按周工交付的 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),不做归一化以免整树摘要失效。
16 KiB
状态机:告警 与 下行命令
状态字段全部是
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.py23 条全过,其中第 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 自报,可以伪造,它不是身份认证。 "调用方是谁"靠内网来源白名单 + 签名;这一列证明的是领取事实一致—— 这条结果是不是领走这一轮的那个节点回的。
三、两条状态机的共同原则
- 状态只由事件驱动,不允许任意跳转:没有"把
CLOSED直接改回NEW"这种口子。 - 改不了就明确拒绝:白名单外的迁移一律
400,不是静默忽略。 - 动作白名单:
GET /alarms/{id}/actions返回当前状态下允许的动作,前端据此渲染按钮,不靠猜。 - 审计留痕:每个状态变更写
audit_log(后台操作)或alarm_action(家属操作),与状态变更同事务。 审计写失败必须让整个动作回滚,不能catch {}吞掉——否则会出现"告警关了但查不到谁关的", 而且没有任何报错。非事务路径(后台建档等)改成记账 + ERROR 日志,计数进/health的auditFailures。 - 回执 ≠ 结果:
ACKED只代表"设备有回应",业务是否生效要看resultCode和后续核对。 - 恢复 ≠ 关闭:设备状态好转只写
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,再改本文与代码。