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

290 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 状态机:告警 与 下行命令
> 状态字段全部是 `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,再改本文与代码。