commit a7605ef8b84b8b8f87e16d3916d6f56b846fc546 Author: aomin Date: Fri Nov 13 17:32:37 2026 +0800 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),不做归一化以免整树摘要失效。 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..764a152 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_size = 2 + +[*.json] +indent_size = 2 + +[*.md] +indent_size = 2 +trim_trailing_whitespace = false + +[*.sql] +indent_size = 4 +trim_trailing_whitespace = false + +# start-gateway.bat 为 GBK 编码(配合脚本内 chcp 936), +# EditorConfig 不支持声明 GBK,此处只约束行尾;编辑时勿转成 UTF-8。 +[*.bat] +end_of_line = crlf diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e4d0998 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,31 @@ +# ───────────────────────────────────────────── +# 行尾策略:基线按交付字节原样保存,不做自动归一化 +# +# 理由:本仓库是受控基线,源码树摘要按字节计算(见 docs/reference/原始交付包指纹.md)。 +# 一旦让 git 自动转换行尾,克隆出来的树与交付包就不是同一份字节,摘要对不上, +# 复核时会被误判成"代码被改过"。 +# +# 交付包本身行尾并不统一(14 个文件为 CRLF:8 个 java、2 个 html、application.yml、 +# common.js、sim_device.py、start-gateway.bat),这是既有状态。归一化属于基线变更, +# 需要会签,不在导入时顺手做。 +# +# 新增文件请按 CONTRIBUTING.md 第七节的约定写(UTF-8 + LF), +# 这样新老文件的差异只会出现在新增内容里,不会整文件翻红。 +# ───────────────────────────────────────────── +* -text + +# 二进制:禁止文本 diff +*.jar binary +*.war binary +*.zip binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.docx binary +*.xlsx binary +*.pptx binary +*.woff binary +*.woff2 binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db21e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +# ───────────────────────────────────────────── +# M-Z 业务底座 .gitignore +# 原则:源码、迁移脚本、接口契约、文档进仓库; +# 构建产物、本地配置、日志、证据原始数据不进。 +# ───────────────────────────────────────────── + +# ---------- 构建产物 ---------- +target/ +build/ +out/ +*.class +*.jar +*.war +*.ear + +# ---------- 本地配置(可能含口令,绝不入库) ---------- +.env +.env.* +*.local.yml +application-local.yml +application-*-local.yml +application-secret.yml +*.pem +*.key +*.p12 +*.jks + +# ---------- 日志与临时 ---------- +logs/ +*.log +*.tmp +*.bak +*.orig +*.rej +*.swp + +# ---------- IDE ---------- +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.settings/ +.classpath +.project +nbproject/ + +# ---------- Python(设备模拟器) ---------- +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +venv/ + +# ---------- 运行与证据产物(体积大、每轮变化,按受控目录单独交付) ---------- +evidence/ +*.jsonl + +# ---------- 系统 ---------- +.DS_Store +Thumbs.db +desktop.ini diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d788ea5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# 变更记录 + +本文件记录仓库层面的变更(目录结构、契约版本、迁移序列、安全处置)。 +代码内部改动以提交历史为准,不在此重复登记。 + +版本号与 `docs/handover/03_Z-02_清理后哈希与构建说明.md` 第五节的「版本标识一致性」保持一致。 + +--- + +## [V1.9-baseline] - 2026-11-13 + +首次入库,对应周工交付的 `SEC-Z-01 清理后` 源码包。 + +### Added + +- `aomin-gateway/`:M-Z 业务层完整 Maven 工程 87 文件,含 84 个 `src/main` 文件、`pom.xml`、`README.md`、`start-gateway.bat`。 +- `demo/`:演示前端与设备模拟器 9 文件(`web/` 静态页 7 个、`sim/sim_device.py`、`README.md`)。**不是正式三端。** +- `docs/handover/`:交付与安全清理材料 6 份(`00` 包说明、`01` 差异清单、`02` 扫描报告、`03` 哈希与构建、`04` 证据边界、`gitignore_建议`)。 +- `docs/contract/`:接口契约 6 份,含 J-00 接入接口规格(md + OpenAPI yaml)、OpenAPI 使用说明、内部五接口定义、状态机、Mock 样例。 +- `docs/reference/`:原始交付包指纹说明,以及两个原始 `SHA256SUMS` 清单、12 条迁移脚本哈希。 +- `docs/reference/SHA256SUMS_源码树.txt`:84 个源码文件的逐文件哈希,`sha256sum -c` 可直接校验(导入时自校验 84/84 通过)。交付方给的整树摘要 `5E9F894B…` 算法描述不足复现,此清单纯为替代物。 +- 仓库级规范文件:`README.md`、`LICENSE`、`CHANGELOG.md`、`CONTRIBUTING.md`、`SECURITY.md`、`.gitignore`、`.gitattributes`、`.editorconfig`。 + +### Changed + +- 目录重排,便于跨平台工具与外部读取: + - `02_前端与设备端/` → `demo/`(`web/`、`sim/` 子路径不变,`demo/README.md` 原文未改) + - `04_OpenAPI与内部IoT契约/` → `docs/contract/` + - `00`~`04` 五份交接件与 `gitignore_建议.txt` → `docs/handover/` + - 两个 `SHA256SUMS_包内.txt` → `docs/reference/`,重命名以区分作用域 +- 原包根目录的 `README.md`(本为后端工程说明)保留为 `aomin-gateway/README.md`,仓库根改为项目总览。**文件内容未改。** + +### Excluded + +- `03_数据库与Flyway迁移/`:与 `aomin-gateway/src/main/resources/db/migration/` 是同一套 12 条的两次出现。仓库只保留工程内一套,避免两处漂移;镜像与工程内副本已核为**逐字节一致**,指纹见 `docs/reference/原始交付包指纹.md`。 +- `target/`、`*.jar`:构建产物。 +- 证据原始数据(`raw/`、日志、截图):按受控目录单独交付。 +- `99_应急恢复(非正式路径)`:应急手段,非主干迁移序列。 +- 评审用虚构台账数据:演示数据。 + +### Security + +- 按 SEC-Z-01 清理后入库:**15 处明文口令 → 0 处**,扫描 0 命中,`.git` **无历史污染**(本仓库为首次提交,交付包内无 `.git`)。 +- 目录重建时已重新扫描一次硬编码凭据,未新增命中;`application.yml` 的口令仅从 `${DB_PASS}` 取,无默认值。 +- 口令**未轮换**,登记为待确认项,判据见 `docs/handover/02_SEC-Z-01_敏感信息扫描报告.md` 第四节。 + +### Known Issues + +以下问题在交付包中即存在,本次整理**未修改代码**(改动会变更受控哈希,需走会签): + +- `src/main/resources/application.yml` 注释残留"本地库已有 83 表""从 V1.0.1 起递增",实际为 87 表、终态 `1.0.28`;`baseline-description` 仍是 `baseline-83-tables`。 +- `config/GatewayProperties.java` 的 `auth.enabled` 默认 `false`,共享密钥默认占位值 `change-me-in-production`;上线前必须注入真实值并打开,否则 `/internal/**` 只靠网络闸防护。 +- 工程内无 `src/test`,无单元测试;验证依赖 155 条外部证据脚本(未随仓库交付)。 +- `03_Z-02` 给的源码树摘要 `5E9F894B…` 无法用文中描述的算法复现(缺分隔符、大小写、路径前缀规则),已试 60 余种组合。只能作标识引用,不作校验凭据。 +- 交付包行尾不统一(14 个文件 CRLF、其余 LF),**未归一化**——归一化会改变整树字节,使摘要复核失效;`.gitattributes` 设为 `* -text` 原样保存。 +- A-Z-01/02/03 未关闭;并发 PULL 与真机未覆盖。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f6fcf09 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,142 @@ +# 协作规范 + +本仓库是 M-Z 业务层的唯一主干。以下规则不是风格偏好,每一条都对应过一类实际事故,改动前请先读完。 + +## 一、分支模型 + +| 分支 | 用途 | 说明 | +|---|---|---| +| `main` | 主干,随时可构建 | 不直接提交。只接受合并 | +| `feat/<简短英文>` | 新功能 | 如 `feat/notify-retry` | +| `fix/<简短英文>` | 缺陷修复 | 如 `fix/raw-ref-null` | +| `docs/<简短英文>` | 仅文档 | 如 `docs/j00-v18` | +| `hotfix/<简短英文>` | 生产应急 | 合并后必须回写 `main` | + +分支名用英文小写与连字符。不建长期存在的个人分支——长期分支与主干拉开两周以上,合并冲突的代价远高于当时直接改主干。 + +## 二、提交信息 + +格式: + +``` +<类型>(<范围>): <一句话说明> + +<可选正文:为什么这么改,以及这样改的代价> +``` + +类型只有六种: + +| 类型 | 用于 | +|---|---| +| `feat` | 新增能力 | +| `fix` | 修缺陷 | +| `refactor` | 不改行为的重构 | +| `docs` | 文档与契约 | +| `chore` | 构建、依赖、配置 | +| `revert` | 回退 | + +范围写模块名,不写人名。示例: + +``` +fix(internal): I-12 原文缺失返回 404 而不是 500 + +rawRef 指向不存在的行时,原先抛 NPE 被全局兜底成 500, +接入侧无法区分"我写错了"和"你这边崩了"。改为显式 404 + 原因码 RAW_REF_NOT_FOUND。 +``` + +**一条提交只做一件事。** 把"改接口"和"顺手格式化整个文件"混在一条提交里,出问题时无法单独回退其中一件。 + +**不要写"按某人要求修改"。** 写清楚改了什么、为什么,谁要求的写在会签与议题里,不写在提交信息里。 + +## 三、绝不入库的东西 + +| 类别 | 具体 | +|---|---| +| 凭据 | 口令、token、AK/SK、私钥、`.pem`、`.key`、`.jks`、证书 | +| 本地配置 | `.env`、`application-local.yml`、`*.local.yml` | +| 构建产物 | `target/`、`*.jar`、`*.war`、`*.class` | +| IDE 与系统 | `.idea/`、`*.iml`、`.vscode/`、`Thumbs.db`、`.DS_Store` | +| 运行产物 | 日志、`*.jsonl`、证据 `raw/`、截图 | +| 公司无关资料 | 员工绩效、客户名单、采购函、其他项目报价 | + +`.gitignore` 已经覆盖上述路径,但**它只挡新增,不挡已提交**。加文件前先 `git status` 看一眼。 + +判断标准很简单:**这个文件一旦公开,会不会造成损失?** 会,就不要放进来。Git 的历史是追加式的,删除只影响最新版本,历史里那份永远在。 + +## 四、迁移脚本(Flyway)硬规则 + +1. **只增不改。** 已执行过的 `V1.0.x__*.sql` 一个字节都不能动。改动会让校验和不符,已部署环境启动即失败(`Migration checksum mismatch`)。 +2. **不重命名、不删除、不调换顺序。** 版本号是执行顺序的唯一依据。 +3. **编号连续递增**,命名 `V1.0.__<英文小写下划线>.sql`。当前终态 `1.0.28`。 +4. **加列与加索引拆成两条 `ALTER`。** INSTANT 与 INPLACE 混在同一条语句里,MySQL 会整体降级为重建表,大表上表现为长时间锁表。 +5. **迁移里不写 `ALGORITHM=INSTANT`。** 本机 MySQL 是 8.0.11,该语法 8.0.12 才有,写了后端起不来;确需指定用 `ALGORITHM=INPLACE, LOCK=NONE`。 +6. **校验和不符不要手改 `flyway_schema_history`。** 用 `flyway repair`,它只重写校验和与描述,不改表结构。 +7. 一期与二期脚本分目录。二期脚本放 `db/migration-phase2/`,**不要**放进 `spring.flyway.locations` 指向的目录,否则一期启动会建出二期空表。 + +## 五、接口契约硬规则 + +本工程**没有运行时 OpenAPI 生成**(未引入 springdoc)。`docs/contract/` 下的 md 与 yaml 是**手工维护**的唯一机读契约,因此"改了代码但没改文档"就是契约漂移,且没有任何工具会报错。 + +| 改动 | 必须同步 | +|---|---| +| 接口增删、字段增删、字段语义变化 | `docs/contract/6_J-00_接入接口规格_V1.8.md` | +| 同上,机读部分 | `docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml` | +| 五个内部接口(I-01/I-02/I-05/I-12/I-18) | `docs/contract/内部五接口定义.md` | +| 状态、结果码、状态迁移 | `docs/contract/状态机.md` | + +契约版本号升级时,文件名、正文、yaml 的 `info.version` 三处必须一致,旧版本文件保留不删。 + +**以下是不许自己决定的判断题,先会签再改:** 幂等键的构成、`rawRef` 的必填性与指向、时间来源与精度口径、命令状态数量、告警状态与动作的映射。这些一旦出现两套写法,表现是"两边都正常,数据互相不认"。 + +## 六、目录约定 + +- **新增文档进 `docs/`**,按性质放 `handover/`(交付与清理)、`contract/`(接口契约)、`reference/`(指纹与校验)。不要堆在仓库根。 +- **目录名用 ASCII**(`docs`、`demo`、`aomin-gateway`),避免跨平台与 URL 编码问题。 +- **文件名可用中文**。`docs/` 下的历史文件保留原始文件名,因为会签材料、复核记录按名字索引,改名等于让它失联。 +- **新增文档不要覆盖旧版**:同主题改版新建文件、版本号 +1,旧件保留。 + +## 七、行尾与编码 + +| 文件 | 约定 | +|---|---| +| `.bat` / `.cmd` | **GBK 编码 + CRLF**,脚本内 `chcp 936`。用 UTF-8 编辑器"修正"会立刻让中文提示变乱码 | +| 新增源码与文档 | UTF-8(无 BOM)+ LF | +| `.jar` / 图片 / 文档 | 二进制,禁止行尾转换 | + +**`.gitattributes` 里 `* -text` 是刻意的,不要改成 `text=auto`。** 基线按交付字节原样保存,源码树摘要才可复现;一旦开启自动转换,克隆出来的树与交付包不是同一份字节,摘要对不上,复核会误判成"被改过"。归一化行尾属于基线变更,要走会签。 + +代价是:交付包内 14 个文件本来就是 CRLF,与新增的 LF 文件混在一起,`git diff` 遇到"整文件行尾变化"时看不出实质改动。改老文件前先确认它的行尾,别让编辑器顺手整篇转换。 + +Windows 上建议开启中文路径正常显示: + +```bash +git config --global core.quotepath false +``` + +## 八、评审 + +| 改动类型 | 至少几人看 | 谁必须看 | +|---|---|---| +| 迁移脚本、表结构 | 2 | 数据库负责人 | +| 接口契约、内部五接口 | 2 | 架构一致性负责人 | +| 权限、鉴权、加解密 | 2 | 架构一致性负责人 | +| 其他代码 | 1 | 模块负责人 | +| 纯文档 | 1 | — | + +评审看三件事,不看代码风格:**改了会不会影响已验收的东西;有没有第二套实现;文档跟上了没有。** + +## 九、大文件 + +单个文件超过 5 MB 不要直接提交。演示素材、证据、构建产物走受控目录或发布附件,仓库只放需要版本追溯的内容。 + +已经提交进去的大文件,删除也只是"最新版本没有",`.git` 里仍然存在。真要清理得重写全部历史,代价远高于当初不放进来。 + +## 十、开工前自检 + +```bash +git status # 有没有把 target/ 和 .env 带进来 +git diff --cached --stat # 改动范围是否符合预期 +git grep -nIE 'password|secret|token' --cached -- ':!docs' # 有没有硬编码凭据 +``` + +第三条命中不一定是问题(变量名也会命中),但每一条都要能说清为什么不是问题。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5aa5558 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +版权所有 © 2026 奥敏 保留所有权利 + +本仓库及其全部内容(包括但不限于源代码、数据库迁移脚本、接口契约、设计文档、演示素材与设备模拟器)为奥敏的专有财产与商业秘密。 + +一、权利保留 +本文件不构成任何形式的许可、授权或权利转让。未取得奥敏的书面许可,任何人不得复制、修改、翻译、汇编、反向工程、分发、公开传播、再许可,或将本仓库内容用于鹤仆·守望项目之外的任何用途。 + +二、保密 +本仓库内容按"内部"密级管理,含服务器地址、账号信息与接口设计。访问者因工作需要获得的访问权限不构成对外披露的授权;访问权限不得转让、不得共享账号、不得将内容复制到公开平台或公共 AI 服务。 + +三、第三方组件 +本仓库引用的第三方库、框架与工具(如 Spring Boot、MyBatis-Plus、Flyway、MySQL 驱动等)遵循其各自的许可证,不在本声明范围内。第三方组件清单见 `aomin-gateway/pom.xml`。 + +四、无担保 +本仓库内容按"现状"提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性与不侵权的担保。 + +五、终止 +违反上述任一条款即自动终止访问权限,已获得的副本应予销毁并书面告知。 + +--- + +*正式授权文本以奥敏出具的书面文件为准;本文件为仓库内的权利声明,替代开源许可证使用。* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d259381 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# 鹤仆·守望 S 线一期 M-Z 业务底座 + +鹤仆·守望老人安全监测平台 S 线一期的**中段业务层唯一主骨架**,承接 J-00 V1.8.1 的五个内部接入接口与 `/api/v1/**` 业务接口。数据库为唯一事实源,Flyway 管理全部迁移,不接受手工改表。 + +本仓库同时是 M 域接入与内部契约的存放地,作为团队唯一的代码事实来源。 + +## 一、版本基线 + +| 项 | 值 | +|---|---| +| 模型版本 | **V1.9**(已冻结;本次仅为 SEC-Z-01 最小安全清理,未升 V1.10) | +| 内部契约 | J-00 **1.8.1** | +| 迁移序列 | Flyway **1.0.28**(12 条),产出 87 张业务表 | +| 技术栈 | JDK 21 · Spring Boot 3.3.5 · MyBatis-Plus 3.5.7 · MySQL 8.0 · Redis 7 | +| 源码树摘要 | `5E9F894B0D1071969A968F2A9B1451279ADF39D423148186886BFD064398F286` | +| 证据批次 | `R20261113-112443`,11 组 / 155 条 / 失败 0 | + +源码树摘要是交付方给出的整树指纹。**它的算法在本仓库无法复现**(描述缺分隔符与大小写规则,试过 60 余种组合均对不上),因此只作标识引用,不作校验凭据。真正可校验的是 [`docs/reference/SHA256SUMS_源码树.txt`](docs/reference/SHA256SUMS_源码树.txt):84 行标准 `sha256sum` 清单,`sha256sum -c` 直接可用,导入时自校验 84/84 通过。 + +## 二、目录结构 + +``` +. +├── aomin-gateway/ 后端 Maven 工程(87 文件,构建与部署见其 README) +│ ├── pom.xml +│ ├── start-gateway.bat Windows 本地启动器(GBK + chcp 936) +│ └── src/main/ +│ ├── java/cn/aomin/gateway/ 业务代码 +│ └── resources/ +│ ├── application.yml 无任何内置口令 +│ └── db/migration/ Flyway 12 条(V1.0.0 ~ V1.0.28) +├── demo/ 演示前端 + 设备模拟器(**不是正式三端**) +│ ├── web/ 运营后台、家属端、地图选点(静态页) +│ └── sim/sim_device.py 扮演网关 + 7 台设备,只调 I-01/I-02/I-12 +├── docs/ +│ ├── handover/ 交付与安全清理材料(Z-01/Z-02) +│ ├── contract/ 接口契约:J-00 规格、OpenAPI、内部五接口、状态机 +│ └── reference/ 原始交付包指纹与迁移脚本哈希 +├── .gitattributes 行尾与二进制策略 +├── .editorconfig 缩进与编码 +├── CHANGELOG.md 版本记录 +├── CONTRIBUTING.md 分支、提交信息、迁移与契约的硬规则 +├── SECURITY.md 敏感信息禁止清单与泄露处置 +└── LICENSE 专有软件声明(非开源) +``` + +## 三、快速开始 + +```bash +# 1) 构建(需要 JDK 21 与 Maven;离线环境加 -o,有私服加 -s ) +cd aomin-gateway +mvn -DskipTests clean package +# 产物:target/aomin-gateway-0.0.1-SNAPSHOT.jar + +# 2) 启动(DB_PASS 必填,缺失时应用立即退出,不会回退到任何内置口令) +export DB_PASS='<口令>' +export DB_USER=aomin +java -jar target/aomin-gateway-0.0.1-SNAPSHOT.jar --server.port=8080 --server.address=0.0.0.0 + +# 3) 自检 +curl -s http://127.0.0.1:8080/health +``` + +正常返回需同时满足 `db: connected`、`tables: 87`、`flyway: 1.0.28`、`status: UP`。 + +建库、环境变量、systemd 单元、Flyway 校验和不符的处理,全部在 [`aomin-gateway/README.md`](aomin-gateway/README.md)。 + +演示前端与模拟器的跑法在 [`demo/README.md`](demo/README.md)。 + +## 四、文档索引 + +| 需要知道什么 | 看哪里 | +|---|---| +| 这批源码的来历、包里有什么、怎么用 | [`docs/handover/00_包内容与使用说明.md`](docs/handover/00_包内容与使用说明.md) | +| 15 处明文各自改成了什么 | [`docs/handover/01_SEC-Z-01_最小安全清理差异清单.md`](docs/handover/01_SEC-Z-01_最小安全清理差异清单.md) | +| 敏感信息扫描范围、方法、盲区、轮换结论 | [`docs/handover/02_SEC-Z-01_敏感信息扫描报告.md`](docs/handover/02_SEC-Z-01_敏感信息扫描报告.md) | +| 清理前后指纹、构建步骤、首提交顺序 | [`docs/handover/03_Z-02_清理后哈希与构建说明.md`](docs/handover/03_Z-02_清理后哈希与构建说明.md) | +| 155 条证据能证明什么、不能证明什么 | [`docs/handover/04_155条证据适用边界.md`](docs/handover/04_155条证据适用边界.md) | +| 五个内部接口的字段与状态机 | [`docs/contract/内部五接口定义.md`](docs/contract/内部五接口定义.md)、[`docs/contract/状态机.md`](docs/contract/状态机.md) | +| 接入接口的机读契约 | [`docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml`](docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml) | +| 原始交付包与迁移脚本的哈希 | [`docs/reference/原始交付包指纹.md`](docs/reference/原始交付包指纹.md) | + +## 五、已知边界与不一致 + +接手上手前必须知道这些,否则容易误判。 + +| 项 | 实际情况 | +|---|---| +| 演示前端是否随包交付 | `docs/handover/00` 第二节称"没给演示前端",但实际交付包内**含 8 个文件**,已整理到 `demo/`。该说明为早于补包的旧稿,以实际文件为准 | +| 迁移镜像目录 | 原包内 `03_数据库与Flyway迁移/` 与工程内 `db/migration/` 是同一套 12 条的两次出现。仓库**只保留工程内一套**(避免两处漂移),镜像已核为逐字节一致,指纹见 `docs/reference/` | +| `application.yml` 注释 | 注释里"本地库已有 83 表""从 V1.0.1 起"是历史残留,实际 87 表、终态 1.0.28。**未改动**——改动会变更受控哈希,需走会签 | +| 内部签名开关 | `auth.enabled` 默认 `false`,共享密钥默认占位值 `change-me-in-production`。上线前必须从环境变量注入并打开,见 `SECURITY.md` | +| 数据库口令轮换 | **未轮换**,登记为待确认项,见 `docs/handover/02` 第四节 | +| A-Z-01/02/03 | **未关闭**。作者自测通过,仍需接收方复验签认,本仓库不构成关闭 | +| 并发 PULL | 未覆盖(v20 是串行 10 条),由谢工按完整矩阵补测 | +| 真机 | 未覆盖,等黄工真机窗口 | +| 单元测试 | 工程内**无 `src/test`**,验证依赖 155 条外部证据脚本(不随仓库交付) | +| 源码树摘要 | 交付方给的 `5E9F894B…` 无法用文档描述的算法复现,改用逐文件清单校验,见 `docs/reference/` | +| 行尾 | 交付包内 14 个文件是 CRLF、其余 LF,属既有状态。**未归一化**(会改变整树字节,使摘要复核失效),`.gitattributes` 设为 `* -text` | + +## 六、什么不进仓库 + +| 不进 | 原因 | +|---|---| +| `target/`、`*.jar` | 构建产物,可由源码重建 | +| `.env`、`*.local.yml`、`*.pem`、`*.key` | 可能含口令与私钥 | +| 证据原始数据(`raw/`、日志、截图) | 体积大、每轮重跑全变,按受控目录单独交付 | +| `99_应急恢复(非正式路径)` | 应急手段,不是主干迁移序列,放进来容易被当成常规路径 | +| 评审用虚构台账数据 | 演示数据,不属于主干 | +| 任何明文口令 | SEC-Z-01 已清零,**一个字符都没有**;新增一处即事故,见 `SECURITY.md` | + +## 七、范围 + +一期只做 **S 线**(麦乐克 + 豪恩两条通路)。G 线、H 线、探视、结算、钱包不在本期。内部接口 `/internal/**` 只允许内网访问,依赖来源白名单,**不要暴露到公网**。 + +## 八、许可 + +专有软件,版权归奥敏所有,非开源。未经书面许可不得复制、分发或用于本项目之外,详见 [`LICENSE`](LICENSE)。 + +--- + +*本仓库由周工交付的 SEC-Z-01 清理后源码整理而来,用于建立 M-Z 主干。* diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..431f7bd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,80 @@ +# 安全策略 + +本仓库含业务源码、接口设计与部署细节,密级为**内部**。以下内容针对"敏感信息如何避免进入仓库、以及进来之后怎么办"。 + +## 一、报告安全问题 + +发现凭据泄露、越权、注入等问题,**不要开公开议题、不要贴截图到群里**。直接联系: + +- 项目负责人(陈工),或 +- 邮箱 `aomin@aomin.cn` + +报告中不要复述口令原文,只说明"哪个文件、哪一行、是哪一类凭据"。凭据原文一旦进入聊天记录或截图,处置成本立刻从"改一个文件"变成"换一套口令 + 重跑全部证据"。 + +## 二、绝不能提交的内容 + +口令、token、AK/SK、私钥与证书、`.env` 与 `*.local.yml`、含口令的截图、真实的第三方服务密钥。 + +完整清单与目录约定见 [`CONTRIBUTING.md`](CONTRIBUTING.md) 第三节。 + +## 三、发现泄露后的处置顺序 + +顺序不能反。**先轮换,再清理,最后记录。** + +1. **立即轮换**该凭据。假定它已经泄露,不要先花时间判断"有没有人看到"。 +2. **评估影响面**:这把凭据能访问什么?如果它出现在一个曾经外发的包里,接收方副本无法回收,轮换是唯一有效手段。 +3. **清理当前工作副本**,并重新扫描全树(含二进制、含压缩包解压后)。 +4. **如实登记**:什么时候、哪个文件、哪类凭据、是否已轮换。不写"已处理"了事。 + +### 不要做的事 + +- **不要只删文件就认为解决了。** Git 记录每一版,删掉后历史里那份仍然存在,`git log -p` 照样能翻出来。 +- **不要手改历史**(`filter-repo`)来"洗掉"凭据后又当没发生过。重写历史会让所有人的本地副本失效,必须全员重新克隆,属于需要提前通知的操作。**这种情况下轮换凭据的成本永远低于重写历史。** +- **不要对 zip / jar 直接做字节搜索。** 成员内容被压缩过,字节搜索必然返回"无命中",是假阴性。必须解开逐成员扫。 + +## 四、当前安全状态 + +| 项 | 状态 | +|---|---| +| 明文口令 | SEC-Z-01 清理后 **0 处**(原 15 处),扫描 0 命中 | +| Git 历史污染 | **无**。本仓库为首次提交,交付包内不含 `.git`,属干净起点 | +| `application.yml` 数据库口令 | 仅从 `${DB_PASS}` 取,**无默认值**;未设置时启动即退出(退出码 1),不会带空口令连库 | +| 数据库口令轮换 | **未轮换**,登记为待确认项。该口令是评审机本地演示库的 `root` 口令,仅监听 `127.0.0.1`;但它进过 6 个历史包,若曾外发则等同已离开本机。判据见 `docs/handover/02_SEC-Z-01_敏感信息扫描报告.md` 第四节 | +| 内部接口签名 | `auth.enabled` 默认 **false**,共享密钥默认占位值 `change-me-in-production`。**上线前必须从环境变量注入真实值并打开**,否则 `/internal/**` 只靠来源网络闸防护 | +| 来源网络闸 | `networkGuardEnabled` 默认 **true**,独立于签名开关。这个默认值是对的:签名可以晚点开,网络闸必须一开始就有 | +| 百度地图 AK | 已替换为运行时占位符 `__BAIDU_AK__`,仓库内无真实 AK | +| 天地图密钥 | 已替换为占位符 `__WB_TMAP_SECRET__` / `__WB_HTTP_PORT__`,仓库内无真实值 | + +### 一个已知例外 + +`demo/web/family/index.html` 保留演示账号提示 `13800000000 / 123456`。这是 `BIZ_DEMO_ENABLED=true` 时由程序向数据库播种的演示数据,不是系统凭据,也不是任何环境的管理员账号。仅在开启演示模式且库里已播种的库上有效。 + +**但它是明文。** 处置方式不是删掉这行,而是保证 `BIZ_DEMO_ENABLED` 只在演示环境开启(生产严禁开启,见 `aomin-gateway/README.md` 第十节)。 + +## 五、扫描怎么做 + +交付时用过的方法,任何人可原样复跑。要点三条:文本与字节分开扫、压缩包解开再扫、换成你要找的凭据值。 + +```bash +# 文本层(排除构建产物与 .git) +grep -rn --binary-files=without-match -iE '(password|passwd|secret|token|access-?key)[[:space:]]*[:=][[:space:]]*["'\'']?[A-Za-z0-9@#$%^&*._-]{6,}' \ + --include='*.java' --include='*.yml' --include='*.yaml' --include='*.xml' \ + --include='*.py' --include='*.html' --include='*.js' --include='*.sql' --include='*.md' \ + --exclude-dir=.git --exclude-dir=target . +``` + +命中不一定是问题(`String token = ...` 也会命中),但**每一条都要能说清为什么不是问题**。说不出,就按泄露处理。 + +二进制与压缩包按 `docs/handover/02_SEC-Z-01_敏感信息扫描报告.md` 第二节的 Python 脚本逐成员扫。 + +## 六、待办 + +| 项 | 责任方 | 判据 | +|---|---|---| +| 演示库 `root` 口令是否轮换 | 项目负责人 | 确认 6 个历史包是否曾外发;若曾外发则轮换并重跑 155 条(约 13 分钟) | +| `auth.enabled` 上线前打开并注入真实密钥 | 部署方 | `/internal/**` 无签名请求被拒 | +| 新增 token、AK/SK 时补做一次扫描 | 提交人 | 按第三节顺序处置 | + +--- + +*仓库托管侧的安全事项(访问控制、证书、备份)不在本文件范围。* diff --git a/aomin-gateway/README.md b/aomin-gateway/README.md new file mode 100644 index 0000000..3e44733 --- /dev/null +++ b/aomin-gateway/README.md @@ -0,0 +1,210 @@ +# aomin-gateway + +鹤仆·守望 **业务层接入网关(M 域)**。承载 J-00 V1.8 里 `/internal/iot/v1/**` 五个接口。 + +技术栈:JDK 21 / Spring Boot 3.3.5 / MyBatis-Plus 3.5.7 / Flyway / MySQL 8 / Redis。 + +--- + +## 一、启动前提 + +MySQL 和 Redis 必须先起来(本机是绿色版,不注册服务): + +``` +G:\aomin-dev\start-mysql.bat +G:\aomin-dev\start-redis.bat +``` + +两个窗口都显示 OK 之后再启本服务。 + +## 二、启动 + +双击: + +``` +G:\aomin-dev\app\aomin-gateway\start-gateway.bat +``` + +或命令行: + +```bat +set JAVA_HOME=G:\aomin-dev\jdk-21.0.2 +cd /d G:\aomin-dev\app\aomin-gateway +mvn -s G:\aomin-dev\maven\conf\settings.xml spring-boot:run +``` + +看到 `Tomcat started on port 8080` 即成功。 + +## 三、验证 + +浏览器或 curl: + +``` +http://127.0.0.1:8080/health +``` + +正常返回: + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "app": "aomin-gateway", + "db": "connected", + "tables": 83, + "schema": "aomin_dev", + "redis": "connected", + "flyway": "1.0.0", + "status": "UP" + } +} +``` + +**`tables` 必须是 83,`flyway` 必须是 1.0.0。** 少一个就说明基线没认上。 + +## 四、数据库 + +| 项 | 值 | +|---|---| +| 地址 | `127.0.0.1:3306` | +| 库 | `aomin_dev` | +| 用户 / 密码 | `root` / 评审机本地演示库口令(见 `05_启动构建与测试说明` §6;生产用环境变量覆盖) | +| 表数 | **87 张业务表**(83 基线 + `sys_user_region_scope` + `iot_raw_ref_gap` + `alarm_dead_letter`),含 `flyway_schema_history` 共 87 | + +### Flyway 基线约定 + +`src/main/resources/db/migration/V1.0.0__baseline_schema.sql` 是 83 张表的完整 DDL,**一经提交不得修改**。 + +- 本机库是手动建的,首次启动时 Flyway 用 `baseline-on-migrate` 把它认定为 V1.0.0,不重复建表; +- 空库部署(测试环境、生产)时,Flyway 会完整执行 V1.0.0 建全部表; +- 今后任何改表,新增 `V1.0.1__xxx.sql`、`V1.0.2__xxx.sql`,**不要动 V1.0.0**。 + +改表前先在 `flyway_schema_history` 里确认当前版本,避免版本号撞车。 + +## 五、代码结构 + +``` +cn.aomin.gateway +├── GatewayApplication @EnableScheduling(命令扫描 + 租约回收要靠它) +├── common +│ ├── ApiResponse {code, msg, data} +│ ├── BizException 400 / 401 / 409 +│ └── GlobalExceptionHandler 统一兜底 +├── config +│ ├── GatewayProperties gateway.* 配置绑定 +│ ├── WebConfig 拦截器挂载 /internal/** +│ └── RedisConfig String 序列化 +├── interceptor +│ └── AuthInterceptor M-01:Token → 时间戳 → nonce → 签名(顺序即成本顺序) +├── security +│ ├── CachedBodyFilter 缓存 body(验签要读,流只能读一次) +│ ├── SignatureVerifier HMAC-SHA256(token, method\npath\nts\nnonce\nsha256(body)) +│ ├── NonceStore Redis SETNX,key 带 token 摘要前缀 +│ ├── RateLimiter 通道 + 设备双维度;设备维度走 pipeline +│ ├── IdempotencyStore 四态:NEW / REPLAY / IN_PROGRESS / CONFLICT +│ └── IdempotentExecutor 把幂等包在业务处理外面 +├── controller +│ ├── HealthController /health 四环节自检 +│ ├── IngestController I-01 / I-02 / I-12 +│ └── CommandController I-18 / I-05 +├── service +│ ├── IngestService M-02 单事务 + 逐条 SAVEPOINT +│ ├── StatusService M-03 覆盖写 +│ ├── RawFrameService M-04 两张原文表分流 +│ └── CommandService M-05 长轮询 + 租约 + 状态流转 +├── dao 全部 JdbcTemplate,没有 MyBatis Mapper +│ ├── DeviceDao 一次 IN 批量查 +│ ├── IotEventDao 18 列 INSERT +│ ├── TechCacheDao GREATEST 防回退的 upsert +│ ├── RawFrameDao 两张原文表 +│ ├── UnregisteredDao 两个场景分开(带/不带 last_payload) +│ └── CommandDao FOR UPDATE SKIP LOCKED +├── domain +│ ├── Channel 四通道枚举(含 vendor 映射、落哪张原文表) +│ ├── DeviceRow / TimeResult record +│ └── dto 请求/响应 DTO,字段名一律按 J-00 V1.8 驼峰 +└── support + ├── EventTimeResolver event_time 派生(纯函数,四入两出) + └── EventValidator 字段级校验,reason 只用 J-00 那四个词 +``` + +**没有 MyBatis Mapper**:逐条 SAVEPOINT、批量 IN、upsert 都要精确控制 SQL,JdbcTemplate 更直接。pom 里的 mybatis-plus 只是留口子,不扫描任何东西。 + +## 六、五个接口:全部已实现 + +| 单元 | 接口 | 状态 | +|---|---|---| +| M-01-U1 | `/internal/**` 公共拦截层 | ✅ Token / 时间戳 / nonce / 签名(`gateway.auth.enabled` 控制) | +| M-02-U1 | `POST /internal/iot/v1/events/batch` | ✅ 单事务 + 逐条 SAVEPOINT,四计数 | +| M-03-U1 | `POST /internal/iot/v1/device-status/batch` | ✅ `device_tech_cache` 覆盖写 | +| M-04-U1 | `POST /internal/iot/v1/raw-frames` | ✅ 按通道分落两张原文表 | +| M-05-U1 | `GET /internal/iot/v1/commands/pending` | ✅ DeferredResult + 500ms 扫描 | +| M-05-U2 | `POST /internal/iot/v1/commands/result` | ✅ 五个 result 值分别处置 | + +### 冒烟测试(2026-11-05 已跑通) + +| 场景 | 期望 | 实测 | +|---|---|---| +| I-01 正常一条 | `accepted=1` | ✅ | +| I-01 原样重发 | `duplicated=1` | ✅(撞 `uk_event_id`) | +| I-01 未建档设备 | `unregistered=1`,进 `unregistered_device` | ✅ | +| I-01 缺 `tsSource` | `rejected=1 SCHEMA_INVALID` | ✅ | +| I-01 前缀与通道不匹配 | `rejected=1 SENSOR_ID_INVALID` | ✅ | +| I-01 墓碑帧 | `event_time=1900-01-01`、`is_backfill=1`、落 `p1900` | ✅ | +| I-02 `online=true` | 写 `ONLINE` | ✅ | +| I-02 `online=false` 且时间更早 | **不改** `online_status`、`last_seen_at` **不回退** | ✅ | +| I-02 `battery` 百分比 | 保持 NULL,只写 `battery_mv` | ✅ | +| I-12 G 线 | `payload` 存 Base64 文本、`payload_size`=解码字节数 | ✅ `FROM_BASE64()` 可还原 | +| I-12 同帧重发 | `duplicated=1` | ✅(撞 `uk_md5`) | +| I-12 `sensorId` 为空 | `device_sn='UNKNOWN'`、`parse_result='FIELD_MISSING'` | ✅ | +| I-18 拉取 | 待下发 → 已下发,打 60s 租约 | ✅ | +| I-18 长轮询唤醒 | 有新命令 500ms 内返回 | ✅ | +| I-18 空等超时 | 到点返回空列表,不报错 | ✅ | +| I-05 `ACKED` code=0 | **设备有回应**,租约清空(原写作"已生效",语义纠正:回执≠执行成功,见下) | ✅ | +| 租约回收 | 已下发 → 待下发,`reclaim_count+1`,`retry_count` 不变 | ✅ | +| 幂等同 key 同 body | 返回首次结果 | ✅ | +| 幂等同 key 异 body | 409 | ✅ | + +> **上表是 2026-11-05 的实测记录,如实保留;命令状态值已在 V1.2(11-10)改为英文七态并落码。** +> 目标态 `PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED`(J-00 V1.8 §3.4), +> 迁移脚本 `V1.0.23__command_seven_states.sql` 把存量中文六值一次刷掉; +> `CommandDao` 七态全量实现,领取置 `CLAIMED`(不再写「已下发」)并记租约代次。 +> **`ACKED` 只代表设备有回应,不等于执行成功** —— 是否生效看 `resultCode`(`0` 为成功)。 +> 详见 `04_OpenAPI与内部IoT契约/状态机.md` §二。 +> +> 命令链与告警重试的可重跑验证见 `06_统一闭环题自测证据/cmd_chain_test.py`(23 条)与 +> `alarm_retry_test.py`(5 条)。 + +**库里留了两台测试设备**(`A1B2C3D4` = OWN_GW 门磁、`MIR:861234567890123` = MIR_CELL 水浸),其余测试数据已清空,可以直接拿这两台 curl 自测。 + +## 七、两个必须遵守的硬约束 + +1. **M-02 同步落库,不要异步入队。** 返回值要带 `accepted / rejected / duplicated / unregistered` 四个计数,其中 `unregistered` 必须现查 `device` 表才知道,入队就丢了。 +2. **查 `device` 必须 `IN (…)` 批量查。** 200 条一批、每秒多批,退化成 200 次单查会把连接池打满(现在只有 10 个连接)。 + +## 八、配置开关 + +| 配置项 | 默认 | 说明 | +|---|---|---| +| `gateway.auth.enabled` | `false` | 打开后启用 Token/签名/时间戳/nonce 校验。**联调初期先别开**,否则 J 模块一个请求都进不来 | +| `spring.flyway.enabled` | `true` | 关掉就不会自动建表/升级,慎用 | +| `logging.level.cn.aomin.gateway` | `DEBUG` | 上线改 INFO | + +日志文件:`G:\aomin-dev\logs\app.log`(按 50MB 滚动,留 15 个)。 + +## 九、两个已踩过的坑(别再踩) + +### 1. 端口被改成 52228 + +日志里出现 `Tomcat initialized with port 52228`,然后报 `Port 52228 was already in use`。 + +原因:系统环境变量里有个 `SERVER__PORT=52228`(WorkBuddy 之类的工具注入的),Spring Boot 的能量绑定把它当成 `server.port` 读进去,**优先级高于 application.yml**。 + +处理:`start-gateway.bat` 里已经 `set "SERVER__PORT="` 并用 `--server.port=8080` 显式指定(命令行参数优先级最高)。**如果手工用 `java -jar` 起,一定带上 `--server.port=8080`。** + +### 2. JDBC URL 里写 `characterEncoding=utf8mb4` + +会直接抛 `Unsupported character encoding 'utf8mb4'`,连接池建不起来、Flyway 也跟着挂。 + +`characterEncoding` 要写 **Java 字符集名 `UTF-8`**,驱动会自动协商到 MySQL 的 utf8mb4。要指定排序规则用 `connectionCollation=utf8mb4_0900_ai_ci`。 diff --git a/aomin-gateway/pom.xml b/aomin-gateway/pom.xml new file mode 100644 index 0000000..7396e8b --- /dev/null +++ b/aomin-gateway/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + cn.aomin + aomin-gateway + 0.0.1-SNAPSHOT + aomin-gateway + 鹤仆·守望 业务层接入网关(M 域) + + + 21 + UTF-8 + 3.5.7 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + org.apache.commons + commons-pool2 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + src/main/resources + false + + + + diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/GatewayApplication.java b/aomin-gateway/src/main/java/cn/aomin/gateway/GatewayApplication.java new file mode 100644 index 0000000..b91c5d7 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/GatewayApplication.java @@ -0,0 +1,55 @@ +package cn.aomin.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * 鹤仆·守望 业务层接入网关(M 域)启动类。 + * + *

承载 J-00 接入接口规格中 /internal/iot/v1/ 下五个接口: + * M-01 公共拦截层、M-02 事件批量接收、M-03 状态快照、M-04 原文接收、M-05 命令拉取。 + * + *

数据访问全部走 {@code JdbcTemplate},没有 MyBatis Mapper, + * 所以不需要 {@code @MapperScan}。pom 里保留 mybatis-plus 只是为后续业务模块留口子, + * 它不会自动扫描任何东西。 + * + *

{@code @EnableScheduling} 是必须的,有两个定时任务靠它: + * 命令长轮询的 500ms 扫描(M-05),以及租约回收/72h 硬顶的 10s 维护(M-05)。 + * + *

{@code @EnableAsync} 给 {@code AlarmTrigger} 用:I-01 入库后要在另一个线程里 + * 立刻扫一轮告警规则,不能让规则处理时间算进接入接口的响应时间。 + */ +@SpringBootApplication +@EnableScheduling +@EnableAsync +public class GatewayApplication { + + public static void main(String[] args) { + requireEnv("DB_PASS", "数据库口令"); + SpringApplication.run(GatewayApplication.class, args); + } + + /** + * 启动前的必需环境变量校验(SEC-Z-01)。 + * + *

{@code application.yml} 里写的是 {@code password: ${DB_PASS}},不设默认值。 + * 但 Spring Boot 的宽松绑定在占位符解析不到时是"忽略"而不是报错,结果会拿空口令去连库, + * 报出来的是一句 {@code Access denied}——看着像口令错了,实际是根本没配。 + * 所以在这里提前拦住,给一句能照着做的提示,而不是让人去猜。 + * + *

只校验"有没有",不校验"对不对",也不打印任何取值。 + */ + private static void requireEnv(String key, String what) { + String v = System.getenv(key); + if (v == null || v.trim().isEmpty()) { + System.err.println(); + System.err.println("[启动中止] 缺少必需的环境变量 " + key + "(" + what + ")。"); + System.err.println(" SEC-Z-01:程序不再内置任何默认口令,也不会回退到包内明文。"); + System.err.println(" 设置方法:Windows set " + key + "=<口令> Linux/macOS export " + key + "=<口令>"); + System.err.println(); + System.exit(1); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/DemoDataInitializer.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/DemoDataInitializer.java new file mode 100644 index 0000000..4383509 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/DemoDataInitializer.java @@ -0,0 +1,230 @@ +package cn.aomin.gateway.biz; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.service.PiiService; +import cn.aomin.gateway.biz.support.Crypto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * 演示数据初始化。 + * + *

只在 {@code sys_user} 为空时执行一次,跑过就不再动—— + * 所以它既能保证新环境一启动就能登录,又不会反复覆盖你改过的数据。 + * + *

造的东西:3 个角色、1 个后台账号、1 个家属账号、若干字典项, + * 以及一栋楼 + 两户 + 一位老人的最小闭环(并挂到已有的 9001/9002 户上)。 + * + *

上线前删掉这个类,或者在配置里把 {@code biz.demo.enabled} 关掉。 + */ +@Component +public class DemoDataInitializer implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(DemoDataInitializer.class); + + private final BizDao dao; + private final PiiService pii; + private final boolean enabled; + + public DemoDataInitializer(BizDao dao, PiiService pii, + @org.springframework.beans.factory.annotation.Value("${biz.demo.enabled:true}") boolean enabled) { + this.dao = dao; + this.pii = pii; + this.enabled = enabled; + } + + @Override + public void run(ApplicationArguments args) { + if (!enabled) { + return; + } + try { + // 每段自己判空,不用"整体跑过一次就跳过"—— + // 否则中途某段失败,剩下几段就永远补不上了(第一版就踩过这个坑)。 + seedRoles(); + seedUsers(); + seedDicts(); + seedOrgAndElder(); + seedAlarms(); + log.info("演示数据已就绪:后台 admin / Admin@123456,家属 13800000000 / 123456"); + } catch (Exception e) { + log.warn("演示数据初始化跳过:{}", e.getMessage()); + } + } + + private void seedRoles() { + role("OPERATOR", "运营", "ALL"); + role("PROPERTY", "物业", "COMMUNITY"); + role("AUDITOR", "审计只读", "ALL"); + } + + private void role(String code, String name, String scope) { + if (dao.count("SELECT COUNT(*) FROM sys_role WHERE role_code=?", code) == 0) { + dao.insert("INSERT INTO sys_role(role_code, role_name, data_scope_type, builtin) " + + "VALUES(?,?,?,1)", code, name, scope); + } + } + + private void seedUsers() { + if (dao.count("SELECT COUNT(*) FROM sys_user WHERE username='admin'") == 0) { + long adminId = dao.insert( + "INSERT INTO sys_user(username, password_hash, real_name, status) VALUES(?,?,?,'ACTIVE')", + "admin", Crypto.hashPassword("Admin@123456"), "系统管理员"); + Map role = dao.one("SELECT id FROM sys_role WHERE role_code='OPERATOR'"); + if (role != null) { + dao.insert("INSERT INTO sys_user_role(user_id, role_id) VALUES(?,?)", + adminId, role.get("id")); + } + } + if (dao.count("SELECT COUNT(*) FROM wx_user WHERE openid='demo-family'") > 0) { + return; + } + // 家属演示账号:openid 固定,方便用 code=openid:demo-family 直接登录 + String phone = "13800000000"; + long wxId = dao.insert("INSERT INTO wx_user(openid, user_type, nickname, phone_enc, phone_hash, " + + "password_hash, pwd_updated_at, first_login_at, last_login_at, status) " + + "VALUES(?,?,?,?,?,?,NOW(3),NOW(3),NOW(3),'ACTIVE')", + "demo-family", "FAMILY", "张女士", + Crypto.encrypt(phone, "dev-only-pii-key-2026"), + Crypto.sha256(phone), Crypto.hashPassword("123456")); + + // 把演示家属挂到 9001 户(测试台账里就有这户) + if (dao.count("SELECT COUNT(*) FROM household WHERE id=9001") > 0 + && dao.count("SELECT COUNT(*) FROM user_house WHERE wx_user_id=?", wxId) == 0) { + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, status, joined_at) " + + "VALUES(?,?,1,15,'ACTIVE',NOW(3))", wxId, 9001L); + } + } + + private void seedDicts() { + dict("device_type", "设备类型", new String[][]{ + {"DOOR", "门磁"}, {"VIB", "水振"}, {"CURRENT", "电流"}, + {"WATER", "水浸"}, {"SOS", "紧急按钮"}, {"SMOKE", "烟感"}}); + dict("alarm_level", "告警级别", new String[][]{ + {"S1", "紧急"}, {"S2", "重要"}, {"S3", "提示"}}); + dict("alarm_status", "告警状态", new String[][]{ + {"NEW", "新告警"}, {"PUSHED", "已推送"}, {"CONFIRMED", "已确认"}, + {"ESCALATED", "已升级"}, {"HANDLED", "已处置"}, {"CLOSED", "已关闭"}, + {"FALSE", "误报"}, {"DELAYED", "已延后"}, {"SUPERSEDED", "已被替代"}}); + dict("care_level", "照护等级", new String[][]{ + {"SELF", "自理"}, {"HALF", "半自理"}, {"FULL", "不能自理"}}); + dict("user_type", "用户类型", new String[][]{ + {"FAMILY", "家属"}, {"VISITOR", "探视人"}, {"ORG_MANAGER", "机构主管"}}); + } + + private void dict(String type, String name, String[][] items) { + if (dao.count("SELECT COUNT(*) FROM sys_dict WHERE dict_type=?", type) > 0) { + return; + } + dao.insert("INSERT INTO sys_dict(dict_type, dict_name) VALUES(?,?)", type, name); + int i = 1; + for (String[] it : items) { + dao.insert("INSERT INTO sys_dict_item(dict_type, item_value, item_label, sort_no, status) " + + "VALUES(?,?,?,?,'ACTIVE')", type, it[0], it[1], i++); + } + } + + /** + * 两条演示告警,让告警列表和角标不是空的。 + * 只动 alarm 表,不碰 iot_event——那几张表是 M 层用例的计数基准,污染了会误判。 + */ + private void seedAlarms() { + if (dao.count("SELECT COUNT(*) FROM alarm") > 0 + || dao.count("SELECT COUNT(*) FROM household WHERE id=9001") == 0) { + return; + } + dao.insert("INSERT INTO alarm(alarm_no, household_id, dev_id, rule_key, tier, level, title, " + + "content, trigger_at, status, escalate_stage) " + + "VALUES(?,?,?,?,?,?,?,?,DATE_SUB(NOW(3), INTERVAL 25 MINUTE),'NEW',0)", + "ALM-DEMO-001", 9001L, "T-G-01", "WATER_LEAK", "T1", "S1", + "卫生间水浸告警", "水浸探针持续导通 30 秒"); + dao.insert("INSERT INTO alarm(alarm_no, household_id, dev_id, rule_key, tier, level, title, " + + "content, trigger_at, status, escalate_stage, confirmed_at, confirmed_by) " + + "VALUES(?,?,?,?,?,?,?,?,DATE_SUB(NOW(3), INTERVAL 3 HOUR),'CONFIRMED',0," + + "DATE_SUB(NOW(3), INTERVAL 170 MINUTE),1)", + "ALM-DEMO-002", 9001L, "T-G-01", "DOOR_TIMEOUT", "T2", "S2", + "入户门超时未关", "门磁持续开启超过 10 分钟"); + } + + /** 造一栋楼 + 单元,并把已有的 9001/9002 两户挂上去,再给 9001 建一位老人。 */ + private void seedOrgAndElder() { + // community.company_id 是 NOT NULL 且无默认值——空库上必须先有公司, + // 否则这一段在第一步就抛 1364,后面的楼/单元/老人全都补不上。 + // 2026-11-09 空库验证时暴露;之前开发库里有公司所以一直没发现。 + long companyId; + Map co = dao.one("SELECT id FROM pm_company ORDER BY id LIMIT 1"); + if (co == null) { + companyId = dao.insert("INSERT INTO pm_company(company_code, name, short_name, status) " + + "VALUES(?,?,?,'生效')", "CO-DEMO", "鹤仆云运营中心", "鹤仆云"); + } else { + companyId = ((Number) co.get("id")).longValue(); + } + + long communityId; + Map c = dao.one("SELECT id FROM community ORDER BY id LIMIT 1"); + if (c == null) { + communityId = dao.insert("INSERT INTO community(community_code, company_id, name, province, city, " + + "district, address, household_count, status) VALUES(?,?,?,?,?,?,?,2,'ACTIVE')", + "C-DEMO", companyId, "鹤仆云示范小区", "广东省", "中山市", "东区", "示范路 1 号"); + } else { + communityId = ((Number) c.get("id")).longValue(); + } + + Long buildingId; + Long unitId; + Map b = dao.one("SELECT id FROM building WHERE community_id=? ORDER BY id LIMIT 1", + communityId); + if (b == null) { + buildingId = dao.insert("INSERT INTO building(community_id, code, name, floor_count, sort_no, status) " + + "VALUES(?,?,?,?,1,'ACTIVE')", communityId, "B1", "1 栋", 18); + } else { + buildingId = ((Number) b.get("id")).longValue(); + } + Map u = dao.one("SELECT id FROM unit WHERE building_id=? ORDER BY id LIMIT 1", buildingId); + if (u == null) { + unitId = dao.insert("INSERT INTO unit(community_id, building_id, code, name, sort_no, status) " + + "VALUES(?,?,?,?,1,'ACTIVE')", communityId, buildingId, "U1", "1 单元"); + } else { + unitId = ((Number) u.get("id")).longValue(); + } + + dao.update("UPDATE household SET community_id=?, building_id=?, unit_id=?, " + + "address_text=CONCAT('鹤仆云示范小区 1 栋 1 单元 ', room_no) WHERE community_id IS NULL", + communityId, buildingId, unitId); + + // 空库时演示家属还没有家:建一户绑上去,否则家属登录进来是一片空白。 + // 带数据的交付包里这段不会触发(household 非空)。2026-11-09 空库验证补 + if (dao.count("SELECT COUNT(*) FROM household") == 0) { + long demoHouseholdId = dao.insert( + "INSERT INTO household(community_id, building_id, unit_id, room_no, floor, " + + "open_status, elder_count, device_count, mode, baseline_state, " + + "address_text, status) VALUES(?,?,?,?,1,'未开通',0,0,'HOME','学习期',?,'生效')", + communityId, buildingId, unitId, "101", "鹤仆云示范小区 1 栋 1 单元 101"); + Map fam = dao.one("SELECT id FROM wx_user WHERE openid='demo-family'"); + if (fam != null) { + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, status, " + + "joined_at) VALUES(?,?,1,15,'ACTIVE',NOW(3))", + fam.get("id"), demoHouseholdId); + } + } + + if (dao.count("SELECT COUNT(*) FROM household WHERE id=9001") > 0 + && dao.count("SELECT COUNT(*) FROM elder WHERE household_id=9001") == 0) { + long piiId = pii.create("张桂芳", "13900000001"); + long elderId = dao.insert("INSERT INTO elder(household_id, pii_id, gender, birth_date, care_level, " + + "live_alone, profile_authorized, status) VALUES(?,?,?,?,?,1,1,'ACTIVE')", + 9001L, piiId, "F", "1943-05-12", "HALF"); + dao.insert("INSERT INTO elder_contact(elder_id, contact_order, pii_id, relation) " + + "VALUES(?,1,?,?)", + elderId, pii.create("张女士", "13800000000"), "女儿"); + dao.update("UPDATE household SET elder_count=1 WHERE id=9001"); + } + dao.update("UPDATE community SET household_count=(SELECT COUNT(*) FROM household " + + "WHERE community_id=?) WHERE id=?", communityId, communityId); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AdminController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AdminController.java new file mode 100644 index 0000000..68e7849 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AdminController.java @@ -0,0 +1,228 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.AdminService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 后台与物业:小区 / 楼栋 / 单元 / 户 / 老人建档 / 账号 / 字典 / 工单 / 审计。 + * + *

这一组只给 sys_user 用。家属端不走这里,所以越权风险靠"前端不暴露 + 后端登录态"两层, + * 正式上线前还要加角色校验(等 C 组权限矩阵)。 + */ +@RestController +@RequestMapping("/api/v1/admin") +public class AdminController { + + private final AdminService admin; + + public AdminController(AdminService admin) { + this.admin = admin; + } + + // ---------------------------------------------------------------- 小区 + + @GetMapping("/communities") + public ApiResponse>> communities( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.communities(page, size)); + } + + @PostMapping("/communities") + public ApiResponse> createCommunity(@RequestBody Dtos.CommunityCreate req) { + return ApiResponse.ok(Map.of("id", admin.createCommunity(req))); + } + + // ---------------------------------------------------------------- 楼栋 / 单元 + + @GetMapping("/buildings") + public ApiResponse>> buildings( + @RequestParam(required = false) Long communityId) { + return ApiResponse.ok(admin.buildings(communityId)); + } + + @PostMapping("/buildings") + public ApiResponse> createBuilding(@RequestBody Dtos.BuildingCreate req) { + return ApiResponse.ok(Map.of("id", admin.createBuilding(req))); + } + + @GetMapping("/units") + public ApiResponse>> units( + @RequestParam(required = false) Long buildingId) { + return ApiResponse.ok(admin.units(buildingId)); + } + + @PostMapping("/units") + public ApiResponse> createUnit(@RequestBody Dtos.UnitCreate req) { + return ApiResponse.ok(Map.of("id", admin.createUnit(req))); + } + + // ---------------------------------------------------------------- 户 / 老人 + + @GetMapping("/households") + public ApiResponse>> households( + @RequestParam(required = false) Long communityId, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.households(communityId, page, size)); + } + + @PostMapping("/households") + public ApiResponse> createHousehold(@RequestBody Dtos.HouseholdCreate req) { + return ApiResponse.ok(Map.of("id", admin.createHousehold(req))); + } + + @PostMapping("/elders") + public ApiResponse> createElder(@RequestBody Dtos.ElderCreate req) { + return ApiResponse.ok(Map.of("id", admin.createElder(req))); + } + + // ---------------------------------------------------------------- 账号 + + @GetMapping("/users") + public ApiResponse>> users( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.sysUsers(page, size)); + } + + @PostMapping("/users") + public ApiResponse> createUser(@RequestBody Dtos.SysUserCreate req) { + return ApiResponse.ok(Map.of("id", admin.createSysUser(req))); + } + + @GetMapping("/roles") + public ApiResponse>> roles() { + return ApiResponse.ok(admin.roles()); + } + + // ---------------------------------------------------------------- 数据范围(按区域) + + /** 范围配置的下拉选项:库里已有的省市区树 + 小区 + 楼栋。 */ + @GetMapping("/scope/options") + public ApiResponse> scopeOptions() { + return ApiResponse.ok(admin.scopeOptions()); + } + + /** 某个账号当前配的范围。 */ + @GetMapping("/users/{id}/scopes") + public ApiResponse>> userScopes(@PathVariable long id) { + return ApiResponse.ok(admin.userScopes(id)); + } + + /** 覆盖保存某个账号的范围。只有运营账号能调。 */ + @PutMapping("/users/{id}/scopes") + public ApiResponse> saveUserScopes(@PathVariable long id, + @RequestBody Dtos.ScopeSave req) { + return ApiResponse.ok(Map.of("saved", admin.saveUserScopes(id, req))); + } + + // ---------------------------------------------------------------- 字典 + + @GetMapping("/dicts") + public ApiResponse>> dicts() { + return ApiResponse.ok(admin.dicts()); + } + + @GetMapping("/dicts/{type}") + public ApiResponse dictItems(@PathVariable String type) { + return ApiResponse.ok(admin.dictItems(type)); + } + + // ---------------------------------------------------------------- 工单 + + @GetMapping("/work-orders") + public ApiResponse> workOrders( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.workOrders(status, page, size)); + } + + @PostMapping("/work-orders") + public ApiResponse> createWorkOrder(@RequestBody Dtos.WorkOrderCreate req) { + return ApiResponse.ok(Map.of("id", admin.createWorkOrder(req))); + } + + // ---------------------------------------------------------------- 设备入库 / 野设备 + + /** 出厂录入。家属能不能绑,全看这里有没有先录。 */ + @PostMapping("/devices/register") + public ApiResponse> registerDevice(@RequestBody Dtos.DeviceRegister req) { + return ApiResponse.ok(admin.registerDevice(req)); + } + + /** 野设备:通电联网了、库里没有,先在这儿等着运营认领。 */ + @GetMapping("/devices/unregistered") + public ApiResponse>> unregisteredDevices() { + return ApiResponse.ok(admin.unregisteredDevices()); + } + + /** 野设备转正:补一个类型,进库等家属来绑。 */ + @PostMapping("/devices/unregistered/adopt") + public ApiResponse> adoptUnregistered(@RequestBody Dtos.UnregAdopt req) { + return ApiResponse.ok(admin.adoptUnregistered(req)); + } + + // ---------------------------------------------------------------- 告警死信 + + /** + * 规则连续处理失败、已放弃自动重试的事件。 + * + *

只列未回放的。要看历史把它改成 {@code ?onlyOpen=false} 也行, + * 但运维日常只关心"现在还有几条没人管"。 + */ + @GetMapping("/dead-letters") + public ApiResponse>> deadLetters( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.deadLetters(page, size)); + } + + /** 回放:把原事件还给告警引擎重跑一次。规则修好之后用这个把丢掉的那条救回来。 */ + @PostMapping("/dead-letters/{id}/replay") + public ApiResponse> replayDeadLetter(@PathVariable long id) { + return ApiResponse.ok(admin.replayDeadLetter(id)); + } + + // ---------------------------------------------------------------- 审计 + + @GetMapping("/audit-logs") + public ApiResponse> auditLogs( + @RequestParam(required = false) String bizType, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.auditLogs(bizType, page, size)); + } + + /** + * 审计写失败、待补录的清单。 + * + *

{@code /health} 里的 {@code auditFailures} 就是这张表的条数—— + * 非 0 意味着"有些操作查不到是谁做的",补录之前一直是待办。 + */ + @GetMapping("/audit-failures") + public ApiResponse> auditFailures( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(admin.auditFailures(page, size)); + } + + /** 补录:把兜底行写回 audit_log,标记已补录。 */ + @PostMapping("/audit-failures/{id}/replay") + public ApiResponse> replayAuditFailure(@PathVariable long id) { + return ApiResponse.ok(admin.replayAuditFailure(id)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AlarmController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AlarmController.java new file mode 100644 index 0000000..12793e0 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AlarmController.java @@ -0,0 +1,102 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.AlarmService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 告警。注意 {@code /ack}(已读)和 {@code /confirm}(确认处置)是两个动作,别合并。 + */ +@RestController +@RequestMapping("/api/v1/alarms") +public class AlarmController { + + private final AlarmService alarms; + + public AlarmController(AlarmService alarms) { + this.alarms = alarms; + } + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) String status, + @RequestParam(required = false) Long householdId, + @RequestParam(required = false) String level, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "false") boolean showSuperseded) { + return ApiResponse.ok(alarms.list(status, householdId, level, page, size, showSuperseded)); + } + + /** + * 告警链:这一组「同一次风险」连续触发的全部记录。 + * + *

前端点开详情时用它把「最初几点开始的」和「现在这次」并排显示, + * 免得家属只看到最新时间,误以为风险是刚发生的。 + */ + @GetMapping("/{id}/chain") + public ApiResponse> chain(@PathVariable long id) { + return ApiResponse.ok(alarms.chain(id)); + } + + /** 首页角标:未关闭数、今日新增、按级别分布。 */ + @GetMapping("/stats") + public ApiResponse> stats(@RequestParam(required = false) Long householdId) { + return ApiResponse.ok(alarms.stats(householdId)); + } + + @GetMapping("/{id}") + public ApiResponse> detail(@PathVariable long id) { + return ApiResponse.ok(alarms.detail(id)); + } + + /** 弹窗已读:只记回执,不改告警状态。 */ + @PostMapping("/{id}/ack") + public ApiResponse> ack(@PathVariable long id, + @RequestParam(required = false) String ackType) { + return ApiResponse.ok(alarms.ack(id, ackType)); + } + + /** 确认:有人接了。 */ + @PostMapping("/{id}/confirm") + public ApiResponse> confirm(@PathVariable long id, + @RequestBody(required = false) Dtos.AlarmAction req) { + return ApiResponse.ok(alarms.confirm(id, req)); + } + + /** 处置:处理完了,但还没关。 */ + @PostMapping("/{id}/handle") + public ApiResponse> handle(@PathVariable long id, + @RequestBody(required = false) Dtos.AlarmAction req) { + return ApiResponse.ok(alarms.handle(id, req)); + } + + /** 关闭:终态,必须有人。 */ + @PostMapping("/{id}/close") + public ApiResponse> close(@PathVariable long id, + @RequestBody(required = false) Dtos.AlarmAction req) { + return ApiResponse.ok(alarms.close(id, req)); + } + + /** 误报:单独终态 FALSE,不占用 CLOSED。 */ + @PostMapping("/{id}/false") + public ApiResponse> markFalse(@PathVariable long id, + @RequestBody(required = false) Dtos.AlarmAction req) { + return ApiResponse.ok(alarms.markFalse(id, req)); + } + + @GetMapping("/{id}/actions") + public ApiResponse>> actions(@PathVariable long id) { + return ApiResponse.ok(alarms.actions(id)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AuthController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AuthController.java new file mode 100644 index 0000000..9ccbffa --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AuthController.java @@ -0,0 +1,62 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.AuthService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 认证接口。这一组是唯一不需要登录的路径(拦截器里按前缀放行)。 + */ +@RestController +@RequestMapping("/api/v1/auth") +public class AuthController { + + private final AuthService auth; + + public AuthController(AuthService auth) { + this.auth = auth; + } + + /** 微信授权登录。未注册返回 needRegister=true + openid,前端据此跳注册页。 */ + @PostMapping("/wx/login") + public ApiResponse> wxLogin(@RequestBody Dtos.WxLogin req) { + return ApiResponse.ok(auth.wxLogin(req)); + } + + /** 发短信验证码。开发态响应里带 code,上线把 biz.sms.echo 关掉。 */ + @PostMapping("/wx/sms") + public ApiResponse> sms(@RequestBody Dtos.SmsReq req) { + return ApiResponse.ok(auth.sendSms(req)); + } + + /** 手机号 + 密码 + 短信码注册(openid 必须先拿到)。 */ + @PostMapping("/wx/register") + public ApiResponse> register(@RequestBody Dtos.WxRegister req) { + return ApiResponse.ok(auth.register(req)); + } + + /** 手机号 + 密码登录。 */ + @PostMapping("/wx/password-login") + public ApiResponse> pwdLogin(@RequestBody Dtos.PwdLogin req) { + return ApiResponse.ok(auth.passwordLogin(req)); + } + + /** 后台登录(运营 / 物业)。没有注册接口,账号只能由运营建。 */ + @PostMapping("/console/login") + public ApiResponse> consoleLogin(@RequestBody Dtos.ConsoleLogin req) { + return ApiResponse.ok(auth.consoleLogin(req)); + } + + /** 当前登录人 + 名下户 / 角色。 */ + @GetMapping("/me") + public ApiResponse> me() { + return ApiResponse.ok(auth.me()); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/DeviceController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/DeviceController.java new file mode 100644 index 0000000..ccc8e9e --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/DeviceController.java @@ -0,0 +1,99 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.BizDeviceService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 设备(业务视角):列表、详情、改名、绑定、解绑、事件、命令。 + */ +@RestController +@RequestMapping("/api/v1/devices") +public class DeviceController { + + private final BizDeviceService devices; + + public DeviceController(BizDeviceService devices) { + this.devices = devices; + } + + @GetMapping + public ApiResponse>> list( + @RequestParam(required = false) Long householdId, + @RequestParam(required = false) String typeCode, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(devices.list(householdId, typeCode, keyword)); + } + + @GetMapping("/{devId}") + public ApiResponse> detail(@PathVariable String devId) { + return ApiResponse.ok(devices.detail(devId)); + } + + @PutMapping("/{devId}") + public ApiResponse> update(@PathVariable String devId, + @RequestBody Dtos.DeviceUpdate req) { + return ApiResponse.ok(devices.update(devId, req)); + } + + /** 扫码 / 手填 SN 绑定:设备没建档时由本接口建档(reg_source=SCAN_ACTIVATE)。 */ + @PostMapping("/bind-by-sn") + public ApiResponse> bindBySn(@RequestBody Dtos.DeviceBindBySn req) { + return ApiResponse.ok(devices.bindBySn(req)); + } + + /** + * 按 SN 预查:家属填完编号,页面直接把类型显示出来。 + * + *

放在 /devices 下是因为它就是"这台机器是什么",不走后台权限。 + * 只回类型和能不能绑,不回户归属。 + */ + @GetMapping("/by-sn/{sn}") + public ApiResponse> bySn(@PathVariable String sn) { + return ApiResponse.ok(devices.lookupBySn(sn)); + } + + @PostMapping("/{devId}/bind") + public ApiResponse> bind(@PathVariable String devId, + @RequestBody Dtos.DeviceBind req) { + return ApiResponse.ok(devices.bind(devId, req)); + } + + @PostMapping("/{devId}/unbind") + public ApiResponse> unbind(@PathVariable String devId) { + return ApiResponse.ok(devices.unbind(devId)); + } + + /** 设备事件流(分页)。时间用 event_time,不是接收时间。 */ + @GetMapping("/{devId}/events") + public ApiResponse> events(@PathVariable String devId, + @RequestParam(required = false) String from, + @RequestParam(required = false) String to, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(devices.events(devId, from, to, page, size)); + } + + /** 下发命令:入库即 PENDING,IoT 长轮询来领。 */ + @PostMapping("/{devId}/commands") + public ApiResponse> command(@PathVariable String devId, + @RequestBody Dtos.CommandReq req) { + return ApiResponse.ok(devices.sendCommand(devId, req)); + } + + @GetMapping("/{devId}/commands") + public ApiResponse>> commands(@PathVariable String devId) { + return ApiResponse.ok(devices.commands(devId)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/FamilyController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/FamilyController.java new file mode 100644 index 0000000..fa5fd21 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/FamilyController.java @@ -0,0 +1,149 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.FamilyService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 家庭与老人。家属端首屏就靠这一组。 + */ +@RestController +@RequestMapping("/api/v1") +public class FamilyController { + + private final FamilyService family; + + public FamilyController(FamilyService family) { + this.family = family; + } + + @GetMapping("/households") + public ApiResponse>> households() { + return ApiResponse.ok(family.myHouseholds()); + } + + /** 家属自助建档:地址 + 地图选点/GPS 坐标,建完自动把当前微信用户设为户主。 */ + @PostMapping("/households") + public ApiResponse> createHousehold(@RequestBody Dtos.HouseholdSelfCreate req) { + return ApiResponse.ok(family.createHousehold(req)); + } + + /** 家属端自助填写老人信息。 */ + @PostMapping("/elders") + public ApiResponse> createElder(@RequestBody Dtos.ElderSelfCreate req) { + return ApiResponse.ok(family.createElder(req)); + } + + /** 按小区名 / 房号找户(只回地址,申请阶段不暴露老人与设备)。 */ + @GetMapping("/households/search") + public ApiResponse>> searchHouseholds(@RequestParam String keyword) { + return ApiResponse.ok(family.searchHouseholds(keyword)); + } + + /** 凭邀请码加入一个户。 */ + @PostMapping("/households/join") + public ApiResponse> join(@RequestBody Dtos.HouseholdJoin req) { + return ApiResponse.ok(family.joinByCode(req)); + } + + /** 申请加入一个户,落 PENDING 等户主审批。 */ + @PostMapping("/households/apply") + public ApiResponse> apply(@RequestBody Dtos.HouseholdApply req) { + return ApiResponse.ok(family.apply(req)); + } + + /** 当前有效的邀请码。 */ + @GetMapping("/households/{id}/invite-info") + public ApiResponse> inviteInfo(@PathVariable long id) { + return ApiResponse.ok(family.inviteInfo(id)); + } + + /** 户成员(正式 + 待审批)。 */ + @GetMapping("/households/{id}/members") + public ApiResponse>> members(@PathVariable long id) { + return ApiResponse.ok(family.members(id)); + } + + /** 待审批申请,只有户主能看。 */ + @GetMapping("/households/{id}/applications") + public ApiResponse>> applications(@PathVariable long id) { + return ApiResponse.ok(family.applications(id)); + } + + @PostMapping("/households/{id}/applications/{wxUserId}/approve") + public ApiResponse> approve(@PathVariable long id, @PathVariable long wxUserId) { + return ApiResponse.ok(family.review(id, wxUserId, true)); + } + + @PostMapping("/households/{id}/applications/{wxUserId}/reject") + public ApiResponse> reject(@PathVariable long id, @PathVariable long wxUserId) { + return ApiResponse.ok(family.review(id, wxUserId, false)); + } + + /** 老人停用 / 启用 / 标记过世(不删行,保留历史回指)。 */ + @PostMapping("/elders/{id}/status") + public ApiResponse> elderStatus(@PathVariable long id, + @RequestParam String value) { + return ApiResponse.ok(family.setElderStatus(id, value)); + } + + /** 户详情:老人 + 设备 + 未关闭告警数,一次给全。 */ + @GetMapping("/households/{id}") + public ApiResponse> household(@PathVariable long id) { + return ApiResponse.ok(family.householdDetail(id)); + } + + @GetMapping("/households/{id}/elders") + public ApiResponse>> elders(@PathVariable long id) { + family.assertHousehold(id); + return ApiResponse.ok(family.eldersOf(id)); + } + + @GetMapping("/households/{id}/devices") + public ApiResponse>> devices(@PathVariable long id) { + family.assertHousehold(id); + return ApiResponse.ok(family.devicesOf(id)); + } + + /** 生成 6 位邀请码,24 小时有效。用于把另一位家属拉进同一户。 */ + @PostMapping("/households/{id}/invite") + public ApiResponse> invite(@PathVariable long id, + @RequestParam(required = false) String relation) { + return ApiResponse.ok(family.invite(id, relation)); + } + + @GetMapping("/elders/{id}") + public ApiResponse> elder(@PathVariable long id) { + return ApiResponse.ok(family.elderDetail(id)); + } + + @PutMapping("/elders/{id}") + public ApiResponse> updateElder(@PathVariable long id, + @RequestBody Dtos.ElderUpdate req) { + return ApiResponse.ok(family.updateElder(id, req)); + } + + @GetMapping("/elders/{id}/contacts") + public ApiResponse>> contacts(@PathVariable long id) { + family.elderDetail(id); // 顺带做越权校验 + return ApiResponse.ok(family.contacts(id)); + } + + @PostMapping("/elders/{id}/contacts") + public ApiResponse> addContact(@PathVariable long id, + @RequestBody Dtos.ContactCreate req) { + long cid = family.addContact(id, req); + return ApiResponse.ok(Map.of("id", cid)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyController.java new file mode 100644 index 0000000..e4343d3 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyController.java @@ -0,0 +1,64 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.service.NotifyService; +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 通知:列表、标记已读、通知偏好。 + */ +@RestController +@RequestMapping("/api/v1") +public class NotifyController { + + private final NotifyService notify; + + public NotifyController(NotifyService notify) { + this.notify = notify; + } + + @GetMapping("/notifications") + public ApiResponse> list( + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(notify.list(status, page, size)); + } + + /** 未读数:{unread: n}。 */ + @GetMapping("/notifications/unread") + public ApiResponse> unread() { + return ApiResponse.ok(notify.unreadCount()); + } + + @PostMapping("/notifications/read-all") + public ApiResponse> readAll() { + return ApiResponse.ok(notify.readAll()); + } + + @PostMapping("/notifications/{id}/read") + public ApiResponse> read(@PathVariable long id) { + return ApiResponse.ok(notify.read(id)); + } + + @GetMapping("/notify/preferences") + public ApiResponse prefs(@RequestParam(required = false) Long householdId) { + return ApiResponse.ok(notify.preferences(householdId)); + } + + @PutMapping("/notify/preferences") + public ApiResponse savePrefs(@RequestParam(required = false) Long householdId, + @RequestBody Dtos.PrefUpdate req) { + return ApiResponse.ok(notify.savePreferences(householdId, req)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyStreamController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyStreamController.java new file mode 100644 index 0000000..7fb8490 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyStreamController.java @@ -0,0 +1,59 @@ +package cn.aomin.gateway.biz.controller; + +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.biz.support.SseHub; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Map; + +/** + * 家属端实时通道。 + * + *
+ *   GET /api/v1/notifications/stream?token=xxx   长连接,服务端单向推送
+ *   GET /api/v1/notifications/online            在线连接数(排障/看板用)
+ * 
+ * + *

为什么要用 query 传 token:浏览器的 {@code EventSource} 不能自定义请求头, + * 带不了 {@code Authorization: Bearer}。好在 {@code BizAuthInterceptor} 本来就支持 + * {@code ?token=}(当初是为小程序下载图片加的),这里直接复用,不用改拦截器。 + * + *

为什么要关缓冲:nginx、cpolar 这类代理默认会攒够一批再发, + * 结果就是"服务端 10:00:00 发了,浏览器 10:00:30 才收到",实时性被吃掉。 + * {@code X-Accel-Buffering: no} 是 nginx 认的标准头,加上它才能真正逐条下发。 + * + *

断线由浏览器自己重连:{@code EventSource} 内置重连(默认 3 秒)。 + * 服务端不主动补发历史——离线期间漏掉的告警,前端重连后会走一次 + * {@code /notifications?status=UNREAD} 补齐,这样"实时"和"不漏"分开保证。 + */ +@RestController +@RequestMapping("/api/v1/notifications") +public class NotifyStreamController { + + private final SseHub sse; + + public NotifyStreamController(SseHub sse) { + this.sse = sse; + } + + @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE + ";charset=UTF-8") + public SseEmitter stream(HttpServletResponse resp) { + // 关掉各级代理的缓冲,否则实时推送会被攒成"定时批量" + resp.setHeader("X-Accel-Buffering", "no"); + resp.setHeader("Cache-Control", "no-cache, no-transform"); + resp.setHeader("Connection", "keep-alive"); + return sse.register(AuthContext.userId()); + } + + @GetMapping("/online") + public Map online() { + return Map.of( + "users", sse.onlineUsers(), + "connections", sse.onlineConnections()); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dao/BizDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dao/BizDao.java new file mode 100644 index 0000000..3212068 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dao/BizDao.java @@ -0,0 +1,79 @@ +package cn.aomin.gateway.biz.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Map; + +/** + * 业务层通用 JDBC 入口。 + * + *

只用 {@link JdbcTemplate} 不引 ORM:83 张表里业务查询大多是"带条件的列表 + 少量联表", + * 写 SQL 更直观,也省掉实体映射的维护成本。 + * + *

纪律:所有对外查询必须带 {@code deleted = 0}(有软删的表), + * 分页必须带 {@code LIMIT},不允许出现无界查询。 + */ +@Repository +public class BizDao { + + private final JdbcTemplate jdbc; + + public BizDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public List> list(String sql, Object... args) { + return jdbc.queryForList(sql, args); + } + + public Map one(String sql, Object... args) { + List> r = jdbc.queryForList(sql, args); + return r.isEmpty() ? null : r.get(0); + } + + public long count(String sql, Object... args) { + Long n = jdbc.queryForObject(sql, Long.class, args); + return n == null ? 0 : n; + } + + public int update(String sql, Object... args) { + return jdbc.update(sql, args); + } + + /** 插入并返回自增主键。 */ + public long insert(String sql, Object... args) { + KeyHolder kh = new GeneratedKeyHolder(); + jdbc.update(con -> { + PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + for (int i = 0; i < args.length; i++) { + ps.setObject(i + 1, args[i]); + } + return ps; + }, kh); + Number k = kh.getKey(); + return k == null ? 0 : k.longValue(); + } + + /** 拼 IN 占位符:ids 非空时返回 {@code (?,?,?)}。 */ + public static String in(int n) { + return "(" + "?,".repeat(Math.max(1, n)).replaceAll(",$", "") + ")"; + } + + /** 通用分页参数兜底。 */ + public static int page(int p) { + return p < 1 ? 1 : p; + } + + public static int size(int s) { + if (s < 1) { + return 20; + } + return Math.min(s, 100); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dto/Dtos.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dto/Dtos.java new file mode 100644 index 0000000..d6f6d2c --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/dto/Dtos.java @@ -0,0 +1,156 @@ +package cn.aomin.gateway.biz.dto; + +/** + * 业务层请求体集合。 + * + *

全部用 record,一个模块一组,不散成一堆单字段类。 + * 校验靠 {@code @NotBlank/@NotNull} + 全局异常处理器转 400,不在 service 里手写 if。 + */ +public final class Dtos { + + private Dtos() { + } + + // ---------------------------------------------------------------- 认证 + + /** 微信授权登录:前端拿 code 换 openid;未注册时返回 needRegister=true。 */ + public record WxLogin(String code) { + } + + public record SmsReq(String phone, String scene) { + } + + public record WxRegister(String openid, String phone, String password, + String smsCode, String nickname, String userType) { + } + + public record PwdLogin(String phone, String password) { + } + + public record ConsoleLogin(String username, String password) { + } + + // ---------------------------------------------------------------- 家庭 / 老人 + + public record ElderUpdate(String name, String gender, String birthDate, + String careLevel, Boolean liveAlone, String remark) { + } + + public record ContactCreate(String name, String mobile, String relation, Integer order) { + } + + public record InviteReq(Long householdId, String relation) { + } + + // ---------------------------------------------------------------- 设备 + + public record DeviceUpdate(String displayName, String remark, String installLocation, + Integer sortNo) { + } + + public record DeviceBind(Long householdId, Long elderId, String installLocation, String zoneNo) { + } + + public record CommandReq(String cmdType, java.util.Map params) { + } + + // ---------------------------------------------------------------- 告警 + + public record AlarmAction(String remark, String reason) { + } + + // ---------------------------------------------------------------- 通知 + + public record PrefUpdate(String quietStart, String quietEnd, + java.util.List types, java.util.List channels) { + } + + // ---------------------------------------------------------------- 后台 + + public record CommunityCreate(String code, String name, String province, String city, + String district, String address, String managerName, + String managerPhone) { + } + + public record BuildingCreate(Long communityId, String code, String name, Integer floorCount) { + } + + public record UnitCreate(Long communityId, Long buildingId, String code, String name) { + } + + public record HouseholdCreate(Long communityId, Long buildingId, Long unitId, + String roomNo, Integer floor, String remark) { + } + + public record ElderCreate(Long householdId, String name, String gender, String birthDate, + String careLevel, Boolean liveAlone, String remark) { + } + + public record SysUserCreate(String username, String password, String realName, + String mobile, String roleCode) { + } + + /** + * 一行数据范围。字段全空的行不生效(不会误配成"全放")。 + * + *

由宽到窄:只给 province = 全省;三个省市区都给 = 那个区; + * 再给 communityId = 那个小区;再给 buildingId = 那一栋。 + */ + public record ScopeRow(String province, String city, String district, + Long communityId, Long buildingId, String remark) { + } + + public record ScopeSave(java.util.List scopes) { + } + + public record WorkOrderCreate(String type, Long householdId, Long elderId, String title, + String content, String priority) { + } + + /** + * 家属端「创建我的家」。前端的地图选点 / GPS 定位结果落在 lng / lat, + * 坐标系必须是 GCJ-02;communityName 等文本用于按需补齐组织树。 + */ + public record HouseholdSelfCreate(String province, String city, String district, + String communityName, String buildingName, String unitName, + String roomNo, Integer floor, String addressDetail, + String addressText, Double lng, Double lat, String gpsSource) { + } + + /** + * 扫码 / 手填 SN 绑定设备。sensorId 是用户看到的那串码(H/S 线带厂商前缀)。 + * 设备没建档时由业务层按「扫码激活」路径补一条,身份三元组一旦存在就永不改写。 + */ + public record DeviceBindBySn(String sensorId, Long householdId, Long elderId, + String typeCode, String displayName, String installLocation, + String zoneNo) { + } + + /** + * 出厂录入(预注册)。 + * + *

这是绑定的前置:没录进库的设备,家属扫不出来也绑不上。 + * 类型必须由录入人明确指定,系统不猜——猜错一次,这台设备就一辈子是错的。 + */ + public record DeviceRegister(String sensorId, String typeCode, String vendor, + String model, String remark) { + } + + /** 野设备转正:设备先通电联网,运营在后台看见它,补上类型才让它进库。 */ + public record UnregAdopt(String sensorId, String typeCode, String vendor, String remark) { + } + + /** 家属端自助填写老人信息(PII 由服务端加密)。 */ + public record ElderSelfCreate(Long householdId, String name, String gender, String birthDate, + String careLevel, Boolean liveAlone, String mobile, + Boolean profileAuthorized, String remark) { + } + + /** 凭 6 位邀请码加入一个户。 */ + public record HouseholdJoin(String code) { + } + + /** 申请加入一个户:按 householdId 或「小区 + 房号」关键词找户,落 PENDING 等户主审批。 */ + public record HouseholdApply(Long householdId, String keyword, String relation, String remark) { + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmEngine.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmEngine.java new file mode 100644 index 0000000..e5135a9 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmEngine.java @@ -0,0 +1,681 @@ +package cn.aomin.gateway.biz.engine; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.service.SmsGateway; +import cn.aomin.gateway.biz.support.SseHub; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 告警规则引擎(最简可演示版)。 + * + *

它在整条链路里的位置:M 层把帧解析成 {@code iot_event} 就结束了, + * 不判告警——这是 J-00 定死的边界。业务层这边用 {@code iot_event.processed} 这个 + * 字段做"已消费"标记,定时把这批原始事件翻译成 {@code alarm}。 + * + *

+ *   设备 ──433/MQTT──→ 网关 ──HTTP──→ M 层 ──→ iot_event(processed=0)
+ *                                                   │
+ *                                          AlarmEngine(本类)
+ *                                                   ↓
+ *                                    alarm(active_key 唯一) + notify_task
+ * 
+ * + *

触发方式(2026-11-09 改):原来是纯轮询"最多晚 8 秒",对 SOS 这种 S1 级告警太慢。 + * 现在主链路改成入库即触发——I-01 返回后由 {@link AlarmTrigger} 异步扫一轮, + * 正常情况下从事件入库到告警落库在 1 秒内。本类的 {@code tick()} 退为 30 秒兜底, + * 只负责捡"进程重启时压着的历史事件"和"某批触发失败"的漏。 + * + *

为什么不在入库事务里直接调 scanOnce:入库是 M 层的热路径,31 条用例都在测它。 + * 规则一旦抛异常会把整批事件回滚,把接入能力拖下水。所以走"事务外 + 异步 + 失败只记日志"。 + * + *

规则目前只有 4 条(SOS / 水浸 / 门磁 / 断电)+ 3 条恢复。 + * 真正的规则表(阈值、静默期、升级链、免打扰)还没批——见《业务层实现说明》第五节待改清单。 + */ +@Component +public class AlarmEngine { + + private static final Logger log = LoggerFactory.getLogger(AlarmEngine.class); + + /** + * event_type → 告警规则。level 用字典 alarm_level 的 S1/S2/S3。 + * + *

{@code deviceType} 是这条规则要求的设备类型(device.type_code), + * {@code null} 表示任何设备都能发(断电/来电是网关级事件)。 + * 为什么要这层校验(2026-11-09 加):上报侧没有强校验时,一台门磁发 + * {@code SOS_PRESS} 也能生成一条 S1 紧急呼叫——家属会被拉起来,出事后还查不出是谁发的。 + * 现在对不上就不生成告警,但原始事件照旧入库,留痕不丢。 + */ + private record Rule(String key, String tier, String level, String title, String content, + String deviceType) { + } + + private static final Map RULES = Map.of( + "SOS_PRESS", new Rule("SOS", "T1", "S1", "紧急呼叫", "老人按下紧急按钮,请立即联系", "SOS"), + "SOS", new Rule("SOS", "T1", "S1", "紧急呼叫", "老人按下紧急按钮,请立即联系", "SOS"), + "WATER_LEAK", new Rule("WATER_LEAK", "T1", "S1", "检测到漏水", "水浸探针持续导通", "WATER"), + "DOOR_OPEN", new Rule("DOOR_OPEN", "T2", "S2", "房门已打开", "门磁触发开启", "DOOR"), + "POWER_OFF", new Rule("POWER_OFF", "T2", "S2", "设备断电", "设备市电供电中断", null), + // J-00 V1.7 §1.5.1 的正式叫法是 POWER_CUT,落地册(433 帧 event_type)叫 POWER_OFF。 + // 两个都收——接入侧换名字不能把告警搞丢。2026-11-09 + "POWER_CUT", new Rule("POWER_OFF", "T2", "S2", "设备断电", "设备市电供电中断", null), + // 烟感:告警规则决策表里 SMOKE 是 SECOND + URGENT,与漏水同一套(含 30 分钟兜底)。 + // 之前 RULES 里没有它,烟感报了也没人知道。2026-11-09 补 + "SMOKE", new Rule("SMOKE", "T1", "S1", "检测到烟雾", "烟感报警,请立即查看", "SMOKE") + ); + + /** 恢复类事件 → 它要关闭的规则。设备自己恢复了不等于单子结了,只写 cleared_at。 */ + private static final Map RECOVER = Map.of( + "DOOR_CLOSE", "DOOR_OPEN", + "WATER_LEAK_CLEAR", "WATER_LEAK", + "SMOKE_CLEAR", "SMOKE", + "SOS_CLEAR", "SOS", + // 来电只恢复断电,不碰 SOS / 漏水 / 门磁 —— 恢复事件必须和触发事件严格配对, + // 否则"来电"会把没处理完的 SOS 也标成已恢复,等于把风险抹掉。2026-11-09 明确 + "POWER_ON", "POWER_OFF", + "POWER_RESTORE", "POWER_OFF" + ); + + /** + * 没人动过的状态。命中这些的旧单会被新一次触发替代(SUPERSEDED)。 + * 一旦有人确认/处置/升级过,就进"保持结果"那一档,不许被新告警盖掉。 + */ + private static final java.util.Set UNHANDLED = + java.util.Set.of("NEW", "PUSHED", "DELAYED"); + + private final BizDao dao; + private final SseHub sse; + private final SmsGateway sms; + private final cn.aomin.gateway.biz.support.SuppressGate suppress; + private final org.springframework.transaction.support.TransactionTemplate tx; + /** 只用于推送合并窗口的 SETNX 计数;取不到(Redis 挂了)时降级为"每次都推"。 */ + private final org.springframework.data.redis.core.StringRedisTemplate redis; + + /** + * 规则连续失败几次后放弃自动重试、转死信。 + * + *

不做无限重试:一条规则处理不了的事件,每 30 秒重试一次只会刷满日志, + * 而且掩盖了"规则本身有 bug"这个事实。3 次(含退避约 2.5 分钟)足够躲过 + * 数据库瞬时抖动,再失败就该有人看了。 + */ + @org.springframework.beans.factory.annotation.Value("${biz.alarm.max-try:3}") + private int maxTry; + + /** + * S1(SOS / 水浸 / 烟感 / 燃气)的实时横幅合并窗口,**默认 1 秒**。 + * + *

为什么是 1 秒而不是 60 秒:I-01 一批最多 200 条、攒批 200 ms,所以 + * "一次抖动"或"补一次数据"产生的重复记录,几乎都落在同一批里、 + * 几百毫秒内处理完。1 秒足够覆盖"同一批",又不会把两次真正独立的告警合成一次。 + * + *

紧急告警宁可多弹,不能少弹——所以 S1 的窗口比 S2/S3 更短。 + */ + @org.springframework.beans.factory.annotation.Value("${biz.alarm.push-merge-s1-seconds:1}") + private int pushMergeS1Seconds; + + /** + * S2 / S3 的实时横幅合并窗口,**默认 5 秒**。 + * + *

次级告警(门磁、电流、水振)抖动更常见,5 秒能把"一次开合抖出一串"收成一条, + * 家属也不会漏掉什么——这类告警本来就不要求秒级反应。 + */ + @org.springframework.beans.factory.annotation.Value("${biz.alarm.push-merge-seconds:5}") + private int pushMergeSeconds; + + /** 实时推送合并用的 Redis 键前缀(户:设备:规则)。 */ + private static final String PUSH_KEY = "alarm:push:"; + + public AlarmEngine(BizDao dao, SseHub sse, SmsGateway sms, + cn.aomin.gateway.biz.support.SuppressGate suppress, + org.springframework.transaction.PlatformTransactionManager txManager, + org.springframework.data.redis.core.StringRedisTemplate redis) { + this.dao = dao; + this.sse = sse; + this.sms = sms; + this.suppress = suppress; + this.tx = new org.springframework.transaction.support.TransactionTemplate(txManager); + this.redis = redis; + } + + @Scheduled(fixedDelay = 30000, initialDelay = 15000) + public void tick() { + try { + // 一轮最多连扫 5 批(1000 条)。停服后积压、或一次补进几千条历史时, + // 不用每 30 秒只啃 200 条、要等半小时才追平。 + for (int i = 0; i < 5; i++) { + if (scanOnce() == 0) { + break; + } + } + } catch (Exception e) { + log.warn("告警引擎本轮失败:{}", e.getMessage()); + } + } + + /** + * 一轮扫描,返回本批取出的事件条数(0 表示已经扫完)。 + * + *

{@code processed=1} 只在"真的处理过"时才写(2026-11-10 修): + * 以前无论成功失败都标已消费,规则一抛异常这条事件就永远不会再被扫到—— + * 对老人安全告警来说,这就是永久漏报,而且自测看不出来(其余事件都正常)。 + * + *

没有时间窗(2026-11-11 二次修,GOV-008 §3.2): + * 历史上这里有两道闸——60 分钟扫描窗、72 小时老化闸。两个都在问同一个问题: + * "这条事件够不够新,值不值得处理"。 + * + *

但这个问题的前提是错的。{@code receive_time} 现在由业务层落库时自己打 + * {@code NOW(3)}(见 {@code IotEventDao}),恒等于"现在"——事件只要进了库, + * 它就是刚收到的。旧实现的错在于把 J-00 信封里 IoT 自报的 {@code receivedAt} + * 直接当判据,外部传个旧时间就能让一条真实告警永远"不新鲜"。 + * + *

所以闸拆掉:{@code processed=0} 的全部要处理,按 {@code id} 升序分批。 + * 打扰不靠入口挡,靠出口收——同户同设备同规则连续触发由新的替代旧的, + * 横幅按 S1 1 秒 / S2S3 5 秒合并,短信 60 秒合并。补 3000 条历史进来, + * 家属最后看到的也只是几条待处理,不会被弹 3000 次。 + * + *

失败重试:失败写 {@code alarm_next_at} 做退避,连续 {@code max-try} + * 次才转死信并置 {@code processed=1},全程留痕。这也是拆掉时间窗后 + * 唯一还在限制"什么时候扫这条"的条件——它和事件新不新无关。 + */ + public int scanOnce() { + // alarm_next_at 非空且未到点的先跳过,其余全部扫。没有任何基于 event_time / + // receive_time 的条件——那两条正是会被外部时间戳绕过的判据。 + List> rows = dao.list( + "SELECT e.id, e.event_id, e.dev_id, e.household_id, e.event_type, e.event_time, " + + "e.receive_time, COALESCE(e.alarm_try,0) AS alarm_try, " + + "d.type_code AS dev_type, d.display_name AS dev_name, d.sensor_id AS dev_sn " + + "FROM iot_event e LEFT JOIN device d ON d.dev_id=e.dev_id " + + "WHERE e.processed=0 AND e.is_backfill=0" + + " AND (e.alarm_next_at IS NULL OR e.alarm_next_at <= NOW(3))" + + " ORDER BY e.id LIMIT 200"); + if (rows.isEmpty()) { + return 0; + } + int made = 0; + int cleared = 0; + int failed = 0; + int dead = 0; + for (Map r : rows) { + // 单条坏数据不能毁掉整批:某条规则抛异常,只跳过它,其余照常消费 + boolean consumed; + String failMsg = null; + try { + consumed = handleOne(r); + } catch (Exception one) { + consumed = false; + failMsg = one.getMessage(); + failed++; + log.warn("事件 {} 规则处理失败,按退避重试:{}", r.get("id"), failMsg); + } + if (!consumed) { + try { + if (postpone(r, failMsg)) { + dead++; + } + } catch (Exception e) { + // 连"延后 / 写死信"都失败了:保持 processed=0,下一轮还会扫到。 + // 宁可多试几次,也不能在这里把事件标成已消费。 + log.error("事件 {} 退避登记失败,保持待处理:{}", r.get("id"), e.getMessage()); + } + continue; + } + try { + // 顺带把 alarm_next_at 清掉:processed=1 后不会再扫,但残留值会让 + // 人工回放(把它改回 0)时立刻又被退避挡住,排障时很误导。 + dao.update("UPDATE iot_event SET processed=1, alarm_next_at=NULL " + + "WHERE id=? AND event_time=?", num(r.get("id")), r.get("event_time")); + } catch (Exception e) { + // 标记失败也要留痕:否则事件会被重复处理,产生重复告警 + log.warn("事件 {} 标记已消费失败:{}", r.get("id"), e.getMessage()); + } + Object type = r.get("event_type"); + if (type != null && RECOVER.containsKey(String.valueOf(type))) { + cleared++; + } else { + made++; + } + } + if (made + cleared + failed + dead > 0) { + log.info("告警引擎:新生成 {} 条,恢复 {} 条,失败待重试 {} 条,转死信 {} 条", + made, cleared, failed, dead); + } + return rows.size(); + } + + /** + * 处理失败后登记退避,或转死信。 + * + * @return {@code true} 表示这条已经放弃自动重试、转成了死信 + */ + private boolean postpone(Map r, String err) { + Long idBoxed = num(r.get("id")); + if (idBoxed == null) { + return false; // 没有主键的脏行,谈不上退避 + } + long id = idBoxed; + Object eventTime = r.get("event_time"); + Long triedBoxed = num(r.get("alarm_try")); + int tries = (triedBoxed == null ? 0 : triedBoxed.intValue()) + 1; + + if (tries >= maxTry) { + // 写死信必须先成功:写不进去就抛,让事件保持 processed=0 继续重试。 + // 反过来的话(先标已消费再写死信)死信写失败就等于事件凭空消失。 + dao.insert("INSERT IGNORE INTO alarm_dead_letter" + + "(event_pk, event_time, dev_id, event_type, tries, last_error) " + + "VALUES(?,?,?,?,?,?)", + id, eventTime, str(r.get("dev_id")), str(r.get("event_type")), + tries, clip(err, 500)); + dao.update("UPDATE iot_event SET processed=1, alarm_try=?, alarm_next_at=NULL " + + "WHERE id=? AND event_time=?", tries, id, eventTime); + log.error("事件 {}({})规则连续失败 {} 次,已转死信、不再自动重试:{}", + id, r.get("event_type"), tries, err); + return true; + } + + // 退避:第一次 15 秒,之后 60 秒。 + // + // 第一次不能设太长——扫描本身是 30 秒一轮,15 + 30 最坏 45 秒能重试一次, + // 故障(比如数据库抖动、触发器挡了一下)往往是瞬时,不该让一条 SOS 等一分钟。 + // 后面拉长到 60 秒:连续两次失败说明不是抖动,再猛试只会刷日志。 + // 这两个数字和 ops/evidence/alarm_retry_test.py 的等待时间是一对,改一个要改另一个。 + int delay = tries <= 1 ? 15 : 60; + dao.update("UPDATE iot_event SET alarm_try=?, " + + "alarm_next_at=DATE_ADD(NOW(3), INTERVAL ? SECOND) " + + "WHERE id=? AND event_time=?", + tries, delay, id, eventTime); + return false; + } + + private static String clip(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } + + /** 还没被人工回放的死信数:健康检查和后台首页都读它。 */ + public long countOpenDeadLetters() { + return dao.count("SELECT COUNT(*) FROM alarm_dead_letter WHERE replayed_at IS NULL"); + } + + /** + * 处理一条事件,返回"能不能标已消费"。 + * + *

返回 {@code false} 只有一种情况:真的处理出错,需要重试。 + * 其余(包括"不认识的事件类型""设备类型对不上")都返回 {@code true}—— + * 那些是确定不需要处理,一直挂着只会每轮重扫同一批。 + */ + private boolean handleOne(Map r) { + String type = str(r.get("event_type")); + Object devId = r.get("dev_id"); + if (devId == null || type == null) { + return true; // 没有设备号的脏数据,谈不上处理 + } + Rule rule = RULES.get(type); + if (rule != null && !typeMatches(rule, r)) { + // 设备类型对不上:原始事件已入库留痕,但不生成告警。 + // 这是确定的业务判断,不是失败,下一轮不必再判一次。 + log.warn("事件 {} 跳过:{}({})是 {},发不出 {} —— 原文已入库,不生成告警", + r.get("id"), r.get("dev_name"), devId, r.get("dev_type"), type); + return true; + } + if (rule != null) { + raise(devId.toString(), num(r.get("household_id")), rule, r); + return true; + } + if (RECOVER.containsKey(type)) { + recover(devId.toString(), RECOVER.get(type), r); + return true; + } + return true; // 与告警无关的事件(心跳、状态上报等),直接消费 + } + + /** + * 触发一条告警。这里是告警覆盖逻辑的唯一入口(2026-11-09 定)。 + * + *

为什么不能"旧单还在就不建新单":那样家属永远只看到一条记录, + * 看不出今天到底触发了几次;而且 {@code trigger_at} 被反复刷新之后, + * 「最早什么时候开始的」这个信息就永久丢了——出事后追责要靠它。 + * + *

三条分支(判据只看旧单状态,不看时间间隔): + * + * + * + * + * + * + * + *
旧单状态旧单新单
NEW / PUSHED / DELAYED(没人管过)置 SUPERSEDED,{@code superseded_by} 指向新单新建
CONFIRMED / ESCALATED / HANDLED(人处理过)原状态原结果不动,只释放抑制键新建
CLOSED / FALSE / SUPERSEDED(已终结)不动新建
+ * + *

「处理过就保留结果」这条是关键:新告警只能盖掉没人理的旧告警, + * 不能盖掉已经有人确认或处置过的记录,否则「我明明处理过了」会变成查无此事。 + * + *

顺序不能换:先释放旧单的 {@code active_key}(它是唯一索引), + * 再插新单,最后回填 {@code superseded_by}——新单 id 要插完才知道。 + * 反过来做会直接撞 {@code uk_alarm_active}。 + */ + private int raise(String devId, Number householdId, Rule rule, Map ev) { + // ① 只读准备放事务外:查旧单、取收件人、判静默(会读 Redis),都不占数据库事务 + Map prev = dao.one( + "SELECT id, status, chain_seq FROM alarm WHERE dev_id=? AND rule_key=? " + + "AND status NOT IN ('CLOSED','FALSE','SUPERSEDED') " + + "ORDER BY id DESC LIMIT 1", devId, rule.key()); + + // ①' 本组第几次:旧单没被人动过 → 累加;动过 → 新开一组,从 1 开始。 + // 声明成 final:下面事务 lambda 里要用,非 final 的局部变量进不了闭包 + boolean sameChain = prev != null && UNHANDLED.contains(String.valueOf(prev.get("status"))); + final int chainSeq = sameChain + ? ((prev.get("chain_seq") instanceof Number n ? n.intValue() : 1) + 1) + : 1; + + long hhId = householdId == null ? 0L : householdId.longValue(); + List receivers = receiverIds(householdId); + boolean isS1 = "S1".equals(rule.level()); + + // 每个人是否处于免打扰:APP 记录人人都有,推不推分开算 + java.util.Map quietMap = new java.util.LinkedHashMap<>(); + java.util.List pushIds = new java.util.ArrayList<>(); + for (Long wxId : receivers) { + boolean quiet = !isS1 && suppress.isQuiet(wxId, hhId); + quietMap.put(wxId, quiet); + if (!quiet) { + pushIds.add(wxId); + } + } + // S1 永远穿透静默,不可能进 DELAYED + boolean delayed = !isS1 && !receivers.isEmpty() && pushIds.isEmpty(); + + String no = alarmNo(); + String triggerAt = str(ev.get("event_time")); + String deviceName = str(ev.get("dev_name")); + String deviceSn = str(ev.get("dev_sn")); + + // ② 一个事务:释放旧键 → 插告警 → 旧单被替代 → 替代留痕 → 通知任务 + // + // 这五步以前是各自自动提交的,中间任何一步失败都会留下"告警建了但通知没有" + // 或者"旧单被替代了但新单没建"的残局,而且事件已被标消费,永远补不回来。 + // 现在要么全成,要么全滚,事件保持 processed=0 等下一轮重试。 + Long alarmId; + try { + alarmId = tx.execute(status -> { + if (prev != null) { + // 释放旧单的抑制键,否则新单撞 uk_alarm_active + dao.update("UPDATE alarm SET active_key=NULL, updated_at=NOW(3) WHERE id=?", + prev.get("id")); + } + long id; + try { + id = dao.insert("INSERT INTO alarm(alarm_no, household_id, dev_id, rule_key, tier, level, " + + "title, content, trigger_at, status, active_key, escalate_stage, chain_seq, " + + "source_event_id) " + + "VALUES(?,?,?,?,?,?,?,?,?,?,?,0,?,?)", + no, householdId, devId, rule.key(), rule.tier(), rule.level(), + rule.title(), rule.content(), + ev.get("event_time"), delayed ? "DELAYED" : "NEW", + devId + ":" + rule.key(), chainSeq, num(ev.get("id"))); + } catch (org.springframework.dao.DuplicateKeyException dup) { + // 撞 uk_alarm_active:并发生成了同一条,属于正常,放弃这一条即可 + status.setRollbackOnly(); + return 0L; + } + + if (prev != null && UNHANDLED.contains(String.valueOf(prev.get("status")))) { + long prevId = ((Number) prev.get("id")).longValue(); + dao.update("UPDATE alarm SET status='SUPERSEDED', superseded_by=?, active_key=NULL, " + + "updated_at=NOW(3) WHERE id=?", id, prevId); + // 旧单还没发出去的通知一起作废(2026-11-11 修)。 + // + //

以前这里只改告警状态,通知任务照旧 PENDING,发送端会一条条发。 + // 结果是:列表里每个设备只剩最新一条(替代生效,看着很正常), + // 家属手机却按"事件条数"响——补一次 300 条历史事件就是 300 次推送。 + // 列表和通知必须表现一致:既然旧单被替代了,它那条提醒就没有发送的意义。 + // + //

只撤 PENDING:已经发出去的(SENT)撤不回来,也不该假装没发过。 + dao.update("UPDATE notify_task SET status='CANCELLED', updated_at=NOW(3) " + + "WHERE biz_type='ALARM' AND biz_id=? AND status='PENDING'", prevId); + replaceLog(prevId, no); + } + + // 通知任务必须和告警同生共死:失败就抛,整个事务回滚,等下轮重试。 + // 以前这里是 catch 吞掉,结果是"告警在列表里,但谁也不会收到提醒", + // 而且没人知道——这比告警没生成更危险。 + insertNotifyTasks(id, no, rule, receivers, quietMap, alarmIdPayload(id, no, rule, delayed)); + + return id; + }); + } catch (Exception e) { + log.warn("告警落库事务失败 dev={} rule={}:{}", devId, rule.key(), e.getMessage()); + throw e; // 交给 scanOnce:不标 processed,下轮重试 + } + + if (alarmId == null || alarmId == 0L) { + return 0; + } + + // ③ 事务外推实时消息:推送失败不能回滚已经落库的告警 + pushLive(alarmId, no, rule, hhId, devId, pushIds, delayed, triggerAt, deviceName, deviceSn); + return 1; + } + + private String alarmIdPayload(long alarmId, String alarmNo, Rule rule, boolean delayed) { + return "{\"alarmId\":" + alarmId + ",\"alarmNo\":\"" + alarmNo + + "\",\"title\":\"" + rule.title() + "\",\"level\":\"" + rule.level() + + "\",\"suppressed\":" + delayed + "}"; + } + + /** + * 给每个家属建一条 APP 通知任务(含被静默的——静默只是不响铃,列表里还得看得见)。 + * + *

失败必须抛:这个方法在事务里,抛出去整条告警回滚,事件留待重试。 + * 吞掉的后果是"有告警、没人收到通知",且事后无从发现。 + */ + private void insertNotifyTasks(long alarmId, String alarmNo, Rule rule, + List receivers, java.util.Map quietMap, + String payload) { + for (Long wxId : receivers) { + String taskNo = "NT" + System.currentTimeMillis() + + String.format("%04d", ThreadLocalRandom.current().nextInt(10000)); + dao.insert("INSERT INTO notify_task(task_no, biz_type, biz_id, receiver_type, receiver_id, " + + "template_code, channel_plan, payload, dedup_key, status) " + + "VALUES(?,'ALARM',?,'WX',?,?,'[\"APP\"]',?,?,'PENDING')", + taskNo, alarmId, wxId, rule.key(), payload, "ALARM-" + alarmId + "-" + wxId); + } + } + + /** 替代留痕。替掉的单子必须能查到"被谁替了",否则家属以为记录消失了。 */ + private void replaceLog(long oldId, String newAlarmNo) { + try { + dao.insert("INSERT INTO alarm_action_log(alarm_id, action, stage, channel, operator_type, " + + "operator_id, operator_name, remark) VALUES(?,'REPLACE',1,'SYS','SYSTEM',0,'系统',?)", + oldId, "被 " + newAlarmNo + " 替代(本人未处理)"); + } catch (Exception e) { + log.warn("替代留痕失败 alarm={}:{}", oldId, e.getMessage()); + } + } + + /** 一户口在册家属。告警要按人推,所以先取名单,后面静默判定和推送都用它。 */ + private List receiverIds(Number householdId) { + if (householdId == null) { + return List.of(); + } + List> users = dao.list( + "SELECT wx_user_id FROM user_house WHERE household_id=? AND status='ACTIVE'", householdId); + List ids = new java.util.ArrayList<>(); + for (Map u : users) { + Object v = u.get("wx_user_id"); + if (v instanceof Number n) { + ids.add(n.longValue()); + } + } + return ids; + } + + /** + * 设备侧恢复。只写 {@code cleared_at},不动 status——设备自己好了不等于有人处理过。 + * + *

为什么不再按 active_key 找(2026-11-09 改):改成"每次触发新建单"之后, + * 旧单的抑制键都要释放(否则新单撞唯一索引),所以已经没单子攥着 active_key 了。 + * 改成按 {@code dev_id + rule_key + 未终结 + 未清过} 找,语义更准,也防重复清。 + * + *

SUPERSEDED 不在范围内:被替代的单子代表"当时确实发生过",但现在的风险事实 + * 由最新那条承载,不该被这次恢复顺手抹掉。 + */ + private int recover(String devId, String ruleKey, Map ev) { + return dao.update("UPDATE alarm SET cleared_at=?, clear_event_id=?, active_key=NULL, " + + "updated_at=NOW(3) WHERE dev_id=? AND rule_key=? " + + "AND status NOT IN ('CLOSED','FALSE','SUPERSEDED') AND cleared_at IS NULL", + ev.get("event_time"), num(ev.get("id")), devId, ruleKey); + } + + /** + * 通知一户口的所有家属。三个动作,任何一步失败都不影响告警本身落库。 + * + *

    + *
  1. APP 记录:一条 {@code notify_task}(receiver_type=WX),家属端通知中心用;
  2. + *
  3. SSE 实时推:只推当前不在免打扰时段的在线家属;
  4. + *
  5. 短信:只有 S1(SOS / 水浸)发,S2/S3 一律不发。
  6. + *
+ * + *

免打扰是按人算的(2026-11-09 定):爸爸设了 22:00–07:00,妈妈没设, + * 那么半夜那条开门告警妈妈手机照响、爸爸手机不响。不做"按户取最松"。 + * 静默的人不是收不到——{@code notify_task} 照建,打开列表就在那儿,只是不弹不响。 + * + *

短信为什么只给 S1:短信是按条计费的,而且短信响一声跟 APP 弹窗在 + * 紧急程度上的差别只在"手机没网/APP 没开"。S2/S3 每天几十条,短信全发会让家属 + * 直接把号码拉黑——真出事那天反而收不到。这条成本边界是产品定的,不是技术限制。 + */ + /** + * 事务外的实时投递:SSE 推送 + 短信。 + * + *

为什么必须在事务外:这两件事都是不可回滚的外部副作用。 + * 放进事务里,一旦后面回滚,消息已经发出去了,数据库却当没发生过—— + * 家属收到一条"查不到"的告警,比晚几秒更糟。 + * + *

失败只记日志不回滚:告警主记录和 APP 通知任务已经在库里了, + * 家属打开列表仍然能看到。推送是"更快知道",不是"唯一渠道"。 + * + *

短信为什么只给 S1:短信是按条计费的,而且短信响一声跟 APP 弹窗在 + * 紧急程度上的差别只在"手机没网/APP 没开"。S2/S3 每天几十条,短信全发会让家属 + * 直接把号码拉黑——真出事那天反而收不到。这条成本边界是产品定的,不是技术限制。 + */ + private void pushLive(long alarmId, String alarmNo, Rule rule, long hhId, String devId, + List pushIds, boolean delayed, + String triggerAt, String deviceName, String deviceSn) { + boolean isS1 = "S1".equals(rule.level()); + + if (!pushIds.isEmpty()) { + // 同一户、同一设备、同一规则在合并窗口内只弹一次横幅。 + // 门磁接触不良、水浸探针抖动、补一次历史数据,都会在几秒内连出十几条; + // 短信已经按窗口合并了,横幅不合并的话手机上照样连着响十几次。 + if (!pushWindowPassed(hhId, devId, rule.key(), isS1)) { + log.info("告警 {}({})在 {} 秒合并窗口内已推过同设备同规则,跳过横幅", + alarmNo, rule.level(), isS1 ? pushMergeS1Seconds : pushMergeSeconds); + } else { + try { + java.util.Map msg = new java.util.LinkedHashMap<>(); + msg.put("type", "alarm"); + msg.put("alarmId", alarmId); + msg.put("alarmNo", alarmNo); + msg.put("householdId", hhId); + msg.put("level", rule.level()); + msg.put("tier", rule.tier()); + msg.put("ruleKey", rule.key()); + msg.put("title", rule.title()); + msg.put("content", rule.content()); + // 设备别名 + 机身编号:横幅上要能看出是哪台,"设备断电"更必须带 + msg.put("deviceName", deviceName); + msg.put("deviceSn", deviceSn); + msg.put("triggerAt", triggerAt == null ? "" : triggerAt); + msg.put("delayed", delayed); + int pushed = sse.pushToUsers(pushIds, "alarm", msg); + if (pushed > 0) { + log.info("告警 {} 实时推送成功 {} 条连接", alarmNo, pushed); + } + } catch (Exception e) { + log.warn("告警 {} SSE 推送失败:{}", alarmNo, e.getMessage()); + } + } + } else { + log.info("告警 {} 全户处于免打扰时段,只入列表不推送", alarmNo); + } + + // 短信:只发 SOS / 漏水,且只发没被静默的人。 + // 烟感也是 S1(要弹窗、要响),但按产品口径"只有 SOS 和水浸发短信",这里单独判。 + if (isS1 && smsAllowed(rule) && !pushIds.isEmpty()) { + try { + // 短信里带上设备别名,家属一眼知道是哪儿:"客厅烟感:水浸探针持续导通"。 + // 不带机身编号——短信留给家属看的,不是给运维对单的,13 位号码反而挡视线。 + String smsBody = deviceName == null || deviceName.isBlank() + ? rule.content() : deviceName + ":" + rule.content(); + sms.sendAlarmSms(hhId, alarmId, devId, rule.key(), rule.level(), + rule.title(), smsBody, alarmNo, pushIds); + } catch (Exception e) { + log.warn("告警 {} 短信生成失败:{}", alarmNo, e.getMessage()); + } + } + } + + /** + * 推送合并窗口:这个「户 + 设备 + 规则」在窗口内是否还没推过。 + * + *

窗口按级别分两档:**S1 1 秒、S2/S3 5 秒**。都压得很短,因为 + * I-01 一批最多 200 条、攒批 200 ms —— 一次抖动产生的重复记录几乎都在同一批, + * 几百毫秒内就处理完了。1 秒挡得住"同一批",挡不掉两次真正独立的告警。 + * + *

用 Redis {@code SET NX EX} 而不是"先 GET 再 SET":后者在并发下两条都能读到 + * "还没有",于是两条都推出去,等于没合并。 + * + *

Redis 不可用时返回 true(照推):宁可多弹一次,也不能因为缓存挂了 + * 让告警不出现在手机上——合并只是体验优化,不是告警的前置条件。 + */ + private boolean pushWindowPassed(long hhId, String devId, String ruleKey, boolean isS1) { + int window = isS1 ? pushMergeS1Seconds : pushMergeSeconds; + if (window <= 0) { + return true; + } + try { + Boolean first = redis.opsForValue().setIfAbsent( + PUSH_KEY + hhId + ":" + devId + ":" + ruleKey, "1", + java.time.Duration.ofSeconds(window)); + return Boolean.TRUE.equals(first); + } catch (Exception e) { + log.warn("推送合并窗口判断失败,本条按推送处理:{}", e.getMessage()); + return true; + } + } + + /** 发短信的规则白名单:SOS / 漏水。加规则时想清楚短信是计费的。 */ + private static boolean smsAllowed(Rule rule) { + return "SOS".equals(rule.key()) || "WATER_LEAK".equals(rule.key()); + } + + private static String alarmNo() { + return "AL" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + /** + * 事件类型和这台设备对不对得上。设备类型查不到时不拦——宁可多一条告警, + * 也别因为脏数据把真告警吞掉;查得到且明确不符才跳过。 + */ + private static boolean typeMatches(Rule rule, Map row) { + String devType = str(row.get("dev_type")); + return rule.deviceType() == null || devType == null || rule.deviceType().equals(devType); + } + + private static Long num(Object o) { + return o instanceof Number n ? n.longValue() : null; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmTrigger.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmTrigger.java new file mode 100644 index 0000000..cd4834f --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmTrigger.java @@ -0,0 +1,82 @@ +package cn.aomin.gateway.biz.engine; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 告警触发闸门:入库完就扫,不再等定时器。 + * + *

为什么要有这个类,而不是让 Controller 直接调 AlarmEngine: + * 三个理由,缺一不可—— + *

    + *
  1. 必须异步:{@code @Async} 只有在"从别的 Bean 调进来"时才生效。 + * 如果把 {@code @Async} 方法写在 AlarmEngine 里、由它自己调自己, + * Spring 代理被绕过,实际还是同步执行,接入接口会被规则引擎拖慢。
  2. + *
  3. 必须防抖:模拟器或真实网关一次批量推 200 条事件,是一条 I-01 请求, + * 触发一次扫描;但如果 IoT 侧连续推 10 个批次,就会连开 10 次扫描, + * 彼此抢同一批 {@code processed=0} 的行。
  4. + *
  5. 必须单飞:同一时刻只允许一轮扫描在跑。撞车不会产生重复告警 + * ({@code alarm.uk_active} 兜得住),但会白白多查几次库。
  6. + *
+ * + *

它不是唯一入口:{@link AlarmEngine#tick()} 那个 30 秒定时器仍然在, + * 职责降级为"兜底"——比如进程重启时库里还压着未消费的事件,或者某个批次的事件 + * 因为异常没触发成功。主链路靠这里,定时器只负责捡漏。 + */ +@Component +public class AlarmTrigger { + + private static final Logger log = LoggerFactory.getLogger(AlarmTrigger.class); + + private final AlarmEngine engine; + + /** 单飞标志:正在扫的时候又来了新事件,就记一笔,本轮结束后立刻补扫。 */ + private final AtomicBoolean running = new AtomicBoolean(false); + + /** 有待补扫的标记。丢弃触发会导致"快速连按第二下没反应",必须排队而不是扔。 */ + private final AtomicBoolean pending = new AtomicBoolean(false); + + public AlarmTrigger(AlarmEngine engine) { + this.engine = engine; + } + + /** + * 有事件入库了,去扫一轮。 + * + *

调用方({@code IngestController})只在本批次 {@code accepted > 0} 时调, + * 全被拒收或全重复的批次不必惊动规则引擎。 + * + *

为什么不用"太频繁就丢掉"的防抖:演示时就是会快速连按几次 SOS / 门磁, + * 丢掉第二次等于"按了没反应"。所以改成排队:正在扫就置 pending,本轮结束后 + * 循环再扫,直到没有新触发为止。代价是极端高频时同一线程会多跑几轮,可接受。 + */ + @Async + public void onEventsIngested(int accepted) { + if (!running.compareAndSet(false, true)) { + pending.set(true); + return; + } + long t0 = System.nanoTime(); + int rounds = 0; + try { + do { + pending.set(false); + rounds++; + engine.scanOnce(); + } while (pending.get()); + } catch (Exception e) { + // 规则引擎失败绝不能冒泡到接入接口:接入是主链路,告警是旁路 + log.warn("实时扫描失败(已由定时器兜底):{}", e.getMessage()); + } finally { + running.set(false); + long ms = (System.nanoTime() - t0) / 1_000_000; + if (ms > 500 || rounds > 1) { + log.info("实时扫描 {} 轮,用时 {}ms(accepted={})", rounds, ms, accepted); + } + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AdminService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AdminService.java new file mode 100644 index 0000000..5dda9b5 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AdminService.java @@ -0,0 +1,668 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.biz.support.Crypto; +import cn.aomin.gateway.common.BizException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 后台与物业:小区、楼栋、单元、户、老人建档、账号、字典、工单、审计。 + * + *

这一组是运营/物业用的,家属端不会调。凡是写操作都落 {@code audit_log}—— + * 物业改房号、改老人信息这种事,事后必须查得到是谁改的。 + */ +@Service +public class AdminService { + + private static final DateTimeFormatter NO_TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final BizDao dao; + private final PiiService pii; + private final String piiKey; + private final cn.aomin.gateway.biz.support.AdminScope scope; + private final cn.aomin.gateway.biz.support.AuditGuard auditGuard; + + public AdminService(BizDao dao, PiiService pii, + cn.aomin.gateway.biz.support.AdminScope scope, + cn.aomin.gateway.biz.support.AuditGuard auditGuard, + @org.springframework.beans.factory.annotation.Value("${biz.pii.key:dev-only-pii-key-2026}") String piiKey) { + this.dao = dao; + this.pii = pii; + this.scope = scope; + this.auditGuard = auditGuard; + this.piiKey = piiKey; + } + + // ---------------------------------------------------------------- 小区 / 楼栋 / 单元 + + public List> communities(int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + List args = new ArrayList<>(); + String scopeSql = scope.sql("c.id", args); // 物业只看到自己名下的小区 + List> rows = dao.list( + "SELECT c.id, c.community_code, c.name, c.city, c.district, c.address, " + + "c.household_count, c.manager_name, c.status, " + + "(SELECT COUNT(*) FROM building b WHERE b.community_id=c.id) AS building_count " + + "FROM community c WHERE 1=1" + scopeSql + + " ORDER BY c.id DESC LIMIT ? OFFSET ?", + concat(args, s, (p - 1) * s)); + return rows; + } + + public long createCommunity(Dtos.CommunityCreate req) { + if (req.name() == null || req.name().isBlank()) { + throw BizException.badRequest("小区名称不能为空"); + } + if (dao.count("SELECT COUNT(*) FROM community WHERE community_code=?", req.code()) > 0) { + throw BizException.conflict("小区编码已存在"); + } + // community.company_id 是 NOT NULL 且没有默认值,不传会直接报 + // "Field 'company_id' doesn't have a default value"。 + // 取其所属公司;运营账号没绑公司时落 1(平台自营)。 + Long companyId = currentCompanyId(); + long id = dao.insert("INSERT INTO community(community_code, company_id, name, province, city, " + + "district, address, household_count, manager_name, manager_phone_enc, status) " + + "VALUES(?,?,?,?,?,?,?,0,?,?,'ACTIVE')", + req.code(), companyId, req.name(), req.province(), req.city(), req.district(), + req.address(), req.managerName(), Crypto.encrypt(req.managerPhone(), piiKey())); + audit("CREATE", "community", String.valueOf(id), "新建小区 " + req.name()); + return id; + } + + public List> buildings(Long communityId) { + return dao.list("SELECT id, community_id, code, name, floor_count, sort_no, status " + + "FROM building WHERE (? IS NULL OR community_id=?) ORDER BY sort_no, id", + communityId, communityId); + } + + public long createBuilding(Dtos.BuildingCreate req) { + if (req.communityId() == null || req.name() == null) { + throw BizException.badRequest("communityId 与名称不能为空"); + } + long id = dao.insert("INSERT INTO building(community_id, code, name, floor_count, sort_no, status) " + + "VALUES(?,?,?,?,0,'ACTIVE')", + req.communityId(), req.code(), req.name(), req.floorCount() == null ? 0 : req.floorCount()); + audit("CREATE", "building", String.valueOf(id), "新建楼栋 " + req.name()); + return id; + } + + public List> units(Long buildingId) { + return dao.list("SELECT id, community_id, building_id, code, name, sort_no, status " + + "FROM unit WHERE (? IS NULL OR building_id=?) ORDER BY sort_no, id", buildingId, buildingId); + } + + public long createUnit(Dtos.UnitCreate req) { + if (req.buildingId() == null || req.name() == null) { + throw BizException.badRequest("buildingId 与名称不能为空"); + } + long id = dao.insert("INSERT INTO unit(community_id, building_id, code, name, sort_no, status) " + + "VALUES(?,?,?,?,0,'ACTIVE')", + req.communityId(), req.buildingId(), req.code(), req.name()); + audit("CREATE", "unit", String.valueOf(id), "新建单元 " + req.name()); + return id; + } + + public List> households(Long communityId, int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + List args = new ArrayList<>(); + args.add(communityId); + args.add(communityId); + // 用户级过滤而不是"小区 IN":物业若只配了某栋楼,住户列表不能把同小区其他栋也放出来 + String scopeSql = scope.householdFilter("h.id", args); + List> rows = dao.list( + "SELECT h.id, h.community_id, h.room_no, h.floor, h.address_text, h.elder_count, " + + "h.device_count, h.open_status, h.mode, h.status, c.name AS community_name, " + + "b.name AS building_name, " + + "u.name AS unit_name FROM household h " + + "LEFT JOIN community c ON c.id=h.community_id " + + "LEFT JOIN building b ON b.id=h.building_id " + + "LEFT JOIN unit u ON u.id=h.unit_id " + + "WHERE (? IS NULL OR h.community_id=?)" + scopeSql + + " ORDER BY h.id DESC LIMIT ? OFFSET ?", + concat(args, s, (p - 1) * s)); + return rows; + } + + private Long currentCompanyId() { + Map u = dao.one("SELECT company_id FROM sys_user WHERE id=?", + AuthContext.userId()); + if (u == null || u.get("company_id") == null) { + return 1L; + } + return ((Number) u.get("company_id")).longValue(); + } + + private static Object[] concat(List head, Object... tail) { + Object[] out = new Object[head.size() + tail.length]; + for (int i = 0; i < head.size(); i++) { + out[i] = head.get(i); + } + System.arraycopy(tail, 0, out, head.size(), tail.length); + return out; + } + + @Transactional + public long createHousehold(Dtos.HouseholdCreate req) { + if (req.roomNo() == null || req.roomNo().isBlank()) { + throw BizException.badRequest("房号不能为空"); + } + long id = dao.insert("INSERT INTO household(community_id, building_id, unit_id, room_no, floor, " + + "address_text, elder_count, device_count, open_status, mode, baseline_state, " + + "status, remark) VALUES(?,?,?,?,?,?,0,0,'未开通','HOME','学习期','生效',?)", + req.communityId(), req.buildingId(), req.unitId(), req.roomNo(), + req.floor() == null ? 0 : req.floor(), req.roomNo(), req.remark()); + dao.update("UPDATE community SET household_count=household_count+1 WHERE id=?", req.communityId()); + audit("CREATE", "household", String.valueOf(id), "新建户 " + req.roomNo()); + return id; + } + + // ---------------------------------------------------------------- 老人建档 + + @Transactional + public long createElder(Dtos.ElderCreate req) { + if (req.householdId() == null || req.name() == null || req.name().isBlank()) { + throw BizException.badRequest("householdId 与老人姓名不能为空"); + } + if (dao.count("SELECT COUNT(*) FROM household WHERE id=?", req.householdId()) == 0) { + throw new BizException(404, "户不存在"); + } + long piiId = pii.create(req.name(), null); + long id = dao.insert("INSERT INTO elder(household_id, pii_id, gender, birth_date, care_level, " + + "live_alone, profile_authorized, status, remark) " + + "VALUES(?,?,?,?,?,?,0,'ACTIVE',?)", + req.householdId(), piiId, req.gender(), req.birthDate(), req.careLevel(), + req.liveAlone() != null && req.liveAlone() ? 1 : 0, req.remark()); + dao.update("UPDATE household SET elder_count=(SELECT COUNT(*) FROM elder " + + "WHERE household_id=? AND status='ACTIVE') WHERE id=?", req.householdId(), req.householdId()); + audit("CREATE", "elder", String.valueOf(id), "老人建档"); + return id; + } + + // ---------------------------------------------------------------- 账号 + + public List> sysUsers(int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + return dao.list("SELECT u.id, u.username, u.real_name, u.company_id, u.status, " + + "u.last_login_at, u.created_at, " + + "(SELECT GROUP_CONCAT(r.role_name) FROM sys_user_role ur " + + "JOIN sys_role r ON r.id=ur.role_id WHERE ur.user_id=u.id) AS roles " + + "FROM sys_user u ORDER BY u.id DESC LIMIT ? OFFSET ?", s, (p - 1) * s); + } + + @Transactional + public long createSysUser(Dtos.SysUserCreate req) { + if (req.username() == null || req.username().isBlank()) { + throw BizException.badRequest("账号不能为空"); + } + if (dao.count("SELECT COUNT(*) FROM sys_user WHERE username=?", req.username()) > 0) { + throw BizException.conflict("账号已存在"); + } + long id = dao.insert("INSERT INTO sys_user(username, password_hash, real_name, mobile_enc, " + + "mobile_hash, status) VALUES(?,?,?,?,?,'ACTIVE')", + req.username(), Crypto.hashPassword(req.password()), req.realName(), + Crypto.encrypt(req.mobile(), piiKey()), + req.mobile() == null ? null : Crypto.sha256(req.mobile())); + String roleCode = req.roleCode() == null ? "OPERATOR" : req.roleCode(); + Map role = dao.one("SELECT id FROM sys_role WHERE role_code=?", roleCode); + if (role != null) { + dao.insert("INSERT INTO sys_user_role(user_id, role_id) VALUES(?,?)", + id, role.get("id")); + } + audit("CREATE", "sys_user", String.valueOf(id), "新建后台账号 " + req.username()); + return id; + } + + public List> roles() { + return dao.list("SELECT role_code, role_name, data_scope_type FROM sys_role ORDER BY id"); + } + + // ---------------------------------------------------------------- 数据范围(按区域) + + /** + * 范围配置下拉用的选项。 + * + *

不引国标行政区划码表:选项直接从库里已有小区反推。 + * 好处是选出来的值必然和 {@code community.province/city/district} 字面一致, + * 不会出现"选了广东省、库里写广东"这种匹配不上的鬼事;数据量也就几十个。 + */ + public Map scopeOptions() { + List> comms = dao.list( + "SELECT id, name, community_code, province, city, district FROM community " + + "ORDER BY province, city, district, id"); + List> blds = dao.list( + "SELECT b.id, b.community_id, b.name, c.name AS community_name FROM building b " + + "JOIN community c ON c.id=b.community_id " + + "ORDER BY b.community_id, b.sort_no, b.id"); + + // 省 → 市 → 区,三级去重成树。前端拿它渲染三个级联下拉。 + java.util.LinkedHashMap>> tree = + new java.util.LinkedHashMap<>(); + for (Map c : comms) { + String p = str(c.get("province")); + String ci = str(c.get("city")); + String d = str(c.get("district")); + if (p == null || p.isBlank()) { + continue; + } + tree.computeIfAbsent(p, k -> new java.util.LinkedHashMap<>()) + .computeIfAbsent(ci == null ? "" : ci, k -> new java.util.LinkedHashSet<>()) + .add(d == null ? "" : d); + } + List> regions = new ArrayList<>(); + for (var pe : tree.entrySet()) { + List> cities = new ArrayList<>(); + for (var ce : pe.getValue().entrySet()) { + Map city = new java.util.LinkedHashMap<>(); + city.put("city", ce.getKey()); + city.put("districts", new ArrayList<>(ce.getValue())); + cities.add(city); + } + Map prov = new java.util.LinkedHashMap<>(); + prov.put("province", pe.getKey()); + prov.put("cities", cities); + regions.add(prov); + } + + Map out = new java.util.LinkedHashMap<>(); + out.put("regions", regions); + out.put("communities", comms); + out.put("buildings", blds); + return out; + } + + /** 某个账号当前配的范围。 */ + public List> userScopes(long userId) { + return dao.list("SELECT id, province, city, district, community_id, building_id, remark " + + "FROM sys_user_region_scope WHERE user_id=? ORDER BY id", userId); + } + + /** + * 覆盖保存某个账号的范围。 + * + *

整体覆盖(先删后插)而不是逐行 diff:范围行本来就是个整体, + * 增量改容易留下"前端以为删了、库里还在"的脏行。 + */ + @Transactional + public int saveUserScopes(long userId, Dtos.ScopeSave req) { + // 只有运营能配。物业不能给自己扩范围,否则"数据范围"等于没有。 + long op = dao.count("SELECT COUNT(*) FROM sys_user_role ur JOIN sys_role r ON r.id=ur.role_id " + + "WHERE ur.user_id=? AND r.role_code='OPERATOR'", AuthContext.userId()); + if (op == 0) { + throw new BizException(403, "只有运营账号可以配置数据范围"); + } + Map u = dao.one("SELECT id, username FROM sys_user WHERE id=?", userId); + if (u == null) { + throw new BizException(404, "账号不存在"); + } + + dao.update("DELETE FROM sys_user_region_scope WHERE user_id=?", userId); + int n = 0; + if (req != null && req.scopes() != null) { + for (Dtos.ScopeRow r : req.scopes()) { + if (r == null) { + continue; + } + boolean blank = isBlank(r.province()) && isBlank(r.city()) && isBlank(r.district()) + && r.communityId() == null && r.buildingId() == null; + if (blank) { + // 全空行直接丢:留着会命中所有小区,等于把范围改成"全放" + continue; + } + dao.insert("INSERT INTO sys_user_region_scope(user_id, province, city, district, " + + "community_id, building_id, remark) VALUES(?,?,?,?,?,?,?)", + userId, emptyToNull(r.province()), emptyToNull(r.city()), emptyToNull(r.district()), + r.communityId(), r.buildingId(), emptyToNull(r.remark())); + n++; + } + } + audit("UPDATE", "sys_user", String.valueOf(userId), + "配置数据范围 " + n + " 行(" + u.get("username") + ")"); + return n; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } + + private static String emptyToNull(String s) { + return isBlank(s) ? null : s.trim(); + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + // ---------------------------------------------------------------- 字典 + + public List> dicts() { + return dao.list("SELECT dict_type, dict_name, remark FROM sys_dict ORDER BY dict_type"); + } + + public Object dictItems(String type) { + return dao.list("SELECT item_value, item_label, sort_no FROM sys_dict_item " + + "WHERE dict_type=? AND status='ACTIVE' ORDER BY sort_no, id", type); + } + + // ---------------------------------------------------------------- 工单 + + public Map workOrders(String status, int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + String where = " WHERE (? IS NULL OR status=?)"; + long total = dao.count("SELECT COUNT(*) FROM work_order" + where, status, status); + List> rows = dao.list( + "SELECT id, order_no, type, source, community_id, household_id, elder_id, title, " + + "content, priority, status, assignee_name, sla_due_at, finished_at, created_at " + + "FROM work_order" + where + " ORDER BY id DESC LIMIT ? OFFSET ?", + status, status, s, (p - 1) * s); + return BizDeviceService.page(rows, total, p, s); + } + + public long createWorkOrder(Dtos.WorkOrderCreate req) { + String no = "WO" + LocalDateTime.now().format(NO_TS) + + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); + long id = dao.insert("INSERT INTO work_order(order_no, type, source, household_id, elder_id, " + + "title, content, priority, status, created_by) " + + "VALUES(?,?,?,?,?,?,?,?,'PENDING',?)", + no, req.type() == null ? "OTHER" : req.type(), "CONSOLE", + req.householdId(), req.elderId(), req.title(), req.content(), + req.priority() == null ? "NORMAL" : req.priority(), AuthContext.userId()); + dao.insert("INSERT INTO work_order_log(work_order_id, action, operator_type, operator_id, operator_name) " + + "VALUES(?,?,?,?,?)", id, "CREATE", AuthContext.require().type(), + AuthContext.userId(), AuthContext.require().name()); + audit("CREATE", "work_order", String.valueOf(id), "新建工单 " + no); + return id; + } + + // ---------------------------------------------------------------- 审计 + + public Map auditLogs(String bizType, int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + String where = " WHERE (? IS NULL OR biz_type=?)"; + long total = dao.count("SELECT COUNT(*) FROM audit_log" + where, bizType, bizType); + List> rows = dao.list( + "SELECT id, operator_type, operator_id, operator_name, action_type, biz_type, " + + "biz_key, content, ip, created_at FROM audit_log" + where + + " ORDER BY id DESC LIMIT ? OFFSET ?", bizType, bizType, s, (p - 1) * s); + return BizDeviceService.page(rows, total, p, s); + } + + // ---------------------------------------------------------------- 审计失败兜底 + + /** + * 审计写失败、还没补录的清单。 + * + *

这些行是"某个后台操作成功了,但查不到是谁做的"。补录之前, + * 对应业务在审计上就是空白——所以这张表不是日志,是待办。 + */ + public Map auditFailures(int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + long total = dao.count("SELECT COUNT(*) FROM audit_fail_log WHERE replay_status=0"); + List> rows = dao.list( + "SELECT id, operator_type, operator_id, operator_name, action_type, biz_type, " + + "biz_key, content, error_msg, replay_status, replayed_at, created_at " + + "FROM audit_fail_log WHERE replay_status=0 ORDER BY id DESC LIMIT ? OFFSET ?", + s, (p - 1) * s); + return BizDeviceService.page(rows, total, p, s); + } + + /** + * 补录一条:把兜底行原样写回 {@code audit_log},并标记已补录。 + * + *

只写回内容,不改 {@code created_at}——那是"操作实际发生的时间", + * 补录只是把当时漏掉的那一行补上,不能让审计时间线错位。 + */ + public Map replayAuditFailure(long id) { + if (!AuthContext.isSys()) { + throw new BizException(403, "只有后台账号可以补录审计"); + } + Map row = dao.one( + "SELECT id, operator_type, operator_id, operator_name, action_type, biz_type, " + + "biz_key, content, replay_status FROM audit_fail_log WHERE id = ?", id); + if (row == null) { + throw new BizException(404, "这条记录不存在"); + } + Object status = row.get("replay_status"); + if (status != null && ((Number) status).intValue() != 0) { + throw new BizException(409, "这条已经处理过了,不能重复补录"); + } + dao.insert("INSERT INTO audit_log(operator_type, operator_id, operator_name, action_type, " + + "biz_type, biz_key, content) VALUES(?,?,?,?,?,?,?)", + row.get("operator_type"), row.get("operator_id"), row.get("operator_name"), + row.get("action_type"), row.get("biz_type"), row.get("biz_key"), row.get("content")); + dao.update("UPDATE audit_fail_log SET replay_status=1, replayed_at=NOW(3) " + + "WHERE id=? AND replay_status=0", id); + audit("AUDIT_REPLAY", "audit_fail_log", String.valueOf(id), "补录漏写的审计 #" + id); + return Map.of("id", id, "replayed", true); + } + + // ---------------------------------------------------------------- 设备入库(预注册) + + /** 认识的设备类型。和前端 common.js 的 TYPE 字典、展示层的中文名一一对应。 */ + private static final Map DEVICE_TYPES = Map.of( + "DOOR", "门磁", "WATER", "水浸", "VIB", "水振", + "CURRENT", "电流", "SOS", "紧急按钮", "SMOKE", "烟感"); + + private static final java.util.Set VENDORS = java.util.Set.of("OWN", "HORN", "MIR"); + + /** + * 出厂录入:把一批设备先录进库,家属之后才能绑。 + * + *

这是"能不能绑"的唯一开关。没有这一步,家属扫码填了 SN 也绑不上—— + * 因为 SN 本身不含类型信息(8 位十六进制是流水号),光看编号永远不知道是门磁还是水浸。 + * 想让它绑上,就得有人告诉系统它是什么。 + * + *

录进来是 {@code status=已入库}、{@code household_id=NULL}:在库但还没归谁。 + */ + @Transactional + public Map registerDevice(Dtos.DeviceRegister req) { + if (req == null || req.sensorId() == null || req.sensorId().isBlank()) { + throw BizException.badRequest("设备编号不能为空"); + } + String sn = req.sensorId().trim(); + String type = req.typeCode() == null ? "" : req.typeCode().trim().toUpperCase(); + if (!DEVICE_TYPES.containsKey(type)) { + throw BizException.badRequest("设备类型必须选一个:" + String.join(" / ", DEVICE_TYPES.values())); + } + + // 厂商决定通道,通道又决定编号格式——这三件事是绑在一起的,不能各填各的 + String vendor = req.vendor() == null || req.vendor().isBlank() + ? guessVendor(sn) : req.vendor().trim().toUpperCase(); + if (!VENDORS.contains(vendor)) { + throw BizException.badRequest("厂商只能是 OWN / HORN / MIR"); + } + String channel = "HORN".equals(vendor) ? "HORN_HOST" + : "MIR".equals(vendor) ? "MIR_CELL" : "OWN_GW"; + if (!sensorIdFitsChannel(sn, channel)) { + throw BizException.badRequest("OWN".equals(vendor) + ? "自有设备编号要 8 位大写十六进制,例如 3F7A9C24" + : vendor + " 线的编号必须带前缀,例如 " + vendor + ":861234567890123"); + } + + Map exist = dao.one( + "SELECT dev_id, status, household_id FROM device WHERE sensor_id=? AND deleted=0 LIMIT 1", sn); + if (exist != null) { + throw BizException.conflict("这台设备已经在库里了(" + exist.get("dev_id") + + ",状态 " + exist.get("status") + "),不用重复录入"); + } + + String devId = "D" + System.currentTimeMillis() + + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); + dao.insert("INSERT INTO device(dev_id, sensor_id, source_channel, type_code, vendor, " + + "household_id, elder_id, install_location, zone_no, display_name, " + + "id_basis, reg_source, ts_precision, sort_no, status, source, deleted) " + + "VALUES(?,?,?,?,?,NULL,NULL,NULL,NULL,?, 'SUB_SN','PRE_REGISTER',?,99,'已入库','ADMIN',0)", + devId, sn, channel, type, vendor, DEVICE_TYPES.get(type), + "OWN".equals(vendor) ? "A" : "B"); + audit("CREATE", "device", devId, "出厂录入 " + sn + " / " + DEVICE_TYPES.get(type) + " / " + vendor); + + // 这台要是正好在野设备表里待着,顺手标掉,免得后台两处都显示 + dao.update("UPDATE unregistered_device SET status='已注册', dev_id=?, handled_by=?, " + + "handled_at=NOW(3), handle_reason='后台出厂录入' WHERE sensor_id=? AND status='待核查'", + devId, AuthContext.userId(), sn); + + Map out = new LinkedHashMap<>(); + out.put("devId", devId); + out.put("sensorId", sn); + out.put("typeCode", type); + out.put("typeName", DEVICE_TYPES.get(type)); + out.put("vendor", vendor); + out.put("status", "已入库"); + return out; + } + + /** 野设备列表:通电联网了、但我们库里没有的机器。 */ + public List> unregisteredDevices() { + return dao.list("SELECT sensor_id, source_channel, vendor, model, first_seen_at, last_seen_at, " + + "report_count, status FROM unregistered_device WHERE status='待核查' " + + "ORDER BY last_seen_at DESC LIMIT 200"); + } + + /** + * 野设备转正:补一个类型,让它进库。 + * + *

和"出厂录入"的区别只在入口——机器是它自己先报上来的。 + * 落库后一样是 {@code 已入库 / 未绑户},等家属来绑。 + */ + @Transactional + public Map adoptUnregistered(Dtos.UnregAdopt req) { + if (req == null || req.sensorId() == null || req.sensorId().isBlank()) { + throw BizException.badRequest("设备编号不能为空"); + } + String sn = req.sensorId().trim(); + Map u = dao.one("SELECT sensor_id, status FROM unregistered_device WHERE sensor_id=? LIMIT 1", sn); + if (u == null) { + throw new BizException(404, "野设备表里没有这台(" + sn + "),可能已经被处理过了"); + } + if (!"待核查".equals(String.valueOf(u.get("status")))) { + throw BizException.conflict("这台已经处理过了,状态是 " + u.get("status")); + } + Map r = registerDevice(new Dtos.DeviceRegister( + sn, req.typeCode(), req.vendor(), null, req.remark())); + dao.update("UPDATE unregistered_device SET handle_reason=? WHERE sensor_id=? AND status='待核查'", + req.remark() == null || req.remark().isBlank() ? "野设备转正" : req.remark(), sn); + return r; + } + + private static String guessVendor(String sn) { + if (sn.startsWith("HORN:")) return "HORN"; + if (sn.startsWith("MIR:")) return "MIR"; + return "OWN"; + } + + // ---------------------------------------------------------------- 告警死信 + + /** + * 未回放的死信。 + * + *

死信不是垃圾桶:它代表"这条事件确实发生过,但规则连续 {@code max-try} 次都没处理成"。 + * 必须有人能看到、能重放,否则和直接丢掉没有区别。 + */ + public List> deadLetters(int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + return dao.list("SELECT d.id, d.event_pk, d.event_time, d.dev_id, " + + "COALESCE(dev.display_name, d.dev_id) AS dev_name, d.event_type, " + + "d.tries, d.last_error, d.replayed_at, d.created_at " + + "FROM alarm_dead_letter d LEFT JOIN device dev ON dev.dev_id = d.dev_id " + + "WHERE d.replayed_at IS NULL " + + "ORDER BY d.created_at DESC LIMIT ? OFFSET ?", s, (p - 1) * s); + } + + /** + * 回放一条死信:把原事件还给告警引擎重跑一次。 + * + *

扫描条件只剩两条:{@code processed=0}、{@code alarm_next_at} 为空或已到点。 + * 这里置 {@code NOW(3)} 是为了立刻可扫(不必等下一轮退避),置 NULL 也能扫到—— + * 时间窗已经拆掉,回放一条几小时前的死信不会再被"不够新"挡住。 + * + *

{@code alarm_try} 归零:回放是人工判断"规则已经修好了,再给一次机会", + * 不归零的话它一进扫描就再次满足 {@code tries >= max-try},直接又进死信。 + */ + @Transactional + public Map replayDeadLetter(long id) { + if (!AuthContext.isSys()) { + throw new BizException(403, "只有后台账号可以回放死信"); + } + Map row = dao.one( + "SELECT id, event_pk, event_time, dev_id, event_type, replayed_at " + + "FROM alarm_dead_letter WHERE id = ?", id); + if (row == null) { + throw new BizException(404, "死信不存在"); + } + if (row.get("replayed_at") != null) { + throw new BizException(409, "这条死信已经回放过了,不能重复回放"); + } + int n = dao.update("UPDATE iot_event SET processed=0, alarm_try=0, alarm_next_at=NOW(3) " + + "WHERE id=? AND event_time=?", + row.get("event_pk"), row.get("event_time")); + if (n == 0) { + throw new BizException(409, "原事件已不存在(可能所在月份分区已被清理),无法回放"); + } + dao.update("UPDATE alarm_dead_letter SET replayed_at=NOW(3) WHERE id=? AND replayed_at IS NULL", id); + audit("REPLAY", "alarm_dead_letter", String.valueOf(id), + "回放死信:事件 " + row.get("event_pk") + "(" + row.get("event_type") + ")"); + Map out = new LinkedHashMap<>(); + out.put("ok", true); + out.put("eventPk", row.get("event_pk")); + return out; + } + + /** 编号格式和通道必须对得上——这是 M 层入库时的同一个规则,录入时就先挡住。 */ + private static boolean sensorIdFitsChannel(String sn, String channel) { + if ("OWN_GW".equals(channel)) { + return sn.matches("^[0-9A-F]{8}$"); + } + if ("HORN_HOST".equals(channel) || "HORN_CELL".equals(channel)) { + return sn.startsWith("HORN:") && sn.matches("^(HORN|MIR):[A-Za-z0-9._:-]{1,59}$"); + } + if ("MIR_CELL".equals(channel)) { + return sn.startsWith("MIR:") && sn.matches("^(HORN|MIR):[A-Za-z0-9._:-]{1,59}$"); + } + return false; + } + + /** + * 所有写操作都调它,写 {@code audit_log}。 + * + *

失败不再静默(2026-11-11):原来是 {@code catch (Exception ignore) {}}, + * 连日志都没有——物业改了房号、审计没写进去,事后对账才发现,而且不知道丢了哪些。 + * 这里是非事务路径,抛出去只会让用户重试、造成重复数据,所以改成 + * {@link AuditGuard} 记账 + ERROR 日志,计数进 {@code /health}。 + * 告警处置那条链在事务里,那里是直接抛、整条回滚(见 {@code AlarmService#addAction})。 + */ + public void audit(String action, String bizType, String bizKey, String content) { + var who = AuthContext.require(); + try { + dao.insert("INSERT INTO audit_log(operator_type, operator_id, operator_name, action_type, " + + "biz_type, biz_key, content) VALUES(?,?,?,?,?,?,?)", + who.type(), AuthContext.userId(), who.name(), + action, bizType, bizKey, content); + } catch (Exception e) { + // 落兜底表:谁、对什么、做了什么、为什么没写进去,一条不丢,后台可补录。 + // 不能再只 ++ 一个内存计数——重启就没了,也查不出丢的是哪几条。 + auditGuard.failed("admin." + action + "/" + bizType, action, bizType, bizKey, content, + who.type(), AuthContext.userId(), who.name(), e); + } + } + + private String piiKey() { + return piiKey; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AlarmService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AlarmService.java new file mode 100644 index 0000000..0b4601c --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AlarmService.java @@ -0,0 +1,435 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.common.BizException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 告警。 + * + *

四个词必须分开(M05 口径): + *

    + *
  • 已读({@code alarm_popup_ack}):弹窗看过,只是 UI 行为,不动主表状态;
  • + *
  • 确认(CONFIRMED):有人接了,写 {@code confirmed_by/at};
  • + *
  • 处置(HANDLED):人处理完了,还没关;
  • + *
  • 关闭(CLOSED):写 {@code closed_by/at},终态。
  • + *
+ * + *

设备恢复不等于关闭:恢复只写 {@code cleared_at} / {@code clear_event_id}, + * 安全告警(水浸、SOS)不得因为设备自己好了就自动关单——这条松了, + * 出事时说不清到底有没有人看过。 + */ +@Service +public class AlarmService { + + private static final List OPEN = List.of("NEW", "PUSHED", "CONFIRMED", "ESCALATED", "HANDLED", "DELAYED"); + + private final BizDao dao; + private final FamilyService family; + private final cn.aomin.gateway.biz.support.AdminScope adminScope; + + public AlarmService(BizDao dao, FamilyService family, + cn.aomin.gateway.biz.support.AdminScope adminScope) { + this.dao = dao; + this.family = family; + this.adminScope = adminScope; + } + + public Map list(String status, Long householdId, String level, + int page, int size, boolean showSuperseded) { + int p = BizDao.page(page); + int s = BizDao.size(size); + StringBuilder where = new StringBuilder(" WHERE 1=1"); + List args = new ArrayList<>(); + if (status != null && !status.isBlank()) { + if ("OPEN".equalsIgnoreCase(status)) { + where.append(" AND a.status IN ('NEW','PUSHED','CONFIRMED','ESCALATED','HANDLED','DELAYED')"); + } else { + where.append(" AND a.status=?"); + args.add(status); + } + } else if (!showSuperseded) { + // 被替代的默认不展示(2026-11-09 定):列表里每条都是"要处理的", + // 中间那些被顶掉的单子只作为历史,通过详情页的链路看,不占列表。 + where.append(" AND a.status <> 'SUPERSEDED'"); + } + if (householdId != null) { + family.assertHousehold(householdId); + where.append(" AND a.household_id=?"); + args.add(householdId); + } else if (!AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + where.append(" AND a.household_id IN (SELECT household_id FROM user_house " + + "WHERE wx_user_id=? AND status='ACTIVE')"); + args.add(AuthContext.userId()); + } else { + // 运营/物业:没配范围 = 全放;物业配了区域就只看范围内的户。 + // 之前这里对 SYS 一律跳过,结果物业在告警链路上等同于运营(2026-11-10 修)。 + where.append(adminScope.householdFilter("a.household_id", args)); + } + if (level != null && !level.isBlank()) { + where.append(" AND a.level=?"); + args.add(level); + } + long total = dao.count("SELECT COUNT(*) FROM alarm a" + where, args.toArray()); + List q = new ArrayList<>(args); + q.add(s); + q.add((p - 1) * s); + List> rows = dao.list( + "SELECT a.id, a.alarm_no, a.household_id, a.elder_id, a.dev_id, a.rule_key, " + + "a.tier, a.level, a.title, a.content, a.trigger_at, a.status, " + + "a.confirmed_at, a.closed_at, a.cleared_at, a.escalate_stage, a.chain_seq, a.source_event_id, " + + "d.display_name AS device_name, d.sensor_id AS device_sn, d.install_location, " + + "h.room_no " + + "FROM alarm a LEFT JOIN device d ON d.dev_id=a.dev_id " + + "LEFT JOIN household h ON h.id=a.household_id" + + where + " ORDER BY a.trigger_at DESC, a.id DESC LIMIT ? OFFSET ?", + q.toArray()); + return BizDeviceService.page(rows, total, p, s); + } + + public Map detail(long id) { + Map a = mustGet(id); + Map out = new LinkedHashMap<>(a); + out.put("actions", actions(id)); + out.put("chain", chain(id)); + out.put("popupAcks", dao.list( + "SELECT user_id, ack_type, acked_at FROM alarm_popup_ack WHERE alarm_id=? " + + "ORDER BY acked_at DESC LIMIT 20", id)); + return out; + } + + /** + * 告警链:这一组「同一次风险」里的所有记录。 + * + *

要解决的问题:门磁今天开了 8 次,库里是 8 条记录——1 被 2 替代、2 被 3 替代…… + * 家属点开最新那条只看到 14:32,会以为"刚开的门",其实风险从 07:15 就开始了。 + * 所以详情页必须能把"最初的告警时间"和"现在这次"摆在一起。 + * + *

分组规则(跟 {@code AlarmEngine.raise} 的替代逻辑严格对应): + * 沿 {@code superseded_by} 反向递归——被本节点替代过的,都是本组的。 + * 有人确认/处置过的地方链会自然断开(那条没被替代,{@code superseded_by} 为空), + * 正好就是"新的一组"的起点。 + * + *

+     *   1 ←2 ←3(已处置)   4 ←5(当前)
+     *   点 5 → {4,5}      点 3 → {1,2,3}     点 1/2/4(已被替代)→ {自己}
+     * 
+ * + *

为什么点被替代的单只返回它自己:它不是这一组的代表,点它说明是从历史里翻出来的。 + * 前端拿 {@code latestId} 给一个"查看最新"的跳转就够了,不把整组铺开—— + * 否则同一个组在列表里有多个入口,家属会以为是好几件事。 + */ + public Map chain(long id) { + Map a = dao.one( + "SELECT id, household_id, status FROM alarm WHERE id=?", id); + if (a == null) { + throw new BizException(404, "告警不存在"); + } + family.assertHousehold(((Number) a.get("household_id")).longValue()); + + boolean superseded = "SUPERSEDED".equals(String.valueOf(a.get("status"))); + List> items = superseded + ? dao.list("SELECT id, alarm_no, status, level, title, trigger_at, superseded_by, " + + "cleared_at, chain_seq FROM alarm WHERE id=?", id) + : dao.list("WITH RECURSIVE ch AS (" + + " SELECT id, superseded_by, 0 AS lv FROM alarm WHERE id=?" + + " UNION ALL" + + " SELECT a2.id, a2.superseded_by, ch.lv+1 FROM alarm a2 " + + " JOIN ch ON a2.superseded_by = ch.id WHERE ch.lv < 40)" + + " SELECT a2.id, a2.alarm_no, a2.status, a2.level, a2.title, a2.trigger_at, " + + " a2.superseded_by, a2.cleared_at, a2.chain_seq " + + " FROM ch JOIN alarm a2 ON a2.id = ch.id ORDER BY a2.id", id); + + String firstAt = null; + String lastAt = null; + long latestId = id; + for (Map it : items) { + String t = str(it.get("trigger_at")); + if (t != null) { + if (firstAt == null || t.compareTo(firstAt) < 0) { + firstAt = t; + } + if (lastAt == null || t.compareTo(lastAt) > 0) { + lastAt = t; + } + } + } + // 顺 superseded_by 走到底 = 本组最新一条。链上最多 40 跳,超了说明数据有问题。 + long cur = id; + for (int i = 0; i < 40; i++) { + Map n = dao.one("SELECT superseded_by FROM alarm WHERE id=?", cur); + Object nx = n == null ? null : n.get("superseded_by"); + if (!(nx instanceof Number num)) { + latestId = cur; + break; + } + cur = num.longValue(); + latestId = cur; + } + + Map out = new LinkedHashMap<>(); + out.put("anchorId", id); + out.put("isSuperseded", superseded); + out.put("latestId", latestId); + out.put("count", items.size()); + out.put("firstAt", firstAt); + out.put("lastAt", lastAt); + out.put("items", items); + return out; + } + + /** 弹窗已读:只记回执,不动告警状态。 */ + public Map ack(long id, String ackType) { + mustGet(id); + dao.insert("INSERT INTO alarm_popup_ack(alarm_id, user_id, ack_type, acked_at) " + + "VALUES(?,?,?,NOW(3))", id, AuthContext.userId(), + ackType == null ? "VIEWED" : ackType); + addAction(id, "ACK", null, "弹窗已读"); + return detail(id); + } + + /** + * 确认处置:NEW/PUSHED/ESCALATED/DELAYED → CONFIRMED。 + * + *

为什么把状态判断写进 WHERE:原来是"先读状态、内存里判、再按 id 更新"。 + * 两个人在同一秒点"确认"时,两次读都看到 NEW、两次都过判断,审计会写两条, + * 后一条覆盖前一条的操作人。改成条件更新后,第二个 affected=0,直接 409 让他刷新。 + * + *

为什么加 DELAYED:免打扰时段生成的单就是这个状态,家属端按钮 + * 本来就把它算作可确认({@code family/index.html}),后端却不在白名单里 —— + * 家属点了会收到 400「当前状态 DELAYED 不能确认」。前后端口径必须一致。 + * + *

事务边界覆盖"状态更新 + 动作日志 + 审计":审计写不进去就整体回滚, + * 不能出现"告警关了但查不到谁关的"。 + */ + @Transactional + public Map confirm(long id, Dtos.AlarmAction req) { + mustGet(id); + int n = dao.update("UPDATE alarm SET status='CONFIRMED', confirmed_by=?, confirmed_at=NOW(3) " + + "WHERE id=? AND status IN ('NEW','PUSHED','ESCALATED','DELAYED')", + AuthContext.userId(), id); + if (n == 0) { + throw conflictAfterNoop(id); + } + addAction(id, "CONFIRM", req == null ? null : req.remark(), "确认告警"); + return detail(id); + } + + @Transactional + public Map handle(long id, Dtos.AlarmAction req) { + mustGet(id); + int n = dao.update("UPDATE alarm SET status='HANDLED', confirmed_by=COALESCE(confirmed_by,?), " + + "confirmed_at=COALESCE(confirmed_at,NOW(3)) " + + "WHERE id=? AND status IN ('NEW','PUSHED','CONFIRMED','ESCALATED','DELAYED')", + AuthContext.userId(), id); + if (n == 0) { + throw conflictAfterNoop(id); + } + addAction(id, "HANDLE", req == null ? null : req.remark(), "已处置"); + return detail(id); + } + + /** + * 关闭:必须写人;不允许"系统自动关"。 + * + *

已经是 CLOSED 时幂等返回、不重复写审计(同一个人重复点不该留两条记录)。 + * FALSE / SUPERSEDED 是另外两种终态,不能从它们转成 CLOSED。 + */ + @Transactional + public Map close(long id, Dtos.AlarmAction req) { + Map a = mustGet(id); + if ("CLOSED".equals(String.valueOf(a.get("status")))) { + return detail(id); + } + // 释放 active_key:它是"同一设备同一规则只挂一条"的抑制键。 + // 结单了还不释放,设备下次再触发同类事件就再也生成不了告警。 + int n = dao.update("UPDATE alarm SET status='CLOSED', closed_by=?, closed_at=NOW(3), " + + "active_key=NULL, updated_at=NOW(3) " + + "WHERE id=? AND status NOT IN ('CLOSED','FALSE','SUPERSEDED')", + AuthContext.userId(), id); + if (n == 0) { + throw conflictAfterNoop(id); + } + addAction(id, "CLOSE", req == null ? null : req.remark(), "人工关闭"); + return detail(id); + } + + /** 误报:单独一个终态,不占用 CLOSED——否则"关掉了"和"是误报"分不清。 */ + @Transactional + public Map markFalse(long id, Dtos.AlarmAction req) { + mustGet(id); + String reason = req == null || req.reason() == null ? "OTHER" : req.reason(); + int n = dao.update("UPDATE alarm SET status='FALSE', false_reason=?, closed_by=?, closed_at=NOW(3), " + + "active_key=NULL, updated_at=NOW(3) " + + "WHERE id=? AND status NOT IN ('CLOSED','FALSE','SUPERSEDED')", + reason, AuthContext.userId(), id); + if (n == 0) { + throw conflictAfterNoop(id); + } + addAction(id, "FALSE", reason, "标记误报:" + reason); + return detail(id); + } + + /** 设备自己恢复了:只写 cleared_at,不动 status;顺手释放抑制键,让下次事件能再报。 */ + public void markCleared(long id, Long eventId) { + dao.update("UPDATE alarm SET cleared_at=NOW(3), clear_event_id=?, active_key=NULL, " + + "updated_at=NOW(3) WHERE id=? AND cleared_at IS NULL", eventId, id); + // 走系统动作:这条由设备恢复事件驱动,线程里没有登录人, + // 用人工动作那条路径会直接抛"未登录"。 + addSystemAction(id, "CLEARED", null, "设备侧恢复"); + } + + /** + * 处理记录(时间轴)。 + * + *

必须自己校验归属(2026-11-10 修):这个方法对外暴露成 + * {@code GET /alarms/{id}/actions},如果只靠 {@link #detail} 那条路径校验, + * 直接调这个接口就能读到别人家的处理记录——包括谁在什么时候确认、写了什么备注。 + * 所以校验放在这里,任何入口都绕不过去。 + */ + public List> actions(long alarmId) { + Map a = dao.one("SELECT id, household_id FROM alarm WHERE id=?", alarmId); + if (a == null) { + throw new BizException(404, "告警不存在"); + } + Object hh = a.get("household_id"); + if (hh != null) { + family.assertHousehold(((Number) hh).longValue()); + } + return dao.list("SELECT action, stage, channel, operator_type, operator_name, remark, created_at " + + "FROM alarm_action_log WHERE alarm_id=? ORDER BY id", alarmId); + } + + /** 首页角标:未关闭数、今日新增、按级别分布。 */ + public Map stats(Long householdId) { + StringBuilder scope = new StringBuilder(); + List args = new ArrayList<>(); + if (householdId != null) { + family.assertHousehold(householdId); + scope.append(" AND household_id=?"); + args.add(householdId); + } else if (!AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + scope.append(" AND household_id IN (SELECT household_id FROM user_house " + + "WHERE wx_user_id=? AND status='ACTIVE')"); + args.add(AuthContext.userId()); + } + Map out = new LinkedHashMap<>(); + out.put("open", dao.count("SELECT COUNT(*) FROM alarm " + + "WHERE status NOT IN ('CLOSED','FALSE','SUPERSEDED')" + scope, args.toArray())); + // 今日新增不数被替代的:一组触发 6 次只算 1 件"今天发生的事", + // 否则首页数字会虚高 6 倍,家属会以为家里出了六回事。 + out.put("today", dao.count("SELECT COUNT(*) FROM alarm " + + "WHERE DATE(trigger_at)=CURDATE() AND status <> 'SUPERSEDED'" + scope, args.toArray())); + out.put("byLevel", dao.list("SELECT level, COUNT(*) AS cnt FROM alarm " + + "WHERE status NOT IN ('CLOSED','FALSE','SUPERSEDED')" + scope + + " GROUP BY level", args.toArray())); + return out; + } + + // ---------------------------------------------------------------- 内部 + + /** + * 被替代的单子不许再动(2026-11-09 定:只有最新那条可以编辑状态)。 + * + *

要堵的场景:家属从历史里翻到第 2 条「房门已打开」,点"我知道了"。 + * 如果放行,那条会变成 CONFIRMED,而真正待处理的第 5 条还是 NEW—— + * 家属以为处理完了,其实没处理。所以这里必须卡死,并告诉他去处理哪一条。 + */ + /** + * 条件更新没命中时,回去看一看到底为什么,给一句能照着做的话。 + * + *

只在失败路径多查一次,成功路径不受影响。 + * + *

要堵的场景(2026-11-09 定):家属从历史里翻到第 2 条「房门已打开」, + * 点"我知道了"。如果放行,那条会变成 CONFIRMED,而真正待处理的第 5 条还是 NEW—— + * 家属以为处理完了,其实没处理。所以这里必须卡死,并告诉他去处理哪一条。 + */ + private BizException conflictAfterNoop(long id) { + Map cur = dao.one("SELECT status FROM alarm WHERE id=?", id); + String st = cur == null ? "?" : String.valueOf(cur.get("status")); + if ("SUPERSEDED".equals(st)) { + return BizException.badRequest("这条告警已被更新的告警替代,不能单独处理;" + + "请处理本组最新那条(详情页有入口)"); + } + if ("CLOSED".equals(st) || "FALSE".equals(st)) { + return BizException.conflict("这条告警已经是终态(" + st + "),不能再改"); + } + return BizException.conflict("这条告警的状态已经变了(可能已被家人或同事处理),刷新后再试"); + } + + private Map mustGet(long id) { + // 带出设备别名和机身编号:家属看到的是"SOS 床头 3F7A9C23", + // 不是内部 dev_id(T-G-03 / D1794…)——那个号客户手上没有。2026-11-09 + Map a = dao.one( + "SELECT a.*, d.display_name AS device_name, d.sensor_id AS device_sn, " + + "d.install_location AS device_location, " + // 是哪条事件把它恢复的:家属看到"已恢复(由设备来电恢复)", + // 运营看到的是 clear_event_id。两者都要能对上。2026-11-09 + + "ce.event_type AS clear_event_type " + + "FROM alarm a LEFT JOIN device d ON d.dev_id=a.dev_id " + + "LEFT JOIN iot_event ce ON ce.id=a.clear_event_id " + + "WHERE a.id=?", id); + if (a == null) { + throw new BizException(404, "告警不存在"); + } + family.assertHousehold(((Number) a.get("household_id")).longValue()); + return a; + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + /** + * 人工动作留痕:{@code alarm_action_log}(给用户看的时间轴)+ {@code audit_log}(合规审计)。 + * + *

审计失败必须抛,不能再吞(2026-11-11 修):原来 {@code audit_log} 的 insert + * 包在 {@code catch (Exception ignore) {}} 里,事务照样提交。结果是 + * 「告警被关掉了、时间轴上有这条,但 {@code audit_log} 查不到谁关的」—— + * 而且没有任何报错,事后只有对账才发现。评审把它列为阻断项是对的。 + * + *

现在:本方法只被 {@code @Transactional} 的人工动作调用,insert 抛出去 + * 整条动作回滚,宁可让用户重试,也不留"操作了但无审计"的记录。 + * + *

系统自动动作走 {@link #addSystemAction},那条路径没有登录人, + * 不能进 {@code audit_log}(审计的对象是人)。 + */ + private void addAction(long alarmId, String action, String remark, String note) { + String detail = remark == null ? note : note + ":" + remark; + AuthContext.Principal p = AuthContext.require(); + insertActionLog(alarmId, action, p.type(), p.id(), p.name(), detail); + // 告警的状态流转也要进审计:谁在什么时候把哪条告警关了,事后必须查得到。 + // 不加 try/catch —— 失败就让它抛,事务回滚。 + dao.insert("INSERT INTO audit_log(operator_type, operator_id, operator_name, action_type, " + + "biz_type, biz_key, content) VALUES(?,?,?,?,?,?,?)", + p.type(), p.id(), p.name(), action, "alarm", String.valueOf(alarmId), detail); + } + + /** + * 系统自动动作留痕:只写时间轴,不写 {@code audit_log}。 + * + *

起因:{@link #markCleared} 由设备恢复事件驱动,线程里没有登录人。 + * 原来它复用 {@link #addAction},而 {@code addAction} 第一句就 + * {@code AuthContext.require()} —— 无上下文直接抛 {@code IllegalStateException}。 + * 现在拆开,谁调用都不会因为"没人登录"炸掉。 + */ + private void addSystemAction(long alarmId, String action, String remark, String note) { + String detail = remark == null ? note : note + ":" + remark; + insertActionLog(alarmId, action, "SYSTEM", null, "系统", detail); + } + + private void insertActionLog(long alarmId, String action, String operatorType, + Long operatorId, String operatorName, String detail) { + dao.insert("INSERT INTO alarm_action_log(alarm_id, action, stage, channel, operator_type, " + + "operator_id, operator_name, remark) VALUES(?,?,1,'APP',?,?,?,?)", + alarmId, action, operatorType, operatorId, operatorName, detail); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AuthService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AuthService.java new file mode 100644 index 0000000..b8e1f66 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AuthService.java @@ -0,0 +1,311 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.biz.support.Crypto; +import cn.aomin.gateway.biz.support.TokenService; +import cn.aomin.gateway.common.BizException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 账户与登录。 + * + *

两条链彻底分开(10-29 定稿): + *

    + *
  • {@code wx_user}:小程序自助注册,微信授权拿 openid → 手机号 + 密码 + 短信码注册, + * 之后密码 / 短信 / 微信三种都能登。只放家属(FAMILY)、探视人(VISITOR)、机构主管(ORG_MANAGER)。
  • + *
  • {@code sys_user}:运营和物业,后台建的账号,没有注册接口,只认账号密码。
  • + *
+ * + *

微信授权 ≠ 登录:没注册过的 openid 一律返回 {@code needRegister=true}, + * 让前端跳注册页,绝不自动建号——自动建号会产生一堆没人认领的空账号。 + * + *

短信码存 Redis 5 分钟,不落库。爆破锁定本期不做(字段留着,逻辑不写)。 + */ +@Service +public class AuthService { + + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + + private final BizDao dao; + private final TokenService tokens; + private final RedisTemplate redis; + private final boolean smsEcho; + private final String piiKey; + + public AuthService(BizDao dao, TokenService tokens, + @org.springframework.beans.factory.annotation.Qualifier("redisTemplate") + RedisTemplate redis, + @Value("${biz.sms.echo:true}") boolean smsEcho, + @Value("${biz.pii.key:dev-only-pii-key-2026}") String piiKey) { + this.dao = dao; + this.tokens = tokens; + this.redis = redis; + this.smsEcho = smsEcho; + this.piiKey = piiKey; + } + + // ---------------------------------------------------------------- 微信 + + /** + * 微信登录:code 换 openid。开发态没有 wx API,用 code 派生一个稳定 openid; + * 接真微信时只改这一个方法(调 jscode2session),其余逻辑不动。 + */ + public Map wxLogin(Dtos.WxLogin req) { + if (req == null || req.code() == null || req.code().isBlank()) { + throw BizException.badRequest("code 不能为空"); + } + String openid = devOpenid(req.code()); + Map u = dao.one( + "SELECT id, nickname, user_type, status FROM wx_user WHERE openid=?", openid); + if (u == null) { + Map out = new LinkedHashMap<>(); + out.put("needRegister", true); + out.put("openid", openid); + return out; + } + if (!"ACTIVE".equals(String.valueOf(u.get("status")))) { + throw BizException.unauthorized("账号已停用"); + } + long id = ((Number) u.get("id")).longValue(); + String name = u.get("nickname") == null ? "家属" : String.valueOf(u.get("nickname")); + dao.update("UPDATE wx_user SET last_login_at=NOW(3) WHERE id=?", id); + Map out = new LinkedHashMap<>(); + out.put("needRegister", false); + out.put("token", tokens.issue(id, AuthContext.TYPE_WX, name)); + out.put("userId", id); + out.put("userType", u.get("user_type")); + out.put("nickname", name); + return out; + } + + /** 开发态 openid 派生:同一 code 永远得到同一 openid,方便反复调试。 */ + private String devOpenid(String code) { + if (code.startsWith("openid:")) { + return code.substring(7); + } + return "wx_" + Crypto.sha256(code).substring(0, 24); + } + + // ---------------------------------------------------------------- 短信 + + /** 发码。开发态把码原样返回(sms.echo=true),上线关掉。 */ + public Map sendSms(Dtos.SmsReq req) { + String phone = req == null ? null : req.phone(); + if (!Crypto.isMobile(phone)) { + throw BizException.badRequest("手机号格式不对"); + } + String scene = req.scene() == null || req.scene().isBlank() ? "REGISTER" : req.scene(); + String key = "biz:sms:" + scene + ":" + phone; + String old = redis.opsForValue().get(key + ":cd"); + if (old != null) { + throw BizException.badRequest("发送太频繁,请稍后再试"); + } + String code = String.format("%06d", ThreadLocalRandom.current().nextInt(1_000_000)); + redis.opsForValue().set(key, code, Duration.ofMinutes(5)); + redis.opsForValue().set(key + ":cd", "1", Duration.ofSeconds(60)); + redis.delete(key + ":fail"); // 重新发码就重置错误计数 + log.info("短信验证码 scene={} phone={} code={}", scene, Crypto.maskMobile(phone), code); + Map out = new LinkedHashMap<>(); + out.put("sent", true); + out.put("expireSeconds", 300); + if (smsEcho) { + out.put("code", code); // 仅开发态 + } + return out; + } + + /** + * 校验短信码。 + * + *

6 位数字、5 分钟有效,如果不计错误次数,任何人写个循环就能在有效期内撞开。 + * 这里按「同一 phone+scene 错 5 次即作废」——不做账号锁定(那是另一件事, + * 会误伤忘密码的正常用户),只让这一条码失效,重新获取即可。 + */ + private void verifySms(String phone, String scene, String code) { + if (code == null || code.isBlank()) { + throw BizException.badRequest("短信验证码不能为空"); + } + String key = "biz:sms:" + scene + ":" + phone; + String failKey = key + ":fail"; + + String fails = redis.opsForValue().get(failKey); + if (fails != null && parseLong(fails) >= 5) { + redis.delete(key); + throw BizException.badRequest("验证码错误次数过多,请重新获取"); + } + String expect = redis.opsForValue().get(key); + if (expect == null) { + throw BizException.badRequest("验证码已过期,请重新获取"); + } + if (!expect.equals(code)) { + Long n = redis.opsForValue().increment(failKey); + if (n != null && n == 1L) { + redis.expire(failKey, Duration.ofMinutes(10)); + } + throw BizException.badRequest("验证码不对"); + } + redis.delete(key); + redis.delete(failKey); + } + + private static long parseLong(String s) { + try { + return Long.parseLong(s.trim()); + } catch (Exception e) { + return 0; + } + } + + // ---------------------------------------------------------------- 注册 + + public Map register(Dtos.WxRegister req) { + if (req == null || req.openid() == null || req.openid().isBlank()) { + throw BizException.badRequest("openid 不能为空,请重新走微信授权"); + } + if (!Crypto.isMobile(req.phone())) { + throw BizException.badRequest("手机号格式不对"); + } + verifySms(req.phone(), "REGISTER", req.smsCode()); + + String hash = Crypto.sha256(req.phone()); + Long dup = dao.count("SELECT COUNT(*) FROM wx_user WHERE phone_hash=?", hash); + if (dup != null && dup > 0) { + throw BizException.badRequest("该手机号已注册,请直接登录"); + } + Map exist = dao.one("SELECT id FROM wx_user WHERE openid=?", req.openid()); + if (exist != null) { + throw BizException.conflict("该微信已注册过,请直接登录"); + } + String type = "VISITOR".equalsIgnoreCase(req.userType()) ? "VISITOR" : "FAMILY"; + String nickname = req.nickname() == null || req.nickname().isBlank() + ? Crypto.maskMobile(req.phone()) : req.nickname(); + + long id = dao.insert( + "INSERT INTO wx_user(openid, user_type, nickname, phone_enc, phone_hash, " + + "password_hash, pwd_updated_at, first_login_at, last_login_at, status) " + + "VALUES(?,?,?,?,?,?,NOW(3),NOW(3),NOW(3),'ACTIVE')", + req.openid(), type, nickname, + Crypto.encrypt(req.phone(), piiKey()), + hash, + Crypto.hashPassword(req.password())); + + Map out = new LinkedHashMap<>(); + out.put("userId", id); + out.put("token", tokens.issue(id, AuthContext.TYPE_WX, nickname)); + out.put("userType", type); + out.put("nickname", nickname); + return out; + } + + private String piiKey() { + return piiKey; + } + + // ---------------------------------------------------------------- 密码登录 + + public Map passwordLogin(Dtos.PwdLogin req) { + if (req == null || !Crypto.isMobile(req.phone())) { + throw BizException.badRequest("手机号格式不对"); + } + Map u = dao.one( + "SELECT id, nickname, user_type, password_hash, status FROM wx_user WHERE phone_hash=?", + Crypto.sha256(req.phone())); + if (u == null) { + throw BizException.unauthorized("手机号或密码不对"); + } + if (!"ACTIVE".equals(String.valueOf(u.get("status")))) { + throw BizException.unauthorized("账号已停用"); + } + if (!Crypto.verifyPassword(req.password(), (String) u.get("password_hash"))) { + dao.update("UPDATE wx_user SET failed_count=failed_count+1 WHERE id=?", u.get("id")); + throw BizException.unauthorized("手机号或密码不对"); + } + long id = ((Number) u.get("id")).longValue(); + String name = u.get("nickname") == null ? "家属" : String.valueOf(u.get("nickname")); + dao.update("UPDATE wx_user SET failed_count=0, last_login_at=NOW(3) WHERE id=?", id); + Map out = new LinkedHashMap<>(); + out.put("token", tokens.issue(id, AuthContext.TYPE_WX, name)); + out.put("userId", id); + out.put("userType", u.get("user_type")); + out.put("nickname", name); + return out; + } + + /** 后台登录:只有 sys_user,没有注册入口。 */ + public Map consoleLogin(Dtos.ConsoleLogin req) { + if (req == null || req.username() == null || req.username().isBlank()) { + throw BizException.badRequest("账号不能为空"); + } + Map u = dao.one( + "SELECT id, username, real_name, password_hash, status FROM sys_user WHERE username=?", + req.username()); + if (u == null) { + throw BizException.unauthorized("账号或密码不对"); + } + if (!"ACTIVE".equals(String.valueOf(u.get("status")))) { + throw BizException.unauthorized("账号已停用"); + } + if (!Crypto.verifyPassword(req.password(), (String) u.get("password_hash"))) { + throw BizException.unauthorized("账号或密码不对"); + } + long id = ((Number) u.get("id")).longValue(); + String name = String.valueOf(u.get("real_name")); + dao.update("UPDATE sys_user SET failed_count=0, last_login_at=NOW(3) WHERE id=?", id); + + List> roles = dao.list( + "SELECT r.role_code, r.role_name, r.data_scope_type FROM sys_role r " + + "JOIN sys_user_role ur ON ur.role_id=r.id WHERE ur.user_id=?", id); + Map out = new LinkedHashMap<>(); + out.put("token", tokens.issue(id, AuthContext.TYPE_SYS, name)); + out.put("userId", id); + out.put("username", u.get("username")); + out.put("realName", name); + out.put("roles", roles); + return out; + } + + // ---------------------------------------------------------------- 当前用户 + + public Map me() { + AuthContext.Principal p = AuthContext.require(); + Map out = new LinkedHashMap<>(); + out.put("userId", p.id()); + out.put("type", p.type()); + out.put("name", p.name()); + if (AuthContext.TYPE_SYS.equals(p.type())) { + Map u = dao.one( + "SELECT username, real_name, company_id, last_login_at FROM sys_user WHERE id=?", + p.id()); + out.put("profile", u); + out.put("roles", dao.list( + "SELECT r.role_code, r.role_name, r.data_scope_type FROM sys_role r " + + "JOIN sys_user_role ur ON ur.role_id=r.id WHERE ur.user_id=?", p.id())); + } else { + Map u = dao.one( + "SELECT nickname, user_type, phone_enc, last_login_at FROM wx_user WHERE id=?", + p.id()); + if (u != null) { + String phone = Crypto.decrypt((byte[]) u.remove("phone_enc"), piiKey()); + u.put("phoneMasked", Crypto.maskMobile(phone)); + } + out.put("profile", u); + out.put("households", dao.list( + "SELECT h.id, h.room_no, h.address_text, uh.is_owner, uh.status " + + "FROM user_house uh JOIN household h ON h.id=uh.household_id " + + "WHERE uh.wx_user_id=? AND uh.status='ACTIVE' ORDER BY uh.is_owner DESC", p.id())); + } + return out; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/BizDeviceService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/BizDeviceService.java new file mode 100644 index 0000000..ab9c7c4 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/BizDeviceService.java @@ -0,0 +1,480 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.common.BizException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +/** + * 设备管理(业务视角)。 + * + *

与 M 域那几个接收接口的分工:接收接口只管"收到什么就落什么",这里管"人怎么用"—— + * 列表、改名、绑定、解绑、查事件、下发命令。 + * + *

绑定是唯一会写 {@code household_id} 的地方(A6 铁律:报文永不写 {@code house_id})。 + * 解绑不删行、只清归属并置"已解绑",否则历史事件全部失去回指。 + */ +@Service +public class BizDeviceService { + + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final BizDao dao; + private final FamilyService family; + + public BizDeviceService(BizDao dao, FamilyService family) { + this.dao = dao; + this.family = family; + } + + public List> list(Long householdId, String typeCode, String keyword) { + StringBuilder sql = new StringBuilder( + "SELECT d.dev_id, d.sensor_id, d.type_code, d.vendor, d.model, d.display_name, " + + "d.household_id, d.elder_id, d.install_location, d.zone_no, d.id_basis, " + + "d.status, d.ts_precision, d.reg_source, d.sort_no, " + + "t.online_status, t.battery_mv, t.csq, t.last_seen_at " + + "FROM device d LEFT JOIN device_tech_cache t ON t.sensor_id=d.sensor_id " + + "WHERE d.deleted=0"); + List args = new java.util.ArrayList<>(); + if (householdId != null) { + family.assertHousehold(householdId); + sql.append(" AND d.household_id=?"); + args.add(householdId); + } else if (!AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + sql.append(" AND d.household_id IN (SELECT household_id FROM user_house " + + "WHERE wx_user_id=? AND status='ACTIVE')"); + args.add(AuthContext.userId()); + } + if (typeCode != null && !typeCode.isBlank()) { + sql.append(" AND d.type_code=?"); + args.add(typeCode); + } + if (keyword != null && !keyword.isBlank()) { + sql.append(" AND (d.display_name LIKE ? OR d.sensor_id LIKE ? OR d.dev_id LIKE ?)"); + String k = "%" + keyword + "%"; + args.add(k); + args.add(k); + args.add(k); + } + sql.append(" ORDER BY d.sort_no, d.id LIMIT 200"); + return dao.list(sql.toString(), args.toArray()); + } + + /** + * 按 SN 查一眼:这台机器入没入库、是什么类型、有没有被占。 + * + *

家属填完编号就调它,把类型直接显示出来——这样他不用选,也不会选错。 + * 不返回 household_id:已经被别人绑走的设备,只告诉他"已被绑定", + * 不能把他的邻居住哪户也漏出去。 + */ + public Map lookupBySn(String sn) { + Map out = new LinkedHashMap<>(); + out.put("sensorId", sn); + if (sn == null || sn.isBlank()) { + out.put("registered", false); + return out; + } + Map d = dao.one( + "SELECT sensor_id, type_code, vendor, status, household_id FROM device " + + "WHERE sensor_id=? AND deleted=0 LIMIT 1", sn.trim()); + if (d == null) { + out.put("registered", false); + return out; + } + out.put("registered", true); + out.put("typeCode", d.get("type_code")); + out.put("vendor", d.get("vendor")); + out.put("status", d.get("status")); + out.put("bound", d.get("household_id") != null); + out.put("bindable", !"停用".equals(String.valueOf(d.get("status"))) + && !"退役".equals(String.valueOf(d.get("status")))); + return out; + } + + public Map detail(String devId) { + Map d = dao.one( + "SELECT d.*, t.online_status, t.battery_mv, t.csq, t.last_seen_at " + + "FROM device d LEFT JOIN device_tech_cache t ON t.sensor_id=d.sensor_id " + + "WHERE d.dev_id=? AND d.deleted=0", devId); + if (d == null) { + throw new BizException(404, "设备不存在"); + } + assertDevice(d); + d.remove("deleted"); + Map out = new LinkedHashMap<>(d); + out.put("recentEvents", events(devId, null, null, 1, 5)); + out.put("pendingCommands", dao.count( + "SELECT COUNT(*) FROM device_command WHERE dev_id=? AND status IN " + + "('PENDING','CLAIMED','SENT')", devId)); + return out; + } + + private void assertDevice(Map d) { + Object hid = d.get("household_id"); + if (hid == null) { + if (!AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + throw new BizException(403, "设备未绑定到你的户"); + } + return; + } + long h = ((Number) hid).longValue(); + if (!family.canSee(h)) { + throw new BizException(403, "无权查看该设备"); + } + } + + public Map update(String devId, Dtos.DeviceUpdate req) { + Map d = mustGet(devId); + assertDevice(d); + dao.update("UPDATE device SET display_name=COALESCE(?,display_name), remark=COALESCE(?,remark), " + + "install_location=COALESCE(?,install_location), sort_no=COALESCE(?,sort_no) " + + "WHERE dev_id=?", + req.displayName(), req.remark(), req.installLocation(), req.sortNo(), devId); + logOp(devId, "UPDATE", "修改设备信息"); + return detail(devId); + } + + /** 绑定:只写归属,不动身份三元组(sensor_id / dev_id / source_channel)。 */ + @Transactional + public Map bind(String devId, Dtos.DeviceBind req) { + if (req == null || req.householdId() == null) { + throw BizException.badRequest("householdId 不能为空"); + } + Map d = mustGet(devId); + long target = req.householdId(); + if (!family.canSee(target)) { + throw new BizException(403, "无权绑定到该户"); + } + Object old = d.get("household_id"); + if (old != null && ((Number) old).longValue() != target) { + throw BizException.conflict("设备已绑定到其它户,请先解绑"); + } + dao.update("UPDATE device SET household_id=?, elder_id=COALESCE(?,elder_id), " + + "install_location=COALESCE(?,install_location), zone_no=COALESCE(?,zone_no), " + + "status='已安装', status_changed_at=NOW(3) WHERE dev_id=?", + target, req.elderId(), req.installLocation(), req.zoneNo(), devId); + dao.update("UPDATE household SET device_count=(SELECT COUNT(*) FROM device " + + "WHERE household_id=? AND deleted=0) WHERE id=?", target, target); + logOp(devId, "BIND", "绑定到户 " + target); + return detail(devId); + } + + /** + * 扫码 / 手填 SN 绑定(家属端「+ 绑定设备」)。 + * + *

与 {@link #bind} 的区别:那个按 {@code dev_id} 操作已有台账;这个按用户扫到的 + * 原始 SN 操作,设备可能压根没建过档。业务层是唯一有权建档的地方—— + * M 层收到未建档设备只做软拒(落 {@code unregistered_device}),不会自己建。 + * + *

身份三元组({@code sensor_id / vendor / source_channel})只在建档时写一次, + * 之后任何绑定动作都不改写。{@code household_id} 依旧只由绑定写。 + */ + @Transactional + public Map bindBySn(Dtos.DeviceBindBySn req) { + if (req == null || req.sensorId() == null || req.sensorId().isBlank()) { + throw BizException.badRequest("设备编号不能为空"); + } + if (req.householdId() == null) { + throw BizException.badRequest("householdId 不能为空"); + } + String sn = req.sensorId().trim(); + if (sn.length() > 64 || !sn.matches("[A-Za-z0-9:_-]{4,64}")) { + throw BizException.badRequest("设备编号格式不对(4–64 位字母数字,厂商前缀用 HORN: / MIR:)"); + } + long target = req.householdId(); + if (!family.canSee(target)) { + throw new BizException(403, "无权绑定到该户"); + } + + Map d = dao.one("SELECT dev_id, sensor_id, household_id, vendor, " + + "source_channel, type_code, status FROM device WHERE sensor_id=? AND deleted=0 LIMIT 1", sn); + + // 必须先入库才能绑(2026-11-09 改)。 + // 原来这里查不到就"首见建档",后果是:SN 只是一串流水号,谁都不知道它到底是什么设备, + // 系统只能猜一个类型——猜成门磁,一台水浸就被当成门磁挂在墙上,而且类型建完还改不了。 + // 现在把判断权交回给人:运营在后台录入时选类型,家属只负责把机器绑到自己家。 + if (d == null) { + throw BizException.badRequest("这台设备还没入库(" + sn + ")。" + + "设备出厂要先在后台「设备入库」里录入类型,家属才能绑;" + + "如果它自己已经通电联网上报了,去后台设备页的「野设备」把它转正。"); + } + String devId = String.valueOf(d.get("dev_id")); + String devStatus = String.valueOf(d.get("status")); + if ("退役".equals(devStatus) || "停用".equals(devStatus)) { + throw BizException.conflict("这台设备当前状态是「" + devStatus + "」,不能绑。先让运营恢复。"); + } + Object old = d.get("household_id"); + if (old != null && ((Number) old).longValue() != target) { + throw BizException.conflict("设备已绑定到其它户,请先解绑"); + } + // 类型不取前端传的,以库里录入的为准;前端只管起名和位置 + dao.update("UPDATE device SET household_id=?, elder_id=COALESCE(?,elder_id), " + + "install_location=COALESCE(?,install_location), zone_no=COALESCE(?,zone_no), " + + "display_name=COALESCE(?,display_name), " + + "status='已安装', status_changed_at=NOW(3) WHERE dev_id=?", + target, req.elderId(), req.installLocation(), req.zoneNo(), + req.displayName() == null || req.displayName().isBlank() ? null : req.displayName(), + devId); + logOp(devId, "BIND", "按 SN 绑定到户 " + target); + dao.update("UPDATE household SET device_count=(SELECT COUNT(*) FROM device " + + "WHERE household_id=? AND deleted=0) WHERE id=?", target, target); + return detail(devId); + } + + @Transactional + public Map unbind(String devId) { + Map d = mustGet(devId); + assertDevice(d); + Object old = d.get("household_id"); + dao.update("UPDATE device SET household_id=NULL, elder_id=NULL, status='已解绑', " + + "status_changed_at=NOW(3) WHERE dev_id=?", devId); + if (old != null) { + long h = ((Number) old).longValue(); + dao.update("UPDATE household SET device_count=(SELECT COUNT(*) FROM device " + + "WHERE household_id=? AND deleted=0) WHERE id=?", h, h); + } + logOp(devId, "UNBIND", "从户内解绑"); + // 这里千万不能再调 detail(devId):detail 内部会 assertDevice, + // 而此刻 household_id 已经置空,家属身份会被判成"设备未绑定到你的户"→403, + // 整个事务回滚,等于解绑永远失败。返回一个轻量结果就好。 + Map out = new LinkedHashMap<>(); + out.put("devId", devId); + out.put("unbound", true); + out.put("previousHouseholdId", old); + return out; + } + + // ---------------------------------------------------------------- 事件 + + public Map events(String devId, String from, String to, int page, int size) { + // 只 mustGet 不够:那只证明"设备存在",不证明"属于你"。 + // 不加这一行,任何登录用户换个 dev_id 就能读别人家的事件流(含何时开门、何时按了 SOS)。 + assertDevice(mustGet(devId)); + int p = BizDao.page(page); + int s = BizDao.size(size); + StringBuilder where = new StringBuilder(" FROM iot_event WHERE dev_id=?"); + List args = new java.util.ArrayList<>(); + args.add(devId); + if (from != null && !from.isBlank()) { + where.append(" AND event_time >= ?"); + args.add(from); + } + if (to != null && !to.isBlank()) { + where.append(" AND event_time <= ?"); + args.add(to); + } + long total = dao.count("SELECT COUNT(*)" + where, args.toArray()); + List q = new java.util.ArrayList<>(args); + q.add(s); + q.add((p - 1) * s); + List> rows = dao.list( + "SELECT id, event_id, sensor_id, event_type, device_time, event_time, ts_source, " + + "is_backfill, lost_cnt, receive_time, seq, payload " + + where + " ORDER BY event_time DESC, id DESC LIMIT ? OFFSET ?", q.toArray()); + return page(rows, total, p, s); + } + + // ---------------------------------------------------------------- 命令 + + /** 平台支持的下行命令(C1–C8)。白名单之外的 cmdType 一律拒,不做透传。 */ + private static final java.util.Set CMD_WHITELIST = java.util.Set.of( + "set_mode", "set_ack_policy", "time_sync", "set_report_cfg", + "del_sensor", "reboot", "diag_dump", "set_ota"); + + /** 只有网关能执行的命令:子设备没有"模式""上报配置""OTA"这些概念 */ + private static final java.util.Set GATEWAY_ONLY = java.util.Set.of( + "set_mode", "set_report_cfg", "del_sensor", "set_ota"); + + /** + * 家属端(小程序)可用的两条命令。 + * + *

{@code diag_dump}:只读,让设备回报一次当前状态,对应界面的「测试」。 + * {@code reboot}:对应「重启」,点击时前端弹一次确认(会断约 1 分钟)。 + * + *

两个按钮始终显示,不按在线状态隐藏。理由:家属判断不了"该不该显示", + * 按钮时有时无反而让人以为界面坏了;真正的保护放在点击确认和服务端白名单这两层。 + * + *

其余六个不给家属:{@code set_mode} 会改布撤防策略、{@code set_report_cfg} + * 会改上报频率(直接影响电池和漏报)、{@code del_sensor} 会删掉子设备、 + * {@code set_ota} 刷固件失败要返厂、{@code set_ack_policy} 是统计口径、 + * {@code time_sync} 每次连上自动做、点了界面无任何变化。 + */ + private static final java.util.Set FAMILY_ALLOWED = java.util.Set.of( + "diag_dump", "reboot"); + + /** 人在界面等着看结果的,走实时;其余允许设备上线后补发 */ + private static final java.util.Set REALTIME_CMDS = java.util.Set.of( + "reboot", "set_mode", "time_sync"); + + /** + * 下发命令:入库即 {@code PENDING},由 IoT 长轮询(I-18)领走。业务层不直接碰设备。 + * + *

四道闸,缺一道就是越权: + *

    + *
  1. 归属闸:先 {@code assertDevice}——家属只能碰自己家的设备, + * 后台按区域范围判断。这一道必须在角色闸之前,否则家属可以对自己家 + * 之外的设备发命令;
  2. + *
  3. 白名单闸:只认 C1–C8,杜绝把任意 cmdType 透传给接入层;
  4. + *
  5. 角色闸:家属端只放「测试」(diag_dump) 和「重启」(reboot), + * 其余六个是运维语义。不能做成"家属一律禁止"——家属端确实有这两个按钮, + * 一律禁止的结果是按钮点了必然弹 403;
  6. + *
  7. 能力闸:网关专属命令不能发给单品设备,否则 IoT 那边必然报错, + * 错误却会记成"设备故障"。
  8. + *
+ */ + public Map sendCommand(String devId, Dtos.CommandReq req) { + Map d = mustGet(devId); + // 归属校验先做:家属只能碰自己家的设备,后台按区域范围判断 + assertDevice(d); + + if (req == null || req.cmdType() == null || req.cmdType().isBlank()) { + throw BizException.badRequest("cmdType 不能为空"); + } + // 前端历史上传的是大写 REBOOT,这里统一收敛,白名单只用小写比对 + String cmdType = req.cmdType().trim().toLowerCase(); + if (!CMD_WHITELIST.contains(cmdType)) { + throw BizException.badRequest("不支持的命令类型:" + req.cmdType() + + "(可用:" + String.join("/", CMD_WHITELIST) + ")"); + } + // ★ 角色闸(2026-11-11 改):原来是一刀切"家属端不支持下发设备命令"。 + // 现在家属端确实有两个按钮(测试/重启),一刀切会让按钮点了必报错。 + // 改成白名单:家属只能用这两个,其余六个(模式、上报配置、删设备、OTA、 + // ACK 策略、校时)全是运维语义,误操作代价高,家属端一律挡。 + if (!AuthContext.isSys() && !FAMILY_ALLOWED.contains(cmdType)) { + throw new BizException(403, "家属端只能用「测试」和「重启」"); + } + if (GATEWAY_ONLY.contains(cmdType) && !isGateway(devId)) { + throw BizException.badRequest(cmdType + " 只能发给网关,当前设备不是网关"); + } + + long pending = dao.count("SELECT COUNT(*) FROM device_command WHERE dev_id=? " + + "AND status IN ('PENDING','CLAIMED','SENT')", devId); + if (pending >= 5) { + throw BizException.conflict("该设备已有 " + pending + " 条命令在途,等回执再发"); + } + String no = "CMD" + System.currentTimeMillis() + + String.format("%03d", ThreadLocalRandom.current().nextInt(1000)); + // gateway_dev_id 不能为空:G 线子设备填父网关;S/H 线单机没有父节点,就用自己 + Object gw = d.get("parent_dev_id"); + String gatewayDevId = gw == null ? devId : String.valueOf(gw); + // cmd_seq 无默认值。**必须按 gateway_dev_id 取 max**,不是按 dev_id—— + // 表上的唯一键是 uk_gw_seq(gateway_dev_id, cmd_seq),一个网关下多个子设备 + // 各自从 1 开始算会直接撞键。单实例够用;多实例要换 Redis 自增。 + long cmdSeq = dao.count("SELECT COALESCE(MAX(cmd_seq),0)+1 FROM device_command " + + "WHERE gateway_dev_id=?", gatewayDevId); + String deliveryMode = REALTIME_CMDS.contains(cmdType) ? "REALTIME" : "DEFERRED"; + long id; + try { + id = insertCommand(no, devId, gatewayDevId, cmdType, req, cmdSeq, deliveryMode); + } catch (org.springframework.dao.DuplicateKeyException dup) { + // 并发下两个人同时算到同一个 seq,唯一键会挡住。重算一次即可, + // 不做无限重试——真撞两次说明有别的问题,让它报错更容易发现。 + long retrySeq = dao.count("SELECT COALESCE(MAX(cmd_seq),0)+1 FROM device_command " + + "WHERE gateway_dev_id=?", gatewayDevId); + id = insertCommand(no, devId, gatewayDevId, cmdType, req, retrySeq, deliveryMode); + } + logOp(devId, "COMMAND", "下发命令 " + cmdType); + Map out = new LinkedHashMap<>(); + out.put("commandId", id); + out.put("commandNo", no); + out.put("status", "PENDING"); + out.put("deliveryMode", deliveryMode); + out.put("expireAt", LocalDateTime.now().plusHours(72).format(TS)); + return out; + } + + /** 是不是网关:{@code gateway} 表里有这一行才算,不看类型码猜 */ + private boolean isGateway(String devId) { + return dao.count("SELECT COUNT(*) FROM gateway WHERE dev_id=?", devId) > 0; + } + + /** + * 命令记录(最近 20 条)。 + * + *

状态值走 J-00 V1.7 七态。库里若还有没刷成英文的存量行, + * 这里按"中文旧值 → 英文"做一次读时映射,避免界面出现两套值。 + */ + public List> commands(String devId) { + Map d = mustGet(devId); + assertDevice(d); + List> rows = dao.list( + "SELECT command_no, cmd_type, status, delivery_mode, result_code, raw_result_code, " + + "result_msg, retry_count, issued_at, acked_at, created_at " + + "FROM device_command WHERE dev_id=? ORDER BY id DESC LIMIT 20", devId); + for (Map r : rows) { + Object s = r.get("status"); + if (s != null) { + r.put("status", CMD_STATUS_ALIAS.getOrDefault(String.valueOf(s), String.valueOf(s))); + } + } + return rows; + } + + /** 存量中文六值 → 英文七态。迁移脚本跑过之后这张表就成了空转,留着防老库。 */ + private static final Map CMD_STATUS_ALIAS = Map.of( + "待下发", "PENDING", + "已下发", "CLAIMED", + "已生效", "ACKED", + "失败", "FAILED", + "超时", "TIMEOUT", + "已取消", "CANCELLED"); + + // ---------------------------------------------------------------- 内部 + + private Map mustGet(String devId) { + Map d = dao.one( + "SELECT dev_id, household_id, parent_dev_id, sensor_id FROM device " + + "WHERE dev_id=? AND deleted=0", devId); + if (d == null) { + throw new BizException(404, "设备不存在"); + } + return d; + } + + private long insertCommand(String no, String devId, String gatewayDevId, + String cmdType, Dtos.CommandReq req, long cmdSeq, + String deliveryMode) { + return dao.insert("INSERT INTO device_command(command_no, dev_id, gateway_dev_id, cmd_type, " + + "cmd_seq, params, delivery_mode, status, expire_at, max_retry, " + + "operator_type, operator_id) " + + "VALUES(?,?,?,?,?,?,?,'PENDING',?,3,?,?)", + no, devId, gatewayDevId, cmdType, cmdSeq, + req.params() == null ? "{}" : toJson(req.params()), + deliveryMode, + LocalDateTime.now().plusHours(72), AuthContext.require().type(), AuthContext.userId()); + } + + private void logOp(String devId, String type, String summary) { + dao.update("INSERT INTO device_op_log(dev_id, op_type, op_summary, operator_type, operator_id, operator_name) " + + "VALUES(?,?,?,?,?,?)", + devId, type, summary, AuthContext.require().type(), + AuthContext.userId(), AuthContext.require().name()); + } + + private static String toJson(Map m) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(m); + } catch (Exception e) { + return "{}"; + } + } + + static Map page(List> rows, long total, int p, int s) { + Map out = new LinkedHashMap<>(); + out.put("list", rows); + out.put("total", total); + out.put("page", p); + out.put("size", s); + out.put("pages", s <= 0 ? 0 : (int) ((total + s - 1) / s)); + return out; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/FamilyService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/FamilyService.java new file mode 100644 index 0000000..c6ba78e --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/FamilyService.java @@ -0,0 +1,540 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.dto.Dtos; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.common.BizException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 家庭与老人。 + * + *

数据范围只有一条规则:家属(WX)只能看 {@code user_house} 里 status=ACTIVE 的户; + * 运营/物业(SYS)本期全放,等 C 组把 {@code sys_user_scope} 落到接口上再收紧。 + * + *

每次访问单个户/老人/设备前都要过 {@link #assertHousehold}, + * 这是防越权的唯一入口——把它漏在某个接口里,就等于那个接口裸奔。 + */ +@Service +public class FamilyService { + + private final BizDao dao; + private final PiiService pii; + private final cn.aomin.gateway.biz.support.AdminScope adminScope; + + public FamilyService(BizDao dao, PiiService pii, + cn.aomin.gateway.biz.support.AdminScope adminScope) { + this.dao = dao; + this.pii = pii; + this.adminScope = adminScope; + } + + // ---------------------------------------------------------------- 数据范围 + + public boolean canSee(long householdId) { + if (AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + // 运营/物业:先确认户存在,再按数据范围判。 + // 之前这里只判"存不存在",等于 SYS 全放——物业配了范围也拦不住(2026-11-10 修)。 + return dao.count("SELECT COUNT(*) FROM household WHERE id=?", householdId) > 0 + && adminScope.canSeeHousehold(householdId); + } + return dao.count("SELECT COUNT(*) FROM user_house WHERE wx_user_id=? AND household_id=? " + + "AND status='ACTIVE'", AuthContext.userId(), householdId) > 0; + } + + public void assertHousehold(long householdId) { + if (!canSee(householdId)) { + throw new BizException(403, "无权查看该户"); + } + } + + // ---------------------------------------------------------------- 户 + + public List> myHouseholds() { + String sql = "SELECT h.id, h.room_no, h.floor, h.sku, h.open_status, h.mode, " + + "h.elder_count, h.device_count, h.address_text, h.status, " + + "h.longitude, h.latitude, h.gps_source, h.address_detail, " + + "c.name AS community_name, b.name AS building_name, u.name AS unit_name " + + "FROM household h " + + "LEFT JOIN community c ON c.id=h.community_id " + + "LEFT JOIN building b ON b.id=h.building_id " + + "LEFT JOIN unit u ON u.id=h.unit_id "; + if (AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + return dao.list(sql + "ORDER BY h.id DESC LIMIT 100"); + } + return dao.list(sql + "JOIN user_house uh ON uh.household_id=h.id " + + "WHERE uh.wx_user_id=? AND uh.status='ACTIVE' ORDER BY uh.is_owner DESC, h.id DESC", + AuthContext.userId()); + } + + /** + * 家属自助「创建我的家」(类似美团新增收货地址)。 + * + *

组织树按需补齐:小区 / 楼栋 / 单元先按名字找,找不到才建, + * 避免同名小区被重复建档。建完立刻写 {@code user_house}, + * 否则 {@link #assertHousehold} 会把刚建好的户判成无权访问。 + * + *

坐标是 GCJ-02,前端负责把 GPS 的 WGS-84 换算好再提交; + * 这里只校验范围,不做坐标系转换——服务端猜坐标系比不做更危险。 + */ + @Transactional + public Map createHousehold(Dtos.HouseholdSelfCreate req) { + if (req == null || blank(req.communityName()) || blank(req.roomNo())) { + throw BizException.badRequest("小区名称和房号不能为空"); + } + if (req.lng() != null && (req.lng() < 73 || req.lng() > 135)) { + throw BizException.badRequest("经度超出中国范围"); + } + if (req.lat() != null && (req.lat() < 3 || req.lat() > 54)) { + throw BizException.badRequest("纬度超出中国范围"); + } + + // ---- 1. 小区 + Map c = dao.one("SELECT id, company_id FROM community WHERE name=? " + + "ORDER BY id LIMIT 1", req.communityName().trim()); + long communityId; + if (c != null) { + communityId = ((Number) c.get("id")).longValue(); + } else { + Map company = dao.one("SELECT company_id FROM community ORDER BY id LIMIT 1"); + long companyId = company == null || company.get("company_id") == null + ? 1L : ((Number) company.get("company_id")).longValue(); + communityId = dao.insert("INSERT INTO community(community_code, company_id, name, " + + "province, city, district, address, household_count, status) " + + "VALUES(?,?,?,?,?,?,?,0,'ACTIVE')", + "SELF" + System.currentTimeMillis(), companyId, req.communityName().trim(), + nvl(req.province()), nvl(req.city()), nvl(req.district()), nvl(req.addressText())); + } + + // ---- 2. 楼栋 + String bName = blank(req.buildingName()) ? "1 栋" : req.buildingName().trim(); + Map b = dao.one("SELECT id FROM building WHERE community_id=? AND name=? " + + "ORDER BY id LIMIT 1", communityId, bName); + long buildingId = b != null ? ((Number) b.get("id")).longValue() + : dao.insert("INSERT INTO building(community_id, code, name, floor_count, sort_no, status) " + + "VALUES(?,?,?,?,1,'ACTIVE')", communityId, bName, bName, req.floor()); + + // ---- 3. 单元 + String uName = blank(req.unitName()) ? "1 单元" : req.unitName().trim(); + Map u = dao.one("SELECT id FROM unit WHERE community_id=? AND building_id=? " + + "AND name=? ORDER BY id LIMIT 1", communityId, buildingId, uName); + long unitId = u != null ? ((Number) u.get("id")).longValue() + : dao.insert("INSERT INTO unit(community_id, building_id, code, name, sort_no, status) " + + "VALUES(?,?,?,?,1,'ACTIVE')", communityId, buildingId, uName, uName); + + // ---- 4. 户(坐标随户一起落库,V1.0.19) + String addressText = blank(req.addressText()) + ? String.join(" ", java.util.Arrays.asList( + nvl(req.province()), nvl(req.city()), nvl(req.district()), + req.communityName(), bName, uName, req.roomNo()).stream() + .filter(s -> !s.isEmpty()).toList()) + : req.addressText().trim(); + + // 同一个门牌只允许建一次,先查一遍再插。 + // 不查的话数据库会抛唯一键 uk_unit_room,前端只看到"服务内部错误 500", + // 而这时候用户该做的事其实是「加入别人的家」,不是「再建一个」。 + Map exist = dao.one( + "SELECT id FROM household WHERE community_id=? AND building_id=? AND unit_id=? " + + "AND room_no=? AND status='生效' ORDER BY id LIMIT 1", + communityId, buildingId, unitId, req.roomNo().trim()); + if (exist != null) { + long existId = ((Number) exist.get("id")).longValue(); + throw new BizException(409, + "这个门牌已经建过了(户号 " + existId + "),请用「加入别人的家」申请进来,别再建一个"); + } + long hhId; + try { + hhId = dao.insert("INSERT INTO household(community_id, building_id, unit_id, room_no, " + + "floor, open_status, elder_count, device_count, mode, baseline_state, " + + "address_text, address_detail, longitude, latitude, gps_source, " + + "geo_updated_at, status) " + + "VALUES(?,?,?,?,?,'未开通',0,0,'HOME','学习期',?,?,?,?,?,NOW(3),'生效')", + communityId, buildingId, unitId, req.roomNo().trim(), req.floor(), + addressText, nvl(req.addressDetail()), req.lng(), req.lat(), nvl(req.gpsSource())); + } catch (org.springframework.dao.DuplicateKeyException dup) { + // 并发下两个人同时点"建家",先查那一步会同时通过,靠唯一键兜最后一道 + throw new BizException(409, "这个门牌刚刚被别人建了,请刷新后用「加入别人的家」申请"); + } + + // ---- 5. 建家的人就是户主,且立刻有权限 + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, status, " + + "joined_at) VALUES(?,?,1,15,'ACTIVE',NOW(3))", AuthContext.userId(), hhId); + dao.update("UPDATE community SET household_count=(SELECT COUNT(*) FROM household " + + "WHERE community_id=?) WHERE id=?", communityId, communityId); + + return householdDetail(hhId); + } + + private static boolean blank(String s) { + return s == null || s.isBlank(); + } + + private static String nvl(String s) { + return blank(s) ? "" : s.trim(); + } + + /** 户详情:老人、设备、未关闭告警一次给全,小程序首页就靠这一个接口。 */ + public Map householdDetail(long id) { + assertHousehold(id); + Map h = dao.one( + "SELECT h.*, c.name AS community_name, b.name AS building_name, u.name AS unit_name " + + "FROM household h " + + "LEFT JOIN community c ON c.id=h.community_id " + + "LEFT JOIN building b ON b.id=h.building_id " + + "LEFT JOIN unit u ON u.id=h.unit_id WHERE h.id=?", id); + if (h == null) { + throw new BizException(404, "户不存在"); + } + h.remove("created_at"); + h.remove("updated_at"); + Map out = new LinkedHashMap<>(h); + out.put("elders", eldersOf(id)); + out.put("devices", devicesOf(id)); + out.put("openAlarmCount", dao.count( + "SELECT COUNT(*) FROM alarm WHERE household_id=? AND status NOT IN ('CLOSED','FALSE')", id)); + return out; + } + + public List> devicesOf(long householdId) { + return dao.list( + "SELECT d.dev_id, d.sensor_id, d.type_code, d.vendor, d.display_name, " + + "d.install_location, d.zone_no, d.status, d.ts_precision, d.elder_id, " + + "t.online_status, t.battery_mv, t.csq, t.last_seen_at " + + "FROM device d LEFT JOIN device_tech_cache t ON t.sensor_id=d.sensor_id " + + "WHERE d.household_id=? AND d.deleted=0 ORDER BY d.sort_no, d.id", householdId); + } + + // ---------------------------------------------------------------- 老人 + + public List> eldersOf(long householdId) { + List> rows = dao.list( + "SELECT id, pii_id, gender, birth_date, care_level, live_alone, " + + "profile_authorized, status, remark FROM elder " + + "WHERE household_id=? ORDER BY id", householdId); + List> out = new ArrayList<>(rows.size()); + for (Map r : rows) { + out.add(withName(r)); + } + return out; + } + + public Map elderDetail(long elderId) { + Map r = dao.one( + "SELECT id, household_id, pii_id, gender, birth_date, care_level, live_alone, " + + "profile_authorized, status, deceased_at, remark FROM elder WHERE id=?", + elderId); + if (r == null) { + throw new BizException(404, "老人不存在"); + } + assertHousehold(((Number) r.get("household_id")).longValue()); + Map out = withName(r); + out.put("contacts", contacts(elderId)); + out.put("deviceCount", dao.count( + "SELECT COUNT(*) FROM device WHERE elder_id=? AND deleted=0", elderId)); + return out; + } + + private Map withName(Map row) { + Map p = pii.read((Long) row.get("pii_id")); + Map out = new LinkedHashMap<>(row); + out.put("name", p.getOrDefault("name", "")); + out.put("mobileMasked", cn.aomin.gateway.biz.support.Crypto.maskMobile(p.get("mobile"))); + out.remove("pii_id"); + return out; + } + + public Map updateElder(long elderId, Dtos.ElderUpdate req) { + Map r = dao.one("SELECT household_id, pii_id FROM elder WHERE id=?", elderId); + if (r == null) { + throw new BizException(404, "老人不存在"); + } + assertHousehold(((Number) r.get("household_id")).longValue()); + if (req.name() != null) { + Map p = pii.read((Long) r.get("pii_id")); + pii.upsert((Long) r.get("pii_id"), req.name(), p.get("mobile")); + } + dao.update("UPDATE elder SET gender=COALESCE(?,gender), birth_date=COALESCE(?,birth_date), " + + "care_level=COALESCE(?,care_level), live_alone=COALESCE(?,live_alone), " + + "remark=COALESCE(?,remark) WHERE id=?", + req.gender(), req.birthDate(), req.careLevel(), + req.liveAlone() == null ? null : (req.liveAlone() ? 1 : 0), + req.remark(), elderId); + return elderDetail(elderId); + } + + public List> contacts(long elderId) { + List> rows = dao.list( + "SELECT id, contact_order, pii_id, relation, remark FROM elder_contact " + + "WHERE elder_id=? ORDER BY contact_order, id", elderId); + List> out = new ArrayList<>(rows.size()); + for (Map r : rows) { + Map p = pii.read((Long) r.get("pii_id")); + Map m = new LinkedHashMap<>(r); + m.put("name", p.getOrDefault("name", "")); + m.put("mobileMasked", cn.aomin.gateway.biz.support.Crypto.maskMobile(p.get("mobile"))); + m.remove("pii_id"); + out.add(m); + } + return out; + } + + /** + * 家属端自助填写老人信息。 + * + *

姓名手机号一律进 {@code pii_person} 加密,主表只留 pii_id; + * 建完同步 {@code household.elder_count},否则首页那个「登记老人」数字永远不动。 + */ + @Transactional + public Map createElder(Dtos.ElderSelfCreate req) { + if (req == null || req.householdId() == null) { + throw BizException.badRequest("householdId 不能为空"); + } + if (req.name() == null || req.name().isBlank()) { + throw BizException.badRequest("老人姓名不能为空"); + } + long hid = req.householdId(); + assertHousehold(hid); + String mobile = req.mobile() == null || req.mobile().isBlank() ? "" : req.mobile().trim(); + long piiId = pii.create(req.name().trim(), mobile); + long elderId = dao.insert("INSERT INTO elder(household_id, pii_id, gender, birth_date, " + + "care_level, live_alone, profile_authorized, status, remark) " + + "VALUES(?,?,?,?,?,?,?,'ACTIVE',?)", + hid, piiId, req.gender(), req.birthDate(), req.careLevel(), + req.liveAlone() == null || !req.liveAlone() ? 0 : 1, + req.profileAuthorized() == null || req.profileAuthorized() ? 1 : 0, + req.remark()); + dao.update("UPDATE household SET elder_count=(SELECT COUNT(*) FROM elder " + + "WHERE household_id=? AND status='ACTIVE') WHERE id=?", hid, hid); + return elderDetail(elderId); + } + + /** + * 老人停用 / 启用 / 标记过世。 + * + *

不删行——删了历史事件、告警、设备归属全部失去回指。 + * 停用后不再算进 {@code household.elder_count},也不再出现在家属端首屏。 + */ + @Transactional + public Map setElderStatus(long elderId, String status) { + String st = status == null ? "" : status.trim().toUpperCase(); + if (!List.of("ACTIVE", "INACTIVE", "DECEASED").contains(st)) { + throw BizException.badRequest("status 只能是 ACTIVE / INACTIVE / DECEASED"); + } + Map r = dao.one("SELECT household_id FROM elder WHERE id=?", elderId); + if (r == null) { + throw new BizException(404, "老人不存在"); + } + long hid = ((Number) r.get("household_id")).longValue(); + assertHousehold(hid); + if ("DECEASED".equals(st)) { + dao.update("UPDATE elder SET status=?, deceased_at=COALESCE(deceased_at, NOW()) " + + "WHERE id=?", st, elderId); + } else { + dao.update("UPDATE elder SET status=? WHERE id=?", st, elderId); + } + dao.update("UPDATE household SET elder_count=(SELECT COUNT(*) FROM elder " + + "WHERE household_id=? AND status='ACTIVE') WHERE id=?", hid, hid); + return elderDetail(elderId); + } + + public long addContact(long elderId, Dtos.ContactCreate req) { + elderDetail(elderId); + if (req.name() == null || req.name().isBlank()) { + throw BizException.badRequest("联系人姓名不能为空"); + } + long piiId = pii.create(req.name(), req.mobile()); + int order = req.order() == null ? 1 : req.order(); + return dao.insert("INSERT INTO elder_contact(elder_id, contact_order, pii_id, relation) " + + "VALUES(?,?,?,?)", elderId, order, piiId, req.relation()); + } + + // ---------------------------------------------------------------- 邀请 + + /** 生成加入户的邀请码(24 小时有效)。同一户只保留最新一个。 */ + public Map invite(long householdId, String relation) { + assertHousehold(householdId); + String code = randomCode(); + dao.update("UPDATE user_house SET invite_code=NULL, invite_expire_at=NULL " + + "WHERE household_id=? AND status='ACTIVE'", householdId); + int n = dao.update("UPDATE user_house SET invite_code=?, invite_expire_at=? " + + "WHERE household_id=? AND wx_user_id=? AND status='ACTIVE'", + code, LocalDateTime.now().plusHours(24), householdId, AuthContext.userId()); + if (n == 0) { + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, " + + "status, invite_code, invite_expire_at, invited_by) " + + "VALUES(?,?,1,15,'ACTIVE',?,?,?)", + AuthContext.userId(), householdId, code, + LocalDateTime.now().plusHours(24), AuthContext.userId()); + } + Map out = new LinkedHashMap<>(); + out.put("inviteCode", code); + out.put("expireHours", 24); + out.put("relation", relation); + return out; + } + + /** 当前这个户还有效的邀请码(没有就返回空串)。 */ + public Map inviteInfo(long householdId) { + assertHousehold(householdId); + Map r = dao.one("SELECT invite_code, invite_expire_at FROM user_house " + + "WHERE household_id=? AND invite_code IS NOT NULL AND invite_expire_at>NOW(3) " + + "AND status='ACTIVE' ORDER BY id DESC LIMIT 1", householdId); + // 前端读的是驼峰,这里不转的话每次打开邀请页都会重新生成一个码、把刚发出去的作废掉 + if (r == null) { + return Map.of("inviteCode", "", "expireAt", ""); + } + return Map.of("inviteCode", String.valueOf(r.get("invite_code")), + "expireAt", String.valueOf(r.get("invite_expire_at"))); + } + + /** + * 凭邀请码加入一个户。 + * + *

邀请码挂在邀请人的那条 {@code user_house} 上({@code uk_invite} 保证全局唯一), + * 所以查到码就等于查到了是哪个户、谁邀请的。加入者 is_owner=0、perm_bits=7(看+告警+设备), + * 成员管理和结算留给户主。 + */ + @Transactional + public Map joinByCode(Dtos.HouseholdJoin req) { + if (req == null || req.code() == null || req.code().isBlank()) { + throw BizException.badRequest("邀请码不能为空"); + } + String code = req.code().trim().toUpperCase(); + Map inv = dao.one("SELECT household_id, wx_user_id FROM user_house " + + "WHERE invite_code=? AND invite_expire_at>NOW(3) AND status='ACTIVE' LIMIT 1", code); + if (inv == null) { + throw BizException.badRequest("邀请码不对或已经过期"); + } + long hid = ((Number) inv.get("household_id")).longValue(); + long inviter = ((Number) inv.get("wx_user_id")).longValue(); + long me = AuthContext.userId(); + if (dao.count("SELECT COUNT(*) FROM user_house WHERE wx_user_id=? AND household_id=? " + + "AND status<>'REMOVED'", me, hid) > 0) { + throw BizException.conflict("你已经在这个家里了,不用重复加入"); + } + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, " + + "status, invited_by, joined_at) VALUES(?,?,0,7,'ACTIVE',?,NOW(3))", + me, hid, inviter); + return householdDetail(hid); + } + + /** 按小区名 / 房号找户。只回地址,不回老人姓名和设备——申请阶段不该看到别人家里的信息。 */ + public List> searchHouseholds(String keyword) { + if (keyword == null || keyword.trim().length() < 2) { + throw BizException.badRequest("至少输入两个字(小区名或房号)"); + } + String k = "%" + keyword.trim() + "%"; + return dao.list("SELECT h.id, c.name AS community_name, b.name AS building_name, " + + "u.name AS unit_name, h.room_no, h.address_text FROM household h " + + "LEFT JOIN community c ON c.id=h.community_id " + + "LEFT JOIN building b ON b.id=h.building_id " + + "LEFT JOIN unit u ON u.id=h.unit_id " + + "WHERE c.name LIKE ? OR h.room_no LIKE ? OR h.address_text LIKE ? " + + "ORDER BY h.id DESC LIMIT 20", k, k, k); + } + + /** + * 申请加入一个户:落 {@code status=PENDING},等户主审批。 + * + *

{@code uk_user_house} 的生成列让「同一人同一户只保留一条有效关系」, + * 所以重复申请会被挡住,不用自己写去重。 + */ + @Transactional + public Map apply(Dtos.HouseholdApply req) { + if (req == null) { + throw BizException.badRequest("申请内容不能为空"); + } + long hid; + if (req.householdId() != null) { + hid = req.householdId(); + if (dao.count("SELECT COUNT(*) FROM household WHERE id=?", hid) == 0) { + throw new BizException(404, "找不到这个户"); + } + } else { + List> found = searchHouseholds(req.keyword()); + if (found.size() != 1) { + throw BizException.badRequest(found.isEmpty() + ? "没搜到这个地址,换个关键词或问户主要邀请码" + : "搜到多个户,请选一个再申请"); + } + hid = ((Number) found.get(0).get("id")).longValue(); + } + long me = AuthContext.userId(); + if (dao.count("SELECT COUNT(*) FROM user_house WHERE wx_user_id=? AND household_id=? " + + "AND status<>'REMOVED'", me, hid) > 0) { + throw BizException.conflict("你已经在这个家里,或申请还在等审批"); + } + dao.insert("INSERT INTO user_house(wx_user_id, household_id, is_owner, perm_bits, " + + "status, joined_at) VALUES(?,?,0,0,'PENDING',NULL)", me, hid); + return Map.of("householdId", hid, "status", "PENDING", + "message", "申请已提交,等户主通过"); + } + + /** 户成员(不含已移除的)。状态 PENDING 的是待审批申请。 */ + public List> members(long householdId) { + assertHousehold(householdId); + return dao.list("SELECT uh.id, uh.wx_user_id, w.nickname, uh.is_owner, uh.perm_bits, " + + "uh.status, uh.joined_at, uh.created_at FROM user_house uh " + + "LEFT JOIN wx_user w ON w.id=uh.wx_user_id " + + "WHERE uh.household_id=? AND uh.status<>'REMOVED' " + + "ORDER BY uh.is_owner DESC, uh.id", householdId); + } + + /** 待审批申请。只有户主能看。 */ + public List> applications(long householdId) { + assertOwner(householdId); + return dao.list("SELECT uh.id, uh.wx_user_id, w.nickname, uh.status, uh.created_at " + + "FROM user_house uh LEFT JOIN wx_user w ON w.id=uh.wx_user_id " + + "WHERE uh.household_id=? AND uh.status='PENDING' ORDER BY uh.id DESC", householdId); + } + + /** 通过 / 拒绝申请。通过后 perm_bits=7;拒绝写 REMOVED,生成列释放唯一键,可以重新申请。 */ + @Transactional + public Map review(long householdId, long wxUserId, boolean approve) { + assertOwner(householdId); + int n; + if (approve) { + n = dao.update("UPDATE user_house SET status='ACTIVE', perm_bits=7, " + + "joined_at=NOW(3) WHERE household_id=? AND wx_user_id=? AND status='PENDING'", + householdId, wxUserId); + } else { + n = dao.update("UPDATE user_house SET status='REMOVED', removed_at=NOW(3), " + + "removed_by=? WHERE household_id=? AND wx_user_id=? AND status='PENDING'", + AuthContext.userId(), householdId, wxUserId); + } + if (n == 0) { + throw new BizException(404, "没有这条待审批申请"); + } + return Map.of("wxUserId", wxUserId, "status", approve ? "ACTIVE" : "REMOVED"); + } + + private void assertOwner(long householdId) { + assertHousehold(householdId); + if (AuthContext.TYPE_SYS.equals(AuthContext.require().type())) { + return; + } + if (dao.count("SELECT COUNT(*) FROM user_house WHERE household_id=? AND wx_user_id=? " + + "AND is_owner=1 AND status='ACTIVE'", householdId, AuthContext.userId()) == 0) { + throw new BizException(403, "只有户主能审批成员申请"); + } + } + + private static final String ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; + + private String randomCode() { + SecureRandom r = new SecureRandom(); + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < 6; i++) { + sb.append(ALPHABET.charAt(r.nextInt(ALPHABET.length()))); + } + return sb.toString(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/NotifyService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/NotifyService.java new file mode 100644 index 0000000..ca997a7 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/NotifyService.java @@ -0,0 +1,221 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.support.AuthContext; +import cn.aomin.gateway.common.BizException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 通知。 + * + *

只做"看和读":真正发送在 {@code notify_task} → 渠道网关那一段, + * 业务层负责决定"该通知谁",不负责"怎么发出去"。 + * + *

通知偏好(免打扰时段、只收哪几类)落在 {@code notify_preference}, + * 一个人一个户一条;查不到就按默认全收。 + */ +@Service +public class NotifyService { + + private final BizDao dao; + + public NotifyService(BizDao dao) { + this.dao = dao; + } + + /** + * 通知列表。 + * + *

{@code status} 是**投递状态**(PENDING/SENT/FAILED/CANCELLED); + * 已读与否看 {@code read_at},两者不再混在一个字段里。 + */ + public Map list(String status, int page, int size) { + int p = BizDao.page(page); + int s = BizDao.size(size); + StringBuilder where = new StringBuilder(" WHERE receiver_id=? AND receiver_type='WX'"); + java.util.List args = new java.util.ArrayList<>(); + args.add(AuthContext.userId()); + if (status != null && !status.isBlank()) { + if ("UNREAD".equalsIgnoreCase(status)) { + where.append(" AND read_at IS NULL"); + } else if ("READ".equalsIgnoreCase(status)) { + where.append(" AND read_at IS NOT NULL"); + } else { + where.append(" AND status=?"); + args.add(status); + } + } + long total = dao.count("SELECT COUNT(*) FROM notify_task" + where, args.toArray()); + args.add(s); + args.add((p - 1) * s); + List> rows = dao.list( + "SELECT id, task_no, biz_type, biz_id, template_code, payload, status, read_at, " + + "retry_count, created_at, updated_at FROM notify_task" + + where + " ORDER BY id DESC LIMIT ? OFFSET ?", args.toArray()); + return BizDeviceService.page(rows, total, p, s); + } + + /** 未读数。首页/tab 红点用这个,别再自己数列表。 */ + public Map unreadCount() { + long n = dao.count("SELECT COUNT(*) FROM notify_task WHERE receiver_id=? " + + "AND receiver_type='WX' AND read_at IS NULL", AuthContext.userId()); + return Map.of("unread", n); + } + + public Map read(long id) { + int n = dao.update("UPDATE notify_task SET read_at=COALESCE(read_at, NOW(3)) " + + "WHERE id=? AND receiver_id=?", id, AuthContext.userId()); + if (n == 0) { + throw new BizException(404, "通知不存在"); + } + return Map.of("id", id, "read", true); + } + + public Map readAll() { + int n = dao.update("UPDATE notify_task SET read_at=NOW(3) WHERE receiver_id=? " + + "AND receiver_type='WX' AND read_at IS NULL", AuthContext.userId()); + return Map.of("updated", n); + } + + /** + * 读当前这个人的免打扰设置。 + * + *

返回字段一律驼峰(2026-11-09 修):原来"没记录"走 Map.of 返回 {@code quietStart}, + * "有记录"直接把数据库行返回成 {@code quiet_start}。前端读 {@code pref.quietStart} + * 在后一种情况拿到 undefined,于是回退到默认 22:00——用户明明设成 00:00–23:59, + * 再打开页面看到的是 22:00,以为没保存成功,再点一次保存就真被覆盖回去了。 + * 这种"读和写两种字段名"的坑,只要有一个分支没走到就永远发现不了。 + * + *

时间格式统一 HH:mm:{@code } 只认这个, + * 数据库里是 {@code 22:00:00},直接在页面上显示会多两个冒号。 + */ + public Object preferences(Long householdId) { + long hid = householdId == null ? 0L : householdId; + Map p = dao.one( + "SELECT quiet_start, quiet_end, type_json, channel_json " + + "FROM notify_preference WHERE wx_user_id=? AND household_id=?", + AuthContext.userId(), hid); + + Map out = new java.util.LinkedHashMap<>(); + out.put("householdId", hid); + if (p == null) { + out.put("enabled", false); + out.put("quietStart", ""); + out.put("quietEnd", ""); + out.put("types", List.of()); + out.put("channels", List.of("APP")); + return out; + } + String qs = hhmm(p.get("quiet_start")); + String qe = hhmm(p.get("quiet_end")); + out.put("enabled", qs != null && qe != null && !qs.equals(qe)); + out.put("quietStart", qs == null ? "" : qs); + out.put("quietEnd", qe == null ? "" : qe); + out.put("types", jsonList(p.get("type_json"))); + out.put("channels", jsonList(p.get("channel_json"))); + return out; + } + + /** + * 保存免打扰设置。 + * + *

留空 = 关闭免打扰,不是"用默认值"。{@code SuppressGate} 里 + * "记录存在但 quiet_start 为 NULL"就表示这个人明确不要静默,两边语义必须一致—— + * 否则用户在页面上关掉了,后端还在按默认 22:00 吞他的告警。 + * + *

开始和结束填成一样也按关闭处理(零长度区间没有意义), + * 免得用户以为设了个"从 8 点到 8 点"的静默却什么都没发生。 + */ + public Object savePreferences(Long householdId, cn.aomin.gateway.biz.dto.Dtos.PrefUpdate req) { + long hid = householdId == null ? 0L : householdId; + String qs = blank2null(req.quietStart()); + String qe = blank2null(req.quietEnd()); + if ((qs == null) != (qe == null)) { + throw BizException.badRequest("免打扰的开始和结束要一起填,或者一起留空(留空表示关闭)"); + } + if (qs != null) { + qs = normTime(qs); + qe = normTime(qe); + if (qs == null || qe == null) { + throw BizException.badRequest("时间格式应为 HH:mm,例如 22:00"); + } + if (qs.equals(qe)) { + qs = null; + qe = null; + } + } + String types = toJson(req.types()); + String channels = toJson(req.channels()); + int n = dao.update("UPDATE notify_preference SET quiet_start=?, quiet_end=?, type_json=?, " + + "channel_json=?, updated_at=NOW(3) WHERE wx_user_id=? AND household_id=?", + qs, qe, types, channels, AuthContext.userId(), hid); + if (n == 0) { + dao.insert("INSERT INTO notify_preference(wx_user_id, household_id, quiet_start, quiet_end, " + + "type_json, channel_json) VALUES(?,?,?,?,?,?)", + AuthContext.userId(), hid, qs, qe, types, channels); + } + return preferences(householdId); + } + + /** 数据库 TIME / LocalTime / "22:00:00" → "HH:mm";拿不到返回 null。 */ + private static String hhmm(Object v) { + if (v == null) { + return null; + } + try { + if (v instanceof java.sql.Time t) { + return String.format("%02d:%02d", t.toLocalTime().getHour(), t.toLocalTime().getMinute()); + } + if (v instanceof java.time.LocalTime lt) { + return String.format("%02d:%02d", lt.getHour(), lt.getMinute()); + } + String s = v.toString().trim(); + int c = s.indexOf(':'); + if (c > 0) { + int h = Integer.parseInt(s.substring(0, c)); + int m = Integer.parseInt(s.substring(c + 1, Math.min(s.length(), c + 3))); + if (h >= 0 && h < 24 && m >= 0 && m < 60) { + return String.format("%02d:%02d", h, m); + } + } + } catch (Exception ignored) { + // 脏数据当没设 + } + return null; + } + + /** "22:00" / "22:00:00" → "22:00:00"(写库用);非法返回 null。 */ + private static String normTime(String s) { + String h = hhmm(s); + return h == null ? null : h + ":00"; + } + + private static String blank2null(String s) { + return s == null || s.isBlank() ? null : s.trim(); + } + + /** 数据库里的 JSON 数组字符串 → List;解析不出来就当空。 */ + private static List jsonList(Object v) { + if (v == null) { + return List.of(); + } + try { + return new com.fasterxml.jackson.databind.ObjectMapper() + .readValue(v.toString(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + return List.of(); + } + } + + private static String toJson(List list) { + try { + return new com.fasterxml.jackson.databind.ObjectMapper() + .writeValueAsString(list == null ? List.of() : list); + } catch (Exception e) { + return "[]"; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/PiiService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/PiiService.java new file mode 100644 index 0000000..e818bc0 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/PiiService.java @@ -0,0 +1,69 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.support.Crypto; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * PII 读写。姓名、手机号这类字段按库设计是独立加密表({@code pii_person}), + * 业务表只存 {@code pii_id}。 + * + *

为什么不图省事直接塞进业务表:一旦明文字段落进 {@code elder}, + * 导出、备份、慢查询日志里全都有,事后补救成本比现在高得多。 + */ +@Service +public class PiiService { + + private final BizDao dao; + private final String key; + + public PiiService(BizDao dao, @Value("${biz.pii.key:dev-only-pii-key-2026}") String key) { + this.dao = dao; + this.key = key; + } + + /** 新建一条 PII,返回 pii_id。 */ + public long create(String name, String mobile) { + return dao.insert( + "INSERT INTO pii_person(name_enc, mobile_enc, mobile_hash, key_version) VALUES(?,?,?,1)", + Crypto.encrypt(name, key), + Crypto.encrypt(mobile, key), + mobile == null ? null : Crypto.sha256(mobile)); + } + + /** 更新既有 PII;piiId 为空则新建。 */ + public long upsert(Long piiId, String name, String mobile) { + if (piiId == null || piiId <= 0) { + return create(name, mobile); + } + dao.update("UPDATE pii_person SET name_enc=?, mobile_enc=?, mobile_hash=? WHERE id=?", + Crypto.encrypt(name, key), + Crypto.encrypt(mobile, key), + mobile == null ? null : Crypto.sha256(mobile), + piiId); + return piiId; + } + + /** 读出明文;解不开返回 null(密钥轮换后会出现,不能抛异常)。 */ + public Map read(Long piiId) { + if (piiId == null) { + return Map.of(); + } + Map row = dao.one( + "SELECT name_enc, mobile_enc FROM pii_person WHERE id=?", piiId); + if (row == null) { + return Map.of(); + } + String name = Crypto.decrypt((byte[]) row.get("name_enc"), key); + String mobile = Crypto.decrypt((byte[]) row.get("mobile_enc"), key); + return Map.of("name", name == null ? "" : name, + "mobile", mobile == null ? "" : mobile); + } + + public String maskMobileOf(Long piiId) { + return Crypto.maskMobile(read(piiId).getOrDefault("mobile", "")); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/SmsGateway.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/SmsGateway.java new file mode 100644 index 0000000..a4357e0 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/SmsGateway.java @@ -0,0 +1,157 @@ +package cn.aomin.gateway.biz.service; + +import cn.aomin.gateway.biz.dao.BizDao; +import cn.aomin.gateway.biz.support.Crypto; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +/** + * 短信网关(一期,演示模式不真发)。 + * + *

现在是什么:把"该发给谁、发什么内容"落成一条 {@code notify_task}(receiver_type=SMS), + * 状态直接写 SENT,同时在日志里打一行。这样家属端和后台都能看到"短信已发出"这个事实, + * 但手机上不会真收到——演示时不要拿它当"短信通了"的证据。 + * + *

接真通道要改哪里:只改 {@link #deliver} 一个方法,把日志换成供应商 SDK 调用 + * (阿里云 / 腾讯云短信),成功写 SENT、失败写 FAILED 并把错误码塞进 payload。 + * 上层(AlarmEngine)不用动,这是刻意把边界画在这里。 + * + *

为什么手机号只落脱敏串:{@code wx_user.phone_enc} 是 AES 加密的, + * 通知表里再存一份明文等于多一个泄露面。演示只需要"138****0001"这种可辨认形态, + * 真发时再在网关内部解密、用完即弃,不落库。 + * + *

AI 语音电话是二期,不在本类里留空壳——留了就会有人以为它有实现。 + */ +@Service +public class SmsGateway { + + private static final Logger log = LoggerFactory.getLogger(SmsGateway.class); + + private final BizDao dao; + private final String piiKey; + + /** 演示模式:true 时不真发,只写记录 + 日志。上线前必须改成 false 并接真通道。 */ + @Value("${biz.sms.demo:true}") + private boolean demoMode; + + /** + * 合并窗口(秒):同一户、同一设备、同一规则,在这个窗口内只发一条短信。 + * + *

为什么必须有:短信是同步发的,发出去就收不回来,替代机制救不了它。 + * 补一次历史数据(10 条同设备 SOS)会变成 3 个家属 × 10 条 = 30 条短信, + * 手机上连着响 30 次,而列表里只有 1 条告警——这跟"替代"给人的观感完全矛盾。 + * + *

窗口内合并是安全的:第一条已经告诉家属"出事了",后续重复触发不会再提供新信息, + * 次数记在 {@code chain_seq} 里,打开小程序能看到"又触发了 N 次"。 + * 老人隔一分钟再按一次,窗口变了,照样会再发——不会被永久压掉。 + * + *

设 0 表示关闭合并(每条都发,只按告警 ID 去重)。 + */ + @Value("${biz.sms.merge-window-seconds:60}") + private int mergeWindowSeconds; + + public SmsGateway(BizDao dao, @Value("${biz.pii.key:dev-only-pii-key-2026}") String piiKey) { + this.dao = dao; + this.piiKey = piiKey; + } + + /** + * 给指定的家属发告警短信。 + * + *

只有 S1(SOS / 水浸)会调到这里,S2/S3 一律不发——见 + * {@code AlarmEngine.notifyHousehold} 的说明,短信按条计费且会被拉黑。 + * + * @param onlyUsers 只发给这些人(免打扰过滤后的名单);空或 null 表示本户全员 + * @return 实际生成的通知条数 + */ + public int sendAlarmSms(long householdId, long alarmId, String devId, String ruleKey, String level, + String title, String content, String alarmNo, + java.util.Collection onlyUsers) { + List> users = dao.list( + "SELECT uh.wx_user_id, u.phone_enc FROM user_house uh " + + "JOIN wx_user u ON u.id = uh.wx_user_id " + + "WHERE uh.household_id=? AND uh.status='ACTIVE'", householdId); + java.util.Set allow = onlyUsers == null ? null : new java.util.HashSet<>(onlyUsers); + int n = 0; + for (Map u : users) { + Object wxId = u.get("wx_user_id"); + if (wxId == null) { + continue; + } + long uid = ((Number) wxId).longValue(); + if (allow != null && !allow.isEmpty() && !allow.contains(uid)) { + continue; // 这个人此刻在免打扰,不发 + } + + // 去重键分两种口径: + // 合并开启 → "户 + 设备 + 规则 + 人 + 时间桶",同窗口内只发一条 + // 合并关闭 → 按告警 ID 去重,每条告警一条短信 + // + // 时间桶用"发送时刻"而不是事件时间:补历史数据时一堆旧事件在同一刻被处理, + // 事件时间跨好几天,按事件时间分桶等于不合并。 + String dedup; + if (mergeWindowSeconds <= 0) { + dedup = "ALARM-" + alarmId + "-" + uid + "-SMS"; + } else { + long bucket = System.currentTimeMillis() / (mergeWindowSeconds * 1000L); + dedup = "SMS-" + householdId + "-" + devId + "-" + ruleKey + "-" + uid + "-" + bucket; + } + if (dao.count("SELECT COUNT(*) FROM notify_task WHERE dedup_key=?", dedup) > 0) { + continue; + } + + String masked = maskOf(u.get("phone_enc")); + String taskNo = "SMS" + System.currentTimeMillis() + + String.format("%04d", java.util.concurrent.ThreadLocalRandom.current().nextInt(10000)); + String payload = "{\"alarmId\":" + alarmId + + ",\"alarmNo\":\"" + alarmNo + "\"" + + ",\"level\":\"" + level + "\"" + + ",\"to\":\"" + masked + "\"" + + ",\"text\":\"" + escape(title + "|" + content) + "\"" + + (demoMode ? ",\"demo\":true" : "") + "}"; + + boolean ok = deliver(masked, title, content); + dao.insert("INSERT INTO notify_task(task_no, biz_type, biz_id, receiver_type, receiver_id, " + + "template_code, channel_plan, payload, dedup_key, status) " + + "VALUES(?,'ALARM',?,'SMS',?,?,'[\"SMS\"]',?,?,?)", + taskNo, alarmId, uid, "ALARM_" + level, payload, dedup, ok ? "SENT" : "FAILED"); + n++; + } + return n; + } + + /** + * 真正"发"的动作。演示模式只打日志;接真通道时替换本方法体即可。 + */ + private boolean deliver(String maskedMobile, String title, String content) { + if (demoMode) { + log.info("[短信·模拟] 发往 {} :【鹤仆守望】{}——{}", maskedMobile, title, content); + return true; + } + // TODO 接供应商(阿里云/腾讯云短信):这里换成 SDK 调用, + // 失败抛异常或返回 false,由调用方写 FAILED。 + log.warn("短信真实通道尚未接入,本条按失败处理:{}", maskedMobile); + return false; + } + + /** 从 phone_enc 解出手机号再脱敏;拿不到就返回"家属手机"而不是编一个号。 */ + private String maskOf(Object phoneEnc) { + if (!(phoneEnc instanceof byte[] blob) || blob.length == 0) { + return "家属手机"; + } + try { + return Crypto.maskMobile(Crypto.decrypt(blob, piiKey)); + } catch (Exception e) { + return "家属手机"; + } + } + + private static String escape(String s) { + return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AdminScope.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AdminScope.java new file mode 100644 index 0000000..e7483db --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AdminScope.java @@ -0,0 +1,209 @@ +package cn.aomin.gateway.biz.support; + +import cn.aomin.gateway.biz.dao.BizDao; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 后台账号的数据范围。 + * + *

规则只有一条:没配就是全放,配了就收窄。只对 SYS(运营/物业/审计)生效, + * 家属走自己的 {@code user_house} 逻辑。 + * + *

范围有两种来源,新表优先: + *

    + *
  1. {@code sys_user_region_scope}(2026-11-10 起):按行政区划配,一行一个覆盖范围, + * 可宽可窄——只填省就是全省,加市区逐级收窄,再给 {@code community_id} / {@code building_id} + * 就细到小区/楼栋。物业配几行覆盖一片,新小区落在已选区域内自动纳入。
  2. + *
  3. {@code sys_user_scope}(老):按小区逐个勾,只为兼容一期已配的账号保留。
  4. + *
+ * + *

为什么放宽到区域:物业覆盖 30 个小区就要配 30 行,新增小区不会自动纳入; + * 而且同一套"按小区"模型没法给机构端复用——机构的服务范围同样是地理区域。 + * + *

为什么不缓存进令牌:范围随时可能被管理员改,缓存进去就会出现 + * "改了权限还得等 7 天令牌过期"。 + */ +@Component +public class AdminScope { + + private final BizDao dao; + + public AdminScope(BizDao dao) { + this.dao = dao; + } + + /** + * 解析出来的范围。 + * + * @param all 不限(没配过任何范围) + * @param communities 整个小区可见 + * @param buildings 只有这几栋楼可见(比小区更细) + */ + public record RegionScope(boolean all, Set communities, Set buildings) { + + public boolean nothing() { + return !all && communities.isEmpty() && buildings.isEmpty(); + } + } + + /** 解析当前账号的范围。非 SYS 一律 all。 */ + public RegionScope resolve() { + if (!AuthContext.isSys()) { + return new RegionScope(true, Set.of(), Set.of()); + } + long uid = AuthContext.userId(); + + // ---------- ① 新表:按区域配的 ---------- + List> rows = dao.list( + "SELECT province, city, district, community_id, building_id " + + "FROM sys_user_region_scope WHERE user_id=? ORDER BY id", uid); + if (!rows.isEmpty()) { + Set blds = new LinkedHashSet<>(); + for (Map r : rows) { + if (r.get("building_id") instanceof Number n) { + blds.add(n.longValue()); + } + } + // 没指定楼栋的行,按 省/市/区/小区 反查命中的小区。 + // **全字段为空的行必须排除**:不排除就等于 "province IS NULL" 命中所有小区 → 误配成全放。 + List> hit = dao.list( + "SELECT DISTINCT c.id AS cid FROM community c WHERE EXISTS (" + + " SELECT 1 FROM sys_user_region_scope s" + + " WHERE s.user_id=? AND s.building_id IS NULL" + + " AND (s.province IS NOT NULL OR s.city IS NOT NULL" + + " OR s.district IS NOT NULL OR s.community_id IS NOT NULL)" + + " AND (s.province IS NULL OR s.province=c.province)" + + " AND (s.city IS NULL OR s.city=c.city)" + + " AND (s.district IS NULL OR s.district=c.district)" + + " AND (s.community_id IS NULL OR s.community_id=c.id))", uid); + Set comms = new LinkedHashSet<>(); + for (Map r : hit) { + if (r.get("cid") instanceof Number n) { + comms.add(n.longValue()); + } + } + return new RegionScope(false, comms, blds); + } + + // ---------- ② 老表:按小区勾的(兼容) ---------- + List> old = dao.list( + "SELECT scope_type, scope_id FROM sys_user_scope WHERE user_id=?", uid); + if (old.isEmpty()) { + return new RegionScope(true, Set.of(), Set.of()); + } + Set ids = new LinkedHashSet<>(); + for (Map r : old) { + if ("COMMUNITY".equals(String.valueOf(r.get("scope_type"))) + && r.get("scope_id") instanceof Number n) { + ids.add(n.longValue()); + } + } + return new RegionScope(false, ids, Set.of()); + } + + /** + * null = 不限;非空 = 只允许这些社区 id。 + * + *

只配了楼栋时,这里会把楼栋所属的小区也算进来——小区维度的列表 + * (小区下拉、按小区统计)没法表达"只一栋",只能宽到小区。 + * 户/告警这些能表达楼栋的查询请用 {@link #householdFilter}。 + */ + public Set allowedCommunities() { + RegionScope rs = resolve(); + if (rs.all()) { + return null; + } + Set ids = new LinkedHashSet<>(rs.communities()); + if (!rs.buildings().isEmpty()) { + List> parents = dao.list( + "SELECT DISTINCT community_id AS cid FROM building WHERE id IN (" + + BizDao.in(rs.buildings().size()), rs.buildings().toArray()); + for (Map r : parents) { + if (r.get("cid") instanceof Number n) { + ids.add(n.longValue()); + } + } + } + return ids; + } + + /** 拼 SQL 用:没有范围返回空串,有范围返回 " AND 列 IN (?,?,?)"。 */ + public String sql(String column, List args) { + Set ids = allowedCommunities(); + if (ids == null) { + return ""; + } + if (ids.isEmpty()) { + // 配了范围却一个小区都没命中(比如区域写错了)——收到底,不能反而全放 + return " AND 1=0"; + } + args.addAll(ids); + return " AND " + column + " IN " + BizDao.in(ids.size()); + } + + /** + * 户级过滤片段。 + * + * @param column 要比对的列,如 {@code a.household_id}(alarm 表)或 {@code h.id}(household 表) + * @return {@code " AND IN (SELECT id FROM household hh WHERE ...)"};不限时返回空串 + */ + public String householdFilter(String column, List args) { + RegionScope rs = resolve(); + if (rs.all()) { + return ""; + } + List ors = new ArrayList<>(); + if (!rs.communities().isEmpty()) { + ors.add("hh.community_id IN " + BizDao.in(rs.communities().size())); + args.addAll(rs.communities()); + } + if (!rs.buildings().isEmpty()) { + ors.add("hh.building_id IN " + BizDao.in(rs.buildings().size())); + args.addAll(rs.buildings()); + } + if (ors.isEmpty()) { + return " AND 1=0"; + } + return " AND " + column + " IN (SELECT hh.id FROM household hh WHERE " + + String.join(" OR ", ors) + ")"; + } + + /** 单个户的判定:越界返回 false。给"点开某条详情"这类入口用。 */ + public boolean canSeeHousehold(long householdId) { + RegionScope rs = resolve(); + if (rs.all()) { + return true; + } + if (rs.nothing()) { + return false; + } + Map h = dao.one( + "SELECT community_id, building_id FROM household WHERE id=?", householdId); + if (h == null) { + return false; + } + long cid = h.get("community_id") instanceof Number n ? n.longValue() : 0L; + long bid = h.get("building_id") instanceof Number n ? n.longValue() : 0L; + if (rs.communities().contains(cid)) { + return true; + } + return bid != 0 && rs.buildings().contains(bid); + } + + /** 单个对象校验:越界直接 403。 */ + public void assertCommunity(Long communityId) { + Set ids = allowedCommunities(); + if (ids == null || communityId == null) { + return; + } + if (!ids.contains(communityId)) { + throw new cn.aomin.gateway.common.BizException(403, "无权查看该小区"); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuditGuard.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuditGuard.java new file mode 100644 index 0000000..4a5702f --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuditGuard.java @@ -0,0 +1,121 @@ +package cn.aomin.gateway.biz.support; + +import cn.aomin.gateway.biz.dao.BizDao; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 审计写失败的兜底:把"该写的审计"先存下来,事后再补。 + * + *

为什么不是"失败就抛"这么简单:审计和业务能不能一起回滚, + * 取决于调用方在不在事务里,两种情况必须分开处理,混成一个策略一定有一头是错的。 + * + * + * + * + * + * + * + * + * + * + * + * + * + *
场景做法理由
告警处置({@code AlarmService},全程 {@code @Transactional})直接抛,整条回滚告警关了但查不到谁关的,比让用户重试一次严重得多
后台建档 / 改房号 / 账号({@code AdminService},非事务)落 {@code audit_fail_log} + 记日志,业务照常完成业务已经提交了,抛出去只是给用户一个报错——他重试就会多建一户。 + * 正确做法是让这条审计留得住、能补回
+ * + *

为什么必须落表,不能只计数(2026-11-11 二次修,GOV-008 §3.3): + * 上一版只有一个 {@code AtomicLong} + ERROR 日志。三个硬伤: + * ① 重启清零——昨天丢了 30 条,今天打开 {@code /health} 是 0; + * ② 只知道次数,不知道"丢的是哪几条",补不回来; + * ③ 只有日志,没人盯日志就等于没发生。 + * 现在每条失败的审计都是 {@code audit_fail_log} 里的一行:谁、什么时候、对什么、 + * 做了什么、为什么没写进去,全在。后台可列出,可一键补录回 {@code audit_log}。 + * + *

兜底表也写不进去时(数据库整体不可用):退回 ERROR 日志 + 内存计数, + * 这是最后一层,不再往下退——但至少进程内的 {@code /health} 能看出异常。 + */ +@Component +public class AuditGuard { + + private static final Logger log = LoggerFactory.getLogger(AuditGuard.class); + + private final BizDao dao; + + /** 兜底表也写不进去时的进程内计数,仅用于 {@code /health} 降级显示。 */ + private final AtomicLong memoryOnly = new AtomicLong(); + + public AuditGuard(BizDao dao) { + this.dao = dao; + } + + /** + * 记一次审计写失败:优先落兜底表,落不进去才退到内存计数。 + * + * @param scene 出问题的动作,例如 {@code admin.buildHousehold} + */ + public void failed(String scene, + String actionType, + String bizType, + String bizKey, + String content, + String operatorType, + Long operatorId, + String operatorName, + Exception e) { + String err = e == null ? null : clip(e.getMessage(), 500); + try { + dao.insert("INSERT INTO audit_fail_log" + + "(operator_type, operator_id, operator_name, action_type, biz_type, biz_key," + + " content, error_msg) VALUES(?,?,?,?,?,?,?,?)", + operatorType, operatorId, operatorName, actionType, bizType, bizKey, + clip(content, 500), err); + log.error("审计写入失败,已落兜底表待补录 scene={} biz={}/{}:{}", + scene, bizType, bizKey, err); + } catch (Exception alsoFail) { + // 兜底表都写不进去:只剩日志和内存计数。这种情况通常是整个库不可用, + // 业务那边早就报错了,这里只保证不把异常再抛回去盖掉真正的原因。 + memoryOnly.incrementAndGet(); + log.error("审计写入失败,且兜底表也写不进去 scene={} biz={}/{}:{};兜底失败原因:{}", + scene, bizType, bizKey, err, alsoFail.getMessage()); + } + } + + /** 待补录的条数。库不可用时退化成内存计数,保证 {@code /health} 本身不挂。 */ + public long failures() { + try { + return dao.count("SELECT COUNT(*) FROM audit_fail_log WHERE replay_status=0"); + } catch (Exception e) { + return memoryOnly.get(); + } + } + + /** 最近一条待补录的动作名,给 {@code /health} 的告警文案用。 */ + public String lastScene() { + try { + List> rows = dao.list( + "SELECT action_type, biz_type FROM audit_fail_log " + + "WHERE replay_status=0 ORDER BY id DESC LIMIT 1"); + if (rows.isEmpty()) { + return null; + } + Map r = rows.get(0); + return r.get("action_type") + "/" + r.get("biz_type"); + } catch (Exception e) { + return null; + } + } + + private static String clip(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuthContext.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuthContext.java new file mode 100644 index 0000000..b1d659f --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuthContext.java @@ -0,0 +1,58 @@ +package cn.aomin.gateway.biz.support; + +import java.util.List; + +/** + * 当前登录人(线程内)。 + * + *

只放三样:id、类型、显示名。角色与数据范围不缓存进来—— + * 那两个每次请求都可能变,缓存住会出现"改了权限却还要重启才生效"。 + */ +public final class AuthContext { + + /** 家属 / 探视人(wx_user 表) */ + public static final String TYPE_WX = "WX"; + /** 运营 / 物业(sys_user 表) */ + public static final String TYPE_SYS = "SYS"; + + public record Principal(long id, String type, String name) { + } + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private AuthContext() { + } + + public static void set(Principal p) { + HOLDER.set(p); + } + + public static Principal get() { + return HOLDER.get(); + } + + public static Principal require() { + Principal p = HOLDER.get(); + if (p == null) { + throw new IllegalStateException("未登录:拦截器没生效"); + } + return p; + } + + public static long userId() { + return require().id(); + } + + public static boolean isSys() { + return TYPE_SYS.equals(require().type()); + } + + public static void clear() { + HOLDER.remove(); + } + + /** 数据范围:WX 只能看自己名下的户;SYS 本期全放(运营/物业各自范围留到 C 组权限细化)。 */ + public static List householdScope() { + return List.of(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/BizAuthInterceptor.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/BizAuthInterceptor.java new file mode 100644 index 0000000..4333aa9 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/BizAuthInterceptor.java @@ -0,0 +1,74 @@ +package cn.aomin.gateway.biz.support; + +import cn.aomin.gateway.common.BizException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Map; + +/** + * 业务 API 的登录校验:/api/** 除 auth 白名单外都要 Bearer 令牌。 + * + *

与 {@code AuthInterceptor}(M-01,管 /internal/** 的签名)分开两个类: + * 一个是机器调机器、验 HMAC 签名;一个是人调系统、验登录态,混在一起以后没法分别改。 + */ +@Component +public class BizAuthInterceptor implements HandlerInterceptor { + + private final TokenService tokens; + + /** 免登录路径:写死成集合,不用 startsWith("/api/v1/auth/"), + * 否则 /auth/me 也会被放行,变成"谁都能查当前用户"。 */ + private static final java.util.Set PUBLIC = java.util.Set.of( + "/api/v1/auth/wx/login", + "/api/v1/auth/wx/sms", + "/api/v1/auth/wx/register", + "/api/v1/auth/wx/password-login", + "/api/v1/auth/console/login"); + + public BizAuthInterceptor(TokenService tokens) { + this.tokens = tokens; + } + + @Override + public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { + String path = req.getRequestURI(); + // 只放行这五个:登录、注册、发码。注意 /auth/me 不在里面——那是要登录的。 + if (PUBLIC.contains(path)) { + return true; + } + String auth = req.getHeader("Authorization"); + String token = null; + if (auth != null && auth.regionMatches(true, 0, "Bearer ", 0, 7)) { + token = auth.substring(7).trim(); + } + if (token == null && req.getParameter("token") != null) { + token = req.getParameter("token"); // 小程序下载图片等场景带不了头 + } + Map p = tokens.verify(token); + if (p == null) { + throw BizException.unauthorized("未登录或登录已过期"); + } + String type = String.valueOf(p.get("typ")); + + // 后台接口必须运营/物业账号。只验"有令牌"不验"是谁", + // 等于任何家属自助注册后都能读全平台数据、给自己开运营账号。 + if (path.startsWith("/api/v1/admin/") && !AuthContext.TYPE_SYS.equals(type)) { + throw new BizException(403, "需要运营账号才能访问后台接口"); + } + + AuthContext.set(new AuthContext.Principal( + ((Number) p.get("sub")).longValue(), + type, + String.valueOf(p.get("name")))); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, + Object handler, Exception ex) { + AuthContext.clear(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/Crypto.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/Crypto.java new file mode 100644 index 0000000..a8be6c4 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/Crypto.java @@ -0,0 +1,155 @@ +package cn.aomin.gateway.biz.support; + +import cn.aomin.gateway.common.BizException; + +import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * 业务层加解密工具:口令、PII、手机号检索哈希。 + * + *

三件事各用各的算法,别混: + *

    + *
  • 口令:PBKDF2-HmacSHA256,10 万次迭代,随机盐。只验证、不还原。 + * 格式 {@code pbkdf2$迭代次数$盐$摘要},一列存完,将来换 BCrypt 也能兼容判断。
  • + *
  • PII 姓名/手机号:AES-256-GCM,随机 12 字节 IV 前置。密文可还原——这是和口令的根本区别。
  • + *
  • 手机号检索:SHA-256 十六进制,落 {@code mobile_hash}。用哈希做等值查询,不用解密。
  • + *
+ * + *

密钥只在配置里,不进代码、不进库。本地默认值仅供开发,上线前必须换成环境变量注入。 + */ +public final class Crypto { + + private static final SecureRandom RND = new SecureRandom(); + private static final int PBKDF2_ITER = 100_000; + private static final int GCM_TAG_BITS = 128; + + private Crypto() { + } + + // ---------------------------------------------------------------- 口令 + + public static String hashPassword(String plain) { + if (plain == null || plain.length() < 6) { + throw BizException.badRequest("密码至少 6 位"); + } + byte[] salt = new byte[16]; + RND.nextBytes(salt); + byte[] dk = pbkdf2(plain.toCharArray(), salt, PBKDF2_ITER); + return "pbkdf2$" + PBKDF2_ITER + "$" + + Base64.getEncoder().encodeToString(salt) + "$" + + Base64.getEncoder().encodeToString(dk); + } + + public static boolean verifyPassword(String plain, String stored) { + if (plain == null || stored == null) { + return false; + } + String[] p = stored.split("\\$"); + if (p.length != 4 || !"pbkdf2".equals(p[0])) { + return false; + } + int iter = Integer.parseInt(p[1]); + byte[] salt = Base64.getDecoder().decode(p[2]); + byte[] expect = Base64.getDecoder().decode(p[3]); + byte[] got = pbkdf2(plain.toCharArray(), salt, iter); + return MessageDigest.isEqual(expect, got); + } + + private static byte[] pbkdf2(char[] pwd, byte[] salt, int iter) { + try { + PBEKeySpec spec = new PBEKeySpec(pwd, salt, iter, 256); + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + .generateSecret(spec).getEncoded(); + } catch (Exception e) { + throw new IllegalStateException("PBKDF2 不可用", e); + } + } + + // ---------------------------------------------------------------- PII + + public static byte[] encrypt(String plain, String key) { + if (plain == null) { + return null; + } + try { + byte[] iv = new byte[12]; + RND.nextBytes(iv); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, aesKey(key), new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = c.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return out; + } catch (Exception e) { + throw new IllegalStateException("PII 加密失败", e); + } + } + + public static String decrypt(byte[] blob, String key) { + if (blob == null || blob.length <= 12) { + return null; + } + try { + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.DECRYPT_MODE, aesKey(key), new GCMParameterSpec(GCM_TAG_BITS, blob, 0, 12)); + return new String(c.doFinal(blob, 12, blob.length - 12), StandardCharsets.UTF_8); + } catch (Exception e) { + // 密钥换过就会解不开。对上层表现为"读不到",不能把服务打挂。 + return null; + } + } + + private static SecretKeySpec aesKey(String key) { + try { + byte[] k = MessageDigest.getInstance("SHA-256") + .digest(("aomin-pii|" + key).getBytes(StandardCharsets.UTF_8)); + return new SecretKeySpec(k, "AES"); + } catch (Exception e) { + throw new IllegalStateException("AES 密钥派生失败", e); + } + } + + // ---------------------------------------------------------------- 检索哈希 + + public static String sha256(String plain) { + if (plain == null) { + return null; + } + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-256").digest(plain.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public static boolean isMobile(String s) { + return s != null && s.matches("^1[3-9]\\d{9}$"); + } + + /** 手机号脱敏展示:138****0000 */ + public static String maskMobile(String m) { + if (m == null || m.length() != 11) { + return m; + } + return m.substring(0, 3) + "****" + m.substring(7); + } + + /** 姓名脱敏:张*、李** */ + public static String maskName(String n) { + if (n == null || n.isEmpty()) { + return n; + } + return n.charAt(0) + "*".repeat(Math.max(1, n.length() - 1)); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PageResult.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PageResult.java new file mode 100644 index 0000000..1cd44b2 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PageResult.java @@ -0,0 +1,14 @@ +package cn.aomin.gateway.biz.support; + +import java.util.List; + +/** + * 分页返回体。字段名和前端习惯一致:{@code list / total / page / size / pages}。 + */ +public record PageResult(List list, long total, int page, int size, int pages) { + + public static PageResult of(List list, long total, int page, int size) { + int pages = size <= 0 ? 0 : (int) ((total + size - 1) / size); + return new PageResult<>(list, total, page, size, pages); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PartitionMaintainer.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PartitionMaintainer.java new file mode 100644 index 0000000..b7634f4 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PartitionMaintainer.java @@ -0,0 +1,280 @@ +package cn.aomin.gateway.biz.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +/** + * 事件表分区维护。 + * + *

为什么必须有这个任务:基线里 {@code iot_event} 按 {@code event_time} 月分区, + * 但只建到 {@code p202612},后面挂一个 {@code pmax} 兜底。 + * 没有这个任务的话,2027 年 1 月起的数据全进 {@code pmax},越堆越厚, + * 到时候既 {@code DROP PARTITION} 收不掉(它装着全部新数据),也 {@code ADD PARTITION} 加不进去—— + * 只能重建表。分区表的意义是"回收靠删分区",不是"建好了就完事"。 + * + *

做法:每天检查一次,永远保持未来 {@code ahead-months} 个月的分区已就位。 + * 加分区用 {@code REORGANIZE PARTITION pmax} 切一刀,把 {@code pmax} 的下界往后推一个月。 + * 这是在线 DDL,不锁全表读,也不搬数据(切出来的新区间本来就是空的)。 + * + *

为什么放在应用里而不是让运维每月手跑:手跑一定会忘。忘了不会立刻报错, + * 等发现时数据已经堆进 {@code pmax} 几个月了。这种"不报错但慢慢烂"的事必须自动化。 + * + *

删旧分区默认关着({@code auto-drop=false})。删数据是不可逆动作, + * 保留期算错一次就没了。默认只在日志里提示"哪些分区已超期、可以手工 DROP", + * 要自动回收就把 {@code biz.partition.auto-drop} 打开。 + * + *

多实例部署时用 Redis 锁互斥——{@code ALTER TABLE} 并发执行会互相等锁, + * 更糟的是两个实例同时 {@code REORGANIZE} 同一个 {@code pmax},第二个会失败并刷错误日志。 + */ +@Component +public class PartitionMaintainer { + + private static final Logger log = LoggerFactory.getLogger(PartitionMaintainer.class); + + private static final DateTimeFormatter TAG = DateTimeFormatter.ofPattern("yyyyMM"); + private static final DateTimeFormatter BOUND = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 需要维护分区的事件类表。基线里只有这两张是分区表(已对库核实): + * {@code iot_event} 6 个分区、{@code device_link_stat} 4 个。 + * {@code routine_hist} 虽然也是时序表,但基线里没建分区,所以不在这里。 + */ + private static final List TARGETS = List.of( + new Target("iot_event", "event_time"), + new Target("device_link_stat", "period_end") + ); + + private final JdbcTemplate jdbc; + private final org.springframework.data.redis.core.StringRedisTemplate redis; + + /** 未来保留几个月的空分区。3 个月足够跨过一次长假运维空窗 */ + @Value("${biz.partition.ahead-months:3}") + private int aheadMonths; + + /** 事件保留月数,超过才考虑回收。二期口径是"事件留 2 年" */ + @Value("${biz.partition.retention-months:24}") + private int retentionMonths; + + /** 是否真的 DROP 超期分区。默认 false,只提示 */ + @Value("${biz.partition.auto-drop:false}") + private boolean autoDrop; + + @Value("${biz.partition.enabled:true}") + private boolean enabled; + + public PartitionMaintainer(JdbcTemplate jdbc, + org.springframework.data.redis.core.StringRedisTemplate redis) { + this.jdbc = jdbc; + this.redis = redis; + } + + /** + * 每天 03:10 跑一次。错开整点——整点是各种备份、统计任务的拥挤时段。 + * + *

启动时也跑一次({@code initialDelay} 60 秒):新装的库如果基线分区已经过期, + * 不能等到第二天凌晨才补,那 24 小时里进来的数据都会落 pmax。 + */ + @Scheduled(initialDelay = 60_000, fixedDelay = 6 * 60 * 60 * 1000L) + public void ensureAhead() { + if (!enabled) { + return; + } + String lockKey = "aomin:lock:partition:maintain"; + boolean locked; + try { + locked = Boolean.TRUE.equals(redis.opsForValue() + .setIfAbsent(lockKey, "1", java.time.Duration.ofMinutes(10))); + } catch (Exception e) { + // 取不到锁就不动。以前这里是"按单实例继续执行"——只有在确认只有一个实例时才对, + // 多实例下等于两边同时 REORGANIZE 同一个 pmax:一个成功、一个报错刷日志, + // 严重时 ALTER 互相等锁把表卡住。宁可这轮不补(6 小时后还会再来), + // 也不要在不确定的情况下动分区。 + log.warn("分区维护取不到 Redis 锁,本轮跳过:{}", e.getMessage()); + return; + } + if (!locked) { + return; + } + try { + for (Target t : TARGETS) { + try { + maintain(t); + } catch (Exception e) { + // 一张表失败不能影响另一张 + log.error("分区维护失败 table={}:{}", t.table(), e.getMessage()); + } + } + } finally { + try { + redis.delete(lockKey); + } catch (Exception ignore) { + // 锁有 10 分钟 TTL,删不掉也会自己过期 + } + } + } + + private void maintain(Target t) { + List parts = listPartitions(t.table()); + if (parts.isEmpty()) { + log.debug("{} 不是分区表,跳过", t.table()); + return; + } + boolean hasMax = parts.stream().anyMatch(Partition::isMax); + if (!hasMax) { + log.warn("{} 有分区但没有 pmax 兜底,不敢动(加分区必须先有 MAXVALUE 区)", t.table()); + return; + } + + // pmax 里有数据 = 曾经有一段没补上、数据堆进兜底区了。这时 REORGANIZE 会把 + // 那些行搬到新分区,等于对一张正在用的表做数据迁移:锁表、写满临时空间, + // 而且搬的过程中事件写入会被阻塞——对告警链路是致命的。 + // 所以只报不干,等人确认(是清数据还是停机搬)。 + if (maxPartitionHasRows(t.table())) { + log.warn("{} 的 pmax 里已有数据(说明之前漏补过分区),REORGANIZE 会搬数据并锁表," + + "本轮跳过,需人工确认后处理", t.table()); + return; + } + + // pmax 的下界 = 已有月分区里最大的上界。例如 p202612 是 <2027-01-01, + // 说明 pmax 从 2027-01 起接管,下一个要补的就是 p202701。 + YearMonth next = parts.stream() + .filter(p -> !p.isMax() && p.upperBound() != null) + .map(Partition::upperBound) + .max(Comparator.naturalOrder()) + .orElse(YearMonth.now()); + YearMonth need = YearMonth.now().plusMonths(aheadMonths); + + int added = 0; + while (next.isBefore(need) || next.equals(need)) { + addOne(t, next); + next = next.plusMonths(1); + added++; + if (added > 36) { + // 防呆:配置写错(比如 ahead-months: 1000)不能把库锁死 + log.warn("{} 本轮已加 {} 个分区,达到上限停止", t.table(), added); + break; + } + } + if (added > 0) { + log.info("{} 分区补齐 {} 个,现在覆盖到 {}", t.table(), added, next.minusMonths(1)); + } + + // 超期分区:默认只提示 + YearMonth keepFrom = YearMonth.now().minusMonths(retentionMonths); + List expired = new ArrayList<>(); + for (Partition p : parts) { + if (p.isMax() || p.isTombstone()) { + continue; + } + if (p.upperBound() != null && p.upperBound().isBefore(keepFrom)) { + expired.add(p.name()); + } + } + if (expired.isEmpty()) { + return; + } + if (autoDrop) { + for (String name : expired) { + try { + jdbc.execute("ALTER TABLE " + t.table() + " DROP PARTITION " + name); + log.info("{} 回收超期分区 {}(保留期 {} 个月)", t.table(), name, retentionMonths); + } catch (Exception e) { + log.error("{} 回收分区 {} 失败:{}", t.table(), name, e.getMessage()); + } + } + } else { + log.info("{} 有 {} 个分区已超保留期 {} 个月,未自动回收(biz.partition.auto-drop=false):{}", + t.table(), expired.size(), retentionMonths, expired); + } + } + + /** + * pmax 里有没有数据。 + * + *

正常情况下 pmax 恒为空(所有行都该落进月分区)。查不到结果时按"有数据"处理—— + * 分区维护是低优先级任务,宁可少补一轮,也不能在状态不明时对在用表做 REORGANIZE。 + */ + private boolean maxPartitionHasRows(String table) { + try { + Long n = jdbc.queryForObject( + "SELECT COUNT(*) FROM " + table + " PARTITION (pmax)", Long.class); + return n != null && n > 0; + } catch (Exception e) { + log.warn("{} 查 pmax 数据量失败,按'有数据'处理、本轮不动:{}", table, e.getMessage()); + return true; + } + } + + /** 把 pmax 切一刀:新区间独立成月分区,剩下的继续叫 pmax */ + private void addOne(Target t, YearMonth month) { String name = "p" + month.format(TAG); + String bound = month.plusMonths(1).atDay(1).format(BOUND); + String sql = "ALTER TABLE " + t.table() + " REORGANIZE PARTITION pmax INTO (" + + "PARTITION " + name + " VALUES LESS THAN ('" + bound + "')," + + "PARTITION pmax VALUES LESS THAN (MAXVALUE))"; + jdbc.execute(sql); + log.info("{} 新增月分区 {}(< {})", t.table(), name, bound); + } + + /** + * 读分区清单。{@code PARTITION_DESCRIPTION} 对 RANGE COLUMNS 是字符串 {@code '2027-01-01'}, + * 对 MAXVALUE 是字面量 {@code MAXVALUE}。 + */ + private List listPartitions(String table) { + List> rows = jdbc.queryForList( + "SELECT PARTITION_NAME, PARTITION_DESCRIPTION, PARTITION_METHOD " + + "FROM information_schema.PARTITIONS " + + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? " + + "AND PARTITION_NAME IS NOT NULL " + + "ORDER BY PARTITION_ORDINAL_POSITION", table); + List out = new ArrayList<>(); + for (Map r : rows) { + String name = String.valueOf(r.get("PARTITION_NAME")); + String desc = r.get("PARTITION_DESCRIPTION") == null + ? null : String.valueOf(r.get("PARTITION_DESCRIPTION")); + out.add(Partition.of(name, desc)); + } + return out; + } + + record Target(String table, String column) { + } + + /** + * 一个分区的边界视图。 + * + *

{@code lowerBound} 不从库里现成读(information_schema 只给上界), + * 而是由"上一个分区的上界"推——所以 pmax 的下界靠调用方传,这里只记上界。 + */ + record Partition(String name, YearMonth upperBound, boolean isMax) { + + static Partition of(String name, String desc) { + if (desc == null || "MAXVALUE".equalsIgnoreCase(desc)) { + return new Partition(name, null, true); + } + String v = desc.replace("'", ""); + try { + return new Partition(name, YearMonth.from(LocalDate.parse(v)), false); + } catch (Exception e) { + // p1900 / p_bad 这类固定边界解析不成月份,当墓碑区处理:不参与回收 + return new Partition(name, null, false); + } + } + + /** 墓碑区:p1900(event_time=1900-01-01 的断档行)与 p_bad(垃圾时间) */ + boolean isTombstone() { + return name.startsWith("p1900") || name.startsWith("p_bad"); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SseHub.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SseHub.java new file mode 100644 index 0000000..94c3d9a --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SseHub.java @@ -0,0 +1,148 @@ +package cn.aomin.gateway.biz.support; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * SSE 连接池:谁在线,就往谁的连接上推。 + * + *

为什么是 SSE 不是 WebSocket:这条链路只需要"服务端单向推给家属端", + * 家属端回话走普通 HTTP(确认、处置)。WebSocket 要额外维护握手、心跳、重连和跨域配置, + * 而 SSE 就是一个不结束的 GET 响应,浏览器自带 {@code EventSource} 和断线重连, + * 穿透(cpolar / nginx)也只需要关掉缓冲即可,运维面小得多。 + * + *

为什么按 userId 存而不是按 householdId:同一个人可能在多个户里(自己的家、父母家), + * 按人存一份连接,推送时再按户筛人,避免一个人开两个页面建两条连接。 + * + *

连接会消失得毫无征兆:关页面、手机锁屏、代理超时都算。所以三个回调 + * (完成/超时/错误)都要挂清理,另外每 25 秒发一次注释心跳,让中间的代理别把它当空闲连接掐掉。 + */ +@Component +public class SseHub { + + private static final Logger log = LoggerFactory.getLogger(SseHub.class); + + /** userId → 该用户的全部连接(同一个人多标签页会有多条)。 */ + private final Map> byUser = new ConcurrentHashMap<>(); + + /** + * 建立一条连接。 + * + *

{@code timeout=0} 表示不设超时——家属端可能整天挂着。真正断开靠心跳失败和浏览器回收, + * 不靠服务端设时限(设了反而会出现"到点必断、断了必重连"的无谓抖动)。 + */ + public SseEmitter register(Long userId) { + SseEmitter emitter = new SseEmitter(0L); + CopyOnWriteArrayList list = + byUser.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()); + list.add(emitter); + + Runnable remove = () -> { + CopyOnWriteArrayList l = byUser.get(userId); + if (l != null) { + l.remove(emitter); + if (l.isEmpty()) { + byUser.remove(userId, l); + } + } + }; + emitter.onCompletion(remove); + emitter.onTimeout(remove); + emitter.onError(e -> remove.run()); + + // 先打一个 hello:让前端立刻知道"连上了",否则它要等到第一条告警才确认连通, + // 中间这段时间分不清"没告警"和"没连上"。 + if (!send(emitter, SseEmitter.event().name("hello") + .data(Map.of("userId", userId, "ts", System.currentTimeMillis())))) { + remove.run(); + } + log.debug("SSE 连接建立 userId={} 当前该用户连接数={}", userId, list.size()); + return emitter; + } + + /** 推给一批用户;推失败的连接当场清掉,不让坏连接一直占着。 */ + public int pushToUsers(Collection userIds, String eventName, Object data) { + if (userIds == null || userIds.isEmpty()) { + return 0; + } + int sent = 0; + for (Long uid : userIds) { + List list = byUser.get(uid); + if (list == null || list.isEmpty()) { + continue; + } + for (SseEmitter e : list) { + if (send(e, SseEmitter.event().name(eventName).data(data))) { + sent++; + } else { + list.remove(e); + } + } + } + return sent; + } + + /** + * 唯一的发送出口,必须加锁。 + * + *

为什么:{@code SseEmitter.send()} 不是线程安全的。 + * 告警推送在业务线程、心跳在调度线程,两边同时写同一条异步响应, + * Tomcat 会抛 {@code AsyncRequestNotUsableException: Response not usable after response errors} + * ——现象就是"前几条能收到,后面突然断了",而且日志里是一串 ERROR。 + * 按 emitter 实例加锁,是最省事也最可靠的做法(连接数按户算,最多几十条)。 + */ + private boolean send(SseEmitter e, SseEmitter.SseEventBuilder ev) { + synchronized (e) { + try { + e.send(ev); + return true; + } catch (Exception ex) { + try { + e.complete(); + } catch (Exception ignored) { + // 已经断了,complete 也会抛 + } + return false; + } + } + } + + /** 当前在线人数(按用户去重),用于后台看板与排障。 */ + public int onlineUsers() { + return byUser.size(); + } + + public int onlineConnections() { + return byUser.values().stream().mapToInt(List::size).sum(); + } + + /** + * 心跳。发的是注释行(冒号开头),浏览器不会当作事件派发,只用来保活。 + * + *

15 秒一次:Tomcat 的 {@code connectionTimeout} 默认 20 秒, + * 心跳必须比它短,否则空闲 20 秒就被容器判定超时、连接作废。 + */ + @Scheduled(fixedDelay = 15000, initialDelay = 15000) + public void heartbeat() { + for (Map.Entry> en : byUser.entrySet()) { + CopyOnWriteArrayList list = en.getValue(); + for (SseEmitter e : list) { + if (!send(e, SseEmitter.event().comment("ping"))) { + list.remove(e); + } + } + if (list.isEmpty()) { + byUser.remove(en.getKey(), list); + } + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SuppressGate.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SuppressGate.java new file mode 100644 index 0000000..316d77a --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SuppressGate.java @@ -0,0 +1,131 @@ +package cn.aomin.gateway.biz.support; + +import cn.aomin.gateway.biz.dao.BizDao; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.time.LocalTime; +import java.util.List; +import java.util.Map; + +/** + * 免打扰闸门。 + * + *

按人各管各的手机(2026-11-09 定):{@code notify_preference} 是 + * {@code wx_user_id + household_id} 维度,同一条告警对不同家属可以一个响一个不响。 + * 不做"按户取最松"——那等于让最能熬夜的人替所有人决定。 + * + *

谁能穿透静默:只有 S1。SOS 和水浸不管几点都弹、都发短信。 + * S2/S3 在静默期只落库进列表,不弹窗、不响铃、不发短信。 + * + *

配置读法: + *

    + *
  • 有本户记录 → 用本户的;
  • + *
  • 只有 {@code household_id=0} 的记录 → 用全局默认;
  • + *
  • 一条都没有 → 用代码默认(22:00–07:00)。
  • + *
+ * 记录存在但 {@code quiet_start} 为 NULL,表示这个人自己关掉了免打扰,此时 不静默。 + * 这跟"没配置"是两件事:前者是他明确表示不要静默,后者是他还没设过。 + * + *

一期没做:{@code type_json}(按告警类型细分静默)和 {@code channel_json} + * (按渠道静默,比如"静默只关短信、弹窗照弹")都先不读。定稿 V1.0 §6.5 要求 + * SOS/涉水/燃气/烟感/跌倒不得延迟,当前只有前两类有设备,已经覆盖。 + */ +@Component +public class SuppressGate { + + /** 没配置时的默认静默区间:老人夜里起夜开门不该把子女手机吵醒。 */ + private static final int DEF_START_MIN = 22 * 60; // 22:00 + private static final int DEF_END_MIN = 7 * 60; // 07:00 + + private final BizDao dao; + + public SuppressGate(BizDao dao) { + this.dao = dao; + } + + /** + * 此刻是否应该对这个人静默。 + * + * @param wxUserId 家属账号 + * @param householdId 户 + * @return true = 静默(不弹窗、不响铃、不发短信,只进列表) + */ + public boolean isQuiet(long wxUserId, long householdId) { + Map p = dao.one( + "SELECT quiet_start, quiet_end FROM notify_preference " + + "WHERE wx_user_id=? AND household_id IN (0,?) " + + "ORDER BY household_id DESC LIMIT 1", wxUserId, householdId); + + int start; + int end; + if (p == null) { + start = DEF_START_MIN; + end = DEF_END_MIN; + } else { + Object qs = p.get("quiet_start"); + Object qe = p.get("quiet_end"); + if (qs == null || qe == null) { + // 明确配成空 = 这个人不要免打扰 + return false; + } + start = toMinutes(qs); + end = toMinutes(qe); + if (start < 0 || end < 0) { + return false; + } + if (start == end) { + return false; // 起止相同视为关闭 + } + } + return inWindow(LocalTime.now(), start, end); + } + + /** 时段是否跨午夜。22:00→07:00 跨,01:00→05:00 不跨。 */ + static boolean crossMidnight(int start, int end) { + return start > end; + } + + static boolean inWindow(LocalTime now, int startMin, int endMin) { + int m = now.getHour() * 60 + now.getMinute(); + if (crossMidnight(startMin, endMin)) { + return m >= startMin || m < endMin; + } + return m >= startMin && m < endMin; + } + + /** + * 给这批人里"当前不该被打扰"的那些,用来在推送时逐个跳过。 + * 空集合表示都在可打扰时段。 + */ + public java.util.Set quietUsers(List userIds, long householdId) { + java.util.Set out = new java.util.HashSet<>(); + for (Long uid : userIds) { + if (uid != null && isQuiet(uid, householdId)) { + out.add(uid); + } + } + return out; + } + + /** {@code java.sql.Time} / {@code LocalTime} / "22:00:00" 都能吃。 */ + private static int toMinutes(Object v) { + try { + if (v instanceof java.sql.Time t) { + return t.toLocalTime().getHour() * 60 + t.toLocalTime().getMinute(); + } + if (v instanceof LocalTime lt) { + return lt.getHour() * 60 + lt.getMinute(); + } + String s = v.toString().trim(); + int c = s.indexOf(':'); + if (c > 0) { + return Integer.parseInt(s.substring(0, c)) * 60 + + Integer.parseInt(s.substring(c + 1, Math.min(s.length(), c + 3))); + } + } catch (Exception ignored) { + // 脏数据按"不静默"处理:宁可多响一次,也别把 SOS 吞了 + } + return -1; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/TokenService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/TokenService.java new file mode 100644 index 0000000..70f625d --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/TokenService.java @@ -0,0 +1,94 @@ +package cn.aomin.gateway.biz.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 令牌签发与校验(HMAC-SHA256 自签,不引第三方 JWT 库)。 + * + *

格式:{@code base64url(payload).base64url(签名)}。payload 只放四个字段: + * 用户 id、类型(WX/SYS)、显示名、过期时间戳。 + * + *

为什么不用标准 JWT:这里只需要"防篡改 + 有过期",不需要跨系统互认。 + * 自己签 30 行就能看懂,出问题好查;换成 nimbus 反而多一层依赖。 + * + *

密钥从 {@code biz.token.secret} 读,本地有默认值,上线必须换成环境变量。 + */ +@Component +public class TokenService { + + private static final ObjectMapper M = new ObjectMapper(); + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + private final byte[] key; + private final long ttlSeconds; + + public TokenService(@Value("${biz.token.secret:dev-only-change-me-2026}") String secret, + @Value("${biz.token.ttl-hours:168}") long ttlHours) { + this.key = secret.getBytes(StandardCharsets.UTF_8); + this.ttlSeconds = ttlHours * 3600; + } + + public String issue(long userId, String type, String name) { + long exp = System.currentTimeMillis() / 1000 + ttlSeconds; + Map p = new LinkedHashMap<>(); + p.put("sub", userId); + p.put("typ", type); + p.put("name", name); + p.put("exp", exp); + try { + String body = B64.encodeToString(M.writeValueAsBytes(p)); + return body + "." + B64.encodeToString(sign(body)); + } catch (Exception e) { + throw new IllegalStateException("令牌签发失败", e); + } + } + + /** 校验通过返回载荷;任何异常都返回 null,由调用方转 401。 */ + @SuppressWarnings("unchecked") + public Map verify(String token) { + if (token == null || token.indexOf('.') < 0) { + return null; + } + int dot = token.lastIndexOf('.'); + String body = token.substring(0, dot); + byte[] sig; + try { + sig = B64D.decode(token.substring(dot + 1)); + } catch (Exception e) { + return null; + } + if (!java.security.MessageDigest.isEqual(sign(body), sig)) { + return null; + } + try { + Map p = M.readValue(B64D.decode(body), Map.class); + Number exp = (Number) p.get("exp"); + if (exp == null || exp.longValue() < System.currentTimeMillis() / 1000) { + return null; + } + return p; + } catch (Exception e) { + return null; + } + } + + private byte[] sign(String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(body.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/common/ApiResponse.java b/aomin-gateway/src/main/java/cn/aomin/gateway/common/ApiResponse.java new file mode 100644 index 0000000..921807a --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/common/ApiResponse.java @@ -0,0 +1,62 @@ +package cn.aomin.gateway.common; + +/** + * 统一返回体。 + * + *

五个 /internal 接口的返回格式统一为 {code, msg, data}, + * 前端和 IoT 侧只看 code:0 表示成功,非 0 表示业务失败。 + * + *

与 J-00 V1.6 §2 的错误码约定保持一致: + * 400 参数/信封不合法、401 鉴权失败、409 幂等冲突、500 服务内部错误。 + */ +public class ApiResponse { + + private int code; + private String msg; + private T data; + + public ApiResponse() { + } + + public ApiResponse(int code, String msg, T data) { + this.code = code; + this.msg = msg; + this.data = data; + } + + public static ApiResponse ok(T data) { + return new ApiResponse<>(0, "ok", data); + } + + public static ApiResponse ok() { + return new ApiResponse<>(0, "ok", null); + } + + public static ApiResponse fail(int code, String msg) { + return new ApiResponse<>(code, msg, null); + } + + public int getCode() { + return code; + } + + public void setCode(int code) { + this.code = code; + } + + public String getMsg() { + return msg; + } + + public void setMsg(String msg) { + this.msg = msg; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/common/BizException.java b/aomin-gateway/src/main/java/cn/aomin/gateway/common/BizException.java new file mode 100644 index 0000000..0be915d --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/common/BizException.java @@ -0,0 +1,44 @@ +package cn.aomin.gateway.common; + +/** + * 业务异常。抛出后由 GlobalExceptionHandler 统一转成 ApiResponse,不打印完整堆栈。 + * + *

约定:code 用 HTTP 语义值(400/401/409/500),msg 必须是能直接给对接方看的短句。 + */ +public class BizException extends RuntimeException { + + private final int code; + + public BizException(int code, String msg) { + super(msg); + this.code = code; + } + + public BizException(String msg) { + this(400, msg); + } + + public int getCode() { + return code; + } + + /** 信封字段缺失、类型不对、枚举值非法 */ + public static BizException badRequest(String msg) { + return new BizException(400, msg); + } + + /** 签名不对、时间戳超窗、nonce 复用 */ + public static BizException unauthorized(String msg) { + return new BizException(401, msg); + } + + /** 幂等冲突:同一 idemKey 已处理过且结果不同 */ + public static BizException conflict(String msg) { + return new BizException(409, msg); + } + + /** 来源不在内网白名单:/internal/** 只接受内网调用 */ + public static BizException forbidden(String msg) { + return new BizException(403, msg); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/common/GlobalExceptionHandler.java b/aomin-gateway/src/main/java/cn/aomin/gateway/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..4c9d5eb --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/common/GlobalExceptionHandler.java @@ -0,0 +1,107 @@ +package cn.aomin.gateway.common; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +/** + * 全局异常处理。 + * + *

原则:HTTP 状态码与 body 里的 code 保持一致,接入侧只判断一个维度即可。 + * 业务异常不打印堆栈(噪音大),未知异常打印完整堆栈。 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BizException.class) + public ResponseEntity> handleBiz(BizException e) { + log.warn("业务异常 code={} msg={}", e.getCode(), e.getMessage()); + HttpStatus status = HttpStatus.resolve(e.getCode()); + return ResponseEntity.status(status == null ? HttpStatus.BAD_REQUEST : status) + .body(ApiResponse.fail(e.getCode(), e.getMessage())); + } + + /** @Valid 校验失败 */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValid(MethodArgumentNotValidException e) { + String msg = e.getBindingResult().getFieldErrors().stream() + .map(f -> f.getField() + " " + f.getDefaultMessage()) + .collect(Collectors.joining("; ")); + // 未取到字段级错误时退回对象级错误 + if (msg.isEmpty()) { + msg = e.getBindingResult().getGlobalErrors().stream() + .map(err -> err.getDefaultMessage()) + .collect(Collectors.joining("; ")); + } + log.warn("参数校验失败: {}", msg); + return ResponseEntity.badRequest().body(ApiResponse.fail(400, msg)); + } + + /** 请求体不是合法 JSON,或缺字段导致反序列化失败 */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { + log.warn("报文无法解析: {}", e.getMostSpecificCause().getMessage()); + return ResponseEntity.badRequest().body(ApiResponse.fail(400, "报文格式错误")); + } + + /** + * 缺必填 query 参数(如 I-18 的 {@code nodeId})。 + * + *

J-00 §1.7 把"参数不合法"归 400。不接这一手,Spring 会把它扔进下面的 + * {@code Exception} 兜底,变成 500——接入侧看到 500 会当成我方故障去重试, + * 而实际上重试一百次也一样错。 + */ + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { + log.warn("缺必填参数: {}", e.getParameterName()); + return ResponseEntity.badRequest() + .body(ApiResponse.fail(400, "缺必填参数:" + e.getParameterName())); + } + + /** + * 请求方法用错(如把 POST /notifications/{id}/read 当 GET 调)。 + * 不接这一手会掉进 500 兜底,联调的人会误以为服务挂了。 + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e) { + log.warn("方法不支持: {} {}(应用 {})", e.getMethod(), + e.getMessage(), String.join("/", e.getSupportedMethods() == null + ? new String[0] : e.getSupportedMethods())); + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ApiResponse.fail(405, "请用 " + String.join(" / ", + e.getSupportedMethods() == null ? new String[0] : e.getSupportedMethods()))); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleOther(Exception e) { + log.error("未捕获异常", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.fail(500, "服务内部错误")); + } + + /** + * 路径不存在。 + * + *

Spring Boot 3.2 起,找不到 handler 会抛 NoResourceFoundException, + * 如果不单独接住,就会落到下面的 Exception 兜底里变成 500。 + * 那时候对接方看到"服务内部错误",会去查我们的代码,实际是他自己路径写错了。 + */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNotFound(NoResourceFoundException e) { + log.warn("接口不存在: {} {}", e.getHttpMethod(), e.getResourcePath()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.fail(404, "接口不存在:" + e.getResourcePath())); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/config/GatewayProperties.java b/aomin-gateway/src/main/java/cn/aomin/gateway/config/GatewayProperties.java new file mode 100644 index 0000000..5ce36d0 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/config/GatewayProperties.java @@ -0,0 +1,200 @@ +package cn.aomin.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * M 域的开关与密钥,全部从 {@code application.yml} 的 {@code gateway.*} 读。 + * + *

{@code auth.enabled} 默认 false:联调阶段先不拦, + * 等 M-02 跑通再打开。否则 J 模块那边一个请求都进不来,联调会卡死。 + */ +@Component +@ConfigurationProperties(prefix = "gateway") +public class GatewayProperties { + + private final Auth auth = new Auth(); + private final RateLimit rateLimit = new RateLimit(); + private final Idempotency idempotency = new Idempotency(); + private final Raw raw = new Raw(); + + public Raw getRaw() { + return raw; + } + + public Auth getAuth() { + return auth; + } + + public RateLimit getRateLimit() { + return rateLimit; + } + + public Idempotency getIdempotency() { + return idempotency; + } + + public static class Auth { + /** 总开关。false 时只打日志不校验 */ + private boolean enabled = false; + + /** + * 共享密钥。J-00 §1.2 的签名公式里 key 就是它: + * {@code HMAC-SHA256(token, method+"\n"+path+"\n"+timestamp+"\n"+nonce+"\n"+sha256(body))} + * + *

生产环境必须从环境变量或配置中心注入,不要写死在 yml 里提交。 + */ + private String token = "change-me-in-production"; + + /** 时间戳允许偏差,秒。J-00 §1.2 定 300 */ + private long maxSkewSeconds = 300; + + /** nonce 保留时长,秒。J-00 §1.2 定 600 */ + private long nonceTtlSeconds = 600; + + /** + * 来源网络闸开关({@code InternalNetworkGuard})。**默认开**,与 {@link #enabled} 无关。 + * + *

{@code enabled=false}(联调态)时签名全跳过,如果这时网络闸也关着, + * {@code /internal/**} 就是完全裸奔——文档写的"仅内网"变成一句空话。 + * 所以这两件事分开关:签名可以晚点开,网络闸必须一开始就有。 + */ + private boolean networkGuardEnabled = true; + + /** + * 允许访问 {@code /internal/**} 的网段,CIDR 写法({@code 192.168.1.0/24})。 + * + *

**留空即用内置默认**:{@code 127.0.0.0/8}、{@code ::1/128}、 + * {@code 10.0.0.0/8}、{@code 172.16.0.0/12}、{@code 192.168.0.0/16}。 + * 一旦配了值就以配置为准,默认值不再参与——要收紧就只写 IoT 服务器那一个地址。 + */ + private java.util.List allowedCidrs = new java.util.ArrayList<>(); + + public boolean isNetworkGuardEnabled() { + return networkGuardEnabled; + } + + public void setNetworkGuardEnabled(boolean networkGuardEnabled) { + this.networkGuardEnabled = networkGuardEnabled; + } + + public java.util.List getAllowedCidrs() { + return allowedCidrs; + } + + public void setAllowedCidrs(java.util.List allowedCidrs) { + this.allowedCidrs = allowedCidrs == null ? new java.util.ArrayList<>() : allowedCidrs; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public long getMaxSkewSeconds() { + return maxSkewSeconds; + } + + public void setMaxSkewSeconds(long maxSkewSeconds) { + this.maxSkewSeconds = maxSkewSeconds; + } + + public long getNonceTtlSeconds() { + return nonceTtlSeconds; + } + + public void setNonceTtlSeconds(long nonceTtlSeconds) { + this.nonceTtlSeconds = nonceTtlSeconds; + } + } + + public static class RateLimit { + /** 单设备每分钟帧数上限。J-00 §1.6 */ + private int perDevicePerMinute = 60; + + /** 单通道每分钟条数上限。J-00 §1.6 */ + private int perChannelPerMinute = 5000; + + public int getPerDevicePerMinute() { + return perDevicePerMinute; + } + + public void setPerDevicePerMinute(int perDevicePerMinute) { + this.perDevicePerMinute = perDevicePerMinute; + } + + public int getPerChannelPerMinute() { + return perChannelPerMinute; + } + + public void setPerChannelPerMinute(int perChannelPerMinute) { + this.perChannelPerMinute = perChannelPerMinute; + } + } + + public static class Idempotency { + /** 幂等结果保留时长,小时。J-00 §1.2 定 24 */ + private long ttlHours = 24; + + public long getTtlHours() { + return ttlHours; + } + + public void setTtlHours(long ttlHours) { + this.ttlHours = ttlHours; + } + } + + /** + * 原文表的补列({@code adapter_in_raw.link_type})取值来源。 + * + *

{@code link_type} 不发在 J-00 报文里,也不由 {@code sourceChannel} 推—— + * 麦乐克两套 TCP(bignbiot 与铠湾 V3.0)同属 {@code MIR_CELL},推不出来。 + * 它是部署事实:哪个监听端口接哪套协议,只有部署的人知道,所以放配置。 + * + *

两级查找,先精确后兜底,都查不到就留 NULL(列可空,不编): + *

    + *
  1. {@code linkTypes}:按接入端点(I-12 的 {@code gatewayDevId})精确匹配;
  2. + *
  3. {@code channelLinkTypes}:按 {@code sourceChannel} 兜底,只适合"一个通道只有一套协议"的线, + * 例如 {@code HORN_HOST} 恒 MQTT。
  4. + *
+ */ + public static class Raw { + + /** 接入端点 id → link_type。优先于 channelLinkTypes */ + private Map linkTypes = new LinkedHashMap<>(); + + /** sourceChannel → link_type,兜底用 */ + private Map channelLinkTypes = new LinkedHashMap<>(); + + public Map getLinkTypes() { + return linkTypes; + } + + public void setLinkTypes(Map linkTypes) { + this.linkTypes = linkTypes == null ? new LinkedHashMap<>() : linkTypes; + } + + public Map getChannelLinkTypes() { + return channelLinkTypes; + } + + public void setChannelLinkTypes(Map channelLinkTypes) { + this.channelLinkTypes = channelLinkTypes == null ? new LinkedHashMap<>() : channelLinkTypes; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/config/RedisConfig.java b/aomin-gateway/src/main/java/cn/aomin/gateway/config/RedisConfig.java new file mode 100644 index 0000000..585fcc6 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/config/RedisConfig.java @@ -0,0 +1,33 @@ +package cn.aomin.gateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 配置。 + * + *

默认的 RedisTemplate 用 JDK 序列化,写进去的 key 会带二进制前缀, + * redis-cli 里看到的是乱码、TTL 也不好核对。这里统一改成字符串序列化, + * 幂等键和 nonce 都是纯字符串,用 String 序列化最合适。 + */ +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + StringRedisSerializer serializer = new StringRedisSerializer(); + template.setKeySerializer(serializer); + template.setValueSerializer(serializer); + template.setHashKeySerializer(serializer); + template.setHashValueSerializer(serializer); + + template.afterPropertiesSet(); + return template; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/config/WebConfig.java b/aomin-gateway/src/main/java/cn/aomin/gateway/config/WebConfig.java new file mode 100644 index 0000000..4549f47 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/config/WebConfig.java @@ -0,0 +1,81 @@ +package cn.aomin.gateway.config; + +import cn.aomin.gateway.biz.support.BizAuthInterceptor; +import cn.aomin.gateway.interceptor.AuthInterceptor; +import cn.aomin.gateway.interceptor.InternalNetworkGuard; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web 配置。 + * + *

只做一件事:把 M-01 拦截层挂到 /internal/** 上。 + * + *

两层,顺序不能颠倒: + *

    + *
  1. {@link InternalNetworkGuard}(order -1):来源必须是内网。**和签名开关无关**, + * {@code gateway.auth.enabled=false} 时它照样拦——否则联调态下谁都能伪造事件;
  2. + *
  3. {@link AuthInterceptor}(order 0):Token / 时间戳 / nonce / 签名。
  4. + *
+ * + *

为什么路径是 /internal:J-00 V1.6 的约定是"只允许 IoT 调业务层、 + * 业务层不持 IoT 端点",这五个接口必须走内网、且和对外的小程序 API 分开, + * 便于后续在 Nginx 上按前缀做 IP 白名单。 + * + *

/health 和 /actuator/** 故意不放进来 —— 健康检查不需要鉴权, + * 否则运维探活还得带签名。 + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final AuthInterceptor authInterceptor; + private final InternalNetworkGuard internalNetworkGuard; + private final BizAuthInterceptor bizAuthInterceptor; + + public WebConfig(AuthInterceptor authInterceptor, + InternalNetworkGuard internalNetworkGuard, + BizAuthInterceptor bizAuthInterceptor) { + this.authInterceptor = authInterceptor; + this.internalNetworkGuard = internalNetworkGuard; + this.bizAuthInterceptor = bizAuthInterceptor; + } + + /** + * H5 演示端(家属端 / 后台端)。 + * + *

故意指到工程外的 {@code web} 目录,不塞进 {@code resources/static}: + * 改一行样式不用重新打包,刷新浏览器就生效——现场给老板演示时这一点很要紧。 + * 目录不存在也不会报错,只是 404。 + * + *

路径不再写死盘符(2026-11-09):整个包要能被拷到别人的 D 盘、 + * E 盘、U 盘上跑,写死 {@code G:/aomin-dev/web} 就白屏了。 + * 取值顺序:命令行 {@code -Dbiz.web.dir=...} → 环境变量 {@code WEB_DIR} + * → 相对路径 {@code web}(相对 JVM 工作目录)。 + * 启动脚本 start-gateway.bat 会显式传 -D,所以正常走第一条。 + */ + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String dir = System.getProperty("biz.web.dir", + System.getenv().getOrDefault("WEB_DIR", "web")); + registry.addResourceHandler("/app/**") + .addResourceLocations("file:" + dir + "/") + .setCachePeriod(0); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 来源网络闸排在最前:不是内网来的请求,连签名都不用算 + registry.addInterceptor(internalNetworkGuard) + .addPathPatterns("/internal/**") + .order(-1); + registry.addInterceptor(authInterceptor) + .addPathPatterns("/internal/**") + .order(0); + // 对外业务 API:验登录令牌,和 /internal 的签名校验是两套东西 + registry.addInterceptor(bizAuthInterceptor) + .addPathPatterns("/api/**") + .order(1); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/controller/CommandController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/CommandController.java new file mode 100644 index 0000000..3eb29af --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/CommandController.java @@ -0,0 +1,83 @@ +package cn.aomin.gateway.controller; + +import cn.aomin.gateway.common.ApiResponse; +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.domain.dto.CommandResultRequest; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.domain.dto.PendingCommandResponse; +import cn.aomin.gateway.interceptor.AuthInterceptor; +import cn.aomin.gateway.security.CachedBodyFilter; +import cn.aomin.gateway.security.IdempotentExecutor; +import cn.aomin.gateway.service.CommandService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; + +import java.util.Arrays; +import java.util.List; + +/** + * 两个下行接口,路径与 J-00 V1.6 §3 完全一致。 + * + *

+ *   I-18  GET  /internal/iot/v1/commands/pending   长轮询领命令
+ *   I-05  POST /internal/iot/v1/commands/result    回报执行结果
+ * 
+ * + *

I-18 不带幂等键:它是 GET、无 body、重复拉取天然幂等(靠租约去重)。 + * 所以这里也不调 {@link IdempotentExecutor}。 + */ +@RestController +@RequestMapping("/internal/iot/v1/commands") +public class CommandController { + + private final CommandService commandService; + private final IdempotentExecutor idempotent; + + public CommandController(CommandService commandService, IdempotentExecutor idempotent) { + this.commandService = commandService; + this.idempotent = idempotent; + } + + /** + * @param gatewayIds 逗号分隔,只传当前在线的接入端点。 + * 离线端点的命令因此一条都不会被拉走,天然实现"等上线再发"。 + * @param wait 长轮询最长挂起秒数,0 = 立即返回 + */ + @GetMapping("/pending") + public DeferredResult> pending( + @RequestParam String nodeId, + @RequestParam String gatewayIds, + @RequestParam(defaultValue = "30") int wait, + @RequestParam(defaultValue = "50") int limit) { + + List ids = Arrays.stream(gatewayIds.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + if (ids.isEmpty()) { + throw BizException.badRequest("gatewayIds 不能为空"); + } + return commandService.pull(nodeId, ids, Math.max(0, Math.min(wait, 60)), limit); + } + + @PostMapping("/result") + public ApiResponse result( + @RequestBody CommandResultRequest req, + @RequestHeader(value = AuthInterceptor.H_IDEMPOTENCY, required = false) String idemKey, + HttpServletRequest request) { + + Object body = request.getAttribute(CachedBodyFilter.BODY_ATTR); + byte[] raw = body instanceof byte[] b ? b : new byte[0]; + + IngestResponse r = idempotent.execute(idemKey, raw, IngestResponse.class, + () -> commandService.handleResults(req)); + return ApiResponse.ok(r); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/controller/HealthController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/HealthController.java new file mode 100644 index 0000000..141a495 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/HealthController.java @@ -0,0 +1,133 @@ +package cn.aomin.gateway.controller; + +import cn.aomin.gateway.common.ApiResponse; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 健康检查。 + * + *

这个接口存在的意义不是"能给运维看",而是一次请求验证四件事通: + * Web 容器起来了、MySQL 连得上、Redis 连得上、Flyway 基线认上了。 + * 四个环节任一断了,这里会直接显示断在哪一环,不用去翻日志猜。 + * + *

不放 /internal 下,不需要鉴权。 + */ +@RestController +public class HealthController { + + private final JdbcTemplate jdbcTemplate; + private final StringRedisTemplate redisTemplate; + private final cn.aomin.gateway.biz.support.AuditGuard auditGuard; + + public HealthController(JdbcTemplate jdbcTemplate, StringRedisTemplate redisTemplate, + cn.aomin.gateway.biz.support.AuditGuard auditGuard) { + this.jdbcTemplate = jdbcTemplate; + this.redisTemplate = redisTemplate; + this.auditGuard = auditGuard; + } + + @GetMapping("/health") + public ApiResponse> health() { + Map data = new LinkedHashMap<>(); + data.put("app", "aomin-gateway"); + data.put("time", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"))); + + // MySQL + try { + // 排除 flyway_schema_history:它是 Flyway 自己的版本表,不算业务表。 + // 业务表基线是 83 张,这里是日常核对"表有没有被误删"的锚点。 + Integer tables = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = DATABASE() AND table_name <> 'flyway_schema_history'", + Integer.class); + data.put("db", "connected"); + data.put("tables", tables); + data.put("schema", jdbcTemplate.queryForObject("SELECT DATABASE()", String.class)); + } catch (Exception e) { + data.put("db", "FAILED: " + rootMessage(e)); + } + + // Redis + try { + String pong = redisTemplate.getConnectionFactory().getConnection().ping(); + data.put("redis", "PONG".equalsIgnoreCase(pong) ? "connected" : pong); + } catch (Exception e) { + data.put("redis", "FAILED: " + rootMessage(e)); + } + + // Flyway 基线版本 + try { + String ver = jdbcTemplate.queryForObject( + "SELECT MAX(version) FROM flyway_schema_history WHERE success = 1", String.class); + data.put("flyway", ver == null ? "empty" : ver); + } catch (Exception e) { + data.put("flyway", "未初始化(首次启动会自动 baseline)"); + } + + // 告警积压与死信 + // + // 这两项不决定"服务能不能起",但一坏就是"老人出事了没人知道", + // 而且现场完全看不出来——普通用户在列表里看到的东西都正常, + // 只是少了一条。所以必须让运维能从 /health 一眼看到。 + // + // 故意不让 status 变 DOWN:死信不等于依赖挂了,把它算进 DOWN 会被 + // 容器编排当成"实例不健康"直接重启,重启解决不了规则 bug。 + // 用 warnings 单独列出来,由监控按 warning 告警。 + List warnings = new ArrayList<>(); + try { + Long backlog = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM iot_event WHERE processed=0 AND is_backfill=0", Long.class); + // 超过 10 分钟还没处理:扫描是 30 秒一轮,正常几秒内就该消费完 + Long stale = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM iot_event WHERE processed=0 AND is_backfill=0 " + + "AND receive_time < DATE_SUB(NOW(3), INTERVAL 10 MINUTE)", Long.class); + Long dead = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM alarm_dead_letter WHERE replayed_at IS NULL", Long.class); + data.put("alarmBacklog", backlog); + data.put("alarmStale", stale); + data.put("alarmDeadLetter", dead); + if (stale != null && stale > 0) { + warnings.add("事件积压 " + stale + " 条超过 10 分钟未处理"); + } + if (dead != null && dead > 0) { + warnings.add("告警死信 " + dead + " 条待人工回放(规则连续失败,已停止自动重试)"); + } + } catch (Exception e) { + data.put("alarm", "FAILED: " + rootMessage(e)); + } + + // 审计写失败待补录条数:非 0 说明"有的操作查不到是谁做的",必须人工介入。 + // 现在读的是 audit_fail_log 表,不是内存计数——重启不清零,也能逐条补录。 + long auditFailures = auditGuard.failures(); + data.put("auditFailures", auditFailures); + if (auditFailures > 0) { + warnings.add("审计写入失败待补录 " + auditFailures + " 条(最近一条:" + auditGuard.lastScene() + + "),后台 /api/v1/admin/audit-failures 可补录"); + } + data.put("warnings", warnings); + + boolean allUp = "connected".equals(data.get("db")) && "connected".equals(data.get("redis")); + data.put("status", allUp ? "UP" : "DOWN"); + + return allUp ? ApiResponse.ok(data) : ApiResponse.fail(503, "依赖不可用"); + } + + private String rootMessage(Exception e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + String m = t.getMessage(); + return m == null ? t.getClass().getSimpleName() : m; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/controller/IngestController.java b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/IngestController.java new file mode 100644 index 0000000..ad67cc7 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/controller/IngestController.java @@ -0,0 +1,103 @@ +package cn.aomin.gateway.controller; + +import cn.aomin.gateway.common.ApiResponse; +import cn.aomin.gateway.biz.engine.AlarmTrigger; +import cn.aomin.gateway.domain.dto.EventBatchRequest; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.domain.dto.RawFrameBatchRequest; +import cn.aomin.gateway.domain.dto.StatusBatchRequest; +import cn.aomin.gateway.interceptor.AuthInterceptor; +import cn.aomin.gateway.security.CachedBodyFilter; +import cn.aomin.gateway.security.IdempotentExecutor; +import cn.aomin.gateway.service.IngestService; +import cn.aomin.gateway.service.RawFrameService; +import cn.aomin.gateway.service.StatusService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 三个上行接口,路径与 J-00 V1.6 §1.1 完全一致。 + * + *

+ *   I-01  POST /internal/iot/v1/events/batch          事件(含墓碑帧)
+ *   I-02  POST /internal/iot/v1/device-status/batch   状态快照
+ *   I-12  POST /internal/iot/v1/raw-frames            原始报文
+ * 
+ * + *

三个方法长得一样,因为差异全在 Service 里。这里只剩两件事: + * 取原始 body(给幂等算 hash)、调幂等执行器。 + * + *

为什么幂等要拿原始 body:用反序列化后再序列化的字符串算 hash, + * 字段顺序、空格、空值处理一变,同一个请求的 hash 就不一样了,重推会被当成新批次。 + */ +@RestController +@RequestMapping("/internal/iot/v1") +public class IngestController { + + private final IngestService ingestService; + private final StatusService statusService; + private final RawFrameService rawFrameService; + private final IdempotentExecutor idempotent; + private final AlarmTrigger alarmTrigger; + + public IngestController(IngestService ingestService, + StatusService statusService, + RawFrameService rawFrameService, + IdempotentExecutor idempotent, + AlarmTrigger alarmTrigger) { + this.ingestService = ingestService; + this.statusService = statusService; + this.rawFrameService = rawFrameService; + this.idempotent = idempotent; + this.alarmTrigger = alarmTrigger; + } + + @PostMapping("/events/batch") + public ApiResponse events( + @RequestBody EventBatchRequest req, + @RequestHeader(value = AuthInterceptor.H_IDEMPOTENCY, required = false) String idemKey, + HttpServletRequest request) { + + IngestResponse r = idempotent.execute(idemKey, rawBody(request), IngestResponse.class, + () -> ingestService.ingest(req)); + + // 入库之后立刻叫醒规则引擎,不再等 30 秒兜底定时器。 + // 这里已经出了入库事务(idempotent.execute 返回时提交完成),所以规则抛异常 + // 也回滚不了数据;onEventsIngested 本身是 @Async,不会拖慢这个响应。 + if (r != null && r.getAccepted() > 0) { + alarmTrigger.onEventsIngested(r.getAccepted()); + } + return ApiResponse.ok(r); + } + + @PostMapping("/device-status/batch") + public ApiResponse deviceStatus( + @RequestBody StatusBatchRequest req, + @RequestHeader(value = AuthInterceptor.H_IDEMPOTENCY, required = false) String idemKey, + HttpServletRequest request) { + + IngestResponse r = idempotent.execute(idemKey, rawBody(request), IngestResponse.class, + () -> statusService.ingest(req)); + return ApiResponse.ok(r); + } + + @PostMapping("/raw-frames") + public ApiResponse rawFrames( + @RequestBody RawFrameBatchRequest req, + @RequestHeader(value = AuthInterceptor.H_IDEMPOTENCY, required = false) String idemKey, + HttpServletRequest request) { + + IngestResponse r = idempotent.execute(idemKey, rawBody(request), IngestResponse.class, + () -> rawFrameService.ingest(req)); + return ApiResponse.ok(r); + } + + private byte[] rawBody(HttpServletRequest request) { + Object body = request.getAttribute(CachedBodyFilter.BODY_ATTR); + return body instanceof byte[] b ? b : new byte[0]; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/CommandDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/CommandDao.java new file mode 100644 index 0000000..2d8ec94 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/CommandDao.java @@ -0,0 +1,328 @@ +package cn.aomin.gateway.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * {@code device_command} 的拉取与状态流转,实现 J-00 V1.7 §3.4 的**七态状态机**。 + * + *

+ *   PENDING ──I-18 领取──► CLAIMED ──I-05 SENT──► SENT ──ACKED code=0──► ACKED(终态)
+ *      ▲                     │                     │
+ *      ├─ 租约到期 60s ──────┤                     └─ 24h 无回执 ──► FAILED(终态)
+ *      ├─ 可延迟 + 失败重排 ──┘
+ *      └─ 10 次重排 或 72h 硬顶 ──► TIMEOUT(终态)/ 撤单 ──► CANCELLED(终态)
+ * 
+ * + *

为什么 {@code CLAIMED} 必须独立于 {@code SENT}:前者是"IoT 把命令领走了", + * 后者是"已经发到设备了"。合成一个值以后,租约回收判不出该不该重派, + * 也分不清是中间件失联还是设备没响应——故障定位会直接断在这里。 + * + *

租约代次({@code claim_generation})防的是什么: + * 一条命令被领走 → 租约到期 → 回 PENDING → 又被领走。此时第一个 IoT 节点的回执才迟到, + * 如果不校验代次,这条旧回执会把第二次的流转状态覆盖掉,而且不报错。 + * 每次 CLAIMED 让代次 +1,回执更新时带上前一次读到的代次, + * 代次对不上就只更新 0 行,由上层记为"过期回执"。 + * + *

领取节点({@code claimed_node_id})补的是另一半:代次只说明"第几轮", + * 不说明"谁"。没有节点列时,一个代次没变的命令,任何调用方都能用命令号改写它的流转。 + * 现在两列由同一条 UPDATE 写入,I-05 的原子条件同时约束节点 + 代次 + 来源状态。 + */ +@Repository +public class CommandDao { + + // ---------- 七态(英文,J-00 V1.7 §3.4) ---------- + public static final String ST_PENDING = "PENDING"; + public static final String ST_CLAIMED = "CLAIMED"; + public static final String ST_SENT = "SENT"; + public static final String ST_ACKED = "ACKED"; + public static final String ST_FAILED = "FAILED"; + public static final String ST_TIMEOUT = "TIMEOUT"; + public static final String ST_CANCELLED = "CANCELLED"; + + /** 终态:进了就不能再动,任何回执都不改它 */ + public static final List TERMINAL = List.of(ST_ACKED, ST_FAILED, ST_TIMEOUT, ST_CANCELLED); + /** 在途:只有这两个状态可以接收 I-05 回执 */ + public static final List INFLIGHT = List.of(ST_CLAIMED, ST_SENT); + + private static final String COLS = "id, command_no, dev_id, gateway_dev_id, cmd_type, cmd_seq, " + + "params, delivery_mode, retry_count, claim_generation, claimed_node_id, claimed_at, " + + "expire_at, lease_expire_at, status"; + + /** + * 拉取待下发命令。 + * + *

{@code FOR UPDATE SKIP LOCKED}:两个人同时来拉,各拿各的,互不等待。 + * 用应用层锁(synchronized / Redis 锁)也能实现,但那要多一次 Redis 往返, + * 而且锁粒度只能到"整个表"——SKIP LOCKED 是行级的。 + */ + private static final String SQL_PULL = """ + SELECT id, command_no, dev_id, gateway_dev_id, cmd_type, cmd_seq, params, + delivery_mode, retry_count, claim_generation, claimed_node_id, claimed_at, + expire_at, lease_expire_at, status + FROM device_command + WHERE status = ? + AND gateway_dev_id IN (%s) + AND (expire_at IS NULL OR expire_at > NOW(3)) + ORDER BY created_at ASC + LIMIT ? + FOR UPDATE SKIP LOCKED + """; + + /** + * 领取:PENDING → CLAIMED,代次 +1,记下是谁领的。 + * + *

{@code claimed_node_id} 必须和代次一起落:代次只说明"第几次", + * 不说明"谁"。两列同一条 UPDATE 写,才不会出现"代次进了一轮、节点还停在上一轮"的错位。 + * 前置 {@code status = 'PENDING'} 防并发重复领。 + */ + private static final String SQL_CLAIM = """ + UPDATE device_command + SET status = 'CLAIMED', + claim_generation = claim_generation + 1, + claimed_node_id = ?, + claimed_at = NOW(3), + lease_expire_at = ?, + issued_at = COALESCE(issued_at, NOW(3)), + updated_at = NOW(3) + WHERE id IN (%s) AND status = 'PENDING' + """; + + /** 租约回收:CLAIMED 且租约过期 → 回 PENDING。{@code reclaim_count+1},{@code retry_count} 不动 */ + private static final String SQL_RECLAIM = """ + UPDATE device_command + SET status = 'PENDING', lease_expire_at = NULL, + reclaim_count = reclaim_count + 1, updated_at = NOW(3) + WHERE status = 'CLAIMED' + AND lease_expire_at IS NOT NULL + AND lease_expire_at < NOW(3) + """; + + private final JdbcTemplate jdbc; + private final TransactionTemplate txTemplate; + + public CommandDao(JdbcTemplate jdbc, PlatformTransactionManager txManager) { + this.jdbc = jdbc; + this.txTemplate = new TransactionTemplate(txManager); + } + + /** + * 拉取 + 打租约,同一个事务——拉到却没打上租约,等于命令丢了。 + * + * @param nodeId 这次来领的 IoT 节点标识,和代次一起写进命令记录。 + * 它不是身份认证(那层是内网白名单 + 签名),是**领取事实**: + * 没有它,I-05 的回执没法证明"这条结果是领走它的那个节点回的"。 + */ + public List pullAndClaim(String nodeId, List gatewayIds, int limit, long leaseSeconds) { + if (gatewayIds == null || gatewayIds.isEmpty()) { + return List.of(); + } + String placeholders = String.join(",", Collections.nCopies(gatewayIds.size(), "?")); + + return txTemplate.execute(status -> { + List args = new ArrayList<>(); + args.add(ST_PENDING); + args.addAll(gatewayIds); + args.add(limit); + + List rows = jdbc.query( + SQL_PULL.formatted(placeholders), ROW_MAPPER, args.toArray()); + if (rows.isEmpty()) { + return rows; + } + + LocalDateTime leaseExpireAt = LocalDateTime.now().plusSeconds(leaseSeconds); + String idMark = String.join(",", Collections.nCopies(rows.size(), "?")); + List updateArgs = new ArrayList<>(); + updateArgs.add(nodeId); + updateArgs.add(leaseExpireAt); + for (CommandRow r : rows) { + updateArgs.add(r.id()); + } + jdbc.update(SQL_CLAIM.formatted(idMark), updateArgs.toArray()); + + // 回读:代次和租约是库里的真实值,不能拿内存里算的凑。 + // 万一并发下有人先领走了,这里读出来的状态不是 CLAIMED,就不该返回给这次调用方。 + List claimed = jdbc.query( + "SELECT " + COLS + " FROM device_command WHERE id IN (" + idMark + ") AND status = 'CLAIMED'", + ROW_MAPPER, rows.stream().map(CommandRow::id).toArray()); + return claimed; + }); + } + + /** @return 回收了几条 */ + public int reclaimExpiredLeases() { + return jdbc.update(SQL_RECLAIM); + } + + /** + * I-05 回报时用:命令号是字符串 CMD-20261030-000123,不是自增 id。 + * 这里要带出 {@code claim_generation},回执更新时用它挡住过期回执。 + */ + public CommandRow findByCommandNo(String commandNo) { + List list = jdbc.query( + "SELECT " + COLS + " FROM device_command WHERE command_no = ?", ROW_MAPPER, commandNo); + return list.isEmpty() ? null : list.get(0); + } + + public CommandRow findById(long id) { + List list = jdbc.query( + "SELECT " + COLS + " FROM device_command WHERE id = ?", ROW_MAPPER, id); + return list.isEmpty() ? null : list.get(0); + } + + /** + * 按回报结果流转状态。四个守卫缺一不可: + * + *
    + *
  1. 节点守卫 {@code claimed_node_id = ?}:回执必须来自领走这一轮的那个节点;
  2. + *
  3. 代次守卫 {@code claim_generation = ?}:旧回执(命令被回收重派过)改不动;
  4. + *
  5. 前置状态守卫 {@code status IN ('CLAIMED','SENT')}:终态不回退、PENDING 不被莫名的回执改;
  6. + *
  7. 终态守卫:{@code ACKED/FAILED/TIMEOUT/CANCELLED} 一旦落定,重复回执只更新 0 行。
  8. + *
+ * + *

为什么节点和代次都要:代次回答"第几轮",节点回答"这一轮是谁"。 + * 只有代次时,任何知道命令号的调用方都能在代次没变的情况下改写这条命令的流转; + * 只有节点时,租约回收重派后第一轮的迟到回执会被当成第二轮采纳。两个一起才闭合。 + * + *

四条合起来的返回值就是判据:1 = 这次回执生效,0 = 过期/重复/错节点回执。 + * 上层据此把"收到了但没采纳"和"处理失败"分开计数,不再混成一个 rejected。 + * + * @param status 目标状态(只能是 SENT / ACKED / FAILED 之一,其余走别的分支) + * @param timeoutAt 仅 SENT 时给值(now+24h),其余传 null + * @return 受影响行数,0 表示这条回执被守卫挡下 + */ + public int applyResult(long id, int generation, String nodeId, String status, LocalDateTime timeoutAt, + String resultCode, String rawResultCode, String resultMsg, + String rawAck, LocalDateTime ackedAt) { + return jdbc.update(""" + UPDATE device_command + SET status = ?, + timeout_at = COALESCE(?, timeout_at), + result_code = COALESCE(?, result_code), + raw_result_code = COALESCE(?, raw_result_code), + result_msg = COALESCE(?, result_msg), + raw_ack = COALESCE(?, raw_ack), + acked_at = COALESCE(?, acked_at), + lease_expire_at = NULL, + updated_at = NOW(3) + WHERE id = ? + AND claim_generation = ? + AND claimed_node_id = ? + AND status IN ('CLAIMED','SENT') + """, status, timeoutAt, resultCode, rawResultCode, resultMsg, + rawAck, ackedAt, id, generation, nodeId); + } + + /** + * 重排:回 PENDING,{@code retry_count+1}。DEFERRED 命令收到离线/超时后走这条。 + * 同样是"只从在途状态回退",终态和已重排过的不重复计数。 + */ + public int requeue(long id, int generation, String nodeId, int maxRetry) { + return jdbc.update(""" + UPDATE device_command + SET status = 'PENDING', + lease_expire_at = NULL, + retry_count = retry_count + 1, + updated_at = NOW(3) + WHERE id = ? + AND claim_generation = ? + AND claimed_node_id = ? + AND status IN ('CLAIMED','SENT') + AND retry_count < ? + """, id, generation, nodeId, maxRetry); + } + + /** 用户撤单 / 设备解绑:只有还没发出去的命令能撤 */ + public int cancel(long id, int generation) { + return jdbc.update(""" + UPDATE device_command + SET status = 'CANCELLED', lease_expire_at = NULL, updated_at = NOW(3) + WHERE id = ? AND claim_generation = ? AND status IN ('PENDING','CLAIMED') + """, id, generation); + } + + /** + * 72 小时硬顶:到点直接置超时,不管走到哪一步。 + * 覆盖 PENDING/CLAIMED/SENT 三种在途态——终态不动。 + */ + public int expireOverdue() { + return jdbc.update(""" + UPDATE device_command + SET status = 'TIMEOUT', lease_expire_at = NULL, updated_at = NOW(3) + WHERE status IN ('PENDING','CLAIMED','SENT') + AND expire_at IS NOT NULL + AND expire_at < NOW(3) + """); + } + + /** + * 24 小时无回执兜底:SENT 且 timeout_at 已过 → FAILED。 + * 这条以前只有列没有扫描,现在补上——否则"有没有回执"永远判不出来。 + */ + public int failSilentSent() { + return jdbc.update(""" + UPDATE device_command + SET status = 'FAILED', + result_msg = COALESCE(result_msg, '24h 未收到回执,判失败'), + updated_at = NOW(3) + WHERE status = 'SENT' + AND timeout_at IS NOT NULL + AND timeout_at < NOW(3) + """); + } + + /** 某台设备当前有几条在途命令(发新命令前的限流用) */ + public int countInflight(String devId) { + Integer n = jdbc.queryForObject( + "SELECT COUNT(*) FROM device_command WHERE dev_id = ? " + + "AND status IN ('PENDING','CLAIMED','SENT')", Integer.class, devId); + return n == null ? 0 : n; + } + + /** 某台设备最近 N 条命令(家属端/后台命令记录页用) */ + public List recentByDev(String devId, int limit) { + return jdbc.query("SELECT " + COLS + " FROM device_command WHERE dev_id = ? " + + "ORDER BY id DESC LIMIT ?", ROW_MAPPER, devId, limit); + } + + private static final org.springframework.jdbc.core.RowMapper ROW_MAPPER = (rs, i) -> new CommandRow( + rs.getLong("id"), + rs.getString("command_no"), + rs.getString("dev_id"), + rs.getString("gateway_dev_id"), + rs.getString("cmd_type"), + rs.getObject("cmd_seq", Long.class), + rs.getString("params"), + rs.getString("delivery_mode"), + rs.getInt("retry_count"), + rs.getInt("claim_generation"), + rs.getString("claimed_node_id"), + rs.getObject("claimed_at", LocalDateTime.class), + rs.getObject("expire_at", LocalDateTime.class), + rs.getObject("lease_expire_at", LocalDateTime.class), + rs.getString("status")); + + /** + * 一条命令。 + * + * @param gatewayId 这条命令从哪个端点进去——不是网关 SN,独立设备填自身标识。 + * IoT 拿它去自己的连接表查,查到就发,查不到报 DEVICE_OFFLINE。 + * @param claimedNodeId 这一轮是哪个 IoT 节点领走的。历史在途行可能为 NULL(迁移前领的), + * 新领取的一律有值。 + */ + public record CommandRow(long id, String commandNo, String devId, String gatewayId, + String cmdType, Long cmdSeq, String params, String deliveryMode, + int retryCount, int claimGeneration, + String claimedNodeId, LocalDateTime claimedAt, + LocalDateTime expireAt, LocalDateTime leaseExpireAt, + String status) { + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/DeviceDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/DeviceDao.java new file mode 100644 index 0000000..d3ac753 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/DeviceDao.java @@ -0,0 +1,66 @@ +package cn.aomin.gateway.dao; + +import cn.aomin.gateway.domain.DeviceRow; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * {@code device} 表只读访问。 + * + *

一条 SQL 查一批,不逐条查。200 条数据查 200 次,光网络往返就 200ms, + * 直接把 M-00 §4.9 的性能预算吃掉。 + */ +@Repository +public class DeviceDao { + + /** + * 必须带 {@code deleted = 0}:这张表用 {@code deleted} 做软删(0 存活,删除时置为自身 id)。 + * 不加这个条件,已删除设备会被当成在役,事件挂到已经解绑的户上。 + */ + private static final String SQL_SELECT_BY_SENSOR_IDS = """ + SELECT id, dev_id, sensor_id, household_id, ts_precision, vendor, source_channel + FROM device + WHERE sensor_id IN (%s) + AND deleted = 0 + """; + + private static final RowMapper MAPPER = (rs, i) -> new DeviceRow( + rs.getLong("id"), + rs.getString("dev_id"), + rs.getString("sensor_id"), + rs.getObject("household_id", Long.class), + rs.getString("ts_precision"), + rs.getString("vendor"), + rs.getString("source_channel")); + + private final JdbcTemplate jdbc; + + public DeviceDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * @return key 是 {@code sensor_id};查不到的设备不在 Map 里,调用方据此判"未建档" + */ + public Map selectBySensorIds(Collection sensorIds) { + Map result = new HashMap<>(sensorIds.size() * 2); + if (sensorIds.isEmpty()) { + return result; + } + String placeholders = String.join(",", java.util.Collections.nCopies(sensorIds.size(), "?")); + List rows = jdbc.query( + SQL_SELECT_BY_SENSOR_IDS.formatted(placeholders), + MAPPER, + sensorIds.toArray()); + for (DeviceRow r : rows) { + result.put(r.sensorId(), r); + } + return result; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/IotEventDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/IotEventDao.java new file mode 100644 index 0000000..931497d --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/IotEventDao.java @@ -0,0 +1,134 @@ +package cn.aomin.gateway.dao; + +import cn.aomin.gateway.domain.DeviceRow; +import cn.aomin.gateway.domain.TimeResult; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; +import java.time.LocalDateTime; + +/** + * {@code iot_event} 写入。 + * + *

16 列里 11 列是纯抄 J-00 信封,见 M-00 §4.1: + *

+ *   抄      eventId / sensorId / sourceChannel / eventType / deviceTime /
+ *           tsSource / receivedAt / seq / payload / rawRef / protocolVersion
+ *   查表补  devId / householdId                      (来自 device)
+ *   判      isBackfill                                (墓碑帧=1)
+ *   置      processed = 0                             (等规则引擎扫)
+ *   派生    eventTime                                 (EventTimeResolver)
+ * 
+ * + *

不做预查重。唯一键 {@code uk_event_id(event_id, event_time)} 与 + * {@code uk_sensor_seq(sensor_id, seq, event_time)} 本身就是判据, + * 撞了自然抛 {@link org.springframework.dao.DuplicateKeyException}。 + * 先查再插会多一次往返,而且两个线程同时查都会说"不存在"。 + */ +@Repository +public class IotEventDao { + + private static final String SQL_INSERT = """ + INSERT INTO iot_event + (event_id, dev_id, sensor_id, household_id, source_channel, + event_type, device_time, ts_source, is_backfill, retry_cnt, lost_cnt, + receive_time, received_at_remote, + seq, payload, raw_ref, protocol_version, processed, event_time) + VALUES (?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, + NOW(3), ?, + ?, ?, ?, ?, 0, ?) + """; + + private final JdbcTemplate jdbc; + + public IotEventDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * 写一行事件。 + * + * @throws org.springframework.dao.DuplicateKeyException 撞唯一键(重推),由调用方计 duplicated + */ + public void insertOne(String eventId, + DeviceRow device, + String sourceChannel, + String eventType, + LocalDateTime deviceTime, + int tsSource, + boolean isBackfill, + Integer retryCnt, + Integer lostCnt, + LocalDateTime receivedAtRemote, + Long seq, + String payloadJson, + String rawRef, + String protocolVersion, + TimeResult time) { + + jdbc.update(SQL_INSERT, (PreparedStatement ps) -> { + int i = 1; + ps.setString(i++, eventId); + ps.setString(i++, device.devId()); + ps.setString(i++, device.sensorId()); + if (device.householdId() == null) { + ps.setNull(i++, Types.BIGINT); + } else { + ps.setLong(i++, device.householdId()); + } + ps.setString(i++, sourceChannel); + + ps.setString(i++, eventType); + if (deviceTime == null) { + ps.setNull(i++, Types.TIMESTAMP); + } else { + ps.setObject(i++, deviceTime); + } + // ts_source 故意没有 DEFAULT:漏赋值要当场报 1364,不能悄悄变成 A 级数据 + ps.setInt(i++, tsSource); + ps.setInt(i++, isBackfill ? 1 : 0); + setNullableInt(ps, i++, retryCnt); + setNullableInt(ps, i++, lostCnt); + // receive_time 由库端 NOW(3) 打戳(见 SQL),这里只落 IoT 自报的时刻作参考 + if (receivedAtRemote == null) { + ps.setNull(i++, Types.TIMESTAMP); + } else { + ps.setObject(i++, receivedAtRemote); + } + + if (seq == null) { + ps.setNull(i++, Types.BIGINT); + } else { + ps.setLong(i++, seq); + } + if (payloadJson == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, payloadJson); + } + if (rawRef == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, rawRef); + } + if (protocolVersion == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, protocolVersion); + } + ps.setObject(i, time.eventTime()); + }); + } + + private static void setNullableInt(PreparedStatement ps, int idx, Integer v) throws SQLException { + if (v == null) { + ps.setNull(idx, Types.TINYINT); + } else { + ps.setInt(idx, v); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawFrameDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawFrameDao.java new file mode 100644 index 0000000..fdba1b6 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawFrameDao.java @@ -0,0 +1,140 @@ +package cn.aomin.gateway.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.time.LocalDateTime; + +/** + * 两张原文表的写入,实现 M-00 §6。 + * + *

三条铁律: + *

    + *
  1. {@code payload} 存 Base64 文本本身,不解码。列是 {@code MEDIUMTEXT} + utf8mb4, + * 装不了任意二进制——解码后存会报 {@code Incorrect string value}, + * 严格模式关掉则被替换成 {@code ?},数据永久损坏且不报错;
  2. + *
  3. {@code payload_md5} 直接抄 {@code rawRef},绝不重算。 + * 对 Base64 文本算出来的值跟"解码后字节的 MD5"完全不同, + * 重算会让 {@code iot_event.raw_ref} 再也 join 不上;
  4. + *
  5. {@code payload_size} 填解码后的字节数({@code frameBytes}),不是 Base64 文本长度。
  6. + *
+ * + *

三列已由 Flyway V1.0.1 补上({@code db/migration/V1.0.1__raw_tables_columns.sql}): + * {@code ingest_raw_log.protocol_version}、{@code adapter_in_raw.protocol_version}、 + * {@code adapter_in_raw.link_type}。三列均可空,取不到就传 {@code null},不拒收、不降级。 + * + *

{@code link_type} 不从 {@code sourceChannel} 推——麦乐克两套 TCP(bignbiot 与铠湾 V3.0) + * 同属 {@code MIR_CELL},推不出来。取值由 {@code LinkTypeResolver} 按接入端点({@code gatewayDevId}) + * 查本地配置,查不到就是 {@code null},宁可空着也不编。 + */ +@Repository +public class RawFrameDao { + + private static final String SQL_INGEST_RAW = """ + INSERT INTO ingest_raw_log + (gateway_dev_id, sensor_id, payload, payload_size, payload_md5, + parse_result, err_msg, protocol_version, received_at) + VALUES (?, ?, ?, ?, ?, + ?, ?, ?, ?) + """; + + private static final String SQL_ADAPTER_RAW = """ + INSERT INTO adapter_in_raw + (device_sn, dev_id, sensor_id, source_channel, link_type, vendor, + raw_payload, payload_md5, idem_key, idem_level, + parse_result, protocol_version, is_duplicate, received_at) + VALUES (?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, + ?, ?, 0, ?) + """; + + private final JdbcTemplate jdbc; + + public RawFrameDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * G 线走这张表。{@code topic} / {@code qos} / {@code pkt_time} 是 MQTT 痕迹, + * I-12 报文里没有这三个字段,一律留空——不要拿默认值 1 当"QoS 1"解释。 + */ + public void insertIngestRaw(String gatewayDevId, + String sensorId, + String payloadBase64, + Integer payloadBytes, + String payloadMd5, + String parseResult, + String errMsg, + String protocolVersion, + LocalDateTime receivedAt) { + + jdbc.update(SQL_INGEST_RAW, (PreparedStatement ps) -> { + int i = 1; + setNullableString(ps, i++, gatewayDevId); + setNullableString(ps, i++, sensorId); + ps.setString(i++, payloadBase64); + if (payloadBytes == null) { + ps.setNull(i++, Types.INTEGER); + } else { + ps.setInt(i++, payloadBytes); + } + ps.setString(i++, payloadMd5); + ps.setString(i++, parseResult == null ? "OK" : parseResult); + setNullableString(ps, i++, errMsg); + setNullableString(ps, i++, protocolVersion); + ps.setObject(i, receivedAt); + }); + } + + /** + * H/S 线走这张表。 + * + *

{@code vendor} 由 {@code sourceChannel} 推出({@code HORN_*}→HORN、{@code MIR_CELL}→MIR), + * 不是从报文里猜——通道是部署时配死的,{@code HORN_CELL} 下不可能挂麦乐克的设备。 + * + *

{@code device_sn} 是 {@code NOT NULL},而 I-12 的 {@code sensorId} 可空。 + * 解析不出时填 {@code UNKNOWN} 并把 {@code parseResult} 标成 {@code FIELD_MISSING}—— + * 不丢帧,因为"设备连上来发了我方解析不了的东西"正是排障最需要的证据。 + */ + public void insertAdapterRaw(String deviceSn, + String devId, + String sensorId, + String sourceChannel, + String linkType, + String vendor, + String rawPayloadBase64, + String payloadMd5, + String idemKey, + int idemLevel, + String parseResult, + String protocolVersion, + LocalDateTime receivedAt) { + + jdbc.update(SQL_ADAPTER_RAW, (PreparedStatement ps) -> { + int i = 1; + ps.setString(i++, deviceSn); + setNullableString(ps, i++, devId); + setNullableString(ps, i++, sensorId); + ps.setString(i++, sourceChannel); + setNullableString(ps, i++, linkType); + ps.setString(i++, vendor); + ps.setString(i++, rawPayloadBase64); + ps.setString(i++, payloadMd5); + ps.setString(i++, idemKey); + ps.setInt(i++, idemLevel); + ps.setString(i++, parseResult == null ? "OK" : parseResult); + setNullableString(ps, i++, protocolVersion); + ps.setObject(i, receivedAt); + }); + } + + private static void setNullableString(PreparedStatement ps, int idx, String v) throws java.sql.SQLException { + if (v == null) { + ps.setNull(idx, Types.VARCHAR); + } else { + ps.setString(idx, v); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawRefGapDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawRefGapDao.java new file mode 100644 index 0000000..abb7ff2 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawRefGapDao.java @@ -0,0 +1,94 @@ +package cn.aomin.gateway.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * {@code iot_raw_ref_gap} —— 孤儿摘要登记。 + * + *

解决什么问题。J-00 V1.7.1 把 {@code rawRef} 升为必填,那只挡住"字段为空"。 + * 但 I-12(原文)与 I-01(事件)是两次独立请求:接入侧漏发原文、超时、 + * 或者顺序反了(先 I-01 后 I-12),事件照样能入库成功,{@code raw_ref} 指向一个 + * 不存在的记录。这类行如果不登记,就会出现"每家都号称能回查原文,实际查不到"。 + * + *

为什么是软标记,不是拒收。真机上 I-12 可能比 I-01 晚到几十毫秒, + * 硬拒会把正常事件挡在门外——V1.7 那次"64 位 SHA-256 被误判 SCHEMA_INVALID" + * 就是这个坑的翻版:契约允许,实现拒收,接真机第一天全量失败, + * 日志还只显示"字段非法",查不出原因。 + * + *

能自愈。原文后到时把 {@code resolved_at} 置位,行不删—— + * 留着才能算孤儿率和自愈率;全删掉就只剩"此刻有多少条",没有历史。 + */ +@Repository +public class RawRefGapDao { + + private final JdbcTemplate jdbc; + + public RawRefGapDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + /** + * 这个摘要对应的原文在不在。 + * + *

只看一条链:G 线原文落 {@code ingest_raw_log},S/H 线落 {@code adapter_in_raw}。 + * 不两张表都比,是因为 {@code source_channel} 已经确定走哪条路, + * 跨线比会多一次全表条件查询,而结论不会变。 + * + * @param gatewayRaw true = G 线(查 ingest_raw_log),false = S/H 线(查 adapter_in_raw) + */ + public boolean rawExists(String rawRef, boolean gatewayRaw) { + if (rawRef == null || rawRef.isEmpty()) { + return true; // 空摘要不在本方法的职责范围(必填校验在 EventValidator) + } + Long n = gatewayRaw + ? jdbc.queryForObject( + "SELECT COUNT(*) FROM ingest_raw_log WHERE payload_md5=?", Long.class, rawRef) + : jdbc.queryForObject( + "SELECT COUNT(*) FROM adapter_in_raw WHERE idem_key=? OR payload_md5=?", + Long.class, rawRef, rawRef); + return n != null && n > 0; + } + + /** + * 登记一条孤儿。已登记过的只累加 {@code hit_cnt}。 + * + *

墓碑帧一个 {@code rawRef} 会被多条事件引用(多对一),所以这里按 + * {@code raw_ref} 唯一,不是按 {@code event_id}。 + * + *

{@code resolved_at=NULL} 是刻意加的:万一同一摘要先到译文、后到孤儿, + * 也要把它重新标成未解决,不能因为"曾经解决过"就放过。 + */ + public void record(String rawRef, String eventId, String sensorId, String channel) { + jdbc.update(""" + INSERT INTO iot_raw_ref_gap + (raw_ref, event_id, sensor_id, source_channel, first_seen_at, last_seen_at, hit_cnt, resolved_at) + VALUES (?, ?, ?, ?, NOW(3), NOW(3), 1, NULL) + ON DUPLICATE KEY UPDATE + hit_cnt = hit_cnt + 1, + last_seen_at = NOW(3), + resolved_at = NULL + """, rawRef, eventId, sensorId, channel); + } + + /** + * 原文后到:置 {@code resolved_at}。已在解决态的不重复写。 + * + * @return 真正被置位的行数(0 = 本来就没有孤儿记录,正常) + */ + public int resolve(String rawRef) { + if (rawRef == null || rawRef.isEmpty()) { + return 0; + } + return jdbc.update( + "UPDATE iot_raw_ref_gap SET resolved_at=NOW(3) WHERE raw_ref=? AND resolved_at IS NULL", + rawRef); + } + + /** 当前未解决的孤儿数(给后台/巡检展示用)。 */ + public long unresolvedCount() { + Long n = jdbc.queryForObject( + "SELECT COUNT(*) FROM iot_raw_ref_gap WHERE resolved_at IS NULL", Long.class); + return n == null ? 0 : n; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/TechCacheDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/TechCacheDao.java new file mode 100644 index 0000000..6900fc1 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/TechCacheDao.java @@ -0,0 +1,112 @@ +package cn.aomin.gateway.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.time.LocalDateTime; + +/** + * {@code device_tech_cache} 覆盖写,实现 M-00 §5.3 的四条规则。 + * + *

唯一键 {@code uk_dev_id(dev_id)},所以一设备一行,收到新快照就覆盖。 + * + *

四条规则全在这条 SQL 里,改它之前先看 M-00 §5.3: + *

    + *
  1. {@code last_seen_at} 用 {@code GREATEST} 防回退——乱序到达时后一批可能时间更早, + * 直接覆盖会让"最后一次上报"倒退,离线判定跟着误判;
  2. + *
  3. 不写 {@code battery}(百分比)——换算式会变,换算在业务层;
  4. + *
  5. 不写 {@code undelivered_count}——那是业务层断档累计值,不是设备报的 {@code lostCnt}, + * 覆盖了断档报表就再也算不对;
  6. + *
  7. 不写 {@code last_heartbeat_at}——I-02 没有"这是心跳"的标记, + * 拿告警当心跳等于伪造"设备在正常心跳"的证据。
  8. + *
+ */ +@Repository +public class TechCacheDao { + + private static final String SQL_UPSERT = """ + INSERT INTO device_tech_cache + (dev_id, sensor_id, online_status, battery_mv, csq, last_seq, lost_cnt, + last_seen_at, device_online_at, raw_snapshot, synced_at) + VALUES (?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, NOW(3)) + ON DUPLICATE KEY UPDATE + sensor_id = VALUES(sensor_id), + online_status = IF(VALUES(online_status) = 'ONLINE', 'ONLINE', online_status), + battery_mv = COALESCE(VALUES(battery_mv), battery_mv), + csq = COALESCE(VALUES(csq), csq), + last_seq = COALESCE(VALUES(last_seq), last_seq), + lost_cnt = COALESCE(VALUES(lost_cnt), lost_cnt), + last_seen_at = GREATEST(COALESCE(last_seen_at, VALUES(last_seen_at)), VALUES(last_seen_at)), + device_online_at = IF(VALUES(online_status) = 'ONLINE', NOW(3), device_online_at), + raw_snapshot = VALUES(raw_snapshot), + synced_at = NOW(3) + """; + + /** + * INSERT 分支要传的在线状态。 + * + *

{@code online=false} 时传 UNKNOWN 而不是 OFFLINE: + * 这条列是 {@code NOT NULL},插不进 null;而一台设备的第一条快照如果是 false, + * 我方其实还不知道它到底行不行——落 UNKNOWN 是唯一符合事实的选择。 + * UPDATE 分支里 {@code IF} 判断不成立,原值保持不变(M-00 §5.4)。 + */ + public static final String ONLINE = "ONLINE"; + public static final String UNKNOWN = "UNKNOWN"; + + private final JdbcTemplate jdbc; + + public TechCacheDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void upsert(String devId, + String sensorId, + boolean online, + Integer batteryMv, + Integer csq, + Long lastSeq, + Integer lostCnt, + LocalDateTime lastSeenAt, + String rawSnapshotJson) { + + jdbc.update(SQL_UPSERT, (PreparedStatement ps) -> { + int i = 1; + ps.setString(i++, devId); + ps.setString(i++, sensorId); + ps.setString(i++, online ? ONLINE : UNKNOWN); + + if (batteryMv == null) { + ps.setNull(i++, Types.SMALLINT); + } else { + ps.setInt(i++, batteryMv); + } + if (csq == null) { + ps.setNull(i++, Types.TINYINT); + } else { + ps.setInt(i++, csq); + } + if (lastSeq == null) { + ps.setNull(i++, Types.BIGINT); + } else { + ps.setLong(i++, lastSeq); + } + if (lostCnt == null) { + ps.setNull(i++, Types.TINYINT); + } else { + ps.setInt(i++, lostCnt); + } + + ps.setObject(i++, lastSeenAt); + // device_online_at:online=true 时给当前时刻,否则 NULL(UPDATE 分支会保住原值) + if (online) { + ps.setObject(i++, LocalDateTime.now()); + } else { + ps.setNull(i++, Types.TIMESTAMP); + } + ps.setString(i, rawSnapshotJson); + }); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/dao/UnregisteredDao.java b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/UnregisteredDao.java new file mode 100644 index 0000000..4576c1b --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/dao/UnregisteredDao.java @@ -0,0 +1,96 @@ +package cn.aomin.gateway.dao; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.Types; +import java.time.LocalDateTime; + +/** + * {@code unregistered_device}:未建档设备的落脚点。 + * + *

为什么不能直接丢:一台没登记过的设备在发数据,这件事本身就是运营要处理的信号。 + * 丢了就永远不知道有野设备在跑(M-00 §4.5 / §5.5)。 + * + *

两个方法对应两种场景,别混用: + *

    + *
  • {@link #upsertFromEvent} —— I-01 用,带 {@code last_payload}(最后一次上报的事件);
  • + *
  • {@link #touchFromStatus} —— I-02 用,不写 last_payload。 + * 快照不是事件,写进去会把"最后一次上报的事件"覆盖成状态快照, + * 运营认领时看到的是错的东西。
  • + *
+ */ +@Repository +public class UnregisteredDao { + + private static final String SQL_FROM_EVENT = """ + INSERT INTO unregistered_device + (sensor_id, source_channel, vendor, first_seen_at, last_seen_at, + report_count, last_payload, status) + VALUES (?, ?, ?, ?, ?, 1, ?, '待核查') + ON DUPLICATE KEY UPDATE + last_seen_at = GREATEST(last_seen_at, VALUES(last_seen_at)), + report_count = report_count + 1, + last_payload = COALESCE(VALUES(last_payload), last_payload), + vendor = COALESCE(vendor, VALUES(vendor)) + """; + + private static final String SQL_FROM_STATUS = """ + INSERT INTO unregistered_device + (sensor_id, source_channel, vendor, first_seen_at, last_seen_at, + report_count, status) + VALUES (?, ?, ?, ?, ?, 1, '待核查') + ON DUPLICATE KEY UPDATE + last_seen_at = GREATEST(last_seen_at, VALUES(last_seen_at)), + report_count = report_count + 1 + """; + + private final JdbcTemplate jdbc; + + public UnregisteredDao(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public void upsertFromEvent(String sensorId, + String sourceChannel, + String vendor, + LocalDateTime seenAt, + String payloadJson) { + jdbc.update(SQL_FROM_EVENT, (PreparedStatement ps) -> { + int i = 1; + ps.setString(i++, sensorId); + ps.setString(i++, sourceChannel); + if (vendor == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, vendor); + } + ps.setObject(i++, seenAt); + ps.setObject(i++, seenAt); + if (payloadJson == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, payloadJson); + } + }); + } + + public void touchFromStatus(String sensorId, + String sourceChannel, + String vendor, + LocalDateTime seenAt) { + jdbc.update(SQL_FROM_STATUS, (PreparedStatement ps) -> { + int i = 1; + ps.setString(i++, sensorId); + ps.setString(i++, sourceChannel); + if (vendor == null) { + ps.setNull(i++, Types.VARCHAR); + } else { + ps.setString(i++, vendor); + } + ps.setObject(i++, seenAt); + ps.setObject(i, seenAt); + }); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/Channel.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/Channel.java new file mode 100644 index 0000000..0f6bcd1 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/Channel.java @@ -0,0 +1,53 @@ +package cn.aomin.gateway.domain; + +/** + * 四个来源通道,取值与 J-00 §1.3 / OpenAPI {@code SourceChannel} 完全一致。 + * + *

不要给它加 {@code KAIWAN}:铠湾是协议名不是厂商, + * V1.0 定稿里 16 处提到铠湾全是"智能无线终端通信协议(4G)V3.0(铠湾)", + * 设备是麦乐克的 MIR-WA301 / SO300 / TE300。 + * 铠湾设备的 {@code sourceChannel} 就该是 {@code MIR_CELL}。 + * + *

这个枚举只决定三件事(OpenAPI 注释原文): + * {@code dev_id} 生成规则、{@code ts_precision} 默认值、原文落哪张表。 + * 它从来不是"协议标识"——麦乐克两套 TCP 同属 MIR_CELL。 + */ +public enum Channel { + + OWN_GW("OWN", true), + HORN_HOST("HORN", false), + HORN_CELL("HORN", false), + MIR_CELL("MIR", false); + + /** 该通道对应的厂商,仅用于 adapter_in_raw.vendor 这个 NOT NULL 列(M-00 §6.5) */ + private final String vendor; + + /** 原文是否落 ingest_raw_log(true)还是 adapter_in_raw(false) */ + private final boolean toGatewayRawLog; + + Channel(String vendor, boolean toGatewayRawLog) { + this.vendor = vendor; + this.toGatewayRawLog = toGatewayRawLog; + } + + public String vendor() { + return vendor; + } + + public boolean toGatewayRawLog() { + return toGatewayRawLog; + } + + /** 找不到返回 null,由调用方转成 {@code UNKNOWN_CHANNEL} 拒收 */ + public static Channel of(String name) { + if (name == null) { + return null; + } + for (Channel c : values()) { + if (c.name().equals(name)) { + return c; + } + } + return null; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/DeviceRow.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/DeviceRow.java new file mode 100644 index 0000000..892677a --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/DeviceRow.java @@ -0,0 +1,25 @@ +package cn.aomin.gateway.domain; + +/** + * {@code device} 表一次批量查询的结果行(M-00 §2.3)。 + * + *

只取入库真正要用的列,不 {@code SELECT *}——这台表的列有三十多个, + * 200 条批量的场景下多取一列就是多一份网络和反序列化开销。 + * + * @param id 自增主键 + * @param devId 业务主键,写 {@code iot_event.dev_id} / {@code device_tech_cache.dev_id} + * @param sensorId 出厂标识 + * @param householdId 户,冗余进 {@code iot_event} 便于按户查 + * @param tsPrecision 建档时的时间精度 A/B/C,派生 {@code event_time} 的两个输入之一 + * @param vendor 厂商 + * @param sourceChannel 建档时的通道,用于校验 IoT 传的通道是否与建档一致 + */ +public record DeviceRow( + long id, + String devId, + String sensorId, + Long householdId, + String tsPrecision, + String vendor, + String sourceChannel) { +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/TimeResult.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/TimeResult.java new file mode 100644 index 0000000..a1242ff --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/TimeResult.java @@ -0,0 +1,18 @@ +package cn.aomin.gateway.domain; + +import java.time.LocalDateTime; + +/** + * {@link cn.aomin.gateway.support.EventTimeResolver} 的两个输出。 + * + * @param eventTime 必须非空——它是 {@code iot_event} 的分区键,NOT NULL。 + * 派生规则见 M-00 §4.6,四行表对应 J-00 §2.3。 + * @param precision 实际精度 A/B/C,取"设备建档精度"与"本次时间来源精度"中更差的那个。 + * 接入侧不参与,只由本类给出。 + */ +public record TimeResult(LocalDateTime eventTime, String precision) { + + public static final String PRECISION_A = "A"; + public static final String PRECISION_B = "B"; + public static final String PRECISION_C = "C"; +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/CommandResultRequest.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/CommandResultRequest.java new file mode 100644 index 0000000..70d45e3 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/CommandResultRequest.java @@ -0,0 +1,151 @@ +package cn.aomin.gateway.domain.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * I-05 命令结果回报,对应 J-00 V1.6 §3.3。 + * + *

{@code result} 五个值,每个值的处置不一样——IoT 只负责如实报,怎么处置是业务层的事: + * + * + * + * + * + * + * + *
resultIoT 怎么得出业务层处置
ACKED收到回执帧看 resultCode 分流
SENT发出去了,本协议无回执通道保持已下发,等 24h 兜底
TIMEOUT发出去了等不到回执实时=失败;可延迟=重排
DEVICE_OFFLINE发前查连接,不在实时=失败;可延迟=重排
FAILEDIoT 自身发送失败同上
+ * + *

{@code resultCode} 是标准字典值(J-00 V1.7 §3.3),由 IoT 翻译后填: + * {@code 0} 成功/{@code 1} 不支持/{@code 2} 参数非法/{@code 3} 执行失败/{@code 4} 设备忙。 + * 厂家原码不得透传到这里,原样字符串填 {@code rawResultCode}。 + * + *

为什么必须分开:厂家原码各不一样(铠湾 {@code 08} 是水浸、bignbiot {@code 08} 是白蚁, + * 命令侧同样有这种坑)。直透到标准列以后,业务层和前端就得按每个厂家各写一套判断, + * 等于把适配器的活摊到全平台。 + * + *

顶层 {@code nodeId} 必填(V1.8 起):它必须和 I-18 领取时落库的 + * {@code claimed_node_id} 一致,否则整条回执按 {@code NODE_MISMATCH} 拒收。 + * 注意它的作用边界——它不是身份认证,值由 IoT 自报,同样可以被伪造; + * 证明"调用方是谁"靠的是内网来源白名单 + 签名。这里校验的是领取事实的一致性: + * 这条结果是不是领走这一轮的那个节点回的。 + */ +public class CommandResultRequest { + + private String nodeId; + private List results = new ArrayList<>(); + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public List getResults() { + return results; + } + + public void setResults(List results) { + this.results = results; + } + + public static class ResultItem { + + private String commandId; + + /** ACKED / SENT / TIMEOUT / DEVICE_OFFLINE / FAILED */ + private String result; + + /** 标准码:0 成功 / 1 不支持 / 2 参数非法 / 3 执行失败 / 4 设备忙。IoT 翻译后填 */ + private String resultCode; + + /** 厂家原始结果码,原样字符串,不解释。V1.7 新增,可选 */ + private String rawResultCode; + + private String resultMsg; + + /** 设备回执到达时刻。SENT / DEVICE_OFFLINE / FAILED 时必须为 null */ + private OffsetDateTime ackAt; + + /** 原始回执帧,Base64 或 hex,排障用 */ + private String rawAck; + + /** + * I-18 领取时拿到的 {@code claimGeneration},原样回带。V1.8 起必填。 + * + *

服务端用它当 {@code WHERE claim_generation = ?} 的条件,旧代次的回执更新 0 行、 + * 计入 {@code duplicated}。缺了直接拒({@code CLAIM_GENERATION_REQUIRED})—— + * 不再"拿库里当前代次顶上",那等于让代次校验自己失效。 + */ + private Integer claimGeneration; + + public String getCommandId() { + return commandId; + } + + public void setCommandId(String commandId) { + this.commandId = commandId; + } + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public String getResultCode() { + return resultCode; + } + + public void setResultCode(String resultCode) { + this.resultCode = resultCode; + } + + public String getRawResultCode() { + return rawResultCode; + } + + public void setRawResultCode(String rawResultCode) { + this.rawResultCode = rawResultCode; + } + + public String getResultMsg() { + return resultMsg; + } + + public void setResultMsg(String resultMsg) { + this.resultMsg = resultMsg; + } + + public OffsetDateTime getAckAt() { + return ackAt; + } + + public void setAckAt(OffsetDateTime ackAt) { + this.ackAt = ackAt; + } + + public String getRawAck() { + return rawAck; + } + + public void setRawAck(String rawAck) { + this.rawAck = rawAck; + } + + public Integer getClaimGeneration() { + return claimGeneration; + } + + public void setClaimGeneration(Integer claimGeneration) { + this.claimGeneration = claimGeneration; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/EventBatchRequest.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/EventBatchRequest.java new file mode 100644 index 0000000..20a17e1 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/EventBatchRequest.java @@ -0,0 +1,167 @@ +package cn.aomin.gateway.domain.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * I-01 事件批量上报请求,对应 J-00 V1.6 §1.3 + OpenAPI {@code EventBatchRequest}。 + * + *

批上限 200 条。外层 {@code sourceChannel} 是缺省值,内层优先—— + * 同机多通道部署时有用(M-00 §6.2 同款规则)。 + * + *

墓碑帧不走独立数组:J-00 §1.3 只定义了 {@code events[]}, + * 全库搜 {@code undelivered} 在 OpenAPI 里出现 0 次。 + * 墓碑帧就是一条 {@code eventType=MSG_UNDELIVERED} 的事件, + * 业务层见它就落 {@code event_time=1900-01-01} + {@code is_backfill=1}(J-00 §2.2 第 506 行)。 + */ +public class EventBatchRequest { + + private String sourceChannel; + private List events; + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public List getEvents() { + return events; + } + + public void setEvents(List events) { + this.events = events; + } + + /** + * 单条事件。 + * + *

字段名一律按 J-00 V1.6 的驼峰写法。 + * 不要用早期信封的 {@code device_sn} / {@code event_ts}, + * 那两个名字在 V1.4 起已废止。 + */ + public static class EventItem { + + /** 事件级幂等键,≤96 字符,不得含平台时间戳 */ + private String eventId; + + /** 设备物理标识,必须与 device.sensor_id 完全一致(含厂商前缀) */ + private String sensorId; + + private String sourceChannel; + + /** 枚举值,出现枚举外的新值不拒收 */ + private String eventType; + + /** 设备侧时间,没有就 null。禁止用 receivedAt 顶替 */ + private OffsetDateTime deviceTime; + + private OffsetDateTime receivedAt; + + /** 0 设备原始 / 1 厂家云 / 2 平台接收 / 3 网关打戳。无默认值,漏传直接拒收 */ + private Integer tsSource; + + private String protocolVersion; + + /** 仅 G 线;H/S 线传 null */ + private Long seq; + + /** 事件明细,原样落 iot_event.payload(JSON 列) */ + private JsonNode payload; + + /** 原始报文 MD5,回指 I-12 的 payload_md5;墓碑帧为 null */ + private String rawRef; + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getSensorId() { + return sensorId; + } + + public void setSensorId(String sensorId) { + this.sensorId = sensorId; + } + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public OffsetDateTime getDeviceTime() { + return deviceTime; + } + + public void setDeviceTime(OffsetDateTime deviceTime) { + this.deviceTime = deviceTime; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public Integer getTsSource() { + return tsSource; + } + + public void setTsSource(Integer tsSource) { + this.tsSource = tsSource; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public void setProtocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + } + + public Long getSeq() { + return seq; + } + + public void setSeq(Long seq) { + this.seq = seq; + } + + public JsonNode getPayload() { + return payload; + } + + public void setPayload(JsonNode payload) { + this.payload = payload; + } + + public String getRawRef() { + return rawRef; + } + + public void setRawRef(String rawRef) { + this.rawRef = rawRef; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/IngestResponse.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/IngestResponse.java new file mode 100644 index 0000000..b9315c6 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/IngestResponse.java @@ -0,0 +1,117 @@ +package cn.aomin.gateway.domain.dto; + +import java.util.ArrayList; +import java.util.List; + +/** + * 四计数响应体,对应 J-00 V1.6 §1.7 + OpenAPI {@code IngestResponse}。 + * + *

这四个数不是互斥的四类,是重叠的(见 M-00 §2.4): + *

+ *   total = accepted + rejected
+ *   accepted ⊇ duplicated
+ *   accepted ⊇ unregistered
+ * 
+ * 例:200 条里 2 条格式错、3 条重复、5 条未建档 → + * {@code accepted=198, rejected=2, duplicated=3, unregistered=5}。 + * + *

{@code accepted++} 必须在"通过校验"时做,不能在 INSERT 成功后做—— + * 后者会让 {@code accepted + rejected != total},四个数全乱。 + */ +public class IngestResponse { + + private int accepted; + private int rejected; + private int duplicated; + private int unregistered; + private List rejectedDetail = new ArrayList<>(); + + public static IngestResponse of(int accepted, int rejected, int duplicated, int unregistered) { + IngestResponse r = new IngestResponse(); + r.accepted = accepted; + r.rejected = rejected; + r.duplicated = duplicated; + r.unregistered = unregistered; + return r; + } + + public void addRejected(String eventId, String reason) { + this.rejectedDetail.add(new RejectedDetail(eventId, reason)); + } + + public int getAccepted() { + return accepted; + } + + public void setAccepted(int accepted) { + this.accepted = accepted; + } + + public int getRejected() { + return rejected; + } + + public void setRejected(int rejected) { + this.rejected = rejected; + } + + public int getDuplicated() { + return duplicated; + } + + public void setDuplicated(int duplicated) { + this.duplicated = duplicated; + } + + public int getUnregistered() { + return unregistered; + } + + public void setUnregistered(int unregistered) { + this.unregistered = unregistered; + } + + public List getRejectedDetail() { + return rejectedDetail; + } + + public void setRejectedDetail(List rejectedDetail) { + this.rejectedDetail = rejectedDetail; + } + + /** + * 拒收明细。 + * + *

{@code reason} 只用 J-00 §1.7 原样那四个值,不得自造: + * {@code RATE_LIMITED} / {@code SCHEMA_INVALID} / {@code UNKNOWN_CHANNEL} / {@code SENSOR_ID_INVALID}。 + * 自造词会让 IoT 侧的统计对不上。 + */ + public static class RejectedDetail { + private String eventId; + private String reason; + + public RejectedDetail() { + } + + public RejectedDetail(String eventId, String reason) { + this.eventId = eventId; + this.reason = reason; + } + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/PendingCommandResponse.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/PendingCommandResponse.java new file mode 100644 index 0000000..1ad4170 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/PendingCommandResponse.java @@ -0,0 +1,174 @@ +package cn.aomin.gateway.domain.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * I-18 长轮询响应,对应 J-00 V1.6 §3.2。 + * + *

字段名与 J-00 完全一致:{@code commandId} / {@code gatewayId} / {@code targetId}。 + * 不要用 {@code devId} / {@code gatewayDevId} / {@code commandNo}—— + * 那是材料包 B6 写错的名字,会让 IoT 侧查不到通道,全部报 DEVICE_OFFLINE。 + */ +public class PendingCommandResponse { + + private String nodeId; + private List commands = new ArrayList<>(); + private boolean hasMore; + + public static PendingCommandResponse empty(String nodeId) { + PendingCommandResponse r = new PendingCommandResponse(); + r.nodeId = nodeId; + return r; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public List getCommands() { + return commands; + } + + public void setCommands(List commands) { + this.commands = commands; + } + + public boolean isHasMore() { + return hasMore; + } + + public void setHasMore(boolean hasMore) { + this.hasMore = hasMore; + } + + public static class PendingCommand { + + /** 命令号,字符串。**不是数字**,数字会让前导零丢失 */ + private String commandId; + + /** + * 这条命令从哪个端点进去——不是网关 SN。 + * 独立设备填设备自身标识,H 线填厂家云节点标识。 + */ + private String gatewayId; + + /** 真正的目标设备 */ + private String targetId; + + private String cmdType; + + private String deliveryMode; + + /** 命令参数,原样透传 */ + private JsonNode params; + + /** 租约到期时刻,IoT 侧靠它算什么时候该重来 */ + private OffsetDateTime leaseExpireAt; + + /** 命令整体过期时刻(创建 + 72h),到点无论走到哪步都作废 */ + private OffsetDateTime expireAt; + + private int retryCount; + + /** + * 本次领取的代次(V1.7.2 新增)。 + * + *

IoT 必须原样保存,并在 I-05 回执里回带。原因:一条命令可能被领走两次 + * (第一次租约 60 秒到期没回话,被回收重派)。此时第一个节点的回执迟到, + * 如果只按命令号落库,这条旧回执会把第二次的流转覆盖掉,而且不报错。 + * + *

代次只在服务端 { CLAIMED } 时 +1,所以"代次对得上"就等于"这条回执 + * 确实来自最近一次领取"。不回带也不会被拒(兼容旧 IoT),但服务端只能退化成 + * "拿库里的当前值自比",等于没校验——排障时分不清是谁的回执。 + */ + private int claimGeneration; + + public String getCommandId() { + return commandId; + } + + public void setCommandId(String commandId) { + this.commandId = commandId; + } + + public String getGatewayId() { + return gatewayId; + } + + public void setGatewayId(String gatewayId) { + this.gatewayId = gatewayId; + } + + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public String getCmdType() { + return cmdType; + } + + public void setCmdType(String cmdType) { + this.cmdType = cmdType; + } + + public String getDeliveryMode() { + return deliveryMode; + } + + public void setDeliveryMode(String deliveryMode) { + this.deliveryMode = deliveryMode; + } + + public JsonNode getParams() { + return params; + } + + public void setParams(JsonNode params) { + this.params = params; + } + + public OffsetDateTime getLeaseExpireAt() { + return leaseExpireAt; + } + + public void setLeaseExpireAt(OffsetDateTime leaseExpireAt) { + this.leaseExpireAt = leaseExpireAt; + } + + public OffsetDateTime getExpireAt() { + return expireAt; + } + + public void setExpireAt(OffsetDateTime expireAt) { + this.expireAt = expireAt; + } + + public int getRetryCount() { + return retryCount; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public int getClaimGeneration() { + return claimGeneration; + } + + public void setClaimGeneration(int claimGeneration) { + this.claimGeneration = claimGeneration; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/RawFrameBatchRequest.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/RawFrameBatchRequest.java new file mode 100644 index 0000000..973681b --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/RawFrameBatchRequest.java @@ -0,0 +1,132 @@ +package cn.aomin.gateway.domain.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * I-12 原始报文批量上报,对应 J-00 V1.6 §1.9 + OpenAPI {@code RawFrameBatchRequest}。 + * + *

这是全集的口子,不是 I-01 的附件。所有上行帧都从这里进一次, + * 其中"能变成业务事件"的那部分另由 I-01 再报一次。 + * 同一帧走两条链路不是重复——一条存档,一条产生业务后果。 + * + *

批上限 200 条。 + */ +public class RawFrameBatchRequest { + + private String sourceChannel; + private List frames; + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public List getFrames() { + return frames; + } + + public void setFrames(List frames) { + this.frames = frames; + } + + public static class RawFrameItem { + + /** MD5(原始帧字节),32 位小写 hex。它就是幂等键 */ + private String rawRef; + + /** + * 解析出设备标识时带上。未建档设备也要带——有值才追得回是哪个 SN。 + * 实在解析不出可留空,原文照样落库。 + */ + private String sensorId; + + private String sourceChannel; + + /** 平台收到这一帧的时刻。不是设备时间 */ + private OffsetDateTime receivedAt; + + /** + * Base64,定死这一种表示。 + * 十六进制会把体积翻倍,而 200 条/批本来就是按字节数撑上限。 + * {@code rawRef} 对解码后的字节算——两种表示算出来一样, + * 所以"写 hex 或 Base64 等于没定"。 + */ + private String rawFrame; + + /** 解码后的字节数。用于自检——对不上说明 Base64 编码有问题 */ + private Integer frameBytes; + + /** 接入端点标识(网关 SN 或独立设备自身标识) */ + private String gatewayDevId; + + private String protocolVersion; + + public String getRawRef() { + return rawRef; + } + + public void setRawRef(String rawRef) { + this.rawRef = rawRef; + } + + public String getSensorId() { + return sensorId; + } + + public void setSensorId(String sensorId) { + this.sensorId = sensorId; + } + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public OffsetDateTime getReceivedAt() { + return receivedAt; + } + + public void setReceivedAt(OffsetDateTime receivedAt) { + this.receivedAt = receivedAt; + } + + public String getRawFrame() { + return rawFrame; + } + + public void setRawFrame(String rawFrame) { + this.rawFrame = rawFrame; + } + + public Integer getFrameBytes() { + return frameBytes; + } + + public void setFrameBytes(Integer frameBytes) { + this.frameBytes = frameBytes; + } + + public String getGatewayDevId() { + return gatewayDevId; + } + + public void setGatewayDevId(String gatewayDevId) { + this.gatewayDevId = gatewayDevId; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public void setProtocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/StatusBatchRequest.java b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/StatusBatchRequest.java new file mode 100644 index 0000000..7307d7d --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/StatusBatchRequest.java @@ -0,0 +1,144 @@ +package cn.aomin.gateway.domain.dto; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * I-02 设备状态快照批量上报,对应 J-00 V1.6 §1.8 + OpenAPI {@code StatusBatchRequest}。 + * + *

批上限 500 条。这份数据不产生任何业务后果,只落 {@code device_tech_cache} + * 做界面展示与离线判定的原料(M-00 §5)。 + */ +public class StatusBatchRequest { + + private String sourceChannel; + private List statuses; + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public List getStatuses() { + return statuses; + } + + public void setStatuses(List statuses) { + this.statuses = statuses; + } + + public static class StatusItem { + + private String sensorId; + private String sourceChannel; + + /** + * 只表示"最近是否有报文",不表示设备好坏。 + * M-03 只在 true 时写 ONLINE,false 不改状态(M-00 §5.4)。 + */ + private Boolean online; + + /** 最后一次收到有效报文的时刻 */ + private OffsetDateTime lastSeenAt; + + /** 毫伏整数。不换算成百分比——换算式会变,换算在业务层 */ + private Integer batteryMv; + + /** 0-31,99 = 未知。99 不是弱信号 */ + private Integer csq; + + /** 仅 G 线 */ + private Long lastSeq; + + /** 仅 G 线,0-255 饱和 */ + private Integer lostCnt; + + private String protocolVersion; + + /** 快照生成时刻。幂等键要用它 */ + private OffsetDateTime reportedAt; + + public String getSensorId() { + return sensorId; + } + + public void setSensorId(String sensorId) { + this.sensorId = sensorId; + } + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public Boolean getOnline() { + return online; + } + + public void setOnline(Boolean online) { + this.online = online; + } + + public OffsetDateTime getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(OffsetDateTime lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } + + public Integer getBatteryMv() { + return batteryMv; + } + + public void setBatteryMv(Integer batteryMv) { + this.batteryMv = batteryMv; + } + + public Integer getCsq() { + return csq; + } + + public void setCsq(Integer csq) { + this.csq = csq; + } + + public Long getLastSeq() { + return lastSeq; + } + + public void setLastSeq(Long lastSeq) { + this.lastSeq = lastSeq; + } + + public Integer getLostCnt() { + return lostCnt; + } + + public void setLostCnt(Integer lostCnt) { + this.lostCnt = lostCnt; + } + + public String getProtocolVersion() { + return protocolVersion; + } + + public void setProtocolVersion(String protocolVersion) { + this.protocolVersion = protocolVersion; + } + + public OffsetDateTime getReportedAt() { + return reportedAt; + } + + public void setReportedAt(OffsetDateTime reportedAt) { + this.reportedAt = reportedAt; + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/AuthInterceptor.java b/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..f75c0f7 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/AuthInterceptor.java @@ -0,0 +1,136 @@ +package cn.aomin.gateway.interceptor; + +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.config.GatewayProperties; +import cn.aomin.gateway.security.CachedBodyFilter; +import cn.aomin.gateway.security.NonceStore; +import cn.aomin.gateway.security.SignatureVerifier; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * M-01-U1 公共拦截层,五个 {@code /internal} 接口共用。 + * + *

只做四件事,别的都不要往这里塞: + *

    + *
  1. Token:{@code X-Internal-Token},常量时间比对;
  2. + *
  3. 时间戳:{@code X-Timestamp} 与服务器时间差 ≤ 300 秒;
  4. + *
  5. 防重放:{@code X-Nonce} 在 Redis 里 600 秒内不可复用;
  6. + *
  7. 签名:{@code X-Sign = HMAC-SHA256(token, method\npath\ntimestamp\nnonce\nsha256(body))}。
  8. + *
+ * + *

限流和幂等不在这里做: + *

    + *
  • 限流要知道"本批多少条"和"哪个通道",那是解析之后的事,放在 Service; + * 通道维度一次算一批,设备维度走 pipeline 批量判。
  • + *
  • 幂等要包住整个业务处理并在成功后写回结果,拦截器拿不到响应体,放在 Service 更直接 + * (见 {@link cn.aomin.gateway.security.IdempotentExecutor})。
  • + *
+ * + *

顺序是有讲究的:Token(最便宜)→ 时间戳(一次减法)→ nonce(一次 Redis) + * → 签名(要算 HMAC,最贵)。前面挡掉的请求不用付后面的代价。 + * + *

{@code gateway.auth.enabled=false} 时全部跳过,只打日志—— + * 联调初期 IoT 侧还没接签名,开了就是一个请求都进不来。 + */ +@Component +public class AuthInterceptor implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class); + + public static final String H_TOKEN = "X-Internal-Token"; + public static final String H_SIGN = "X-Sign"; + public static final String H_TIMESTAMP = "X-Timestamp"; + public static final String H_NONCE = "X-Nonce"; + public static final String H_IDEMPOTENCY = "X-Idempotency-Key"; + + private final GatewayProperties props; + private final SignatureVerifier signatureVerifier; + private final NonceStore nonceStore; + + public AuthInterceptor(GatewayProperties props, + SignatureVerifier signatureVerifier, + NonceStore nonceStore) { + this.props = props; + this.signatureVerifier = signatureVerifier; + this.nonceStore = nonceStore; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String token = request.getHeader(H_TOKEN); + String sign = request.getHeader(H_SIGN); + String ts = request.getHeader(H_TIMESTAMP); + String nonce = request.getHeader(H_NONCE); + + if (!props.getAuth().isEnabled()) { + log.debug("M-01 鉴权未启用 {} {} token={} idem={}", + request.getMethod(), request.getRequestURI(), + token == null ? "-" : "有", + request.getHeader(H_IDEMPOTENCY)); + return true; + } + + // ① 四个头缺任一 → 401。 + // 不要分别报"缺签名""缺时间戳"——对接方看到五条不同错误反而不知道全貌。 + if (token == null || sign == null || ts == null || nonce == null) { + throw BizException.unauthorized( + "缺少鉴权头:需要 " + H_TOKEN + " / " + H_SIGN + " / " + H_TIMESTAMP + " / " + H_NONCE); + } + + // ② Token:常量时间比对,避免按字节短路泄漏前缀 + if (!MessageDigest.isEqual( + token.getBytes(StandardCharsets.UTF_8), + props.getAuth().getToken().getBytes(StandardCharsets.UTF_8))) { + throw BizException.unauthorized("Token 不匹配"); + } + + // ③ 时间戳窗口 + long now = System.currentTimeMillis() / 1000; + long skew; + try { + skew = Math.abs(now - Long.parseLong(ts)); + } catch (NumberFormatException e) { + throw BizException.unauthorized("X-Timestamp 必须是秒级 Unix 时间戳"); + } + if (skew > props.getAuth().getMaxSkewSeconds()) { + throw BizException.unauthorized( + "时间戳超出窗口(偏差 " + skew + " 秒,允许 " + props.getAuth().getMaxSkewSeconds() + " 秒)"); + } + + // ④ nonce 防重放。Redis 挂了 fail-closed —— 这是安全能力 + try { + if (!nonceStore.tryUse(token, nonce)) { + throw BizException.unauthorized("nonce 已被使用(重放)"); + } + } catch (BizException e) { + throw e; + } catch (Exception e) { + nonceStore.logUnavailable(e); + throw BizException.unauthorized("防重放服务不可用,请求暂不处理"); + } + + // ⑤ 验签。body 由 CachedBodyFilter 提前缓存,读不到就是 0 字节(GET 场景) + byte[] body = (byte[]) request.getAttribute(CachedBodyFilter.BODY_ATTR); + String path = request.getRequestURI(); + // path 含 query:J-00 §1.2 第 2 条,按发送时的原始路径算 + if (request.getQueryString() != null) { + path = path + "?" + request.getQueryString(); + } + String expected = signatureVerifier.sign(request.getMethod(), path, ts, nonce, body); + + if (!signatureVerifier.verify(expected, sign)) { + log.warn("验签失败 {} {},期望 {} 实际 {}", request.getMethod(), path, expected, sign); + throw BizException.unauthorized("签名不匹配"); + } + + return true; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/InternalNetworkGuard.java b/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/InternalNetworkGuard.java new file mode 100644 index 0000000..ea3700e --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/InternalNetworkGuard.java @@ -0,0 +1,196 @@ +package cn.aomin.gateway.interceptor; + +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.config.GatewayProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +/** + * 来源网络闸:{@code /internal/**} 只接受内网调用。 + * + *

为什么单独有这一层:{@code gateway.auth.enabled} 默认 false(联调态不验签), + * 这时 {@link AuthInterceptor} 全部跳过,任何能连到 8080 的人都能调 I-01 伪造事件。 + * 文档写的是"仅内网",那就得有代码兜住这句话,不能只靠部署时记得配防火墙。 + * 这一层和签名校验是两件独立的事:签名关掉时它照样生效。 + * + *

只认 {@code getRemoteAddr()},不认 {@code X-Forwarded-For}。 + * 后者是请求头,调用方随便写;按它判断等于把门开着还挂个牌子。 + * 部署在 Nginx 后面时,{@code remoteAddr} 是 Nginx 所在机器(本机或内网),仍在白名单内—— + * 真正的公网边界由 Nginx 那层做,这里兜的是"绕过 Nginx 直连 8080"。 + * + *

默认允许环回 + 三段 RFC1918 私网,覆盖本机自测、局域网演示、同机 Nginx 反代三种场景。 + * 需要更严(例如只放行某一台 IoT 服务器)就配 {@code gateway.auth.allowed-cidrs}, + * 配了之后以配置为准,内置默认值不再参与。 + * + *

要临时放开(现场演示机在公网 IP 上、没有反代)可以配 + * {@code gateway.auth.network-guard-enabled: false},但那样就等于承认"仅内网"没落地, + * 交付时必须说明。 + */ +@Component +public class InternalNetworkGuard implements HandlerInterceptor { + + private static final Logger log = LoggerFactory.getLogger(InternalNetworkGuard.class); + + private final GatewayProperties props; + + /** 解析后的白名单;配置为空时用内置默认值 */ + private final List cidrs; + + public InternalNetworkGuard(GatewayProperties props) { + this.props = props; + this.cidrs = parse(props.getAuth().getAllowedCidrs()); + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!props.getAuth().isNetworkGuardEnabled()) { + return true; + } + String remote = clientIp(request); + if (remote == null) { + // 拿不到地址就按不放行处理。Nginx 反代时可能是 unix socket, + // 那种情况把 network-guard-enabled 关掉或显式把反代地址配进白名单。 + log.warn("M-01 拿不到来源地址,拒绝 {} {}", request.getMethod(), request.getRequestURI()); + throw BizException.forbidden("无法识别来源地址,/internal 接口仅接受内网调用"); + } + if (!allowed(remote)) { + log.warn("M-01 来源不在内网白名单,拒绝 {} {} from={}", + request.getMethod(), request.getRequestURI(), remote); + throw BizException.forbidden( + "/internal 接口仅接受内网调用,来源 " + remote + " 不在白名单内"); + } + return true; + } + + /** + * 取真实对端地址。IPv6 环回 {@code ::1} 与 IPv4-mapped {@code ::ffff:127.0.0.1} 都归一成 IPv4 形式, + * 免得同一台机器换个栈就进不来。 + */ + static String clientIp(HttpServletRequest request) { + String addr = request.getRemoteAddr(); + if (addr == null || addr.isBlank()) { + return null; + } + if (addr.startsWith("/")) { + // 某些容器把 remoteAddr 写成 "/127.0.0.1" + addr = addr.substring(1); + } + if (addr.startsWith("::ffff:") && addr.indexOf('.') > 0) { + return addr.substring(7); + } + return addr; + } + + boolean allowed(String ip) { + byte[] raw = toBytes(ip); + if (raw == null) { + return false; + } + for (Cidr c : cidrs) { + if (c.contains(raw)) { + return true; + } + } + return false; + } + + // ---------------- 白名单解析 ---------------- + + private static List parse(List configured) { + List source = new ArrayList<>(); + if (configured == null || configured.isEmpty()) { + // 内置默认:环回 + 三段私网。IPv6 只放环回——内网 IPv6 段各家不一致,配了才放。 + source.add("127.0.0.0/8"); + source.add("::1/128"); + source.add("10.0.0.0/8"); + source.add("172.16.0.0/12"); + source.add("192.168.0.0/16"); + } else { + source.addAll(configured); + } + List out = new ArrayList<>(); + for (String s : source) { + if (s == null || s.isBlank()) { + continue; + } + try { + out.add(Cidr.of(s.trim())); + } catch (RuntimeException e) { + // 配错了不能静默放过——那等于白名单失效。启动期直接抛,配置错误立刻可见。 + throw new IllegalStateException("gateway.auth.allowed-cidrs 配置非法:" + s, e); + } + } + if (out.isEmpty()) { + throw new IllegalStateException("gateway.auth.allowed-cidrs 解析后为空,/internal 将无人可调"); + } + return out; + } + + /** + * 把 IPv4/IPv6 字面量转成定长字节。 + * + *

先正则后解析:{@code InetAddress.getByName("some-host")} 会真的去查 DNS, + * 而白名单里写域名解析成什么取决于当时的网络环境,等于把判断交给外部因素。 + * 所以只接受数字地址,不是字面量就直接判不匹配。 + */ + private static byte[] toBytes(String ip) { + if (ip == null || ip.isBlank()) { + return null; + } + boolean v4 = ip.matches("\\d{1,3}(\\.\\d{1,3}){3}"); + boolean v6 = ip.contains(":") && ip.matches("[0-9a-fA-F:.]+"); + if (!v4 && !v6) { + return null; + } + try { + return InetAddress.getByName(ip).getAddress(); + } catch (Exception e) { + return null; + } + } + + /** 一个网段。v4 按 4 字节比,v6 按 16 字节比,长度不一致直接不匹配 */ + record Cidr(byte[] network, int prefix) { + + static Cidr of(String text) { + int slash = text.indexOf('/'); + String addr = slash < 0 ? text : text.substring(0, slash); + byte[] net = toBytes(addr); + if (net == null) { + throw new IllegalArgumentException("不是合法地址"); + } + int max = net.length * 8; + int p = slash < 0 ? max : Integer.parseInt(text.substring(slash + 1)); + if (p < 0 || p > max) { + throw new IllegalArgumentException("前缀长度越界(" + net.length * 8 + " 位地址最多 /" + max + ")"); + } + return new Cidr(net, p); + } + + boolean contains(byte[] ip) { + if (ip.length != network.length) { + return false; + } + int full = prefix / 8; + for (int i = 0; i < full; i++) { + if (ip[i] != network[i]) { + return false; + } + } + int rest = prefix % 8; + if (rest == 0) { + return true; + } + int mask = (0xFF << (8 - rest)) & 0xFF; + return (ip[full] & mask) == (network[full] & mask); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/CachedBodyFilter.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/CachedBodyFilter.java new file mode 100644 index 0000000..ccca6dc --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/CachedBodyFilter.java @@ -0,0 +1,108 @@ +package cn.aomin.gateway.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 把请求 body 缓存下来,让验签能读到它。 + * + *

为什么必须有这一层:{@code HttpServletRequest} 的输入流只能读一次。 + * 验签要算 {@code sha256(body)},读完之后 Spring 再去反序列化 JSON 就拿到空流, + * 表现为"验签通过但参数全是 null",非常难查。 + * + *

放在拦截器之前({@code @Order} 最小),因为拦截器执行时 body 已经要被消费了。 + * + *

只包 {@code /internal/} 路径:{@code /health} 不需要验签,缓存它纯属浪费内存。 + */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class CachedBodyFilter extends OncePerRequestFilter { + + /** + * 缓存 body 的 attribute 名。 + * + *

拦截器通过 {@code request.getAttribute(BODY_ATTR)} 取,不要去强转 Wrapper—— + * 中间只要再插一个 Filter,强转就会 ClassCastException。 + */ + public static final String BODY_ATTR = "cn.aomin.gateway.cachedBody"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + + String path = request.getRequestURI(); + if (!path.startsWith("/internal/")) { + chain.doFilter(request, response); + return; + } + + CachedBodyRequest wrapped = new CachedBodyRequest(request); + // 存进 attribute 让拦截器取;Wrapper 的 setAttribute 会委托给原 request,所以这里设一次就够 + wrapped.setAttribute(BODY_ATTR, wrapped.body()); + chain.doFilter(wrapped, response); + } + + /** 只覆盖真正有用的两个方法:{@code getInputStream} 和 {@code getReader} */ + static class CachedBodyRequest extends jakarta.servlet.http.HttpServletRequestWrapper { + + private final byte[] body; + + CachedBodyRequest(HttpServletRequest request) throws IOException { + super(request); + // 这里必须读完——后面的调用方(验签、Jackson)都用这份缓存 + this.body = request.getInputStream().readAllBytes(); + } + + byte[] body() { + return body; + } + + @Override + public jakarta.servlet.ServletInputStream getInputStream() { + java.io.ByteArrayInputStream in = new java.io.ByteArrayInputStream(body); + return new jakarta.servlet.ServletInputStream() { + @Override + public int read() { + return in.read(); + } + + @Override + public int read(byte[] b, int off, int len) { + return in.read(b, off, len); + } + + @Override + public boolean isFinished() { + return in.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener listener) { + // 同步读取,不需要异步回调 + } + }; + } + + @Override + public java.io.BufferedReader getReader() { + return new java.io.BufferedReader( + new java.io.InputStreamReader( + new java.io.ByteArrayInputStream(body), + java.nio.charset.StandardCharsets.UTF_8)); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotencyStore.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotencyStore.java new file mode 100644 index 0000000..5d5ad5b --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotencyStore.java @@ -0,0 +1,113 @@ +package cn.aomin.gateway.security; + +import cn.aomin.gateway.config.GatewayProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * 幂等键,实现 J-00 §1.2「24 小时内重复请求直接返回首次结果」。 + * + *

四种结果,不是两种(M-00 §3.5): + * + * + * + * + * + * + *
状态什么情况怎么回
{@link State#NEW}这个 key 第一次见正常处理
{@link State#REPLAY}同 key 同内容,且已处理完返回首次结果,不再落库
{@link State#IN_PROGRESS}同 key 同内容,上一条还在处理409,让 IoT 稍后重试
{@link State#CONFLICT}同 key 但内容不同409,这是 IoT 侧的 bug
+ * + *

存的值格式:{@code bodyHash|responseJson};处理中只有 {@code PROC|bodyHash}。 + * 带上 bodyHash 才能区分"重推同一批"(可以返回缓存)和"复用了 key 的另一个批次"(必须拒)。 + * + *

Redis 挂了 fail-open:放行让业务继续跑。代价是可能重复入库, + * 但 {@code iot_event} 的唯一键会挡住,最坏结果是 {@code duplicated} 计数偏高,不会写脏数据。 + */ +@Component +public class IdempotencyStore { + + private static final Logger log = LoggerFactory.getLogger(IdempotencyStore.class); + + private static final String PROC_PREFIX = "PROC|"; + private static final long PROC_TTL_SECONDS = 120; + + public enum State {NEW, REPLAY, IN_PROGRESS, CONFLICT} + + /** + * @param state 判定结果 + * @param cachedResponse 仅 {@link State#REPLAY} 有值,是首次处理的响应体 + */ + public record Decision(State state, String cachedResponse) { + public static Decision of(State s) { + return new Decision(s, null); + } + } + + private final StringRedisTemplate redis; + private final GatewayProperties props; + + public IdempotencyStore(StringRedisTemplate redis, GatewayProperties props) { + this.redis = redis; + this.props = props; + } + + public Decision begin(String idemKey, String bodyHash) { + String key = "iot:idem:" + idemKey; + try { + Boolean ok = redis.opsForValue() + .setIfAbsent(key, PROC_PREFIX + bodyHash, Duration.ofSeconds(PROC_TTL_SECONDS)); + if (Boolean.TRUE.equals(ok)) { + return Decision.of(State.NEW); + } + + String v = redis.opsForValue().get(key); + if (v == null) { + // 刚好在 SETNX 与 GET 之间过期了,当成新的处理 + return Decision.of(State.NEW); + } + if (v.startsWith(PROC_PREFIX)) { + String h = v.substring(PROC_PREFIX.length()); + return Decision.of(h.equals(bodyHash) ? State.IN_PROGRESS : State.CONFLICT); + } + + int sep = v.indexOf('|'); + if (sep < 0) { + return Decision.of(State.CONFLICT); + } + String h = v.substring(0, sep); + String json = v.substring(sep + 1); + return h.equals(bodyHash) + ? new Decision(State.REPLAY, json) + : Decision.of(State.CONFLICT); + + } catch (DataAccessException e) { + log.warn("Redis 不可用,幂等判定放行(fail-open),靠数据库唯一键兜底:{}", e.getMessage()); + return Decision.of(State.NEW); + } + } + + /** 处理成功后写入结果,供 24 小时内的重推直接返回 */ + public void complete(String idemKey, String bodyHash, String responseJson) { + try { + redis.opsForValue().set( + "iot:idem:" + idemKey, + bodyHash + "|" + responseJson, + Duration.ofHours(props.getIdempotency().getTtlHours())); + } catch (DataAccessException e) { + log.warn("幂等结果写入失败,重推会重新处理一遍(唯一键仍能挡):{}", e.getMessage()); + } + } + + /** 处理失败时释放,允许 IoT 重试——不释放的话对方要等 120 秒才能重发 */ + public void release(String idemKey) { + try { + redis.delete("iot:idem:" + idemKey); + } catch (DataAccessException e) { + log.warn("幂等键释放失败,{} 秒后自动过期:{}", PROC_TTL_SECONDS, e.getMessage()); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotentExecutor.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotentExecutor.java new file mode 100644 index 0000000..6d22b0b --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotentExecutor.java @@ -0,0 +1,81 @@ +package cn.aomin.gateway.security; + +import cn.aomin.gateway.common.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.function.Supplier; + +/** + * 把幂等判定包在业务处理外面,五个接口共用。 + * + *

为什么不放在拦截器:幂等要"处理成功后写回结果",而拦截器拿不到响应体。 + * 放在这里,调用方只要把整段处理塞进 {@link #execute} 就行。 + * + *

为什么必须显式传 body 而不是让调用方自己算 hash: + * hash 一定是"原始请求字节"的 sha256。如果有人用反序列化后再序列化的字符串算, + * 字段顺序、空格、空值处理一变,同一个请求的 hash 就不一样了,重推会被当成新批次。 + */ +@Component +public class IdempotentExecutor { + + private static final Logger log = LoggerFactory.getLogger(IdempotentExecutor.class); + + private final IdempotencyStore store; + private final ObjectMapper objectMapper; + + public IdempotentExecutor(IdempotencyStore store, ObjectMapper objectMapper) { + this.store = store; + this.objectMapper = objectMapper; + } + + /** + * @param idemKey 请求头 {@code X-Idempotency-Key},为 null/空时直接执行业务(GET 就是这种) + * @param body 原始请求体,用来算"内容是否相同" + * @param type 响应类型,命中缓存时反序列化用 + * @param action 真正的业务处理 + */ + public T execute(String idemKey, byte[] body, Class type, Supplier action) { + if (idemKey == null || idemKey.isBlank()) { + return action.get(); + } + + String bodyHash = SignatureVerifier.sha256Hex(body == null ? new byte[0] : body); + IdempotencyStore.Decision decision = store.begin(idemKey, bodyHash); + + switch (decision.state()) { + case REPLAY -> { + // 同 key 同内容,24 小时内处理过。直接返回上次的结果,不再落库。 + log.debug("幂等命中,直接返回首次结果:{}", idemKey); + try { + return objectMapper.readValue(decision.cachedResponse(), type); + } catch (Exception e) { + // 缓存损坏比重复入库好处理:放行让业务重跑一遍,唯一键会挡住重复 + log.warn("幂等缓存无法反序列化,改为重新处理:{}", e.getMessage()); + return action.get(); + } + } + case IN_PROGRESS -> throw BizException.conflict( + "同一批次正在处理中(幂等键 " + idemKey + "),请稍后重试"); + case CONFLICT -> throw BizException.conflict( + "幂等键 " + idemKey + " 已被另一个不同内容的请求占用"); + case NEW -> { + try { + T result = action.get(); + store.complete(idemKey, bodyHash, objectMapper.writeValueAsString(result)); + return result; + } catch (RuntimeException e) { + // 处理失败要释放,否则 IoT 要等 120 秒才能重发同一批 + store.release(idemKey); + throw e; + } catch (Exception e) { + store.release(idemKey); + throw new IllegalStateException("幂等结果序列化失败", e); + } + } + default -> throw new IllegalStateException("未知幂等状态"); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/NonceStore.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/NonceStore.java new file mode 100644 index 0000000..66d7d94 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/NonceStore.java @@ -0,0 +1,55 @@ +package cn.aomin.gateway.security; + +import cn.aomin.gateway.config.GatewayProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +/** + * nonce 防重放:同一个 nonce 在有效期内只能用一次。 + * + *

key 里必须带 token 前缀(M-00 §3.4): + * 换 token 的时候如果只按 nonce 记,新旧 token 的 nonce 空间会互相污染—— + * 新 token 发一个别人用过的 nonce 就被误判重放。 + * + *

Redis 挂了 fail-closed(拒绝),不是 fail-open: + * 这是安全能力,宁可不收数据也不能放行可能被重放的请求。M-00 §3.7。 + */ +@Component +public class NonceStore { + + private static final Logger log = LoggerFactory.getLogger(NonceStore.class); + + private final StringRedisTemplate redis; + private final GatewayProperties props; + + public NonceStore(StringRedisTemplate redis, GatewayProperties props) { + this.redis = redis; + this.props = props; + } + + /** + * @return true 表示这个 nonce 是新的、可以用;false 表示重放 + * @throws cn.aomin.gateway.common.BizException 由调用方在 Redis 异常时抛 401 + */ + public boolean tryUse(String token, String nonce) { + String key = buildKey(token, nonce); + long ttl = props.getAuth().getNonceTtlSeconds(); + Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(ttl)); + return Boolean.TRUE.equals(ok); + } + + private String buildKey(String token, String nonce) { + // 只取 token 的摘要进 key:token 本身不该出现在 Redis 的 key 空间里(可能被 dump 出来) + String tokenTag = SignatureVerifier.sha256Hex( + token.getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16); + return "iot:nonce:" + tokenTag + ":" + nonce; + } + + public void logUnavailable(Exception e) { + log.error("Redis 不可用,nonce 校验无法进行(fail-closed,请求被拒)", e); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/RateLimiter.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/RateLimiter.java new file mode 100644 index 0000000..360591e --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/RateLimiter.java @@ -0,0 +1,123 @@ +package cn.aomin.gateway.security; + +import cn.aomin.gateway.config.GatewayProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * 限流,两个维度两把锁(M-00 §3.9)。 + * + * + * + * + * + * + * + *
维度阈值在哪做为什么
通道5000 / 分钟拦截层,一次请求一次防止整条链路被打满,不需要解析 body 就能判
设备60 / 分钟Service 层,解析后批量防止单台设备异常刷;批量场景用 pipeline,否则 200 次往返比入库还慢
+ * + *

固定分钟窗,不是滑动窗:实现简单、一次 INCR 就够。 + * 代价是窗口边界可能放过去两倍流量,但这里是防异常刷不是计费,可以接受。 + * + *

Redis 挂了 fail-open(放行):限流是可用性能力不是安全能力, + * 挂了还拒绝等于自己把业务停了。M-00 §3.7。 + */ +@Component +public class RateLimiter { + + private static final Logger log = LoggerFactory.getLogger(RateLimiter.class); + + private static final DateTimeFormatter MINUTE = DateTimeFormatter.ofPattern("yyyyMMddHHmm"); + + private final StringRedisTemplate redis; + private final GatewayProperties props; + + public RateLimiter(StringRedisTemplate redis, GatewayProperties props) { + this.redis = redis; + this.props = props; + } + + /** + * 通道维度:一次请求算一批。 + * + * @param channel 来源通道(含批级缺省值解析结果,未知时传接口名) + * @param delta 本批条数 + * @return true 放行 + */ + public boolean acquireChannel(String channel, int delta) { + String key = "iot:rl:ch:" + channel + ":" + LocalDateTime.now().format(MINUTE); + try { + Long n = redis.opsForValue().increment(key, delta); + if (n != null && n == delta) { + // 第一次写入才设过期,避免每个请求都续期导致 key 永不过期 + redis.expire(key, Duration.ofSeconds(120)); + } + return n == null || n <= props.getRateLimit().getPerChannelPerMinute(); + } catch (DataAccessException e) { + log.warn("Redis 不可用,通道限流放行(fail-open):{}", e.getMessage()); + return true; + } + } + + /** + * 设备维度:批量判,只返回超限的那些 sensorId。 + * + *

用 pipeline 一次发完,不逐条往返——200 条设备逐个 INCR 就是 200 次 RTT, + * 比入库本身(96ms)还慢,限流反而成了瓶颈。 + * + * @return 空的 Set 表示全部放行 + */ + public Set exceededDevices(Collection sensorIds) { + if (sensorIds.isEmpty()) { + return Set.of(); + } + String minute = LocalDateTime.now().format(MINUTE); + int limit = props.getRateLimit().getPerDevicePerMinute(); + + List keys = new ArrayList<>(sensorIds.size()); + for (String id : sensorIds) { + keys.add("iot:rl:dev:" + id + ":" + minute); + } + + try { + List results = redis.executePipelined(new SessionCallback() { + @Override + @SuppressWarnings("unchecked") + public Object execute(RedisOperations operations) throws DataAccessException { + for (String k : keys) { + operations.opsForValue().increment(k, 1); + operations.expire(k, Duration.ofSeconds(120)); + } + return null; + } + }); + + Set exceeded = new HashSet<>(); + int idx = 0; + // pipeline 里每个 key 有两条命令(INCR + EXPIRE),所以结果数组是 2 倍长,取偶数位 + for (Object o : results) { + if (idx % 2 == 0 && o instanceof Long n && n > limit) { + exceeded.add(((List) new ArrayList<>(sensorIds)).get(idx / 2)); + } + idx++; + } + return exceeded; + } catch (DataAccessException e) { + log.warn("Redis 不可用,设备限流放行(fail-open):{}", e.getMessage()); + return Set.of(); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/security/SignatureVerifier.java b/aomin-gateway/src/main/java/cn/aomin/gateway/security/SignatureVerifier.java new file mode 100644 index 0000000..d5bcab5 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/security/SignatureVerifier.java @@ -0,0 +1,81 @@ +package cn.aomin.gateway.security; + +import cn.aomin.gateway.config.GatewayProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; + +/** + * 验签,实现 J-00 §1.2 的公式: + * + *
+ *   X-Sign = HMAC-SHA256( token,
+ *              method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256hex(body) )
+ * 
+ * + *

四个坑,每一个都能让对接方调到怀疑人生: + *

    + *
  1. key 是 token 本身,不是另一个 secret;
  2. + *
  3. 分隔符是换行 {@code \n},不是冒号、不是竖线;
  4. + *
  5. body 要先算 sha256 的十六进制小写再拼进去,不是直接拼原文;
  6. + *
  7. GET 请求(I-18)没有 body,取空串的 sha256 + * ({@code e3b0c442...b855}),不要取字面量 {@code "null"}。
  8. + *
+ * + *

{@code path} 含 query,按发送时的原始路径算(J-00 §1.2 第 2 条)。 + * 去掉 query 会验签失败。 + */ +@Component +public class SignatureVerifier { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final HexFormat HEX = HexFormat.of(); + + private final GatewayProperties props; + + public SignatureVerifier(GatewayProperties props) { + this.props = props; + } + + /** 供 IoT 侧对照用的算法实现,本方法幂等、无副作用 */ + public String sign(String method, String path, String timestamp, String nonce, byte[] body) { + String bodyHash = sha256Hex(body == null ? new byte[0] : body); + String canonical = method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + bodyHash; + return hmacHex(props.getAuth().getToken(), canonical); + } + + /** + * 常量时间比较,避免按字节短路泄漏签名前缀。 + * {@code MessageDigest.isEqual} 在 JDK 里已做常量时间处理。 + */ + public boolean verify(String expected, String actual) { + if (expected == null || actual == null) { + return false; + } + return MessageDigest.isEqual( + expected.toLowerCase().getBytes(StandardCharsets.UTF_8), + actual.toLowerCase().getBytes(StandardCharsets.UTF_8)); + } + + public static String sha256Hex(byte[] data) { + try { + return HEX.formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (Exception e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + + public static String hmacHex(String key, String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + return HEX.formatHex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("HMAC-SHA256 不可用", e); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/service/CommandService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/service/CommandService.java new file mode 100644 index 0000000..b43451d --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/service/CommandService.java @@ -0,0 +1,397 @@ +package cn.aomin.gateway.service; + +import cn.aomin.gateway.common.ApiResponse; +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.dao.CommandDao; +import cn.aomin.gateway.dao.CommandDao.CommandRow; +import cn.aomin.gateway.domain.dto.CommandResultRequest; +import cn.aomin.gateway.domain.dto.CommandResultRequest.ResultItem; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.domain.dto.PendingCommandResponse; +import cn.aomin.gateway.domain.dto.PendingCommandResponse.PendingCommand; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.async.DeferredResult; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * M-05 命令拉取与结果回收,实现 M-00 第七章。 + * + *

长轮询为什么这么写:IoT 侧希望"有新命令立刻拿到,没有就挂着", + * 而不是每秒问一次。做法是 {@link DeferredResult} 先把请求挂起, + * 定时任务每 500ms 扫一遍挂起的请求,查到命令就 {@code setResult} 立刻返回。 + * + *

一期单实例,挂起的请求通常只有 1~2 个,所以每次扫描逐个查库可以接受。 + * 如果 waiter 涨到几十个,要改成"一次查出所有 gatewayIds 的并集,再按网关分配", + * 否则 50 个 waiter × 每 500ms 一次 = 100 QPS 全打在命令表上。 + * + *

租约与两个计数器: + *

    + *
  • {@code retry_count}:设备侧失败了几次(IoT 报 TIMEOUT/DEVICE_OFFLINE/FAILED 时 +1);
  • + *
  • {@code reclaim_count}:IoT 拿走了却没回话几次(租约到期时 +1)。
  • + *
+ * 混成一个就分不清是设备坏了还是中间件坏了。 + */ +@Service +public class CommandService { + + private static final Logger log = LoggerFactory.getLogger(CommandService.class); + + private static final int MAX_LIMIT = 100; + private static final int DEFAULT_LIMIT = 50; + private static final long LEASE_SECONDS = 60; + private static final int MAX_RETRY = 10; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + /** 挂起等命令的请求 */ + private final List waiters = new CopyOnWriteArrayList<>(); + + private final CommandDao commandDao; + private final ObjectMapper objectMapper; + + public CommandService(CommandDao commandDao, ObjectMapper objectMapper) { + this.commandDao = commandDao; + this.objectMapper = objectMapper; + } + + // ------------------------------------------------------------------ + // I-18 拉取 + // ------------------------------------------------------------------ + + /** + * @param waitSeconds 0 表示立即返回(不发命令也马上给空结果) + */ + public DeferredResult> pull(String nodeId, + List gatewayIds, + int waitSeconds, + int limit) { + if (nodeId == null || nodeId.isBlank()) { + throw BizException.badRequest("nodeId 必填"); + } + if (gatewayIds == null || gatewayIds.isEmpty()) { + throw BizException.badRequest("gatewayIds 必填(只传当前在线的接入端点)"); + } + if (gatewayIds.size() > MAX_LIMIT) { + throw BizException.badRequest("gatewayIds 单次最多 " + MAX_LIMIT + " 个"); + } + int effectiveLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); + + // 先查一次:大部分情况下命令本来就已经在等着了 + PendingCommandResponse now = pullNow(nodeId, gatewayIds, effectiveLimit); + if (!now.getCommands().isEmpty() || waitSeconds <= 0) { + DeferredResult> immediate = new DeferredResult<>(); + immediate.setResult(ApiResponse.ok(now)); + return immediate; + } + + DeferredResult> deferred = + new DeferredResult<>(waitSeconds * 1000L, () -> ApiResponse.ok(PendingCommandResponse.empty(nodeId))); + + Waiter w = new Waiter(nodeId, gatewayIds, effectiveLimit, deferred); + waiters.add(w); + deferred.onCompletion(() -> waiters.remove(w)); + deferred.onTimeout(() -> waiters.remove(w)); + return deferred; + } + + /** 定时扫描挂起的请求,有新命令立刻回。按 M-00 §7.1 取 500ms */ + @Scheduled(fixedRate = 500) + public void scanWaiters() { + if (waiters.isEmpty()) { + return; + } + for (Waiter w : waiters) { + if (w.result.isSetOrExpired()) { + waiters.remove(w); + continue; + } + try { + PendingCommandResponse hit = pullNow(w.nodeId, w.gatewayIds, w.limit); + if (!hit.getCommands().isEmpty()) { + w.result.setResult(ApiResponse.ok(hit)); + waiters.remove(w); + } + } catch (Exception e) { + // 单次扫描失败不能让挂起的请求集体超时 + log.warn("扫描待下发命令失败 nodeId={}:{}", w.nodeId, e.getMessage()); + } + } + } + + /** + * 拉一次。 + * + *

{@code hasMore} 判据:本次恰好取满 {@code limit} 条,说明可能还有。 + * 不额外 count 一次——多一次查询换一个近似值不值。 + */ + public PendingCommandResponse pullNow(String nodeId, List gatewayIds, int limit) { + List rows = commandDao.pullAndClaim(nodeId, gatewayIds, limit, LEASE_SECONDS); + PendingCommandResponse resp = PendingCommandResponse.empty(nodeId); + List list = new ArrayList<>(rows.size()); + for (CommandRow r : rows) { + PendingCommand c = new PendingCommand(); + c.setCommandId(r.commandNo()); + c.setGatewayId(r.gatewayId()); + c.setTargetId(r.devId()); + c.setCmdType(r.cmdType()); + c.setDeliveryMode(r.deliveryMode()); + c.setParams(parseParams(r.params())); + c.setLeaseExpireAt(toOffset(r.leaseExpireAt())); + c.setExpireAt(toOffset(r.expireAt())); + c.setRetryCount(r.retryCount()); + // 代次必须回给 IoT,否则回执永远证明不了"属于哪一轮领取" + c.setClaimGeneration(r.claimGeneration()); + list.add(c); + } + resp.setCommands(list); + resp.setHasMore(rows.size() >= limit); + return resp; + } + + // ------------------------------------------------------------------ + // I-05 结果回报 + // ------------------------------------------------------------------ + + /** + * 处理一批回报。 + * + *

状态流转(J-00 V1.7 §3.4): + *

+     *   ACKED + code=0      → ACKED(终态)
+     *   ACKED + code≠0      → FAILED(设备明确说不支持/参数错,重试没意义)
+     *   SENT                → SENT,timeout_at = now+24h(无回执通道,等兜底)
+     *   TIMEOUT/DEVICE_OFFLINE/FAILED
+     *       实时命令        → FAILED
+     *       可延迟命令      → 回 PENDING 重排(retry_count+1,超 10 次或 72h 才作废)
+     * 
+ * + *

四计数在 I-05 上的含义(与 I-01 不同,这里的 {@code duplicated} 是"收到了但没采纳"): + *

    + *
  • {@code accepted}:回执生效,命令状态真的变了;
  • + *
  • {@code rejected}:报文本身有问题(命令号不存在、字段缺失);
  • + *
  • {@code duplicated}:过期或重复回执——终态已定、代次对不上、状态不允许。 + * 这类不算错,但也绝不能算生效;
  • + *
  • {@code unregistered}:恒 0(命令侧没有"未建档设备"这回事)。
  • + *
+ */ + public IngestResponse handleResults(CommandResultRequest req) { + if (req == null || req.getResults() == null || req.getResults().isEmpty()) { + throw BizException.badRequest("results 不能为空"); + } + // 回执必须说明"是哪个节点回的"。这不是身份认证(那层是内网白名单 + 签名), + // 是领取事实的对照物:没有它,回执和"谁领走了"就对不上,只能靠命令号猜。 + if (req.getNodeId() == null || req.getNodeId().isBlank()) { + throw BizException.badRequest("nodeId 必填(回执必须声明来源节点)"); + } + String nodeId = req.getNodeId().trim(); + + int accepted = 0; + int rejected = 0; + int ignored = 0; + List details = new ArrayList<>(); + + for (ResultItem item : req.getResults()) { + if (item == null || item.getCommandId() == null || item.getResult() == null) { + rejected++; + details.add(new IngestResponse.RejectedDetail( + item == null ? null : item.getCommandId(), "SCHEMA_INVALID")); + continue; + } + CommandRow row = commandDao.findByCommandNo(item.getCommandId()); + if (row == null) { + rejected++; + details.add(new IngestResponse.RejectedDetail(item.getCommandId(), "COMMAND_NOT_FOUND")); + continue; + } + // 回执必须自带代次。缺了就不知道这条回执属于"第几轮领取"—— + // 以前的做法是"缺了就拿库里当前代次顶上",等于让代次校验自己失效: + // 旧回执不带号码,系统自动发一个新号码给它,怎么都对得上。 + // 现在缺了直接拒,由 IoT 侧补上(J-00 §3.4 本来就是必填)。 + if (item.getClaimGeneration() == null) { + rejected++; + details.add(new IngestResponse.RejectedDetail( + item.getCommandId(), "CLAIM_GENERATION_REQUIRED")); + log.warn("命令 {} 回执未带 claimGeneration,拒绝采纳", item.getCommandId()); + continue; + } + // 终态已经定了:重复回执一律不采纳,也不报错(IoT 重推是正常行为) + if (CommandDao.TERMINAL.contains(row.status())) { + ignored++; + log.debug("命令 {} 已是终态 {},本次回执忽略", item.getCommandId(), row.status()); + continue; + } + // 节点校验:代次管"第几轮",节点管"这一轮是谁",两个都要。 + // 历史在途行(迁移前领取)没有节点值,不猜、不伪造——按无法核对来源拒收, + // 这类命令最多 60 秒后租约到期回收重派,重领之后节点列就有值了。 + if (row.claimedNodeId() == null || row.claimedNodeId().isBlank()) { + rejected++; + details.add(new IngestResponse.RejectedDetail(item.getCommandId(), "NODE_UNKNOWN")); + log.warn("命令 {} 无领取节点(迁移前领取的历史行),拒绝采纳", item.getCommandId()); + continue; + } + if (!nodeId.equals(row.claimedNodeId())) { + rejected++; + details.add(new IngestResponse.RejectedDetail(item.getCommandId(), "NODE_MISMATCH")); + log.warn("命令 {} 由节点 {} 领走,回执却来自 {},拒绝采纳", + item.getCommandId(), row.claimedNodeId(), nodeId); + continue; + } + + try { + int affected = applyResult(row, item, nodeId); + if (affected == 1) { + accepted++; + } else { + // 代次不符 / 状态不允许:旧回执,或命令已被回收重派 + ignored++; + log.info("命令 {} 回执未采纳(代次 {} 状态 {}):{}", + item.getCommandId(), row.claimGeneration(), row.status(), item.getResult()); + } + } catch (Exception e) { + rejected++; + details.add(new IngestResponse.RejectedDetail(item.getCommandId(), "SCHEMA_INVALID")); + log.warn("命令结果处理失败 commandId={}:{}", item.getCommandId(), e.getMessage()); + } + } + + IngestResponse resp = IngestResponse.of(accepted, rejected, ignored, 0); + resp.setRejectedDetail(details); + return resp; + } + + /** + * 落一条回执,返回受影响行数(1 生效 / 0 被守卫挡下)。 + * + *

注意 {@code ACKED} 只管"设备有回应",成不成功看 {@code resultCode}: + * 标准码 {@code 0} 才算成功,其余(含不认识的值)一律 FAILED。 + * 厂家原码原样进 {@code raw_result_code},不参与判断。 + */ + private int applyResult(CommandRow row, ResultItem item, String nodeId) { + String result = item.getResult().toUpperCase(); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime ackAt = item.getAckAt() == null ? null : toLocal(item.getAckAt()); + String stdCode = standardCode(item.getResultCode()); + String raw = item.getRawResultCode(); + // 代次只认回执自带的值,不做任何回退。 + // 回带旧代次 → WHERE 不命中 → 0 行 → 上层计 ignored(过期回执),改不动新一轮流转。 + // 缺代次在入口就被拒(CLAIM_GENERATION_REQUIRED),走不到这里。 + int gen = item.getClaimGeneration(); + if (gen != row.claimGeneration()) { + log.info("命令 {} 回执代次不符(回带 {} / 当前 {}),按过期回执丢弃", + row.commandNo(), gen, row.claimGeneration()); + return 0; + } + + switch (result) { + case "ACKED" -> { + // 设备有回应 ≠ 执行成功。只有标准码 0 才算成功,其余一律失败 + boolean ok = "0".equals(stdCode); + return commandDao.applyResult(row.id(), gen, nodeId, + ok ? CommandDao.ST_ACKED : CommandDao.ST_FAILED, + null, stdCode, raw, item.getResultMsg(), item.getRawAck(), + ackAt != null ? ackAt : now); + } + case "SENT" -> { + // 发出去了,但这条协议没有回执通道。不能判成功也不能判失败, + // 保持 SENT 并给 24 小时兜底——电池设备可能一整天不联网, + // 它只要 24 小时内上报任何一条数据,就说明还活着(M-00 §7.4)。 + return commandDao.applyResult(row.id(), gen, nodeId, CommandDao.ST_SENT, + now.plusHours(24), stdCode, raw, item.getResultMsg(), + item.getRawAck(), null); + } + case "TIMEOUT", "DEVICE_OFFLINE", "FAILED" -> { + if (isDeferred(row)) { + // 可延迟命令:回待下发,等设备下次醒来 + return commandDao.requeue(row.id(), gen, nodeId, MAX_RETRY); + } + // 实时命令:人在等,立刻告诉他没成功。晚 20 分钟再布防没有意义 + return commandDao.applyResult(row.id(), gen, nodeId, CommandDao.ST_FAILED, + null, stdCode, raw, item.getResultMsg(), item.getRawAck(), null); + } + default -> throw new IllegalArgumentException("未知 result:" + item.getResult()); + } + } + + /** + * 把上报的结果码收敛成标准码 {@code 0}–{@code 4}。 + * 认不出来的返回 {@code null} —— 调用方按"非 0 即失败"处理, + * 原值仍会落在 {@code raw_result_code},排障时看得到。 + */ + private String standardCode(String code) { + if (code == null) { + return null; + } + String t = code.trim(); + return switch (t) { + case "0", "1", "2", "3", "4" -> t; + default -> null; + }; + } + + private boolean isDeferred(CommandRow row) { + return "DEFERRED".equalsIgnoreCase(row.deliveryMode()); + } + + // ------------------------------------------------------------------ + // 定时任务 + // ------------------------------------------------------------------ + + /** + * 租约回收 + 72 小时硬顶。 + * + *

顺序不能反:先判 {@code expire_at}(72h 硬顶)再回收租约。 + * 反过来会出现"已经按 72h 置了超时,又被租约回收改回待下发"—— + * 状态回退,而且不报错,只在报表里显示同一条命令走了两条路。 + */ + @Scheduled(fixedRate = 10_000) + public void maintenance() { + try { + int expired = commandDao.expireOverdue(); + int reclaimed = commandDao.reclaimExpiredLeases(); + int silent = commandDao.failSilentSent(); + if (expired > 0 || reclaimed > 0 || silent > 0) { + log.info("命令维护:72h 置超时 {} 条,租约回收 {} 条,24h 无回执判失败 {} 条", + expired, reclaimed, silent); + } + } catch (Exception e) { + log.warn("命令维护任务失败:{}", e.getMessage()); + } + } + + // ------------------------------------------------------------------ + + private JsonNode parseParams(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (Exception e) { + log.warn("params 不是合法 JSON,按 null 返回:{}", e.getMessage()); + return null; + } + } + + private LocalDateTime toLocal(OffsetDateTime t) { + return t.atZoneSameInstant(ZONE).toLocalDateTime(); + } + + private OffsetDateTime toOffset(LocalDateTime t) { + return t == null ? null : t.atZone(ZONE).toOffsetDateTime(); + } + + private record Waiter(String nodeId, List gatewayIds, int limit, + DeferredResult> result) { + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/service/IngestService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/service/IngestService.java new file mode 100644 index 0000000..9eb79ba --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/service/IngestService.java @@ -0,0 +1,325 @@ +package cn.aomin.gateway.service; + +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.dao.DeviceDao; +import cn.aomin.gateway.dao.IotEventDao; +import cn.aomin.gateway.dao.RawRefGapDao; +import cn.aomin.gateway.dao.UnregisteredDao; +import cn.aomin.gateway.domain.Channel; +import cn.aomin.gateway.domain.DeviceRow; +import cn.aomin.gateway.domain.TimeResult; +import cn.aomin.gateway.domain.dto.EventBatchRequest; +import cn.aomin.gateway.domain.dto.EventBatchRequest.EventItem; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.security.RateLimiter; +import cn.aomin.gateway.support.EventTimeResolver; +import cn.aomin.gateway.support.EventValidator; +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * M-02 事件接收(I-01),实现 M-00 第四章。 + * + *

整批一个事务,每条一个 SAVEPOINT: + * 某条抛异常只回滚这一条,其余照常入库。 + * 没有这层,一条坏数据能让整批 200 条全部失败—— + * 而 IoT 侧重推又会撞唯一键,最后一条都进不来。 + * + *

四个计数是重叠的(M-00 §4.3): + * {@code accepted + rejected = total},{@code duplicated} 和 {@code unregistered} 都是 + * {@code accepted} 的子集。所以 {@code accepted++} 必须在"通过校验"那一刻做, + * 不能等 INSERT 成功——后者会让四个数加起来对不上总数。 + * + *

不做预查重:唯一键本身就是判据,撞了自然抛 {@link DuplicateKeyException}。 + * 先查再插会多一次往返,而且两个线程同时查都会说"不存在"。 + */ +@Service +public class IngestService { + + private static final Logger log = LoggerFactory.getLogger(IngestService.class); + + private static final int MAX_BATCH = 200; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + private final DeviceDao deviceDao; + private final IotEventDao eventDao; + private final UnregisteredDao unregisteredDao; + private final RawRefGapDao rawRefGapDao; + private final EventValidator validator; + private final EventTimeResolver timeResolver; + private final RateLimiter rateLimiter; + private final TransactionTemplate txTemplate; + + public IngestService(DeviceDao deviceDao, + IotEventDao eventDao, + UnregisteredDao unregisteredDao, + RawRefGapDao rawRefGapDao, + EventValidator validator, + EventTimeResolver timeResolver, + RateLimiter rateLimiter, + PlatformTransactionManager txManager) { + this.deviceDao = deviceDao; + this.eventDao = eventDao; + this.unregisteredDao = unregisteredDao; + this.rawRefGapDao = rawRefGapDao; + this.validator = validator; + this.timeResolver = timeResolver; + this.rateLimiter = rateLimiter; + this.txTemplate = new TransactionTemplate(txManager); + } + + public IngestResponse ingest(EventBatchRequest req) { + if (req == null || req.getEvents() == null || req.getEvents().isEmpty()) { + throw BizException.badRequest("events 不能为空"); + } + if (req.getEvents().size() > MAX_BATCH) { + throw BizException.badRequest("单批最多 " + MAX_BATCH + " 条,实际 " + req.getEvents().size()); + } + if (Channel.of(req.getSourceChannel()) == null) { + throw BizException.badRequest("sourceChannel 非法:" + req.getSourceChannel()); + } + + List items = req.getEvents(); + + // 通道维度限流:一次算一批,不逐条 + if (!rateLimiter.acquireChannel(req.getSourceChannel(), items.size())) { + IngestResponse r = IngestResponse.of(0, items.size(), 0, 0); + for (EventItem it : items) { + r.addRejected(it == null ? null : it.getEventId(), EventValidator.RATE_LIMITED); + } + return r; + } + + // 设备维度限流:批内去重后一次 pipeline 判,只拿"超限的"回来 + Set sensorIds = new LinkedHashSet<>(); + for (EventItem it : items) { + if (it != null && it.getSensorId() != null) { + sensorIds.add(it.getSensorId()); + } + } + Set exceeded = rateLimiter.exceededDevices(sensorIds); + + // 一次 IN 批量查 device(最多 200 个),不逐条查 + Map devices = deviceDao.selectBySensorIds(sensorIds); + + int[] counters = new int[4]; // accepted, rejected, duplicated, unregistered + List details = new ArrayList<>(); + // 同批合并:一个陌生 SN 出现 10 次只写一次 unregistered_device + Map unreg = new LinkedHashMap<>(); + // 事务提交后再核验 rawRef 能否回查原文(见方法末尾)。 + // 放事务外有两个理由:① 核验查的是原文表,与事件入库无因果关系, + // 不该因为它失败把事件回滚掉;② 少在长事务里多查一次库。 + List refsToVerify = new ArrayList<>(); + + txTemplate.executeWithoutResult(status -> { + for (EventItem item : items) { + // ① 结构校验(只看格式,不看业务) + String bad = validator.checkEvent(item, req.getSourceChannel()); + if (bad != null) { + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item == null ? null : item.getEventId(), bad)); + continue; + } + + // ② 设备限流超限 + if (exceeded.contains(item.getSensorId())) { + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item.getEventId(), EventValidator.RATE_LIMITED)); + continue; + } + + // 从这一刻起算"收下"——后面无论是重复、未建档还是入库失败,它都已经通过了校验 + counters[0]++; + + String channelName = item.getSourceChannel() == null + ? req.getSourceChannel() : item.getSourceChannel(); + Channel channel = Channel.of(channelName); + + // ③ 未建档:不进 iot_event,攒起来最后 upsert + DeviceRow device = devices.get(item.getSensorId()); + if (device == null) { + counters[3]++; + LocalDateTime seenAt = toLocal(item.getReceivedAt()); + unreg.merge(item.getSensorId(), + new UnregAgg(channelName, channel.vendor(), seenAt, payloadOf(item)), + UnregAgg::merge); + continue; + } + + // ④ 逐条 SAVEPOINT + Object savepoint = status.createSavepoint(); + try { + boolean tombstone = EventTimeResolver.EVENT_TOMBSTONE.equals(item.getEventType()); + // 落库这一刻由业务层自己打戳。IoT 传来的 receivedAt 不再当判据—— + // 它只证明"IoT 何时收到",证明不了"平台何时收到",而且可以被传成任意旧值。 + // 时间派生也统一用这个戳:ts_source=2/3 缺设备时间时 event_time 就等于它, + // 三列(event_time / receive_time / received_at_remote)口径才对得上。 + OffsetDateTime ingestNow = OffsetDateTime.now(ZONE); + TimeResult time = timeResolver.resolve( + item.getDeviceTime(), + item.getTsSource(), + ingestNow, + device.tsPrecision(), + item.getEventType()); + + eventDao.insertOne( + item.getEventId(), + device, + channelName, + item.getEventType(), + item.getDeviceTime() == null ? null : toLocal(item.getDeviceTime()), + item.getTsSource(), + tombstone, + intFromPayload(item.getPayload(), "retryCnt"), + intFromPayload(item.getPayload(), "lostCnt"), + toLocal(item.getReceivedAt()), + item.getSeq(), + payloadOf(item), + item.getRawRef(), + item.getProtocolVersion(), + time); + + status.releaseSavepoint(savepoint); + + // 记下待核验的摘要。只有"真插进去了"才记: + // duplicated 说明这一帧之前已经处理过,核过一次就够了,不必重复查原文表。 + if (item.getRawRef() != null && !item.getRawRef().isEmpty()) { + refsToVerify.add(new String[]{ + item.getRawRef(), item.getEventId(), item.getSensorId(), channelName}); + } + + } catch (DuplicateKeyException e) { + // 撞 uk_event_id 或 uk_sensor_seq —— 这就是"重复"的判据 + status.rollbackToSavepoint(savepoint); + status.releaseSavepoint(savepoint); + counters[2]++; + + } catch (DataAccessException e) { + // 不是重复,是真写不进去(字段超长、类型不对、分区不存在等) + status.rollbackToSavepoint(savepoint); + status.releaseSavepoint(savepoint); + counters[0]--; + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item.getEventId(), EventValidator.SCHEMA_INVALID)); + log.warn("事件入库失败,已回滚该条 eventId={}:{}", + item.getEventId(), rootMessage(e)); + } + } + + // ⑤ 未建档设备:同批合并后一次 upsert + for (Map.Entry e : unreg.entrySet()) { + unregisteredDao.upsertFromEvent( + e.getKey(), e.getValue().channel, e.getValue().vendor, + e.getValue().lastSeenAt, e.getValue().payload); + } + }); + + // ⑥ rawRef → 原文存在性核验(评审阻断项 5:必填 ≠ 可回查) + verifyRawRefs(refsToVerify); + + IngestResponse resp = IngestResponse.of(counters[0], counters[1], counters[2], counters[3]); + resp.setRejectedDetail(details); + return resp; + } + + /** + * 核验每条事件的 {@code rawRef} 能否在原文表里找到对应记录。 + * + *

找不到不拒收,只登记进 {@code iot_raw_ref_gap}。真机上 I-12 可能比 I-01 晚到 + * 几十毫秒,硬拒会把正常事件挡在门外——V1.7 那次"契约允许 64 位 SHA-256、实现只认 32 位" + * 就是这个坑:接真机第一天全量失败,日志还只显示"字段非法"。 + * + *

登记后原文一到就自愈({@code RawFrameService} 负责置 {@code resolved_at}); + * 一直没到就是真孤儿,能查出来才追得到接入侧。 + */ + private void verifyRawRefs(List refs) { + if (refs.isEmpty()) { + return; + } + int orphan = 0; + for (String[] r : refs) { + String rawRef = r[0]; + try { + Channel ch = Channel.of(r[3]); + boolean gwRaw = ch != null && ch.toGatewayRawLog(); + if (!rawRefGapDao.rawExists(rawRef, gwRaw)) { + rawRefGapDao.record(rawRef, r[1], r[2], r[3]); + orphan++; + } + } catch (Exception e) { + // 核验是附加动作,绝不能因为查原文表失败把已入库的事件废掉 + log.warn("rawRef 核验失败(不影响入库)eventId={} rawRef={}:{}", + r[1], rawRef, rootMessage(e)); + } + } + if (orphan > 0) { + log.warn("本批 {} 条事件的 rawRef 在原文表查不到(I-12 未到或已失败),已登记 iot_raw_ref_gap", + orphan); + } + } + + private String payloadOf(EventItem item) { + JsonNode p = item.getPayload(); + return p == null ? null : p.toString(); + } + + /** + * 从 payload 里取整数,取不到就 null。 + * + *

键名 {@code retryCnt} / {@code lostCnt} 是本文与 IoT 的约定, + * J-00 只规定了这两列在库里的语义,没规定报文里叫什么。 + * 取不到不影响入库——这两列本来就允许 NULL。 + */ + private Integer intFromPayload(JsonNode payload, String field) { + if (payload == null) { + return null; + } + JsonNode v = payload.get(field); + return v != null && v.isNumber() ? v.asInt() : null; + } + + private LocalDateTime toLocal(OffsetDateTime t) { + return t.atZoneSameInstant(ZONE).toLocalDateTime(); + } + + private String rootMessage(Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage(); + } + + /** 未建档设备在本批内的聚合,保证一个 SN 只 upsert 一次 */ + private record UnregAgg(String channel, String vendor, LocalDateTime lastSeenAt, String payload) { + UnregAgg merge(UnregAgg other) { + // lastSeenAt 取更晚的;payload 保留第一个非空的("最后一次上报的事件"语义上应该取第一个, + // 因为同批内是多条,取任意一条都比丢掉强,且这里只用于认领时参考) + return new UnregAgg( + this.channel != null ? this.channel : other.channel, + this.vendor != null ? this.vendor : other.vendor, + other.lastSeenAt.isAfter(this.lastSeenAt) ? other.lastSeenAt : this.lastSeenAt, + this.payload != null ? this.payload : other.payload); + } + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/service/RawFrameService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/service/RawFrameService.java new file mode 100644 index 0000000..00ee5f5 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/service/RawFrameService.java @@ -0,0 +1,231 @@ +package cn.aomin.gateway.service; + +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.dao.DeviceDao; +import cn.aomin.gateway.dao.RawFrameDao; +import cn.aomin.gateway.dao.RawRefGapDao; +import cn.aomin.gateway.domain.Channel; +import cn.aomin.gateway.domain.DeviceRow; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.domain.dto.RawFrameBatchRequest; +import cn.aomin.gateway.domain.dto.RawFrameBatchRequest.RawFrameItem; +import cn.aomin.gateway.security.RateLimiter; +import cn.aomin.gateway.support.EventValidator; +import cn.aomin.gateway.support.LinkTypeResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * M-04 原始报文接收(I-12),实现 M-00 第六章。 + * + *

三条铁律(改代码前必看 M-00 §6.3–6.5): + *

    + *
  1. {@code rawFrame} 是 Base64 字符串,原样存进 {@code payload} 列,不解码。 + * 那张列是 {@code MEDIUMTEXT} + utf8mb4,装不了任意二进制;
  2. + *
  3. {@code payload_md5} 直接抄 {@code rawRef},绝不对 Base64 文本重算;
  4. + *
  5. {@code payload_size} 填解码后的字节数({@code frameBytes}),不是 Base64 长度。
  6. + *
+ * + *

幂等靠唯一键,不做预查:G 线撞 {@code uk_md5(payload_md5)}, + * S/H 线撞 {@code uk_dev_idem(device_sn, idem_key)},撞了就是 {@code duplicated}。 + * + *

{@code unregistered} 恒 0:J-00 §1.9.3 明写 I-12 不做未建档判定, + * 响应体里有这个字段只是复用同一个 schema。 + */ +@Service +public class RawFrameService { + + private static final Logger log = LoggerFactory.getLogger(RawFrameService.class); + + private static final int MAX_BATCH = 200; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + /** adapter_in_raw.device_sn 是 NOT NULL,解析不出设备时就填这个(M-00 §6.5) */ + private static final String UNKNOWN_SN = "UNKNOWN"; + + private final DeviceDao deviceDao; + private final RawFrameDao rawFrameDao; + private final RawRefGapDao rawRefGapDao; + private final EventValidator validator; + private final RateLimiter rateLimiter; + private final LinkTypeResolver linkTypeResolver; + private final TransactionTemplate txTemplate; + + public RawFrameService(DeviceDao deviceDao, + RawFrameDao rawFrameDao, + RawRefGapDao rawRefGapDao, + EventValidator validator, + RateLimiter rateLimiter, + LinkTypeResolver linkTypeResolver, + PlatformTransactionManager txManager) { + this.deviceDao = deviceDao; + this.rawFrameDao = rawFrameDao; + this.rawRefGapDao = rawRefGapDao; + this.validator = validator; + this.rateLimiter = rateLimiter; + this.linkTypeResolver = linkTypeResolver; + this.txTemplate = new TransactionTemplate(txManager); + } + + public IngestResponse ingest(RawFrameBatchRequest req) { + if (req == null || req.getFrames() == null || req.getFrames().isEmpty()) { + throw BizException.badRequest("frames 不能为空"); + } + if (req.getFrames().size() > MAX_BATCH) { + throw BizException.badRequest("单批最多 " + MAX_BATCH + " 条,实际 " + req.getFrames().size()); + } + if (Channel.of(req.getSourceChannel()) == null) { + throw BizException.badRequest("sourceChannel 非法:" + req.getSourceChannel()); + } + + List frames = req.getFrames(); + + if (!rateLimiter.acquireChannel(req.getSourceChannel(), frames.size())) { + IngestResponse r = IngestResponse.of(0, frames.size(), 0, 0); + for (RawFrameItem it : frames) { + r.addRejected(it == null ? null : it.getRawRef(), EventValidator.RATE_LIMITED); + } + return r; + } + + // 只为拿 adapter_in_raw.dev_id(可空)。G 线走另一张表不需要,但混批场景统一查一次更简单 + Set sensorIds = new LinkedHashSet<>(); + for (RawFrameItem it : frames) { + if (it != null && it.getSensorId() != null) { + sensorIds.add(it.getSensorId()); + } + } + Map devices = deviceDao.selectBySensorIds(sensorIds); + + int[] counters = new int[3]; // accepted, rejected, duplicated + List details = new ArrayList<>(); + // 事务提交后再解除孤儿标记,别让"清标记"失败把整批原文回滚掉 + List refsToResolve = new ArrayList<>(); + + txTemplate.executeWithoutResult(status -> { + for (RawFrameItem item : frames) { + String bad = validator.checkRawFrame(item, req.getSourceChannel()); + if (bad != null) { + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item == null ? null : item.getRawRef(), bad)); + continue; + } + counters[0]++; + + String channelName = item.getSourceChannel() == null + ? req.getSourceChannel() : item.getSourceChannel(); + Channel channel = Channel.of(channelName); + LocalDateTime receivedAt = toLocal(item.getReceivedAt()); + + Object savepoint = status.createSavepoint(); + try { + if (channel.toGatewayRawLog()) { + // ---------- G 线:ingest_raw_log ---------- + rawFrameDao.insertIngestRaw( + item.getGatewayDevId(), + item.getSensorId(), + item.getRawFrame(), // Base64 原文,不解码 + item.getFrameBytes(), + item.getRawRef(), // 直接抄,不重算 + "OK", + null, + item.getProtocolVersion(), // I-12 必填,J-00 §1.9.2 + receivedAt); + } else { + // ---------- H/S 线:adapter_in_raw ---------- + String deviceSn = item.getSensorId() == null ? UNKNOWN_SN : item.getSensorId(); + String parseResult = item.getSensorId() == null ? "FIELD_MISSING" : "OK"; + DeviceRow device = item.getSensorId() == null ? null : devices.get(item.getSensorId()); + + rawFrameDao.insertAdapterRaw( + deviceSn, + device == null ? null : device.devId(), + item.getSensorId(), + channelName, + // 通道推不出链路类型(MIR_CELL 底下两套 TCP),只能查部署配置 + linkTypeResolver.resolve(item.getGatewayDevId(), channelName), + channel.vendor(), // 按通道推,不猜 + item.getRawFrame(), + item.getRawRef(), + item.getRawRef(), // idem_key 取 rawRef,它本身就是内容哈希 + 3, // idem_level=3 内容哈希 + parseResult, + item.getProtocolVersion(), // I-12 必填,J-00 §1.9.2 + receivedAt); + } + status.releaseSavepoint(savepoint); + + // 原文到了:如果这条摘要之前被 I-01 登记成孤儿,现在解除 + if (item.getRawRef() != null && !item.getRawRef().isEmpty()) { + refsToResolve.add(item.getRawRef()); + } + + } catch (DuplicateKeyException e) { + // 同一帧重推:G 线撞 uk_md5,S/H 线撞 uk_dev_idem。这是正常现象,不报错 + status.rollbackToSavepoint(savepoint); + status.releaseSavepoint(savepoint); + counters[2]++; + // 撞唯一键恰恰证明原文已经在库里 —— 之前登记的孤儿标记要一并解除, + // 否则"第一次 I-12 失败、重推成功"这种最常见的情形会永远挂着孤儿。 + if (item.getRawRef() != null && !item.getRawRef().isEmpty()) { + refsToResolve.add(item.getRawRef()); + } + + } catch (DataAccessException e) { + status.rollbackToSavepoint(savepoint); + status.releaseSavepoint(savepoint); + counters[0]--; + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item.getRawRef(), EventValidator.SCHEMA_INVALID)); + log.warn("原文入库失败 rawRef={}:{}", item.getRawRef(), rootMessage(e)); + } + } + }); + + // 原文到位后解除 I-01 登记的孤儿标记(评审阻断项 5 的自愈闭环) + int resolved = 0; + for (String rawRef : refsToResolve) { + try { + resolved += rawRefGapDao.resolve(rawRef); + } catch (Exception e) { + log.warn("解除孤儿标记失败(不影响原文入库)rawRef={}:{}", rawRef, rootMessage(e)); + } + } + if (resolved > 0) { + log.info("原文到达,解除 {} 条孤儿摘要标记", resolved); + } + + // I-12 没有 unregistered 判定,第四个字段恒 0 + IngestResponse resp = IngestResponse.of(counters[0], counters[1], counters[2], 0); + resp.setRejectedDetail(details); + return resp; + } + + private LocalDateTime toLocal(OffsetDateTime t) { + return t.atZoneSameInstant(ZONE).toLocalDateTime(); + } + + private String rootMessage(Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/service/StatusService.java b/aomin-gateway/src/main/java/cn/aomin/gateway/service/StatusService.java new file mode 100644 index 0000000..92f6c05 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/service/StatusService.java @@ -0,0 +1,196 @@ +package cn.aomin.gateway.service; + +import cn.aomin.gateway.common.BizException; +import cn.aomin.gateway.dao.DeviceDao; +import cn.aomin.gateway.dao.TechCacheDao; +import cn.aomin.gateway.dao.UnregisteredDao; +import cn.aomin.gateway.domain.Channel; +import cn.aomin.gateway.domain.DeviceRow; +import cn.aomin.gateway.domain.dto.IngestResponse; +import cn.aomin.gateway.domain.dto.StatusBatchRequest; +import cn.aomin.gateway.domain.dto.StatusBatchRequest.StatusItem; +import cn.aomin.gateway.security.RateLimiter; +import cn.aomin.gateway.support.EventValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * M-03 状态快照(I-02),实现 M-00 第五章。 + * + *

这是覆盖写,不是插事件:一设备一行,收到新快照就更新。 + * 因此 {@code duplicated} 恒为 0——覆盖写不会撞唯一键,也没有"重复"这回事。 + * + *

几条不能踩的线(都在 M-00 §5.3,改代码前先看): + *

    + *
  • {@code online=false} 不改 {@code online_status}:NB 设备休眠 23 小时本来就 false, + * 直接写成 OFFLINE 会让界面误报离线;
  • + *
  • 不写 {@code battery} 百分比,只写 {@code battery_mv};
  • + *
  • 不写 {@code undelivered_count}(那是业务层断档累计值);
  • + *
  • 不写 {@code device_state_log}(那是业务状态机日志,写进去会淹没真正的状态变更)。
  • + *
+ */ +@Service +public class StatusService { + + private static final Logger log = LoggerFactory.getLogger(StatusService.class); + + private static final int MAX_BATCH = 500; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + private final DeviceDao deviceDao; + private final TechCacheDao techCacheDao; + private final UnregisteredDao unregisteredDao; + private final EventValidator validator; + private final RateLimiter rateLimiter; + private final ObjectMapper objectMapper; + private final TransactionTemplate txTemplate; + + public StatusService(DeviceDao deviceDao, + TechCacheDao techCacheDao, + UnregisteredDao unregisteredDao, + EventValidator validator, + RateLimiter rateLimiter, + ObjectMapper objectMapper, + PlatformTransactionManager txManager) { + this.deviceDao = deviceDao; + this.techCacheDao = techCacheDao; + this.unregisteredDao = unregisteredDao; + this.validator = validator; + this.rateLimiter = rateLimiter; + this.objectMapper = objectMapper; + this.txTemplate = new TransactionTemplate(txManager); + } + + public IngestResponse ingest(StatusBatchRequest req) { + if (req == null || req.getStatuses() == null || req.getStatuses().isEmpty()) { + throw BizException.badRequest("statuses 不能为空"); + } + if (req.getStatuses().size() > MAX_BATCH) { + throw BizException.badRequest("单批最多 " + MAX_BATCH + " 条,实际 " + req.getStatuses().size()); + } + if (Channel.of(req.getSourceChannel()) == null) { + throw BizException.badRequest("sourceChannel 非法:" + req.getSourceChannel()); + } + + List items = req.getStatuses(); + + if (!rateLimiter.acquireChannel(req.getSourceChannel(), items.size())) { + IngestResponse r = IngestResponse.of(0, items.size(), 0, 0); + for (StatusItem it : items) { + r.addRejected(it == null ? null : it.getSensorId(), EventValidator.RATE_LIMITED); + } + return r; + } + + Set sensorIds = new LinkedHashSet<>(); + for (StatusItem it : items) { + if (it != null && it.getSensorId() != null) { + sensorIds.add(it.getSensorId()); + } + } + Set exceeded = rateLimiter.exceededDevices(sensorIds); + Map devices = deviceDao.selectBySensorIds(sensorIds); + + // 0=accepted 1=rejected 2=unregistered(重叠关系,见 M-00 §2.4) + int[] counters = new int[3]; + List details = new ArrayList<>(); + + txTemplate.executeWithoutResult(status -> { + for (StatusItem item : items) { + String bad = validator.checkStatus(item, req.getSourceChannel()); + if (bad != null) { + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item == null ? null : item.getSensorId(), bad)); + continue; + } + if (exceeded.contains(item.getSensorId())) { + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item.getSensorId(), EventValidator.RATE_LIMITED)); + continue; + } + + // 通过校验即算"收下" + counters[0]++; + + String channelName = item.getSourceChannel() == null + ? req.getSourceChannel() : item.getSourceChannel(); + Channel channel = Channel.of(channelName); + LocalDateTime lastSeen = toLocal(item.getLastSeenAt()); + + Object savepoint = status.createSavepoint(); + try { + DeviceRow device = devices.get(item.getSensorId()); + if (device == null) { + // 未建档:device_tech_cache.dev_id 是 NOT NULL,插不进去。 + // 改更新 unregistered_device 的活跃度,不丢"这台设备还活着"的证据。 + unregisteredDao.touchFromStatus( + item.getSensorId(), channelName, channel.vendor(), lastSeen); + counters[2]++; + } else { + techCacheDao.upsert( + device.devId(), + item.getSensorId(), + Boolean.TRUE.equals(item.getOnline()), + item.getBatteryMv(), + item.getCsq(), + item.getLastSeq(), + item.getLostCnt(), + lastSeen, + snapshotJson(item)); + } + status.releaseSavepoint(savepoint); + } catch (DataAccessException e) { + status.rollbackToSavepoint(savepoint); + status.releaseSavepoint(savepoint); + counters[0]--; + counters[1]++; + details.add(new IngestResponse.RejectedDetail( + item.getSensorId(), EventValidator.SCHEMA_INVALID)); + log.warn("快照写入失败 sensorId={}:{}", item.getSensorId(), rootMessage(e)); + } + } + }); + + IngestResponse resp = IngestResponse.of(counters[0], counters[1], 0, counters[2]); + resp.setRejectedDetail(details); + return resp; + } + + private String snapshotJson(StatusItem item) { + try { + return objectMapper.writeValueAsString(item); + } catch (Exception e) { + // 快照落不进去不影响主字段,只是排障时少了原文 + log.warn("快照序列化失败 sensorId={}:{}", item.getSensorId(), e.getMessage()); + return null; + } + } + + private LocalDateTime toLocal(OffsetDateTime t) { + return t.atZoneSameInstant(ZONE).toLocalDateTime(); + } + + private String rootMessage(Throwable e) { + Throwable t = e; + while (t.getCause() != null && t.getCause() != t) { + t = t.getCause(); + } + return t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventTimeResolver.java b/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventTimeResolver.java new file mode 100644 index 0000000..46b1e72 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventTimeResolver.java @@ -0,0 +1,101 @@ +package cn.aomin.gateway.support; + +import cn.aomin.gateway.domain.TimeResult; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; + +/** + * 派生 {@code iot_event.event_time} 与精度等级,实现 M-00 §4.6 / J-00 §2.3。 + * + *

纯函数,没有数据库访问,没有状态。 + * 五个接口里只有 I-01 用它,而且只在写库那一刻调用一次。 + * + *

{@code event_time} 是分区键且 NOT NULL,所以"没有可信设备时间"不能靠插 NULL 解决, + * 只能落一个值、再把精度降下来。 + */ +@Component +public class EventTimeResolver { + + /** 墓碑帧专用值。落 {@code iot_event} 的 p1900 分区(分区条件是 {@code < '1901-01-01'}) */ + public static final LocalDateTime TOMBSTONE_TIME = LocalDateTime.of(1900, 1, 1, 0, 0, 0, 0); + + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + /** 墓碑帧的事件类型,J-00 §2.2 第 506 行定死这个值 */ + public static final String EVENT_TOMBSTONE = "MSG_UNDELIVERED"; + + /** + * @param deviceTime 设备侧时间,可空 + * @param tsSource 时源 0/1/2/3 + * @param receivedAt 平台接收时刻,必非空 + * @param devicePrecision 该设备建档时的精度 A/B/C({@code device.ts_precision}) + * @param eventType 事件类型,只有为了识别墓碑帧 + * @return 非空的 event_time + 精度 + */ + public TimeResult resolve(OffsetDateTime deviceTime, + Integer tsSource, + OffsetDateTime receivedAt, + String devicePrecision, + String eventType) { + + // ---------- 墓碑帧:恒 1900-01-01 ---------- + // 判据是 eventType,不是时间字段——J-00 §1.3 的信封里没有"IsBackfill"这种标记, + // 墓碑帧就是一条 eventType=MSG_UNDELIVERED 的普通事件。 + if (EVENT_TOMBSTONE.equals(eventType)) { + return new TimeResult(TOMBSTONE_TIME, TimeResult.PRECISION_C); + } + + LocalDateTime received = toLocal(receivedAt); + int src = tsSource == null ? -1 : tsSource; + + // ---------- tsSource=2:平台接收时刻,S 线裸 TCP 恒此值 ---------- + // deviceTime 按 J-00 §2.3 恒为 NULL,即便 IoT 传了也不采信(传了说明适配器有 bug) + if (src == 2) { + return new TimeResult(received, worse(devicePrecision, TimeResult.PRECISION_B)); + } + + // ---------- 有可信设备时间 ---------- + // tsSource=0 设备原始 / 1 厂家云 / 3 网关打戳,三者只要 deviceTime 有值就都取它。 + // 其中 tsSource=3 那一格 J-00 §2.3 的四行表没写(只写了"为 null"那格), + // 但语义只有一种解释——网关打了戳,戳就是时间。M-00 §4.6 已写死,不等改版。 + if (deviceTime != null) { + return new TimeResult(toLocal(deviceTime), devicePrecision); + } + + // ---------- 时源说有时间、但时间没给 ---------- + // tsSource=3:网关该打戳却没打(固件没带或厂家没透传)→ 落接收时间 + 强制 C。 + // 绝不能落 1900-01-01:那是墓碑帧专用值,也是 is_backfill 的判据之一, + // 拿来当"未知"会让墓碑行与未知时间行混成一堆,断档统计直接算错(J-00 §2.3 第 1 条)。 + // + // tsSource=0/1 走到这里属于报文自相矛盾(说有时间却没给), + // 处理方式同上——不拒收,降精度。丢数据比降精度更糟。 + return new TimeResult(received, TimeResult.PRECISION_C); + } + + private LocalDateTime toLocal(OffsetDateTime t) { + return t.atZoneSameInstant(ZONE).toLocalDateTime(); + } + + /** 取更差的那个精度:A 最好、C 最差。null 视为 C(信息缺失时保守) */ + private String worse(String a, String b) { + return rank(a) >= rank(b) ? normalize(a) : normalize(b); + } + + private int rank(String p) { + if (TimeResult.PRECISION_A.equals(p)) { + return 0; + } + if (TimeResult.PRECISION_B.equals(p)) { + return 1; + } + return 2; + } + + private String normalize(String p) { + return (TimeResult.PRECISION_A.equals(p) || TimeResult.PRECISION_B.equals(p)) + ? p : TimeResult.PRECISION_C; + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventValidator.java b/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventValidator.java new file mode 100644 index 0000000..d118fba --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/support/EventValidator.java @@ -0,0 +1,163 @@ +package cn.aomin.gateway.support; + +import cn.aomin.gateway.domain.Channel; +import cn.aomin.gateway.domain.dto.EventBatchRequest.EventItem; +import cn.aomin.gateway.domain.dto.RawFrameBatchRequest.RawFrameItem; +import cn.aomin.gateway.domain.dto.StatusBatchRequest.StatusItem; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Component; + +import java.util.regex.Pattern; + +/** + * 字段级校验,只查格式,不查业务(M-00 §4.1 第 ③ 步)。 + * + *

"这台设备该不该告警""这个类型对不对"都不在这里—— + * 前者是 D 域规则引擎的事,后者是 IoT 侧翻译的事。 + * + *

返回 {@code null} 表示通过,否则返回 J-00 §1.7 规定的那四个 reason 之一: + * {@code RATE_LIMITED} / {@code SCHEMA_INVALID} / {@code UNKNOWN_CHANNEL} / {@code SENSOR_ID_INVALID}。 + * 不自造第五个词,否则 IoT 侧按 reason 分类的统计就对不上。 + */ +@Component +public class EventValidator { + + public static final String RATE_LIMITED = "RATE_LIMITED"; + public static final String SCHEMA_INVALID = "SCHEMA_INVALID"; + public static final String UNKNOWN_CHANNEL = "UNKNOWN_CHANNEL"; + public static final String SENSOR_ID_INVALID = "SENSOR_ID_INVALID"; + + /** G 线:8 位大写 hex,不带厂商前缀 */ + private static final Pattern OWN_SN = Pattern.compile("^[0-9A-F]{8}$"); + + /** S/H 线:必须带厂商前缀,见 J-00 §1.4 */ + private static final Pattern PREFIXED_SN = Pattern.compile("^(HORN|MIR):[A-Za-z0-9._:-]{1,59}$"); + + /** + * 原始帧摘要:MD5(32 位)或 SHA-256(64 位),一律小写 hex。 + * + *

J-00 V1.7 起「新接口优先 SHA-256」,V1.7.1 起 I-01 也必填。 + * 之前只认 32 位,接入侧按新口径传 64 位会被判 {@code SCHEMA_INVALID}—— + * 契约允许但实现拒收,比 field 可空更坑。两种都收。 + */ + private static final Pattern RAW_REF = Pattern.compile("^([a-f0-9]{32}|[a-f0-9]{64})$"); + + /** + * 校验一条事件。 + * + * @param item 条目 + * @param batchChannel 批级缺省通道(条目自己没带时用它) + */ + public String checkEvent(EventItem item, String batchChannel) { + if (item == null) { + return SCHEMA_INVALID; + } + if (isBlank(item.getEventId()) || item.getEventId().length() > 96) { + return SCHEMA_INVALID; + } + if (isBlank(item.getSensorId())) { + return SCHEMA_INVALID; + } + if (isBlank(item.getEventType())) { + return SCHEMA_INVALID; + } + if (item.getReceivedAt() == null) { + return SCHEMA_INVALID; + } + if (item.getTsSource() == null || item.getTsSource() < 0 || item.getTsSource() > 3) { + // ts_source 没有默认值,漏传必须当场拒——不能让它悄悄变成 A 级数据 + return SCHEMA_INVALID; + } + if (isBlank(item.getProtocolVersion())) { + return SCHEMA_INVALID; + } + if (isBlank(item.getRawRef())) { + // J-00 V1.7.1 起必填。没有摘要就回指不到 I-12,排障时等于"这条事件没有原始报文"。 + // 墓碑帧同样必填——它是独立的一条 I-01 事件,填承载 undelivered[] 的网关帧摘要。 + return SCHEMA_INVALID; + } + if (!RAW_REF.matcher(item.getRawRef()).matches()) { + return SCHEMA_INVALID; + } + + String ch = isBlank(item.getSourceChannel()) ? batchChannel : item.getSourceChannel(); + Channel channel = Channel.of(ch); + if (channel == null) { + return UNKNOWN_CHANNEL; + } + + // payload.deviceTypeRaw 是 J-00 §1.3 定的必填:任何帧都带,含心跳与 UNKNOWN。 + // 没有它,野设备发来的帧无法判断是水浸求救还是门磁误报。 + JsonNode payload = item.getPayload(); + if (payload == null || !payload.hasNonNull("deviceTypeRaw")) { + return SCHEMA_INVALID; + } + + if (!sensorIdMatchesChannel(item.getSensorId(), channel)) { + return SENSOR_ID_INVALID; + } + return null; + } + + public String checkStatus(StatusItem item, String batchChannel) { + if (item == null || isBlank(item.getSensorId()) || item.getLastSeenAt() == null + || item.getReportedAt() == null || item.getOnline() == null + || isBlank(item.getProtocolVersion())) { + return SCHEMA_INVALID; + } + String ch = isBlank(item.getSourceChannel()) ? batchChannel : item.getSourceChannel(); + Channel channel = Channel.of(ch); + if (channel == null) { + return UNKNOWN_CHANNEL; + } + if (!sensorIdMatchesChannel(item.getSensorId(), channel)) { + return SENSOR_ID_INVALID; + } + return null; + } + + public String checkRawFrame(RawFrameItem item, String batchChannel) { + if (item == null || isBlank(item.getRawRef()) || item.getRawFrame() == null + || item.getReceivedAt() == null || isBlank(item.getProtocolVersion())) { + return SCHEMA_INVALID; + } + if (!RAW_REF.matcher(item.getRawRef()).matches()) { + return SCHEMA_INVALID; + } + String ch = isBlank(item.getSourceChannel()) ? batchChannel : item.getSourceChannel(); + if (Channel.of(ch) == null) { + return UNKNOWN_CHANNEL; + } + // sensorId 可空(J-00 §1.9.2:实在解析不出可留空,原文照样落库), + // 给了才校验前缀 + if (!isBlank(item.getSensorId()) && !sensorIdMatchesChannel(item.getSensorId(), Channel.of(ch))) { + return SENSOR_ID_INVALID; + } + return null; + } + + /** + * J-00 §1.4:{@code OWN_GW} 是 8 位 hex 不带前缀; + * {@code HORN_*} 必须 {@code HORN:}、{@code MIR_CELL} 必须 {@code MIR:}。 + * + *

中间件没补前缀就应当拒收,不要让业务层用"长度像不像"去猜—— + * 一旦猜错,{@code uk_vendor_sn} 就废了。 + */ + private boolean sensorIdMatchesChannel(String sensorId, Channel channel) { + // 列宽硬上限:sensor_id 是 VARCHAR(64),超了会在 insert 时抛 Data too long + // (未建档设备走 unregistered_device 也会炸),最后表现成 500,IoT 侧会当成我方故障无限重试。 + // 这类超长只可能来自坏固件,必须在格式校验这一关拦掉。 + if (sensorId.length() > 64) { + return false; + } + return switch (channel) { + case OWN_GW -> OWN_SN.matcher(sensorId).matches(); + case HORN_HOST, HORN_CELL -> sensorId.startsWith("HORN:") && PREFIXED_SN.matcher(sensorId).matches(); + case MIR_CELL -> sensorId.startsWith("MIR:") && PREFIXED_SN.matcher(sensorId).matches(); + }; + } + + private boolean isBlank(String s) { + return s == null || s.isBlank(); + } +} diff --git a/aomin-gateway/src/main/java/cn/aomin/gateway/support/LinkTypeResolver.java b/aomin-gateway/src/main/java/cn/aomin/gateway/support/LinkTypeResolver.java new file mode 100644 index 0000000..fab1930 --- /dev/null +++ b/aomin-gateway/src/main/java/cn/aomin/gateway/support/LinkTypeResolver.java @@ -0,0 +1,64 @@ +package cn.aomin.gateway.support; + +import cn.aomin.gateway.config.GatewayProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 解析 {@code adapter_in_raw.link_type},实现 M-00 §6.7 缺口③。 + * + *

为什么需要它:bignbiot 与铠湾 V3.0 两套 TCP 的 {@code sourceChannel} 都是 + * {@code MIR_CELL},而两套帧格式不同、设备类型码还从 {@code 08} 分叉 + * (铠湾 {@code 08}=水浸,bignbiot {@code 08}=白蚁)。不分出来,一行水浸原文有两种解释。 + * + *

为什么不问 IoT 要字段:J-00 信封已经定了,为一个排障字段去改契约不划算。 + * 而"哪个监听口接哪套协议"本来就是部署事实,写配置比写协议更贴切—— + * 换端口改配置,不用动代码、不用重签契约。 + * + *

查不到就返回 null:列可空,空着只是"不知道",编一个值才是错。 + * 宁可在排障时看到 NULL 再回头补配置,也不要写进去一个看起来像对的假值。 + */ +@Component +public class LinkTypeResolver { + + private static final Logger log = LoggerFactory.getLogger(LinkTypeResolver.class); + + /** 允许值。写错的值不落库,只报警——脏值比空值更难查 */ + public static final Set ALLOWED = Set.of( + "TCP_KAIWAN_V3", "TCP_BIGNBIOT", "MQTT_DIRECT", "AEP_JSON"); + + private final GatewayProperties props; + + public LinkTypeResolver(GatewayProperties props) { + this.props = props; + } + + /** + * @param gatewayDevId I-12 的 {@code gatewayDevId},接入端点标识,可空 + * @param sourceChannel 已归一化的通道名,可空 + * @return 合法取值之一;查不到或配置值非法时返回 {@code null} + */ + public String resolve(String gatewayDevId, String sourceChannel) { + String v = null; + if (gatewayDevId != null) { + v = props.getRaw().getLinkTypes().get(gatewayDevId); + } + if (v == null && sourceChannel != null) { + // 兜底只对"一个通道只有一套协议"的线成立。MIR_CELL 若在这里配了值, + // 等于把两套麦乐克 TCP 强行算成一套,排障时仍是分不出来——所以默认不配。 + v = props.getRaw().getChannelLinkTypes().get(sourceChannel); + } + if (v == null) { + return null; + } + if (!ALLOWED.contains(v)) { + log.warn("link_type 配置值非法,已忽略:gatewayDevId={} sourceChannel={} 值={} 允许值={}", + gatewayDevId, sourceChannel, v, ALLOWED); + return null; + } + return v; + } +} diff --git a/aomin-gateway/src/main/resources/application.yml b/aomin-gateway/src/main/resources/application.yml new file mode 100644 index 0000000..7505613 --- /dev/null +++ b/aomin-gateway/src/main/resources/application.yml @@ -0,0 +1,213 @@ +server: + port: 8080 + shutdown: graceful + tomcat: + threads: + max: 200 + min-spare: 10 + max-connections: 8192 + +spring: + application: + name: aomin-gateway + + # ---------- 数据源 ---------- + datasource: + # characterEncoding 必须写 Java 字符集名 UTF-8(驱动会自动协商成 utf8mb4)。 + # 写成 utf8mb4 会直接抛 Unsupported character encoding,连不上库。 + url: jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:3306}/${DB_NAME:aomin_dev}?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true&connectionCollation=utf8mb4_0900_ai_ci + # 上线必须用环境变量注入,并且换成只有 DML 权限的业务账号(不要 root)。 + # 口令只从环境变量 DB_PASS 取,不设默认值:未设置时启动直接失败, + # 不会静默回退到任何内置口令(SEC-Z-01)。 + # 本机运行示例:Windows set DB_PASS=<口令>  bash export DB_PASS=<口令> + username: ${DB_USER:root} + password: ${DB_PASS} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + pool-name: aomin-pool + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 3000 + idle-timeout: 600000 + max-lifetime: 1800000 + + # ---------- Redis ---------- + # 便携包自带 redis/ 目录,脚本会就地起在 6379。 + # 6379 被别的程序占了才需要改 REDIS_PORT。 + data: + redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + database: 0 + timeout: 3000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 1 + + # ---------- Flyway ---------- + # 本地库已有 83 表(手动建的),用 baseline 把现有结构认定为 V1.0.0, + # 之后所有变更从 V1.0.1 起递增。空库部署时会完整执行 V1.0.0。 + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + baseline-version: 1.0.0 + baseline-description: baseline-83-tables + encoding: UTF-8 + validate-on-migrate: true + + # ---------- JSON ---------- + jackson: + date-format: yyyy-MM-dd HH:mm:ss.SSS + time-zone: Asia/Shanghai + default-property-inclusion: non_null + serialization: + write-dates-as-timestamps: false + + # ---------- 上传限制(原文 raw 可能较大) ---------- + servlet: + multipart: + max-request-size: 20MB + max-file-size: 10MB + +# ---------- MyBatis-Plus ---------- +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + # 打印 SQL,联调期用;上线改 NoLoggingImpl + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + banner: false + db-config: + id-type: auto + +# ---------- 运维端点 ---------- +management: + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: always + health: + redis: + enabled: true + db: + enabled: true + +# ---------- 日志 ---------- +# LOG_DIR 由启动脚本传入(= 包内 logs 目录的绝对路径)。 +# 不写死盘符:整个包会被拷到别人的 D 盘、E 盘、U 盘上跑,写死就起不来。 +# 留了 ./logs 兜底,手工 java -jar 也能用(落在当前工作目录下)。 +logging: + file: + name: ${LOG_DIR:./logs}/app.log + logback: + rollingpolicy: + max-file-size: 50MB + max-history: 15 + total-size-cap: 500MB + level: + root: INFO + cn.aomin.gateway: DEBUG + org.springframework.web: INFO + +# ---------- M 域业务配置 ---------- +gateway: + auth: + # 【上线前必须改 true】现在关着,/internal/** 不验签名(没有时间戳、没有 nonce)。 + # 打开之后 M 层 31 条用例和模拟器都要补 X-Sign / X-Timestamp / X-Nonce 三个头, + # 所以要和 IoT 侧约同一批改,别单方面开。 + enabled: ${GATEWAY_AUTH_ENABLED:false} + token: ${GATEWAY_TOKEN:dev-token-please-change-in-production} + max-skew-seconds: 300 # J-00 §1.2:时间戳偏差 > 300 秒拒 + nonce-ttl-seconds: 600 # J-00 §1.2:nonce 在 Redis 留 600 秒 + # 来源网络闸:和上面的签名开关**互相独立**,默认开。 + # 签名关着的时候它照样生效,兜住"仅内网"这句话——不然任何能连 8080 的人都能伪造事件。 + network-guard-enabled: ${GATEWAY_NETWORK_GUARD:true} + # 留空 = 内置默认(127.0.0.0/8、::1/128、10/8、172.16/12、192.168/16)。 + # 配了就以配置为准,默认值不再参与。要卡死只放 IoT 服务器就写它一个地址: + # allowed-cidrs: ["10.20.30.41/32"] + allowed-cidrs: [] + rate-limit: + per-device-per-minute: 60 + per-channel-per-minute: 5000 + idempotency: + ttl-hours: 24 # J-00 §1.2:24 小时内重复请求直接返回首次结果 + raw: + # adapter_in_raw.link_type 的取值来源(Flyway V1.0.18 新增列)。 + # 这个值不在 J-00 信封里,也推不出来——麦乐克两套 TCP 同属 MIR_CELL。 + # 两级查找:先按 gatewayDevId 精确,再按 sourceChannel 兜底,都查不到就写 NULL。 + # 空着不影响入库,只是排障时少一条上下文。 + link-types: {} + # 接入端点 id(I-12 的 gatewayDevId)→ 链路类型。取值只有四个: + # TCP_KAIWAN_V3 / TCP_BIGNBIOT / MQTT_DIRECT / AEP_JSON + # 示例(等 IoT 定下端点 id 后照这个格式加): + # "S-TCP-24002": TCP_KAIWAN_V3 + # "S-TCP-24003": TCP_BIGNBIOT + channel-link-types: {} + # 兜底用,只适合"一个通道只有一套协议"的线。 + # MIR_CELL 千万别配——它底下压着 bignbiot 与铠湾 V3.0 两套,配了等于假装分得清。 + # HORN_HOST: MQTT_DIRECT + +# ====================================================================== +# 业务层(/api/v1/**) +# +# 这一段是本期新增的"对外业务 API"配置,和上面 gateway.* (M 域接入)分开。 +# ====================================================================== +biz: + token: + # 登录令牌签名密钥。**上线必须用环境变量覆盖**,写死在仓库里等于没有签名。 + secret: ${BIZ_TOKEN_SECRET:dev-only-change-me-2026} + ttl-hours: 168 # 7 天,小程序端不用频繁重新授权 + pii: + # 姓名、手机号的 AES 密钥。换了这把钥匙,存量 PII 就解不开了——更换要走迁移方案。 + key: ${BIZ_PII_KEY:dev-only-pii-key-2026} + sms: + # 开发态把验证码直接放在响应里,方便联调;上线设 false。 + echo: ${BIZ_SMS_ECHO:true} + # 演示模式:只写 notify_task + 日志,不真发。接真通道前必须设 false。 + demo: ${BIZ_SMS_DEMO:true} + # 短信合并窗口(秒):同一户、同一设备、同一规则在这个窗口内只发一条。 + # 短信是同步发的,发出去撤不回来——只能靠发之前就合并。 + # 0 = 不合并(按告警 ID 去重,一条告警一条短信)。 + merge-window-seconds: ${BIZ_SMS_MERGE_SECONDS:60} + demo: + # 【默认关】开了会建 admin / Admin@123456 这个固定账号,生产忘关就是后门。 + # 本地演示由 start-demo.bat / start-gateway.bat 显式设成 true。 + enabled: ${BIZ_DEMO_ENABLED:false} + + # ---------- 告警引擎 ---------- + alarm: + # 没有时间窗,也没有"多老算老"的阈值。 + # 曾经有过 60 分钟窗和 72 小时老化闸,两个都在问"这条够不够新"—— + # 而 receive_time 现在是业务层落库时自己打的 NOW(3),恒等于"现在", + # 事件只要进了库就是刚收到的。旧实现错在把 IoT 自报的 receivedAt 当判据, + # 外部传个旧时间就能让真实告警永远不处理。闸已拆,见 AlarmEngine#scanOnce。 + # 规则连续失败几次后转死信。转死信后会出现在 /health 的 warnings 和 + # 后台 /api/v1/admin/dead-letters,可人工回放。 + max-try: ${ALARM_MAX_TRY:3} + # 实时横幅合并窗口(秒)。**压得很短,不是 60 秒**: + # I-01 一批最多 200 条、攒批 200ms,一次抖动产生的重复记录几乎都落在同一批里、 + # 几百毫秒内就处理完,1 秒足够覆盖"同一批",又挡不掉两次真正独立的告警。 + # S1(SOS/水浸/烟感/燃气)用 1 秒:紧急告警宁可多弹,不能少弹。 + # S2/S3(门磁/电流/水振)用 5 秒:这类抖动更常见,5 秒也不影响反应速度。 + # 0 = 不合并。 + push-merge-s1-seconds: ${ALARM_PUSH_MERGE_S1_SECONDS:1} + push-merge-seconds: ${ALARM_PUSH_MERGE_SECONDS:5} + + # ---------- 事件表分区维护 ---------- + # 基线只把 iot_event 建到 p202612,后面挂 pmax 兜底。没有这个任务, + # 2027-01 起的数据会全进 pmax——既 DROP 不掉也 ADD 不进去,只能重建表。 + partition: + enabled: ${PARTITION_MAINTAIN_ENABLED:true} + # 永远保持未来 N 个月的空分区已就位。3 个月够跨一次长假运维空窗。 + ahead-months: 3 + # 事件保留月数(二期口径:事件留 2 年)。只影响回收判断,不影响加分区。 + retention-months: 24 + # 是否真的 DROP 超期分区。**默认 false**,只在日志里提示"哪些可以手工删"。 + # 删数据不可逆,保留期算错一次就没了,所以默认要人点头。 + auto-drop: false diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.0__baseline_schema.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.0__baseline_schema.sql new file mode 100644 index 0000000..9b67ffe --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.0__baseline_schema.sql @@ -0,0 +1,1764 @@ +-- ============================================================= +-- V1.0.0 基线:83 张表 +-- 来源:守望数据库设计文档 V1.9(陈工) +-- 说明:Flyway 基线条目。本地已有库用 baseline-on-migrate 认定为已执行; +-- 空库部署时会执行本文件建全部表。无外键,建表顺序无关。 +-- 纪律:本文件一经提交不得修改,任何变更用新版本号增量脚本。 +-- ============================================================= + +-- ============================================================= +-- 鹤仆·守望 S 线 · 数据库结构 V1.9 +-- 来源:守望数据库设计文档 V1.9(陈工) +-- 生成:2026-11-04 +-- +-- 内容:83 张表(含 V1.1 全部追加字段与索引),无外键,顺序无关。 +-- 说明:V1.9 文档里的 ALTER TABLE 补丁是给"老库升级"用的, +-- 新库的 CREATE TABLE 已是最终态,已核对无缺失字段, +-- 因此本脚本不需要执行任何 ALTER。 +-- +-- 用法:在空库上执行一次。重跑会报 table already exists。 +-- 要重建请先 DROP DATABASE aomin_dev; +-- ============================================================= + + + + +-- ---------- pm_company ---------- +CREATE TABLE pm_company ( + id BIGINT NOT NULL AUTO_INCREMENT, + company_code VARCHAR(32) NOT NULL COMMENT '公司编码,业务键', + name VARCHAR(100) NOT NULL COMMENT '公司名称', + short_name VARCHAR(50) NULL COMMENT '简称', + contact_name VARCHAR(50) NULL COMMENT '联系人', + contact_phone_enc VARBINARY(256) NULL COMMENT '联系人手机号 AES-256-GCM 密文(含nonce)', + address VARCHAR(200) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/停用', + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_company_code (company_code), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物业公司'; + +-- ---------- contract ---------- +CREATE TABLE contract ( + id BIGINT NOT NULL AUTO_INCREMENT, + contract_no VARCHAR(64) NOT NULL COMMENT '合同编号', + company_id BIGINT NOT NULL COMMENT '物业公司', + sign_date DATE NULL, + start_date DATE NULL, + end_date DATE NULL, + household_quota INT NULL COMMENT '签约户数', + amount DECIMAL(12,2) NULL COMMENT '合同金额(元)', + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/到期/终止', + file_url VARCHAR(300) NULL COMMENT '合同扫描件(MinIO)', + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_contract_no (contract_no), + KEY idx_company (company_id, status), + KEY idx_end_date (end_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='合同'; + +-- ---------- community ---------- +CREATE TABLE community ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_code VARCHAR(32) NOT NULL COMMENT '小区编码', + company_id BIGINT NOT NULL COMMENT '物业公司', + name VARCHAR(100) NOT NULL, + province VARCHAR(32) NULL, + city VARCHAR(32) NULL, + district VARCHAR(32) NULL, + address VARCHAR(200) NULL COMMENT '详细地址(SOS 升级链需推送给 120)', + household_count INT NOT NULL DEFAULT 0 COMMENT '规划户数', + manager_name VARCHAR(50) NULL COMMENT '物业经理', + manager_phone_enc VARBINARY(256) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/停用', + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_community_code (community_code), + KEY idx_company_status (company_id, status), + KEY idx_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小区'; + +-- ---------- building ---------- +CREATE TABLE building ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL COMMENT '楼栋号,如 3', + name VARCHAR(50) NOT NULL COMMENT '楼栋名,如 3号楼', + floor_count INT NULL, + sort_no INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_community_code (community_id, code), + KEY idx_community (community_id, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='楼栋'; + +-- ---------- unit ---------- +CREATE TABLE unit ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_id BIGINT NOT NULL COMMENT '冗余,便于按小区直查', + building_id BIGINT NOT NULL, + code VARCHAR(32) NOT NULL COMMENT '单元号,如 1', + name VARCHAR(50) NOT NULL COMMENT '单元名,如 1单元', + sort_no INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_building_code (building_id, code), + KEY idx_community (community_id), + KEY idx_building (building_id, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单元'; + +-- ---------- household ---------- +CREATE TABLE household ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_id BIGINT NOT NULL, + building_id BIGINT NOT NULL, + unit_id BIGINT NOT NULL, + room_no VARCHAR(32) NOT NULL COMMENT '房号,如 301', + floor INT NULL, + sku VARCHAR(32) NULL COMMENT '售卖套餐(仅计费与报表,不参与规则判断)', + main_line_channel VARCHAR(16) NULL COMMENT '主机线通道 OWN_GW/HORN_HOST/HORN_CELL/MIR_CELL; NULL=无主机线', + open_status VARCHAR(16) NOT NULL DEFAULT '未开通' COMMENT '未开通/已开通/已停用', + open_at DATETIME(3) NULL, + elder_count INT NOT NULL DEFAULT 0 COMMENT '冗余计数,列表页用', + device_count INT NOT NULL DEFAULT 0 COMMENT '冗余计数,列表页用', + -- [V1.1·IoT] 布撤防与基线状态(3 列由接入侧提出,baseline_learn_start_at 为本册补) + mode VARCHAR(8) NOT NULL DEFAULT 'HOME' COMMENT 'HOME/AWAY 安防语义,平台唯一权威,经 set_mode 下行;不复用为业务布撤防', + mode_ts DATETIME(3) NULL COMMENT '模式最后变更时刻;与网关 mode_cache 比对,时间戳新者胜出', + baseline_state VARCHAR(16) NOT NULL DEFAULT '学习期' COMMENT '学习期/已建立;缺此列界面会把没学完显示成正常', + baseline_learn_start_at DATETIME(3) NULL COMMENT '学习期起点,第 8 天起按个人基线', + address_text VARCHAR(300) NULL COMMENT '完整地址冗余,告警推送直接取用', + remark VARCHAR(500) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_unit_room (unit_id, room_no), + KEY idx_community_open (community_id, open_status), + KEY idx_building (building_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户'; + +-- ---------- community_audit_config ---------- +CREATE TABLE community_audit_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_id BIGINT NOT NULL, + audit_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '0免审核(默认) 1启用审核', + last_change_at DATETIME(3) NULL, + last_change_by BIGINT NULL, + last_change_reason VARCHAR(200) NULL, + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_community (community_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小区设备审核配置'; + +-- ---------- community_audit_config_log ---------- +CREATE TABLE community_audit_config_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + community_id BIGINT NOT NULL, + before_value TINYINT NOT NULL, + after_value TINYINT NOT NULL, + reason VARCHAR(200) NOT NULL COMMENT '变更原因,必填', + operator_id BIGINT NOT NULL, + operator_name VARCHAR(50) NOT NULL, + operator_ip VARCHAR(45) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_community_time (community_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审核开关变更留痕'; + +-- ---------- sys_user ---------- +CREATE TABLE sys_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + username VARCHAR(50) COLLATE utf8mb4_bin NOT NULL COMMENT '登录名', + password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt', + real_name VARCHAR(50) NOT NULL, + mobile_enc VARBINARY(256) NULL COMMENT '手机号密文', + mobile_hash CHAR(64) NULL COMMENT 'SHA-256(手机号+盐),用于按手机号查重', + company_id BIGINT NULL COMMENT '所属物业公司;运营侧为空', + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/停用/锁定', + failed_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + locked_until DATETIME(3) NULL, + pwd_updated_at DATETIME(3) NULL, + last_login_at DATETIME(3) NULL, + last_login_ip VARCHAR(45) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_username (username), + KEY idx_mobile_hash (mobile_hash), + KEY idx_company (company_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运营/物业账号'; + +-- ---------- sys_role ---------- +CREATE TABLE sys_role ( + id BIGINT NOT NULL AUTO_INCREMENT, + role_code VARCHAR(32) NOT NULL COMMENT 'OPS_ADMIN/OPS_STAFF/CS/FINANCE/PROP_ADMIN/PROP_STAFF', + role_name VARCHAR(50) NOT NULL, + data_scope_type VARCHAR(16) NOT NULL COMMENT 'ALL/COMMUNITY/BUILDING/SELF', + builtin TINYINT NOT NULL DEFAULT 0 COMMENT '内置角色不可删', + remark VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_role_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色'; + +-- ---------- sys_user_role ---------- +CREATE TABLE sys_user_role ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_role (user_id, role_id), + KEY idx_role (role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号-角色'; + +-- ---------- sys_menu ---------- +CREATE TABLE sys_menu ( + id BIGINT NOT NULL AUTO_INCREMENT, + parent_id BIGINT NOT NULL DEFAULT 0, + menu_name VARCHAR(50) NOT NULL, + menu_type VARCHAR(8) NOT NULL COMMENT 'M目录/D菜单/B按钮', + route_path VARCHAR(200) NULL, + component VARCHAR(200) NULL, + permission_code VARCHAR(64) NULL COMMENT '按钮权限码,如 dev:device:add', + icon VARCHAR(50) NULL, + sort_no INT NOT NULL DEFAULT 0, + visible TINYINT NOT NULL DEFAULT 1, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_parent (parent_id, sort_no), + KEY idx_perm (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='菜单与权限码'; + +-- ---------- sys_role_permission ---------- +CREATE TABLE sys_role_permission ( + id BIGINT NOT NULL AUTO_INCREMENT, + role_id BIGINT NOT NULL, + permission_code VARCHAR(64) NOT NULL COMMENT '权限码,如 dev:device:delete', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_role_perm (role_id, permission_code), + KEY idx_perm (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限'; + +-- ---------- sys_user_scope ---------- +CREATE TABLE sys_user_scope ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + scope_type VARCHAR(16) NOT NULL COMMENT 'COMMUNITY/BUILDING', + scope_id BIGINT NOT NULL COMMENT '小区ID 或 楼栋ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_scope (user_id, scope_type, scope_id), + KEY idx_scope (scope_type, scope_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据范围授权'; + +-- ---------- wx_user ---------- +CREATE TABLE wx_user ( + id BIGINT NOT NULL AUTO_INCREMENT, + openid VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + unionid VARCHAR(64) COLLATE utf8mb4_bin NULL COMMENT '打通服务号模板消息', + -- [V1.1·BIZ] 账号类型与注销锚点 + user_type VARCHAR(16) NOT NULL DEFAULT 'FAMILY' COMMENT 'FAMILY/VISITOR/ORG_MANAGER;物业与运营仍在 sys_user', + nickname VARCHAR(64) NULL, + avatar_url VARCHAR(300) NULL, + phone_enc VARBINARY(256) NULL, + phone_hash CHAR(64) NULL, + -- [V1.1·BIZ] 账密登录相关(可空;Q-5 业务层定稿"只走微信登录但保留这四列",二期开代登录/改密时直接用) + password_hash VARCHAR(100) NULL COMMENT 'BCrypt', + pwd_updated_at DATETIME(3) NULL COMMENT '密码更新时间,判断是否强制重登', + failed_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数', + locked_until DATETIME(3) NULL COMMENT '锁定截止;放库上,避免 Redis 重启后计数清空', + cancelled_at DATETIME(3) NULL COMMENT '注销时刻;满 30 天触发画像匿名化', + subscribe_json JSON NULL COMMENT '各模板订阅授权状态与剩余次数', + first_login_at DATETIME(3) NULL, + last_login_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_openid (openid), + KEY idx_unionid (unionid), + KEY idx_phone_hash (phone_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家属微信用户'; + +-- ---------- pii_person ---------- +CREATE TABLE pii_person ( + id BIGINT NOT NULL AUTO_INCREMENT, + name_enc VARBINARY(256) NULL COMMENT '姓名密文 nonce||ct||tag', + mobile_enc VARBINARY(256) NULL, + id_card_enc VARBINARY(256) NULL, + mobile_hash CHAR(64) NULL COMMENT 'SHA-256(手机号+盐),唯一性校验与检索用', + key_version TINYINT NOT NULL DEFAULT 1 COMMENT '密钥版本,支持轮换', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_mobile_hash (mobile_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PII 独立加密表'; + +-- ---------- elder ---------- +CREATE TABLE elder ( + id BIGINT NOT NULL AUTO_INCREMENT, + household_id BIGINT NOT NULL, + pii_id BIGINT NOT NULL COMMENT '姓名/手机号/身份证在 pii_person', + gender VARCHAR(8) NULL COMMENT '男/女', + birth_date DATE NULL, + care_level VARCHAR(16) NULL COMMENT '自理/半失能/失能', + live_alone TINYINT NOT NULL DEFAULT 0 COMMENT '独居标记,影响 NoWater 阈值', + profile_authorized TINYINT NOT NULL DEFAULT 0 COMMENT '画像授权(二期基线进化用)', + status VARCHAR(16) NOT NULL DEFAULT '在册' COMMENT '在册/离世/迁出', + deceased_at DATE NULL COMMENT '离世日期,30 天后触发匿名化', + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_household (household_id, status), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人'; + +-- ---------- elder_contact ---------- +CREATE TABLE elder_contact ( + id BIGINT NOT NULL AUTO_INCREMENT, + elder_id BIGINT NOT NULL, + contact_order TINYINT NOT NULL COMMENT '1第一联系人 2第二联系人(SOS 10 分钟顺位) 3其他', + pii_id BIGINT NOT NULL, + relation VARCHAR(20) NULL COMMENT '与老人关系', + remark VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_elder_order (elder_id, contact_order), + KEY idx_pii (pii_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老人紧急联系人'; + +-- ---------- family_relation ---------- +CREATE TABLE family_relation ( + id BIGINT NOT NULL AUTO_INCREMENT, + elder_id BIGINT NOT NULL, + wx_user_id BIGINT NOT NULL, + relation VARCHAR(20) NULL COMMENT '子女/配偶/亲属/其他', + is_admin TINYINT NOT NULL DEFAULT 0 COMMENT '该老人的主联系人', + status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1生效 2已拒 3已解绑', + source VARCHAR(20) NOT NULL DEFAULT 'FAMILY' COMMENT 'FAMILY/PROPERTY/OPS', + audit_by BIGINT NULL, + audit_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + active_uk TINYINT GENERATED ALWAYS AS (IF(status IN (0,1), 1, NULL)) STORED COMMENT '生效或待审唯一', + PRIMARY KEY (id), + UNIQUE KEY uk_elder_user_active (elder_id, wx_user_id, active_uk), + KEY idx_user (wx_user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家属与老人关系'; + +-- ---------- user_house ---------- +CREATE TABLE user_house ( + id BIGINT NOT NULL AUTO_INCREMENT, + wx_user_id BIGINT NOT NULL, + household_id BIGINT NOT NULL, + is_owner TINYINT NOT NULL DEFAULT 0 COMMENT '户主:一户一人', + owner_uk TINYINT GENERATED ALWAYS AS (IF(is_owner = 1 AND status = '生效', 1, NULL)) STORED COMMENT '一户一个户主', + perm_bits INT NOT NULL DEFAULT 1 COMMENT '位图:1查看 2告警接收 4设备管理 8成员管理 16结算', + status VARCHAR(16) NOT NULL DEFAULT '待接受' COMMENT '待接受/生效/已移除', + active_uk TINYINT GENERATED ALWAYS AS (IF(status <> '已移除', 1, NULL)) STORED COMMENT '同一人同一户只保留一条有效关系', + invite_code VARCHAR(16) COLLATE utf8mb4_bin NULL COMMENT '邀请码,6-8 位', + invite_expire_at DATETIME(3) NULL, + invited_by BIGINT NULL, + joined_at DATETIME(3) NULL, + removed_at DATETIME(3) NULL, + removed_by BIGINT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_house (wx_user_id, household_id, active_uk), + UNIQUE KEY uk_owner (household_id, owner_uk), + UNIQUE KEY uk_invite (invite_code), + KEY idx_house (household_id, status), + KEY idx_user (wx_user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户与户的成员关系'; + +-- ---------- user_consent ---------- +CREATE TABLE user_consent ( + id BIGINT NOT NULL AUTO_INCREMENT, + wx_user_id BIGINT NOT NULL, + consent_type VARCHAR(32) NOT NULL COMMENT 'CORE核心授权/AI_PROFILE画像授权/PRIVACY_POLICY隐私协议/MARKETING营销', + version VARCHAR(16) NOT NULL COMMENT '协议版本号,如 v2.1', + household_id BIGINT NULL COMMENT '按户授权时填', + elder_id BIGINT NULL COMMENT '画像授权按老人授权时填', + channel VARCHAR(16) NULL COMMENT 'MINIAPP/H5/ADMIN/PAPER', + granted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL COMMENT '撤回时刻;NULL=仍有效', + active_uk TINYINT GENERATED ALWAYS AS (IF(revoked_at IS NULL, 1, NULL)) STORED COMMENT '同类型同版本只能有一条未撤回', + evidence VARCHAR(300) NULL COMMENT '授权凭证(截图/双录编号/纸质扫描件)', + ip VARCHAR(45) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_type_ver (wx_user_id, consent_type, version, active_uk), + KEY idx_user (wx_user_id, consent_type), + KEY idx_elder (elder_id), + KEY idx_household (household_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权与同意记录'; + +-- ---------- device_type_info ---------- +CREATE TABLE device_type_info ( + id BIGINT NOT NULL AUTO_INCREMENT, + type_code VARCHAR(32) NOT NULL COMMENT '类型编码,如 WATER_LEAK', + type_name VARCHAR(64) NOT NULL, + category VARCHAR(32) NULL COMMENT '品类:门磁/水浸/SOS/燃气...', + capabilities JSON NOT NULL COMMENT '能力码数组,规则加载依据', + product_lines JSON NULL COMMENT '适用产品线 ["H","S","G"]', + image_url VARCHAR(300) NULL, + intro TEXT NULL COMMENT '小程序详情页介绍', + install_hint VARCHAR(500) NULL COMMENT '安装位置建议', + pair_guide VARCHAR(500) NULL COMMENT '配对引导文案', + sort_no INT NOT NULL DEFAULT 0, + enabled TINYINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_type_code (type_code), + KEY idx_enabled_sort (enabled, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备类型信息'; + +-- ---------- device ---------- +CREATE TABLE device ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT '业务主键,业务层生成,不可变', + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '出厂标识', + source_channel VARCHAR(16) NOT NULL COMMENT 'OWN_GW/HORN_HOST/HORN_CELL/MIR_CELL', + type_code VARCHAR(32) NOT NULL, + vendor VARCHAR(32) NULL, + model VARCHAR(64) NULL, + sku VARCHAR(32) NULL COMMENT '仅计费与报表', + parent_dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL COMMENT '主机/网关下的子设备', + community_id BIGINT NULL, + building_id BIGINT NULL, + unit_id BIGINT NULL, + household_id BIGINT NULL, + elder_id BIGINT NULL, + install_location VARCHAR(100) NULL COMMENT '安装位置,如 厨房水槽下', + -- [V1.1·IoT] 防区、身份基准与建档来源 + zone_no VARCHAR(8) NULL COMMENT '防区号,同户同类设备的区分依据,如 01=入户门 02=卫生间', + id_basis VARCHAR(16) NOT NULL DEFAULT 'SUB_SN' COMMENT '身份基准 SUB_SN/SUB_ZONE/TYPE_DERIVED;由首条报文锁定,此后不改', + reg_source VARCHAR(16) NOT NULL DEFAULT 'FIRST_SEEN' COMMENT '建档来源 FIRST_SEEN首见/PRE_REGISTER预注册/QR_ACTIVATE扫码激活;只升不降', + ts_precision VARCHAR(4) NOT NULL DEFAULT 'C' COMMENT '时间精度等级 A≤1s/B≤30s/C未知;建档按 source_channel 赋默认、运营可调;基线采样与界面文案按此列判定(4.4.1)。默认取 C 是刻意的:漏判只会少用数据,不会伪造精度', + -- [V1.1·BIZ] 家属端展示用的三个字段 + display_name VARCHAR(32) NULL COMMENT '家属可编辑展示名(主卧门磁);为空按类型+同类序号展示', + remark VARCHAR(200) NULL COMMENT '内部备注,不展示给家属', + sort_no INT NOT NULL DEFAULT 0 COMMENT '小程序展示排序', + status VARCHAR(16) NOT NULL DEFAULT '已入库' COMMENT '已入库/已分配/已安装/已激活/停用/退役/维修', + status_changed_at DATETIME(3) NULL COMMENT '进入当前状态的时间', + source VARCHAR(20) NOT NULL COMMENT 'MANUAL/VENDOR_LIST/PROPERTY_REGISTER', + code_id BIGINT NULL COMMENT '码池分配(逆查 code_pool_alloc)', + suspect_flag VARCHAR(64) NULL COMMENT '串货嫌疑标记,逗号分隔', + activated_at DATETIME(3) NULL, + deleted BIGINT NOT NULL DEFAULT 0 COMMENT '0存活;删除时置为自身 id', + deleted_reason VARCHAR(200) NULL, + created_by BIGINT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dev_id (dev_id), + UNIQUE KEY uk_sensor_alive (sensor_id, deleted), + KEY idx_household (household_id, status), + KEY idx_community_status (community_id, status, source_channel), + KEY idx_status_changed (status, status_changed_at), + KEY idx_type (type_code), + KEY idx_parent (parent_dev_id), + KEY idx_code (code_id), + KEY idx_elder (elder_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备业务档案'; + +-- ---------- device_tech_cache ---------- +CREATE TABLE device_tech_cache ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + online_status VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN' COMMENT 'ONLINE/OFFLINE/UNKNOWN', + battery TINYINT NULL COMMENT '电量百分比,S 线部分型号无', + battery_mv SMALLINT NULL COMMENT '[V1.1·IoT] 电量毫伏原始值;battery 是换算后的展示值,阈值改动后需可复算', + csq TINYINT NULL COMMENT '信号质量', + firmware_version VARCHAR(32) NULL, + last_seen_at DATETIME(3) NULL COMMENT '最后一次任意上报', + device_online_at DATETIME(3) NULL COMMENT '[V1.1·IoT] 最近一次判定为在线的时间;区分设备没上报与平台没判定', + last_heartbeat_at DATETIME(3) NULL COMMENT '最后一次心跳', + undelivered_count INT NOT NULL DEFAULT 0 COMMENT 'G 线未送达计数,H/S 线恒为 0', + -- [V1.1·IoT] 断档计算的两个输入 + last_seq BIGINT NULL COMMENT '该设备最后一次上报的 433 序号;断档 delta=new_seq-last_seq 的减数', + lost_cnt TINYINT NULL COMMENT '哨兵上报的累积未送达计数(byte14,0-255 饱和);与 last_seq 交叉校验', + raw_snapshot JSON NULL COMMENT '原始状态快照,排障用', + synced_at DATETIME(3) NULL COMMENT '与 IoT 对账时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dev_id (dev_id), + KEY idx_online (online_status, last_seen_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备技术状态缓存'; + +-- ---------- device_state_log ---------- +CREATE TABLE device_state_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + from_status VARCHAR(16) NOT NULL, + to_status VARCHAR(16) NOT NULL, + reason VARCHAR(200) NULL COMMENT '停用/退役/维修原因,必填', + source VARCHAR(16) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL/AUTO/IOT', + operator_type VARCHAR(16) NULL COMMENT 'OPS/PROPERTY/FAMILY/SYSTEM', + operator_id BIGINT NULL, + operator_name VARCHAR(50) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_dev_time (dev_id, created_at), + KEY idx_time (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备状态变更记录'; + +-- ---------- device_op_log ---------- +CREATE TABLE device_op_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + op_type VARCHAR(32) NOT NULL COMMENT 'ADD/EDIT/DELETE/BIND_COMMUNITY/STATE_CHANGE/BINDING_CHANGE/BIND_HOUSE/UNBIND_SELF/UNBIND_FORCE/IMPORT/QRCODE_PRINT', + op_summary VARCHAR(300) NULL COMMENT '人可读摘要', + op_detail JSON NULL COMMENT '变更明细(diff)', + reason VARCHAR(200) NULL COMMENT '[V1.1·BIZ] 解绑/强制解绑原因;UNBIND_FORCE 必填,放独立列才能统计与强制', + operator_type VARCHAR(16) NOT NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(50) NULL, + ip VARCHAR(45) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_dev_time (dev_id, created_at), + KEY idx_type_time (op_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备操作留痕'; + +-- ---------- device_binding_family ---------- +CREATE TABLE device_binding_family ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + wx_user_id BIGINT NOT NULL, + is_admin TINYINT NOT NULL DEFAULT 0, + bind_type VARCHAR(20) NOT NULL COMMENT 'FIRST_SCAN/ADMIN_APPROVE/OPS_ASSIGN', + status TINYINT NOT NULL DEFAULT 1 COMMENT '0待审核 1生效 2已拒 3已解绑', + bind_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + unbind_at DATETIME(3) NULL, + unbind_reason VARCHAR(200) NULL, + audit_by BIGINT NULL, + audit_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + admin_uk TINYINT GENERATED ALWAYS AS (IF(is_admin = 1 AND status = 1, 1, NULL)) STORED COMMENT '管理员唯一', + active_uk TINYINT GENERATED ALWAYS AS (IF(status IN (0,1), 1, NULL)) STORED COMMENT '活跃绑定唯一', + PRIMARY KEY (id), + UNIQUE KEY uk_dev_admin (dev_id, admin_uk), + UNIQUE KEY uk_dev_user_active (dev_id, wx_user_id, active_uk), + KEY idx_user (wx_user_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备-家属绑定'; + +-- ---------- property_device_registration ---------- +CREATE TABLE property_device_registration ( + id BIGINT NOT NULL AUTO_INCREMENT, + sn VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + type_code VARCHAR(32) NOT NULL, + model VARCHAR(64) NULL, + sku VARCHAR(32) NULL, + install_location VARCHAR(100) NULL, + community_id BIGINT NOT NULL, + building_id BIGINT NULL, + unit_id BIGINT NULL, + household_id BIGINT NULL, + elder_id BIGINT NULL, + audit_snapshot TINYINT NOT NULL COMMENT '提交瞬间的开关快照 0免审核 1需审核', + status VARCHAR(16) NOT NULL COMMENT '待审核/审核通过/审核驳回/自动通过', + reject_reason VARCHAR(200) NULL, + suspect_flag VARCHAR(64) NULL COMMENT 'SUSPECT_PREFIX/CROSS_COMMUNITY/BATCH/BULK', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL COMMENT '通过后关联', + submit_by BIGINT NOT NULL, + submit_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + audit_by BIGINT NULL, + audit_at DATETIME(3) NULL, + resubmit_count INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + pending_uk TINYINT GENERATED ALWAYS AS (IF(status = '待审核', 1, NULL)) STORED COMMENT '待审唯一', + PRIMARY KEY (id), + UNIQUE KEY uk_sn_pending (sn, community_id, pending_uk), + KEY idx_community_status (community_id, status, submit_at), + KEY idx_dev (dev_id), + KEY idx_submit_by (submit_by, submit_at), + KEY idx_spotcheck (status, suspect_flag, audit_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物业设备登记申请'; + +-- ---------- property_registration_spot_check ---------- +CREATE TABLE property_registration_spot_check ( + id BIGINT NOT NULL AUTO_INCREMENT, + registration_id BIGINT NOT NULL, + community_id BIGINT NOT NULL, + check_result VARCHAR(16) NOT NULL COMMENT 'PASS/FAIL', + issue_types JSON NULL COMMENT 'SN 与实物不符/位置不符/重复登记/其他', + issue_desc VARCHAR(500) NULL, + rectify_required TINYINT NOT NULL DEFAULT 0, + rectify_deadline DATE NULL, + checked_by BIGINT NOT NULL, + checked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_registration (registration_id), + KEY idx_community_time (community_id, checked_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登记事后抽查记录'; + +-- ---------- device_apply ---------- +CREATE TABLE device_apply ( + id BIGINT NOT NULL AUTO_INCREMENT, + apply_no VARCHAR(32) NOT NULL, + community_id BIGINT NOT NULL, + household_id BIGINT NULL, + applicant_user_id BIGINT NOT NULL COMMENT '物业账号', + sku VARCHAR(32) NULL, + qty INT NOT NULL DEFAULT 1, + reason VARCHAR(500) NOT NULL COMMENT '需求原因', + expect_arrive_date DATE NULL, + status VARCHAR(16) NOT NULL DEFAULT '已提交' COMMENT '已提交/处理中/已完成/已驳回', + handle_result VARCHAR(16) NULL COMMENT '补发/补充/驳回', + handle_remark VARCHAR(500) NULL, + related_dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + handled_by BIGINT NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_apply_no (apply_no), + KEY idx_community_status (community_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备申请'; + +-- ---------- code_batch ---------- +CREATE TABLE code_batch ( + id BIGINT NOT NULL AUTO_INCREMENT, + batch_no VARCHAR(32) NOT NULL COMMENT '批次号,如 B20261026-01', + sku VARCHAR(32) NULL, + vendor VARCHAR(32) NULL COMMENT '[V1.1·BIZ] 本批码的厂商;同批混厂商时用于拆分', + qty INT NOT NULL COMMENT '本批数量', + generated_by BIGINT NOT NULL, + generated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + remark VARCHAR(200) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_batch_no (batch_no), + KEY idx_generated (generated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='码生成批次'; + +-- ---------- code_pool ---------- +CREATE TABLE code_pool ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(48) COLLATE utf8mb4_bin NOT NULL COMMENT 'WDS1:A| + 16位Base32 + 2位校验', + batch_id BIGINT NULL, + sku VARCHAR(32) NULL, + -- [V1.1·BIZ] 码到厂商/订单/发货单的追溯链 + vendor VARCHAR(32) NULL COMMENT '码所属厂商 OWN/HORN/MIR;H-01 要求不得由 sku 反推,否则补发防不住串货', + order_id VARCHAR(64) NULL COMMENT '所属订单号', + shipment_no VARCHAR(48) NULL COMMENT '所属发货单号', + status TINYINT NOT NULL DEFAULT 0 COMMENT '0生成 1入库 2分配 3发货 4激活 5作废 6过期 7冻结', + expire_at DATETIME(3) NULL COMMENT '有效期,默认 24 个月', + freeze_reason VARCHAR(200) NULL, + void_reason VARCHAR(200) NULL, + version INT NOT NULL DEFAULT 0 COMMENT '分配/状态变更乐观锁', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_code (code), + KEY idx_batch (batch_id), + KEY idx_status_time (status, created_at), + KEY idx_sku (sku), + KEY idx_expire (status, expire_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='码池'; + +-- ---------- code_pool_alloc ---------- +CREATE TABLE code_pool_alloc ( + id BIGINT NOT NULL AUTO_INCREMENT, + code_id BIGINT NOT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + sku VARCHAR(32) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/已撤销', + active_uk TINYINT GENERATED ALWAYS AS (IF(status = '生效', 1, NULL)) STORED COMMENT '生效分配唯一', + allocated_by BIGINT NOT NULL, + allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_code_dev (code_id, dev_id, active_uk), + KEY idx_dev (dev_id), + KEY idx_code (code_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='码-设备分配(一码多机)'; + +-- ---------- activation_log ---------- +CREATE TABLE activation_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + code_id BIGINT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + household_id BIGINT NULL, + wx_user_id BIGINT NULL, + result VARCHAR(16) NOT NULL COMMENT 'SUCCESS/FAIL', + fail_code VARCHAR(16) NULL COMMENT '40031 前置未激活/40032 归属冲突/40034 码失效', + ip VARCHAR(45) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_code (code_id), + KEY idx_dev (dev_id, created_at), + KEY idx_user (wx_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='激活记录'; + +-- ---------- device_code_add_log ---------- +CREATE TABLE device_code_add_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + household_id BIGINT NOT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + input_code_masked VARCHAR(48) NOT NULL COMMENT '后四位保留,其余掩码', + input_type VARCHAR(16) NOT NULL COMMENT 'SCAN/MANUAL', + result VARCHAR(16) NOT NULL COMMENT 'MATCHED/NOT_FOUND/OCCUPIED/EXISTED/PENDING_AUDIT', + audit_work_order_id BIGINT NULL, + wx_user_id BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_household_time (household_id, created_at), + KEY idx_user_time (wx_user_id, created_at), + KEY idx_result (result, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备码添加记录'; + +-- ---------- unregistered_device ---------- +CREATE TABLE unregistered_device ( + id BIGINT NOT NULL AUTO_INCREMENT, + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + source_channel VARCHAR(16) NOT NULL, + vendor VARCHAR(32) NULL, + model VARCHAR(64) NULL, + first_seen_at DATETIME(3) NOT NULL, + last_seen_at DATETIME(3) NOT NULL, + report_count INT NOT NULL DEFAULT 1, + last_payload JSON NULL, + status VARCHAR(16) NOT NULL DEFAULT '待核查' COMMENT '待核查/已注册/已拒绝', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + handled_by BIGINT NULL, + handled_at DATETIME(3) NULL, + handle_reason VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sensor (sensor_id), + KEY idx_status_seen (status, last_seen_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='未注册设备'; + +-- ---------- dev_id_rule ---------- +CREATE TABLE dev_id_rule ( + id BIGINT NOT NULL AUTO_INCREMENT, + rule_code VARCHAR(32) NOT NULL COMMENT '规则标识,如 G_DOOR', + source_channel VARCHAR(16) NOT NULL, + model_pattern VARCHAR(64) NULL COMMENT '型号正则;NULL 表示该通道兜底规则', + template VARCHAR(100) NOT NULL COMMENT '如 OWN_GW_{sn}', + sn_regex VARCHAR(100) NULL COMMENT 'SN 合法性校验正则', + priority INT NOT NULL DEFAULT 0 COMMENT '匹配优先级,大者优先', + enabled TINYINT NOT NULL DEFAULT 1, + sample VARCHAR(96) NULL COMMENT '示例输出,配置页预览', + version INT NOT NULL DEFAULT 0, + updated_by BIGINT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_rule_code (rule_code), + KEY idx_match (source_channel, enabled, priority) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='dev_id 编号规则'; + +-- ---------- shipment ---------- +CREATE TABLE shipment ( + id BIGINT NOT NULL AUTO_INCREMENT, + shipment_no VARCHAR(48) COLLATE utf8mb4_bin NOT NULL, + order_id VARCHAR(64) COLLATE utf8mb4_bin NULL COMMENT '所属订单,与 code_pool.order_id 同值域', + sku VARCHAR(32) NULL COMMENT '本批发货的售卖口径', + device_qty INT NOT NULL DEFAULT 0 COMMENT '本批发货数量,四查一拒的数量上限', + status VARCHAR(16) NOT NULL DEFAULT '待发货' COMMENT '待发货/已发货/已签收/已撤销', + warehouse VARCHAR(32) NULL COMMENT '发货仓', + logistics_no VARCHAR(64) NULL COMMENT '物流单号', + community_id BIGINT NULL COMMENT '直发小区时填', + household_id BIGINT NULL COMMENT '直发到户时填', + shipped_at DATETIME(3) NULL, + signed_at DATETIME(3) NULL, + operator_id BIGINT NULL COMMENT '经办人(sys_user.id)', + remark VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_shipment_no (shipment_no), + KEY idx_status_time (status, shipped_at), + KEY idx_order (order_id), + KEY idx_household (household_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发货单'; + +-- ---------- shipment_bind ---------- +CREATE TABLE shipment_bind ( + id BIGINT NOT NULL AUTO_INCREMENT, + shipment_id BIGINT NOT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT '设备业务键', + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NULL COMMENT '出厂标识', + code_id BIGINT NULL COMMENT '扫的哪张码(可空)', + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/已撤销', + active_uk TINYINT GENERATED ALWAYS AS (IF(status = '生效', 1, NULL)) STORED COMMENT '一机一单', + scanner_type VARCHAR(16) NULL COMMENT 'WAREHOUSE/PROPERTY/COURIER', + scanner_id BIGINT NULL, + scanned_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + revoke_reason VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dev (dev_id, active_uk), + KEY idx_shipment (shipment_id, status), + KEY idx_code (code_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='发货扫码绑定(一机一单)'; + +-- ---------- order_device_list ---------- +CREATE TABLE order_device_list ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + shipment_id BIGINT NULL COMMENT '已知发货单时填', + dev_type VARCHAR(32) NOT NULL COMMENT '设备类型码,四查一拒的类型比对源', + sku VARCHAR(32) NULL, + qty INT NOT NULL DEFAULT 1, + code_batch_no VARCHAR(32) NOT NULL DEFAULT '' COMMENT '该行设备应属的码批次;空串=不限', + activate_status VARCHAR(16) NOT NULL DEFAULT '待激活' COMMENT '待激活/已激活/激活失败', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_type (order_id, dev_type, code_batch_no), + KEY idx_order (order_id), + KEY idx_shipment (shipment_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单设备清单(家属扫码只读)'; + +-- ---------- rule_def ---------- +CREATE TABLE rule_def ( + id BIGINT NOT NULL AUTO_INCREMENT, + rule_key VARCHAR(32) NOT NULL COMMENT 'NO_WATER/COMBO_FUSION...', + rule_name VARCHAR(64) NOT NULL, + required_caps JSON NULL COMMENT '全部满足', + required_any_caps JSON NULL COMMENT '满足任一', + forbidden_caps JSON NULL COMMENT '存在则禁用', + tier VARCHAR(8) NOT NULL COMMENT 'SECOND/HOUR/DAY', + evaluator VARCHAR(64) NOT NULL COMMENT '求值器 Bean 名', + default_params JSON NULL COMMENT '阈值默认值', + clear_events JSON NULL COMMENT 'V1.8:本条规则认的"解除/停止"事件值数组,如 ["LEAK_CLEAR"];匹配键 (dev_id, rule_key);空=本规则无解除语义,只能人工或超时收口', + alarm_level VARCHAR(10) NOT NULL COMMENT 'URGENT/ATTENTION/INFO', + suppressible TINYINT NOT NULL DEFAULT 1 COMMENT '可被静默时段抑制', + enabled TINYINT NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, + updated_by BIGINT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_rule_key (rule_key), + KEY idx_tier_enabled (tier, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规则定义'; + +-- ---------- iot_event ---------- +CREATE TABLE iot_event ( + id BIGINT NOT NULL AUTO_INCREMENT, + event_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT 'IoT 侧唯一ID,幂等依据', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL, + household_id BIGINT NULL COMMENT '写入时由设备缓存解析,冗余', + source_channel VARCHAR(16) NOT NULL, + event_type VARCHAR(32) NOT NULL COMMENT 'DOOR_OPEN/WATER/LEAK/SOS/FALL/GAS/SMOKE/POWER_CUT/HEARTBEAT...;event=4 的两类必须用专用取值:0x04 未送达告警帧=PRE_ALARM_0X04、墓碑行=MSG_UNDELIVERED(取值同 8.2 的 rule_key,不得自造,见 4.4 对照表)', + device_time DATETIME(3) NULL COMMENT '设备侧时间,部分厂商无', + -- [V1.1·IoT] 时源与补报标记:没有这四列,界面会把"平台收到的时间"当成"事情发生的时间",也分不清墓碑行与真实告警帧 + ts_source TINYINT NOT NULL COMMENT '0设备原始 1厂家云 2平台接收 3网关打戳;时延统计只认 0/1/3;基线采样按 4.4.1 的精度分级 A/B/C 取舍。★故意不给 DEFAULT:漏赋值要当场报 1364,不能悄悄变成 A 级数据(理由见 4.4.2)', + is_backfill TINYINT NOT NULL DEFAULT 0 COMMENT '1=非实时到达:断网补传、建档回灌(此两种 event_time 均为真值)或墓碑行(event=4 且 event_time=1900-01-01);0x04 未送达告警帧为 0', + retry_cnt TINYINT NULL COMMENT '仅 0x04 未送达告警帧:该帧此前的重传次数;墓碑行恒 NULL(网关断档路径无此信息)', + lost_cnt TINYINT NULL COMMENT '仅心跳行:该窗口未送达计数,255=饱和(按 >=255 解释)', + receive_time DATETIME(3) NOT NULL COMMENT '业务层接收时间', + seq BIGINT NULL COMMENT 'G 线 433 序号', + payload JSON NULL, + raw_ref VARCHAR(64) NULL COMMENT '[V1.1·IoT] 原始报文 MD5,回指 ingest_raw_log.payload_md5;不存自增 id(EMQX 两条规则拿不到对方 id)', + protocol_version VARCHAR(16) NULL COMMENT '[V1.1·IoT] 协议版本号;厂商保留协议更改权,历史需可按版本回溯', + processed TINYINT NOT NULL DEFAULT 0 COMMENT '规则是否已处理', + event_time DATETIME(3) NOT NULL COMMENT '分区键', + PRIMARY KEY (id, event_time), + UNIQUE KEY uk_event_id (event_id, event_time), + UNIQUE KEY uk_sensor_seq (sensor_id, seq, event_time) COMMENT 'V1.1 追加:按设备序号去重;event_id 由平台拼装时(含时间戳)重推会变键,只有这条拦得住', + KEY idx_dev_time (dev_id, event_time), + KEY idx_household_time (household_id, event_time), + KEY idx_type_time (event_type, event_time), + KEY idx_pending (processed, event_time), + KEY idx_backfill (is_backfill, event_time) COMMENT 'V1.1 追加:补报/补传过滤,丢帧率统计要排除' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备事件' +PARTITION BY RANGE COLUMNS(event_time) ( + PARTITION p1900 VALUES LESS THAN ('1901-01-01'), + PARTITION p_bad VALUES LESS THAN ('2020-01-01'), + PARTITION p202610 VALUES LESS THAN ('2026-11-01'), + PARTITION p202611 VALUES LESS THAN ('2026-12-01'), + PARTITION p202612 VALUES LESS THAN ('2027-01-01'), + PARTITION pmax VALUES LESS THAN (MAXVALUE) +); + +-- ---------- household_activity ---------- +CREATE TABLE household_activity ( + household_id BIGINT NOT NULL, + last_any_activity DATETIME(3) NULL COMMENT '最后一次任意活动', + last_door_open DATETIME(3) NULL, + last_water DATETIME(3) NULL, + last_current_on DATETIME(3) NULL, + door_open_since DATETIME(3) NULL COMMENT '门持续开启起点', + current_on_since DATETIME(3) NULL COMMENT '持续用电起点', + today_alarm_count INT NOT NULL DEFAULT 0, + stat_date DATE NULL COMMENT 'today_alarm_count 所属日期', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (household_id), + KEY idx_stat_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户活动状态落盘'; + +-- ---------- alarm ---------- +CREATE TABLE alarm ( + id BIGINT NOT NULL AUTO_INCREMENT, + alarm_no VARCHAR(32) COLLATE utf8mb4_bin NOT NULL COMMENT '对外单号 AL20261026xxxx', + household_id BIGINT NOT NULL, + elder_id BIGINT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + rule_key VARCHAR(32) NOT NULL, + tier VARCHAR(8) NOT NULL COMMENT 'SECOND/HOUR/DAY', + level VARCHAR(10) NOT NULL COMMENT 'URGENT/ATTENTION/INFO', + title VARCHAR(100) NOT NULL, + content VARCHAR(500) NULL, + trigger_at DATETIME(3) NOT NULL, + status VARCHAR(12) NOT NULL COMMENT 'NEW/PUSHED/CONFIRMED/ESCALATED/HANDLED/CLOSED/FALSE/DELAYED/SUPERSEDED', + active_key VARCHAR(96) NULL COMMENT 'householdId:ruleKey,仅进行中有值', + superseded_by BIGINT NULL COMMENT '被哪条新告警取代', + delay_until DATETIME(3) NULL COMMENT 'DELAYED 状态的解除时间(静默结束)', + confirmed_by BIGINT NULL, + confirmed_at DATETIME(3) NULL, + closed_by BIGINT NULL, + closed_at DATETIME(3) NULL, + cleared_at DATETIME(3) NULL COMMENT 'V1.8:设备自愈解除的时刻;与 closed_at 分开记——一个证明设备好了,一个证明有人关的', + clear_event_id BIGINT NULL COMMENT 'V1.8:触发解除的 iot_event.id;复盘时能指回原始帧,而不是只留一句"系统关闭"', + false_reason VARCHAR(50) NULL COMMENT '设备误触发/阈值过松/位置不当/家属误操作/其他', + escalate_stage TINYINT NOT NULL DEFAULT 0 COMMENT '0未升级 1客服 2二联系人 3-120', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_alarm_no (alarm_no), + UNIQUE KEY uk_active (active_key), + KEY idx_household_time (household_id, trigger_at), + KEY idx_status_level (status, level, trigger_at), + KEY idx_rule_time (rule_key, trigger_at), + KEY idx_delay (status, delay_until), + KEY idx_cleared (cleared_at) COMMENT 'V1.8:设备自愈率统计' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警'; + +-- ---------- alarm_action_log ---------- +CREATE TABLE alarm_action_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + alarm_id BIGINT NOT NULL, + action VARCHAR(20) NOT NULL COMMENT 'CREATE/PUSH/CONFIRM/ESCALATE/HANDLE/FALSE/CLOSE/REPLACE', + stage TINYINT NOT NULL DEFAULT 0 COMMENT '升级档位', + channel VARCHAR(20) NULL COMMENT 'WECHAT_SUB/WECHAT_MP/SMS/INAPP/PHONE', + operator_type VARCHAR(16) NULL COMMENT 'FAMILY/PROPERTY/OPS/SYSTEM', + operator_id BIGINT NULL, + operator_name VARCHAR(50) NULL, + remark VARCHAR(300) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_alarm_time (alarm_id, created_at), + KEY idx_action_time (action, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警处置链'; + +-- ---------- alarm_stat_daily ---------- +CREATE TABLE alarm_stat_daily ( + id BIGINT NOT NULL AUTO_INCREMENT, + stat_date DATE NOT NULL, + rule_key VARCHAR(32) NOT NULL, + reason VARCHAR(50) NOT NULL DEFAULT '' COMMENT '误报原因;空串=该规则当日总数行', + alarm_cnt INT NOT NULL DEFAULT 0, + false_cnt INT NOT NULL DEFAULT 0, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_date_rule_reason (stat_date, rule_key, reason), + KEY idx_date (stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警日统计'; + +-- ---------- household_holiday ---------- +CREATE TABLE household_holiday ( + household_id BIGINT NOT NULL COMMENT '户', + holiday_date DATE NOT NULL COMMENT '哪一天按假期处理', + source VARCHAR(16) NOT NULL COMMENT 'FAMILY_SET小程序设置/FAMILY_IGNORE告警时点忽略/OPS运营代设', + created_by BIGINT NULL COMMENT '操作人;OPS 代设时必有,家属自设填家属 wx_user.id', + remark VARCHAR(200) NULL COMMENT '如"春节回老家"', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (household_id, holiday_date), + KEY idx_date (holiday_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户假期(业务撤防):该日不报无活动、不进作息统计'; + +-- ---------- alarm_popup_ack ---------- +CREATE TABLE alarm_popup_ack ( + id BIGINT NOT NULL AUTO_INCREMENT, + alarm_id BIGINT NOT NULL COMMENT '告警', + user_id BIGINT NOT NULL COMMENT 'sys_user.id;运营与物业账号,家属不弹窗故不出现', + ack_type VARCHAR(12) NOT NULL DEFAULT 'SEEN' COMMENT 'SEEN我已看到/GO_HANDLE去处置/DISMISS卡片自动收起或手动关闭', + acked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_alarm_user (alarm_id, user_id), + KEY idx_user_time (user_id, acked_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警弹窗阅读回执'; + +-- ---------- notify_template ---------- +CREATE TABLE notify_template ( + id BIGINT NOT NULL AUTO_INCREMENT, + template_code VARCHAR(64) NOT NULL COMMENT '如 ALARM_URGENT_FAMILY', + template_name VARCHAR(100) NOT NULL, + biz_type VARCHAR(32) NOT NULL COMMENT 'ALARM/ACTIVATION/DEVICE/ORDER', + channel VARCHAR(20) NOT NULL COMMENT 'WECHAT_SUB/WECHAT_MP/SMS/INAPP', + wechat_template_id VARCHAR(64) NULL COMMENT '微信侧模板ID', + title VARCHAR(100) NULL, + content VARCHAR(1000) NULL COMMENT '含 {变量} 占位', + variables JSON NULL COMMENT '变量定义与示例', + enabled TINYINT NOT NULL DEFAULT 1, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_tpl_channel (template_code, channel), + KEY idx_biz (biz_type, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板'; + +-- ---------- notify_task ---------- +CREATE TABLE notify_task ( + id BIGINT NOT NULL AUTO_INCREMENT, + task_no VARCHAR(32) NOT NULL, + biz_type VARCHAR(32) NOT NULL COMMENT 'ALARM/ACTIVATION/DEVICE', + biz_id VARCHAR(64) NOT NULL COMMENT '业务主键,如 alarmId', + receiver_type VARCHAR(16) NOT NULL COMMENT 'FAMILY/PROPERTY/OPS/VISITOR', + receiver_id BIGINT NOT NULL, + template_code VARCHAR(64) NOT NULL, + channel_plan JSON NOT NULL COMMENT '["WECHAT_SUB","SMS"]', + payload JSON NULL COMMENT '模板变量', + dedup_key VARCHAR(128) NULL COMMENT '频控合并窗口键', + status VARCHAR(12) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SENT/FAILED/CANCELLED', + retry_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_task_no (task_no), + KEY idx_scan (status, next_retry_at), + KEY idx_dedup (dedup_key, created_at), + KEY idx_biz (biz_type, biz_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知任务'; + +-- ---------- notify_record ---------- +CREATE TABLE notify_record ( + id BIGINT NOT NULL AUTO_INCREMENT, + task_id BIGINT NOT NULL, + channel VARCHAR(20) NOT NULL COMMENT 'WECHAT_SUB/WECHAT_MP/SMS/INAPP', + provider_msg_id VARCHAR(64) NULL COMMENT '微信/短信网关返回的消息ID', + status VARCHAR(16) NOT NULL COMMENT 'SENT/FAILED/DELIVERED/READ', + error_code VARCHAR(32) NULL, + error_msg VARCHAR(300) NULL, + cost_ms INT NULL, + sent_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + callback_at DATETIME(3) NULL COMMENT '回执时间', + PRIMARY KEY (id), + KEY idx_task (task_id), + KEY idx_channel_time (channel, sent_at), + KEY idx_provider (provider_msg_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知发送回执'; + +-- ---------- notify_preference ---------- +CREATE TABLE notify_preference ( + id BIGINT NOT NULL AUTO_INCREMENT, + wx_user_id BIGINT NOT NULL, + household_id BIGINT NOT NULL DEFAULT 0 COMMENT '0=该用户全局默认;非 0=按户覆盖', + quiet_start TIME NULL COMMENT '免打扰开始', + quiet_end TIME NULL COMMENT '免打扰结束', + type_json JSON NULL COMMENT '按告警类型订阅开关,如 {"SOS":true,"WATER":false}', + channel_json JSON NULL COMMENT '通道偏好,如 {"MINIAPP":true,"SMS":true}', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_user_house (wx_user_id, household_id), + KEY idx_house (household_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知偏好'; + +-- ---------- work_order ---------- +CREATE TABLE work_order ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL COMMENT 'WO20261026xxxx', + type VARCHAR(24) NOT NULL COMMENT 'CUSTOMER_CALL/AMBULANCE/REPAIR/INSTALL/DEVICE_APPLY/SUSPECT_REVIEW/FAMILY_APPLY', + source VARCHAR(16) NOT NULL COMMENT 'ALARM/FAMILY/PROPERTY/OPS/SYSTEM', + biz_type VARCHAR(24) NULL COMMENT '关联业务类型', + biz_id VARCHAR(64) NULL COMMENT '关联业务ID', + community_id BIGINT NULL, + household_id BIGINT NULL, + elder_id BIGINT NULL, + title VARCHAR(100) NOT NULL, + content VARCHAR(1000) NULL COMMENT '含地址、老人信息摘要(120 工单需要)', + priority VARCHAR(8) NOT NULL DEFAULT 'NORMAL' COMMENT 'URGENT/NORMAL/LOW', + status VARCHAR(16) NOT NULL DEFAULT '已创建' COMMENT '已创建/已指派/处理中/已完成/已关闭/已取消', + assignee_type VARCHAR(16) NULL COMMENT 'OPS/PROPERTY/CS/AMBULANCE', + assignee_id BIGINT NULL, + assignee_name VARCHAR(50) NULL, + sla_due_at DATETIME(3) NULL COMMENT 'SLA 到期,升级链写入', + first_response_at DATETIME(3) NULL, + finished_at DATETIME(3) NULL, + closed_at DATETIME(3) NULL, + result VARCHAR(500) NULL COMMENT '处理结果', + created_by BIGINT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_status_sla (status, sla_due_at), + KEY idx_assignee (assignee_id, status), + KEY idx_household (household_id, created_at), + KEY idx_biz (biz_type, biz_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单'; + +-- ---------- work_order_log ---------- +CREATE TABLE work_order_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_id BIGINT NOT NULL, + action VARCHAR(20) NOT NULL COMMENT 'CREATE/ASSIGN/ACCEPT/PROCESS/FINISH/CLOSE/CANCEL', + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + operator_type VARCHAR(16) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(50) NULL, + remark VARCHAR(300) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_order_time (order_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工单流转日志'; + +-- ---------- audit_log ---------- +CREATE TABLE audit_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + trace_id VARCHAR(64) NULL, + operator_type VARCHAR(16) NOT NULL COMMENT 'OPS/PROPERTY/FAMILY/SYSTEM', + operator_id BIGINT NULL, + operator_name VARCHAR(50) NULL, + data_scope VARCHAR(64) NULL COMMENT '操作人所属小区集合,审计用', + action_type VARCHAR(40) NOT NULL COMMENT 'DEVICE_ADD/DEVICE_DELETE/CODE_VOID/AUDIT_SWITCH_CHANGE...', + biz_type VARCHAR(24) NULL COMMENT 'DEVICE/CODE/REGISTRATION/ALARM...', + biz_key VARCHAR(96) NULL COMMENT '业务键,如 dev_id', + content VARCHAR(500) NULL COMMENT '人可读摘要', + before_json JSON NULL COMMENT '变更前(只存变化字段)', + after_json JSON NULL COMMENT '变更后(只存变化字段)', + truncated TINYINT NOT NULL DEFAULT 0 COMMENT 'diff 超 8KB 被截断', + ip VARCHAR(45) NULL, + ua VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_operator_time (operator_id, action_type, created_at), + KEY idx_biz (biz_type, biz_key), + KEY idx_action_time (action_type, created_at), + KEY idx_time (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计日志'; + +-- ---------- sys_config ---------- +CREATE TABLE sys_config ( + id BIGINT NOT NULL AUTO_INCREMENT, + config_key VARCHAR(100) NOT NULL COMMENT '如 sos.escalate.1.minutes', + config_value TEXT NULL, + value_type VARCHAR(16) NOT NULL DEFAULT 'STRING' COMMENT 'STRING/INT/BOOL/JSON/DURATION', + group_name VARCHAR(32) NOT NULL COMMENT 'alarm/sos/device/code/notify/reconcile/switch', + description VARCHAR(300) NULL, + editable TINYINT NOT NULL DEFAULT 1 COMMENT '0 只读(如内部开关)', + updated_by BIGINT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_config_key (config_key), + KEY idx_group (group_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置'; + +-- ---------- sys_dict ---------- +CREATE TABLE sys_dict ( + id BIGINT NOT NULL AUTO_INCREMENT, + dict_type VARCHAR(64) NOT NULL COMMENT '如 FALSE_ALARM_REASON', + dict_name VARCHAR(100) NOT NULL, + remark VARCHAR(300) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dict_type (dict_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典类型'; + +-- ---------- sys_dict_item ---------- +CREATE TABLE sys_dict_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + dict_type VARCHAR(64) NOT NULL, + item_value VARCHAR(64) NOT NULL, + item_label VARCHAR(100) NOT NULL, + sort_no INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_type_value (dict_type, item_value), + KEY idx_type_sort (dict_type, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='字典项'; + +-- ---------- outbox_message ---------- +CREATE TABLE outbox_message ( + id BIGINT NOT NULL AUTO_INCREMENT, + topic VARCHAR(32) NOT NULL COMMENT 'outbox-iot/outbox-notify', + biz_key VARCHAR(96) NOT NULL COMMENT '如 dev_id', + payload JSON NOT NULL, + status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发 1成功 2失败待重试 3死信', + retry_count INT NOT NULL DEFAULT 0, + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + last_error VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_scan (status, next_retry_at), + KEY idx_biz (topic, biz_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出站消息表'; + +-- ---------- idempotent_record ---------- +CREATE TABLE idempotent_record ( + id BIGINT NOT NULL AUTO_INCREMENT, + idem_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT 'X-Idempotency-Key', + biz_type VARCHAR(32) NOT NULL COMMENT 'DEVICE_ADD/PROPERTY_SUBMIT/...', + biz_ref VARCHAR(96) NULL COMMENT '首次请求产生的业务主键', + response_code VARCHAR(32) NULL COMMENT '首次响应摘要,重放时返回', + expire_at DATETIME(3) NOT NULL COMMENT '24h 后清理', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_idem_key (idem_key), + KEY idx_expire (expire_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='幂等记录'; + +-- ---------- reconcile_diff ---------- +CREATE TABLE reconcile_diff ( + id BIGINT NOT NULL AUTO_INCREMENT, + batch_id VARCHAR(32) NOT NULL COMMENT '对账批次,如 RC202610261030', + diff_type VARCHAR(24) NOT NULL COMMENT 'MISSING_ON_IOT/EXTRA_ON_IOT/FIELD_MISMATCH', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NULL, + detail JSON NULL COMMENT '差异明细(期望值/实际值)', + status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/FIXED/IGNORED', + handled_by BIGINT NULL, + handled_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_batch (batch_id, diff_type), + KEY idx_status_time (status, created_at), + KEY idx_dev (dev_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='映射对账差异'; + +-- ---------- service_org ---------- +CREATE TABLE service_org ( + id BIGINT NOT NULL AUTO_INCREMENT, + org_no VARCHAR(32) NOT NULL COMMENT 'SO20261027xxxx', + name VARCHAR(100) NOT NULL COMMENT '机构名称,须与合同主体一致', + org_type VARCHAR(24) NOT NULL DEFAULT 'ELDER_CARE' COMMENT 'ELDER_CARE养老机构/COMMUNITY_SERVICE社区服务站/OTHER', + contract_no VARCHAR(64) NULL COMMENT '合作合同编号', + contract_start DATE NULL, + contract_end DATE NULL COMMENT '到期前 30 天提醒续签', + service_scope VARCHAR(500) NULL COMMENT '服务范围:小区ID列表或区/街道文本;派单前按此判断能否承接', + settle_mode VARCHAR(16) NOT NULL DEFAULT 'CONTRACT' COMMENT 'CONTRACT按合同结算;通道2不对单抽成', + contact_name VARCHAR(50) NULL COMMENT '机构联系人', + contact_phone_enc VARBINARY(256) NULL COMMENT '加密存储,与 community.manager_phone_enc 同法', + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/暂停/终止', + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_org_no (org_no), + UNIQUE KEY uk_contract_no (contract_no), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务机构(二期 P2-1)'; + +-- ---------- visit_media ---------- +CREATE TABLE visit_media ( + id BIGINT NOT NULL AUTO_INCREMENT, + visit_order_id BIGINT NOT NULL, + media_type VARCHAR(16) NOT NULL DEFAULT 'PHOTO' COMMENT 'PHOTO/VIDEO', + file_url VARCHAR(300) NOT NULL, + note VARCHAR(500) NULL COMMENT '文字备注:"已到门口,老人在睡觉"这类现场描述,家属直接看到', + gps_lat DECIMAL(10,7) NULL, + gps_lng DECIMAL(10,7) NULL, + gps_accuracy_m SMALLINT NULL COMMENT '定位精度(米);第三方回传可能为 NULL,不可当"没到场"判据', + captured_at DATETIME(3) NULL COMMENT '采集时间:优先取文件 EXIF,取不到才用上传时间(同 iot_event.ts_source 的思路)', + uploader_type VARCHAR(16) NULL COMMENT 'RIDER骑手/ORG_STAFF机构人员/GUARDIAN家属', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_order (visit_order_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='履约照片与备注(二期 P2-3)'; + +-- ---------- settlement ---------- +CREATE TABLE settlement ( + id BIGINT NOT NULL AUTO_INCREMENT, + settle_no VARCHAR(32) NOT NULL COMMENT 'ST202611xxxx', + org_id BIGINT NOT NULL, + period_start DATE NOT NULL, + period_end DATE NOT NULL, + order_count INT NOT NULL DEFAULT 0 COMMENT '周期内服务已完成且未被异议的单数(status=已完成 且 confirm_status 属于已确认/自动通过);取消、超时、异议中一律不计,明细查 visit_order', + amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, + status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '待确认/已确认/已支付/已关闭', + confirmed_at DATETIME(3) NULL, + paid_at DATETIME(3) NULL, + invoice_no VARCHAR(64) NULL, + remark VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_settle_no (settle_no), + UNIQUE KEY uk_org_period (org_id, period_start, period_end), + KEY idx_status_end (status, period_end) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='机构结算单(二期 P2-4)'; + +-- ---------- household_routine ---------- +CREATE TABLE household_routine ( + household_id BIGINT NOT NULL, + weekday_type VARCHAR(8) NOT NULL COMMENT 'WORKDAY/WEEKEND', + slot_no TINYINT NOT NULL COMMENT '半小时时段 0-47(0=00:00-00:30)', + p50_minutes SMALLINT NULL COMMENT '该时段活动时长中位数(分钟);NULL=样本不足,不是 0', + sample_days SMALLINT NOT NULL DEFAULT 0 COMMENT '该时段有效样本天数', + routine_state VARCHAR(16) NOT NULL DEFAULT 'LEARNING' COMMENT 'LEARNING学习期/ESTABLISHED已建立/INSUFFICIENT样本不足', + version INT NOT NULL DEFAULT 0 COMMENT '对应 routine_hist.version;0=尚未归档', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (household_id, weekday_type, slot_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户作息基线-时段表(二期 P2-5)'; + +-- ---------- routine_hist ---------- +CREATE TABLE routine_hist ( + id BIGINT NOT NULL AUTO_INCREMENT, + household_id BIGINT NOT NULL, + stat_month CHAR(7) NOT NULL COMMENT 'YYYY-MM', + version INT NOT NULL, + online_days TINYINT NOT NULL DEFAULT 0 COMMENT '当月在线天数(门槛 25)', + active_days TINYINT NOT NULL DEFAULT 0 COMMENT '当月有活动天数(门槛 20)', + workday_days TINYINT NOT NULL DEFAULT 0 COMMENT '工作日样本天数(门槛 8)', + weekend_days TINYINT NOT NULL DEFAULT 0 COMMENT '周末样本天数(门槛 8)', + routine_state VARCHAR(16) NOT NULL COMMENT 'LEARNING/ESTABLISHED/INSUFFICIENT;门槛任一不满足即 INSUFFICIENT', + slot_json JSON NULL COMMENT '该版本各时段统计快照,供回溯"当时按什么判的"', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_household_month_version (household_id, stat_month, version), + KEY idx_month_state (stat_month, routine_state) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户作息基线-月度样本与历史(二期 P2-5)'; + +-- ---------- household_baseline ---------- +CREATE TABLE household_baseline ( + id BIGINT NOT NULL AUTO_INCREMENT, + household_id BIGINT NOT NULL, + baseline_date DATE NOT NULL, + feature_json JSON NOT NULL COMMENT '各时段活动特征', + sample_days INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT '训练中' COMMENT '训练中/生效/失效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_household_date (household_id, baseline_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='户行为基线(二期)'; + +-- ---------- visitor ---------- +CREATE TABLE visitor ( + id BIGINT NOT NULL AUTO_INCREMENT, + pii_id BIGINT NOT NULL, + phone_hash CHAR(64) NULL, + cert_status VARCHAR(16) NOT NULL DEFAULT '待认证' COMMENT '待认证/已认证/已驳回', + service_area VARCHAR(200) NULL COMMENT '服务小区范围', + rating DECIMAL(3,2) NULL, + status VARCHAR(16) NOT NULL DEFAULT '生效', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_phone_hash (phone_hash), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='探视人(二期)'; + +-- ---------- visit_order ---------- +CREATE TABLE visit_order ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL, + household_id BIGINT NOT NULL, + elder_id BIGINT NULL, + alarm_id BIGINT NULL COMMENT '触发来源', + visitor_id BIGINT NULL COMMENT '通道1抢单人为注册探视人;通道2恒 NULL(执行人只记 executor_name)', + -- [V1.3·P2-2] 通道与机构归属 + dispatch_mode VARCHAR(16) NOT NULL DEFAULT 'CROWD' COMMENT 'CROWD众包抢单/ORG机构直派;取值走 sys_dict,不用 ENUM(加模式不改表)', + org_id BIGINT NULL COMMENT 'dispatch_mode=ORG 时必填;众包单恒 NULL', + executor_name VARCHAR(50) NULL COMMENT '执行人姓名手填:一线护工不开账号,无 user_id 可关联', + -- [V1.3·P2-2] 第三方履约渠道(美团等):只记来源与外部单号,不建门店表/外部订单表 + source_channel VARCHAR(24) NOT NULL DEFAULT 'SELF' COMMENT 'SELF自建/CROWD_3P第三方履约;第三方渠道不定性,见 4.8.0', + ext_order_no VARCHAR(64) NULL COMMENT '第三方平台单号;导流跳转模式下可能永为空', + plan_start DATETIME(3) NULL, + plan_end DATETIME(3) NULL, + actual_start DATETIME(3) NULL, + actual_end DATETIME(3) NULL, + verify_mode VARCHAR(16) NULL COMMENT 'SENSOR传感器/PHOTO_KNOCK拍照+敲门;V1.4 起实际不填(恒 NULL):核验方式由证据反推(有 visit_media 照片即拍照核验,iot_event 有同时段事件即传感佐证),不再声明', + verify_result VARCHAR(16) NULL COMMENT '探视结论:SAFE确认安全/UNKNOWN到场但无法确认(敲门无人应、只看到门关着)/ALARM判断有问题已上报;NULL=尚未探视。由探视员现场判定,不是系统推导;与"取证是否通过"无关', + -- [V1.3·P2-2] 家属确认三时点:完成→12h 催办→24h 自动通过;7 天内可异议.两个 deadline 均为下单时快照 + confirm_deadline_at DATETIME(3) NULL COMMENT 'actual_end+12h,催办起点(下单时按 sys_config 快照写入,不现算)', + auto_confirm_at DATETIME(3) NULL COMMENT 'actual_end+24h,自动通过时点(同上,快照)', + confirm_status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '待确认/已确认/自动通过/已异议', + confirmed_at DATETIME(3) NULL, + dispute_deadline_at DATETIME(3) NULL COMMENT 'actual_end+7d,异议截止', + status VARCHAR(16) NOT NULL DEFAULT '待派单' COMMENT '待派单/待接单/已接单/已到场/已完成/已取消/已超时;待接单仅众包出现(机构直派跳过);已完成即终态,家属确认走 confirm_status 不改本列', + amount DECIMAL(10,2) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + KEY idx_household (household_id, created_at), + KEY idx_visitor (visitor_id, status), + KEY idx_org_status (org_id, status), + KEY idx_confirm (confirm_status, confirm_deadline_at), + KEY idx_source (source_channel, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='探视工单(二期)'; + +-- ---------- visit_evidence ---------- +CREATE TABLE visit_evidence ( + id BIGINT NOT NULL AUTO_INCREMENT, + visit_order_id BIGINT NOT NULL, + evidence_type VARCHAR(24) NOT NULL COMMENT 'PHOTO/SENSOR_EVENT/GPS', + file_url VARCHAR(300) NULL, + sensor_snapshot JSON NULL COMMENT '传感器佐证', + captured_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_order (visit_order_id, evidence_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='履约存证(二期)'; + +-- ---------- pay_order ---------- +CREATE TABLE pay_order ( + id BIGINT NOT NULL AUTO_INCREMENT, + order_no VARCHAR(32) NOT NULL, + visit_order_id BIGINT NOT NULL, + amount DECIMAL(10,2) NOT NULL, + pay_status VARCHAR(16) NOT NULL DEFAULT '待支付' COMMENT '待支付/已支付/已退款/已关闭', + wx_transaction_id VARCHAR(64) NULL, + paid_at DATETIME(3) NULL, + refund_status VARCHAR(16) NULL, + refund_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_order_no (order_no), + UNIQUE KEY uk_visit (visit_order_id), + KEY idx_wx_txn (wx_transaction_id), + KEY idx_status (pay_status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='探视支付订单(二期)'; + +-- ---------- rider_wallet ---------- +CREATE TABLE rider_wallet ( + id BIGINT NOT NULL AUTO_INCREMENT, + visitor_id BIGINT NOT NULL, + balance_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '可用余额', + frozen_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '提现申请中冻结;审核驳回时解冻退回', + total_income_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '累计入账(不含冻结部分)', + total_withdraw_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '累计已到账提现', + status VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '正常/冻结;冻结后只进不出,用于风控或欠款追偿', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁;扣款与提现必须带 version 条件,禁止读-改-写', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_visitor (visitor_id), + KEY idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='探视人钱包(二期 P2-8)'; + +-- ---------- wallet_txn ---------- +CREATE TABLE wallet_txn ( + id BIGINT NOT NULL AUTO_INCREMENT, + txn_no VARCHAR(32) NOT NULL COMMENT 'WT202611xxxx', + visitor_id BIGINT NOT NULL, + visit_order_id BIGINT NULL COMMENT '关联探视单;提现与人工调账为空', + txn_type VARCHAR(24) NOT NULL COMMENT 'RIDE_INCOME接单收入/IDLE_COMP空跑补偿/WITHDRAW提现/REFUND退款冲正/ADJUST人工调账', + gross_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '订单总额;非收入类为 0', + platform_fee DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '平台抽成;比例取自 sys_config 并在此快照,不随配置变化', + net_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '实际入账=gross-platform_fee;提现与冲正为负', + balance_after DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '本次变动后余额;对账时用相邻两行验证流水链是否断', + remark VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_txn_no (txn_no), + UNIQUE KEY uk_order_type (visit_order_id, txn_type), + KEY idx_visitor_time (visitor_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钱包流水(二期 P2-8)'; + +-- ---------- withdraw_order ---------- +CREATE TABLE withdraw_order ( + id BIGINT NOT NULL AUTO_INCREMENT, + withdraw_no VARCHAR(32) NOT NULL COMMENT 'WD202611xxxx', + visitor_id BIGINT NOT NULL, + amount DECIMAL(12,2) NOT NULL COMMENT '申请金额;提交时从 balance 转入 frozen', + fee_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT '手续费;费率取自 sys_config', + actual_amount DECIMAL(12,2) NOT NULL COMMENT '实际到账=amount-fee_amount', + pay_channel VARCHAR(16) NOT NULL DEFAULT 'WX' COMMENT 'WX微信零钱/BANK银行卡', + account_ref VARBINARY(256) NULL COMMENT '收款账号密文;微信零钱走 openid 时为空。与 community.manager_phone_enc 同法加密', + status VARCHAR(16) NOT NULL DEFAULT '待审核' COMMENT '待审核/已打款/已到账/已驳回', + audit_by BIGINT NULL, + audited_at DATETIME(3) NULL, + paid_at DATETIME(3) NULL COMMENT '渠道受理时间,不等于到账', + arrived_at DATETIME(3) NULL COMMENT '对账确认到账时间;微信零钱多为 T+0,银行卡 T+1', + fail_reason VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_withdraw_no (withdraw_no), + KEY idx_visitor_time (visitor_id, created_at), + KEY idx_status (status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='钱包提现单(二期 P2-8)'; + +-- ---------- gateway ---------- +CREATE TABLE gateway ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT '对应 device.dev_id(source_channel=OWN_GW),1:1', + gateway_sn VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '产线烧录 SN;H 线=主机序列号加 HORN: 前缀', + iccid VARCHAR(32) COLLATE utf8mb4_bin NULL COMMENT '当前物联卡 ICCID,冗余自 sim_card', + csq TINYINT NULL COMMENT '4G 信号 0-31,安装验收要求 >=15', + firmware_version VARCHAR(32) NULL, + hw_version VARCHAR(32) NULL, + rf_mode VARCHAR(16) NOT NULL DEFAULT 'GFSK' COMMENT 'GFSK 量产/LORA 备选(固件切换,板不改)', + uplink VARCHAR(16) NOT NULL DEFAULT 'SELF_4G' COMMENT 'SELF_4G/CONCENTRATOR', + mode_cache VARCHAR(8) NULL COMMENT '网关本地缓存的模式 HOME/AWAY;权威值在 household.mode', + mode_synced_at DATETIME(3) NULL COMMENT '模式最后一次下行成功时刻', + online_status VARCHAR(16) NOT NULL DEFAULT 'UNKNOWN' COMMENT 'ONLINE/OFFLINE/UNKNOWN', + last_online_at DATETIME(3) NULL, + last_offline_at DATETIME(3) NULL, + rx_ok BIGINT NULL COMMENT '累计射频帧接收成功数(计数器原值)', + rx_gap BIGINT NULL COMMENT '累计丢帧数(计数器原值)', + power_on_seconds BIGINT NOT NULL DEFAULT 0 COMMENT '本次上电累计秒数', + last_reboot_reason VARCHAR(16) NULL COMMENT 'POWER_ON/WDT/LOW_VOLT/CMD/OTA/UNKNOWN', + last_reboot_at DATETIME(3) NULL, + buf_pending INT NOT NULL DEFAULT 0 COMMENT '本地缓存待补传帧数;>1000 触发运维告警(缓存满是覆盖,会丢数据)', + rf_power_dbm TINYINT NULL COMMENT '当前 433 发射功率 dBm,默认 9;产测硬卡 <=10mW', + seq_window_start BIGINT NULL COMMENT '收发序号窗口下界', + seq_window_end BIGINT NULL COMMENT '收发序号窗口上界', + bound_at DATETIME(3) NULL, + raw_snapshot JSON NULL COMMENT '最近一次 sys 状态原文,排障用', + synced_at DATETIME(3) NULL COMMENT '与 IoT 对账时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dev_id (dev_id), + UNIQUE KEY uk_gateway_sn (gateway_sn), + KEY idx_online (online_status, last_online_at), + KEY idx_iccid (iccid), + KEY idx_buf (buf_pending) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='4G 网关台账(device 的 1:1 扩展)'; + +-- ---------- gateway_online_log ---------- +CREATE TABLE gateway_online_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + gateway_dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + from_status VARCHAR(16) NOT NULL COMMENT 'ONLINE/OFFLINE/UNKNOWN', + to_status VARCHAR(16) NOT NULL, + reason VARCHAR(16) NULL COMMENT 'LWT/TIMEOUT/DISCONNECT/POWER_LOST/PROBE', + csq TINYINT NULL COMMENT '上线时信号强度', + duration_sec INT NULL COMMENT '上一状态持续秒数,写入时算好', + occurred_at DATETIME(3) NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_gw_time (gateway_dev_id, occurred_at), + KEY idx_status_time (to_status, occurred_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网关上下线流水'; + +-- ---------- device_credential ---------- +CREATE TABLE device_credential ( + id BIGINT NOT NULL AUTO_INCREMENT, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT '网关/主机,不接受哨兵(S 线走 433,无网络凭证)', + client_id VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT 'MQTT clientId,通常等于 dev_id', + secret_hash CHAR(64) NULL COMMENT 'SHA-256 十六进制;明文只在签发时返回一次', + algo VARCHAR(16) NOT NULL DEFAULT 'SHA256', + status VARCHAR(16) NOT NULL DEFAULT '生效' COMMENT '生效/停用/已吊销', + active_uk TINYINT GENERATED ALWAYS AS (IF(status = '生效', 1, NULL)) STORED COMMENT '一机一活跃凭据', + activated_at DATETIME(3) NULL, + revoked_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_client_active (client_id, active_uk), + KEY idx_dev (dev_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网关 MQTT 凭据(一机一密)'; + +-- ---------- sim_card ---------- +CREATE TABLE sim_card ( + id BIGINT NOT NULL AUTO_INCREMENT, + iccid VARCHAR(32) COLLATE utf8mb4_bin NOT NULL, + msisdn VARCHAR(20) NULL COMMENT '卡号(11 位手机号形态)', + imsi VARCHAR(32) NULL, + device_kind VARCHAR(16) NOT NULL DEFAULT 'GATEWAY' COMMENT 'GATEWAY(G/H 线一户一卡)/SENSOR(S 线一设备一卡)', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL COMMENT '设备业务键,未装机为 NULL', + carrier VARCHAR(16) NULL COMMENT 'CMCC/CUCC/CTCC', + apn VARCHAR(64) NULL COMMENT '定向 APN', + plan_name VARCHAR(64) NULL COMMENT '套餐名', + activate_date DATE NULL COMMENT '开卡日期', + expire_date DATE NULL COMMENT '到期日;提前 30 天/7 天两级提醒', + remind_status VARCHAR(16) NOT NULL DEFAULT '未提醒' COMMENT '未提醒/已提醒30天/已提醒7天', + remind_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT '正常' COMMENT '正常/停用/已停卡', + bound_at DATETIME(3) NULL, + unbound_at DATETIME(3) NULL, + remark VARCHAR(200) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_iccid (iccid), + KEY idx_expire (expire_date, remind_status), + KEY idx_dev (dev_id), + KEY idx_kind_status (device_kind, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='物联卡台账'; + +-- ---------- device_command ---------- +CREATE TABLE device_command ( + id BIGINT NOT NULL AUTO_INCREMENT, + command_no VARCHAR(48) COLLATE utf8mb4_bin NOT NULL COMMENT '业务层生成,幂等键', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT '目标设备', + gateway_dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL COMMENT 'V1.7 由可空改非空:执行节点=网关 SN/设备自身标识/云节点标识,独立设备填自身标识', + cmd_type VARCHAR(32) NOT NULL COMMENT 'SET_MODE/ARM/DISARM/TIME_SYNC/SET_ACK_POLICY/SET_REPORT_CFG/DEL_SENSOR/REBOOT/DIAG_DUMP/SET_OTA', + cmd_seq BIGINT NOT NULL COMMENT '平台单调递增序号,写进下行报文;网关回执原样回显,据此匹配', + params JSON NULL COMMENT '命令体,整块读写', + delivery_mode VARCHAR(16) NOT NULL DEFAULT 'DEFERRED' COMMENT 'V1.7:REALTIME(实时,设备离线当场失败)/DEFERRED(可延迟,上线重排);创建时定死', + reclaim_count INT NOT NULL DEFAULT 0 COMMENT 'V1.7:租约回收次数;与 retry_count 分开——"中间件没回话"≠"设备坏了"', + lease_expire_at DATETIME(3) NULL COMMENT 'V1.7:租约到期(领取时置 now+60s);超时未回报由扫描任务回收重派', + expire_at DATETIME(3) NULL COMMENT 'V1.7:SENT 的兜底到期;24h 未收到 ACKED 判 TIMEOUT', + dump_path VARCHAR(300) NULL COMMENT '诊断包对象存储路径;二期 I-17 用,一期恒为 NULL', + status VARCHAR(16) NOT NULL DEFAULT '待下发' COMMENT '待下发/已下发/已生效/失败/超时/已取消', + result_code VARCHAR(16) NULL COMMENT '0 成功/1 不支持/2 参数非法/3 执行失败/4 设备离线;设备原始码原样透传,接入侧不解释', + result_msg VARCHAR(200) NULL, + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + issued_at DATETIME(3) NULL, + acked_at DATETIME(3) NULL, + timeout_at DATETIME(3) NULL COMMENT '超时判定时刻,扫描任务用', + operator_type VARCHAR(16) NOT NULL DEFAULT 'SYSTEM' COMMENT 'FAMILY/PROPERTY/OPS/SYSTEM', + operator_id BIGINT NULL, + trace_id VARCHAR(64) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_command_no (command_no), + UNIQUE KEY uk_gw_seq (gateway_dev_id, cmd_seq), + KEY idx_scan (status, timeout_at), + KEY idx_dev_time (dev_id, created_at), + KEY idx_type_time (cmd_type, created_at), + KEY idx_lease (lease_expire_at, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下行命令流水'; + +-- ---------- ingest_raw_log ---------- +CREATE TABLE ingest_raw_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + gateway_dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + topic VARCHAR(128) NULL COMMENT 'MQTT topic', + qos TINYINT NOT NULL DEFAULT 1, + pkt_time DATETIME(3) NULL COMMENT '报文顶层 ts(网关组包时刻),与 received_at 对拍可判断网关时钟漂移', + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NULL COMMENT 'V1.6 新增:报文涉及的传感器;G 线打包帧可为 NULL,解析后回填', + payload MEDIUMTEXT NULL COMMENT '原始报文全文,不裁剪', + payload_size INT NULL COMMENT '报文字节数', + payload_md5 CHAR(32) NOT NULL COMMENT 'V1.6:由 NULL 改 NOT NULL——它是 I-12 的幂等键;iot_event.raw_ref 回指此列', + parse_result VARCHAR(16) NOT NULL DEFAULT 'OK' COMMENT 'OK/JSON_INVALID/FIELD_MISSING/GW_MISMATCH', + err_msg VARCHAR(300) NULL, + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_md5 (payload_md5), + KEY idx_gw_time (gateway_dev_id, received_at), + KEY idx_sensor_time (sensor_id, received_at), + KEY idx_result (parse_result, received_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='网关 MQTT 上行原始报文(保留 30 天)'; + +-- ---------- adapter_in_raw ---------- +CREATE TABLE adapter_in_raw ( + id BIGINT NOT NULL AUTO_INCREMENT, + device_sn VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '厂商侧设备号(SN/IMEI/主机子设备号),排障入口', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL COMMENT '映射后的业务键;未建档为 NULL', + sensor_id VARCHAR(64) COLLATE utf8mb4_bin NULL, + source_channel VARCHAR(16) NOT NULL COMMENT 'HORN_HOST/HORN_CELL/MIR_CELL', + vendor VARCHAR(16) NOT NULL COMMENT 'HORN/MIR(本表不收 G 线)', + raw_payload MEDIUMTEXT NOT NULL COMMENT '厂商原始报文,原样保存', + payload_md5 CHAR(32) NOT NULL, + vendor_event_id VARCHAR(64) COLLATE utf8mb4_bin NULL COMMENT '厂商事件唯一 ID,没有则 NULL', + vendor_event_time DATETIME(3) NULL COMMENT '厂商给出的事件时间原值,事后重算与对账用', + idem_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '三档幂等键,落库前算好', + idem_level TINYINT NOT NULL DEFAULT 3 COMMENT '1=厂商报文ID 2=设备+时间 3=内容哈希', + parse_result VARCHAR(16) NOT NULL DEFAULT 'OK' COMMENT 'OK/JSON_INVALID/FIELD_MISSING/DEVICE_MISMATCH/UNKNOWN_MODEL', + is_duplicate TINYINT NOT NULL DEFAULT 0 COMMENT '1=被判重丢弃(举证关键:证明报文到过但被丢)', + mqtt_msg_id VARCHAR(64) NULL COMMENT '消息 ID,与厂商对账用', + received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_dev_idem (device_sn, idem_key), + KEY idx_dev_time (device_sn, received_at), + KEY idx_dup (is_duplicate, received_at), + KEY idx_dev (dev_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='H/S 线原始报文留痕(保留 90 天)'; + +-- ---------- uplink_stat_daily ---------- +CREATE TABLE uplink_stat_daily ( + id BIGINT NOT NULL AUTO_INCREMENT, + stat_date DATE NOT NULL, + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + household_id BIGINT NULL, + gateway_dev_id VARCHAR(96) COLLATE utf8mb4_bin NULL, + expected_cnt INT NOT NULL DEFAULT 0 COMMENT '应到帧数,丢帧率的分母', + received_cnt INT NOT NULL DEFAULT 0 COMMENT '实到帧数(不含补传)', + backfill_cnt INT NOT NULL DEFAULT 0 COMMENT '补传帧数(is_backfill=1)', + undelivered_cnt INT NOT NULL DEFAULT 0 COMMENT '墓碑行条数', + gap_cnt INT NOT NULL DEFAULT 0 COMMENT 'seq 断档累计(网关视角)', + lost_cnt INT NOT NULL DEFAULT 0 COMMENT '哨兵自报未送达日累计(只累加心跳行;与 iot_event.lost_cnt 同名不同义)', + lost_saturated TINYINT NOT NULL DEFAULT 0 COMMENT '1=当日出现 255 饱和,该日数据不用于验收', + gateway_offline_sec INT NOT NULL DEFAULT 0 COMMENT '当日该网关离线秒数,须从 expected_cnt 扣除', + loss_rate DECIMAL(6,4) NULL COMMENT '丢帧率=gap/(expected 扣离线);验收 连续30天平均<=0.02', + gateway_uptime_ratio DECIMAL(6,4) NULL COMMENT '网关在线率(辅助指标,不与丢帧率混算)', + ack_ok_cnt INT NOT NULL DEFAULT 0 COMMENT 'ACK 成功数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_date_dev (stat_date, dev_id), + KEY idx_house_date (household_id, stat_date), + KEY idx_loss (stat_date, loss_rate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上行到达率/丢帧率日聚合'; + +-- ---------- device_link_stat ---------- +CREATE TABLE device_link_stat ( + id BIGINT NOT NULL AUTO_INCREMENT, + scope VARCHAR(8) NOT NULL COMMENT 'GATEWAY 整机级/DEVICE 设备级;两种颗粒度不可相加', + device_kind VARCHAR(16) NOT NULL COMMENT 'GATEWAY/SENSOR_433/SENSOR_CELL', + dev_id VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + household_id BIGINT NULL COMMENT '冗余快照,设备换户不改写历史行', + period_begin DATETIME(3) NOT NULL, + period_end DATETIME(3) NOT NULL COMMENT '分区键', + tx_cnt INT NULL COMMENT '区间应收条数;算不出为 NULL', + rx_cnt INT NULL, + gap_cnt INT NULL, + loss_rate DECIMAL(6,4) NULL COMMENT '=gap/tx;tx 为 0 或 NULL 时必须 NULL', + cum_tx BIGINT NULL COMMENT '计数器原值,仅自报路径有值', + cum_rx BIGINT NULL, + cum_gap BIGINT NULL, + reset_flag TINYINT NOT NULL DEFAULT 0 COMMENT '1=计数器归零,本行增量已置 NULL', + round_no INT NULL COMMENT '计数器轮次,归零后 +1', + src VARCHAR(8) NOT NULL DEFAULT 'CALC' COMMENT 'DEVICE 自报/GATEWAY 自报/CALC 平台推算;同颗粒度只取一个来源', + calc_state VARCHAR(16) NOT NULL DEFAULT 'OK' COMMENT 'OK/NO_SAMPLE 设备无序号算不出/ANOMALY 序号回退', + raw_ref VARCHAR(64) NULL COMMENT '原始报文回指(报文 MD5)', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id, period_end), + UNIQUE KEY uk_dev_src_period (dev_id, src, period_end), + KEY idx_dev_time (dev_id, period_end), + KEY idx_house_time (household_id, period_end) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='链路质量统计(整机级与设备级不可相加)' +PARTITION BY RANGE COLUMNS(period_end) ( + PARTITION p202610 VALUES LESS THAN ('2026-11-01'), + PARTITION p202611 VALUES LESS THAN ('2026-12-01'), + PARTITION p202612 VALUES LESS THAN ('2027-01-01'), + PARTITION pmax VALUES LESS THAN (MAXVALUE) +); + + + +-- ============================================================= +-- 校验(应返回 83): +-- SELECT COUNT(*) FROM information_schema.tables +-- WHERE table_schema = 'aomin_dev'; +-- ============================================================= diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.18__raw_link_columns.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.18__raw_link_columns.sql new file mode 100644 index 0000000..11bf65f --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.18__raw_link_columns.sql @@ -0,0 +1,88 @@ +-- ===================================================================== +-- V1.0.18__raw_link_columns.sql +-- 数据库设计:V1.10 §1.5.11 / §4.9.6 / §4.9.7 +-- 内容:两张原文表补 protocol_version;adapter_in_raw 补 link_type; +-- 两表各加一条按 received_at 清理用的索引 +-- 依据:《关于两张原文表补三列的变更申请》(周工,2026-11-06) +-- 影响面:只加可空列 + 加索引,不改任何既有列/索引/分区;存量行不回填 +-- 规模前提:两张表由 V1.0.11 建,本期线上为空表(无存量数据) +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- 一、ingest_raw_log:加 protocol_version,再加清理索引 +-- --------------------------------------------------------------------- +-- 分成两条语句,不要合并: +-- 加列在 MySQL 8.0.29+ 支持 ALGORITHM=INSTANT(含任意位置,AFTER 不影响), +-- 而加索引必须 INPLACE 构建索引树。一条 ALTER 里同时出现两类操作时, +-- MySQL 会把整条降级为 INPLACE——空表无所谓,但若在已有存量的环境 +-- (例如补跑的测试库)执行,就等于为了加一列把整表重建一次。 +ALTER TABLE ingest_raw_log + ADD COLUMN protocol_version VARCHAR(16) NULL + COMMENT '[V1.10] 解析所用协议版本,取值见 J-00 附录 B;parse_result≠OK 的帧不进 iot_event,只能靠这一列回溯' + AFTER payload_md5, + ALGORITHM=INSTANT; + +-- 清理条件是 received_at < NOW() - INTERVAL 30 DAY。 +-- 现有索引前导列是 gateway_dev_id / sensor_id / parse_result,均非单独时间列, +-- 不加这一条就只能全表扫。追加型时间列插在 B+ 树最右端,不产生随机写。 +ALTER TABLE ingest_raw_log + ADD KEY idx_received (received_at), + ALGORITHM=INPLACE, LOCK=NONE; + +-- --------------------------------------------------------------------- +-- 二、adapter_in_raw:加 protocol_version + link_type,再加清理索引 +-- --------------------------------------------------------------------- +ALTER TABLE adapter_in_raw + ADD COLUMN link_type VARCHAR(24) NULL + COMMENT '[V1.10] 接入链路类型:TCP_KAIWAN_V3/TCP_BIGNBIOT;接入层内部字段,不进 J-00 信封;其余取值未启用,不写入' + AFTER source_channel, + ADD COLUMN protocol_version VARCHAR(16) NULL + COMMENT '[V1.10] 解析所用协议版本,取值见 J-00 附录 B;与 ingest_raw_log 同列同义' + AFTER idem_level, + ALGORITHM=INSTANT; + +-- 本表的清理是两个条件:90 天全量 + is_duplicate=1 留满 180 天。 +-- idx_dup 的前导列是 is_duplicate,帮不到"只按时间"的那一段。 +ALTER TABLE adapter_in_raw + ADD KEY idx_received (received_at), + ALGORITHM=INPLACE, LOCK=NONE; + +-- ===================================================================== +-- 回滚(只在确认无人依赖这三列时执行;DROP 会丢数据且不可逆) +-- ===================================================================== +-- ALTER TABLE ingest_raw_log DROP KEY idx_received, DROP COLUMN protocol_version; +-- ALTER TABLE adapter_in_raw DROP KEY idx_received, DROP COLUMN protocol_version, DROP COLUMN link_type; +-- +-- 回滚前先看有没有值——列一旦有数据,回滚就等于主动销毁证据: +-- SELECT COUNT(*) FROM ingest_raw_log WHERE protocol_version IS NOT NULL; +-- SELECT COUNT(*) FROM adapter_in_raw WHERE protocol_version IS NOT NULL OR link_type IS NOT NULL; + +-- ===================================================================== +-- 跑完自检(手工执行,不随 Flyway 跑) +-- ===================================================================== +-- ① 列与索引都在(两个 TABLE_NAME 各两行,共 4 行) +-- SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE +-- FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA=DATABASE() +-- AND ((TABLE_NAME='ingest_raw_log' AND COLUMN_NAME='protocol_version') +-- OR (TABLE_NAME='adapter_in_raw' AND COLUMN_NAME IN ('protocol_version','link_type'))) +-- ORDER BY TABLE_NAME, ORDINAL_POSITION; +-- +-- SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME +-- FROM information_schema.STATISTICS +-- WHERE TABLE_SCHEMA=DATABASE() AND INDEX_NAME='idx_received'; +-- +-- ② 三列必须可空,且不得有默认值(给默认值就等于替厂商编版本号) +-- SELECT TABLE_NAME, COLUMN_NAME, IS_NULLABLE, COLUMN_DEFAULT +-- FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA=DATABASE() AND COLUMN_NAME IN ('protocol_version','link_type'); +-- +-- ③ 存量行必须是 NULL(不是 ''、不是 'unknown') +-- SELECT SUM(protocol_version IS NOT NULL) AS pv_filled, SUM(link_type IS NOT NULL) AS lt_filled +-- FROM adapter_in_raw; +-- +-- ④ link_type 只应出现两个在用值;出现 MQTT_DIRECT / AEP_JSON 说明写入了未启用取值 +-- SELECT link_type, COUNT(*) FROM adapter_in_raw GROUP BY link_type; +-- +-- ⑤ 协议版本值不能超过 16 字符(超了说明用了厂商产品名,见 1.5.11 第 3 条) +-- SELECT MAX(CHAR_LENGTH(protocol_version)) FROM ingest_raw_log; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.19__household_geo.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.19__household_geo.sql new file mode 100644 index 0000000..49f9037 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.19__household_geo.sql @@ -0,0 +1,18 @@ +-- ============================================================= +-- V1.0.19 家属端自助建档:户的地理坐标与详细地址 +-- +-- 背景:家属端新增「创建我的家」(类似美团新增收货地址), +-- 需要在地图上选点 / GPS 定位后把坐标随户一起存下来。 +-- 原 household 只有 address_text,没有结构化坐标。 +-- +-- 坐标系:**GCJ-02(火星坐标)**。国内地图服务一律用 GCJ-02, +-- 浏览器 navigator.geolocation 给的是 WGS-84,必须先在前端换算再提交。 +-- 详情见《三段贯通演示环境说明》地图合规小节。 +-- ============================================================= + +ALTER TABLE household + ADD COLUMN longitude DECIMAL(10, 7) NULL COMMENT 'GCJ-02 经度', + ADD COLUMN latitude DECIMAL(10, 7) NULL COMMENT 'GCJ-02 纬度', + ADD COLUMN gps_source VARCHAR(16) NULL COMMENT '坐标来源:MAP_PICK 地图选点 / GPS 定位 / MANUAL 手工填', + ADD COLUMN address_detail VARCHAR(200) NULL COMMENT '门牌详址(楼栋单元房号之外的补充)', + ADD COLUMN geo_updated_at DATETIME(3) NULL COMMENT '坐标最后一次修改时间'; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.20__notify_read_at.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.20__notify_read_at.sql new file mode 100644 index 0000000..4c3c911 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.20__notify_read_at.sql @@ -0,0 +1,21 @@ +-- ============================================================= +-- V1.0.20 通知已读时间 +-- +-- 背景:原来「已读」是直接写 notify_task.status='READ'。 +-- 但 status 是**投递状态机**(PENDING/SENT/FAILED/CANCELLED), +-- 同一个字段又当投递状态又当阅读状态,会导致: +-- · 通知被读之后,投递重试逻辑认不出它该不该重发; +-- · 想统计"发出去多少、读了多少"时两个口径搅在一起。 +-- +-- 改法:加 read_at,读时写时间;未读 = read_at IS NULL。 +-- status 回归纯投递态,已有的 READ 值按 read_at 回填后改回 SENT。 +-- ============================================================= + +ALTER TABLE notify_task + ADD COLUMN read_at DATETIME(3) NULL COMMENT '用户点开时间;NULL = 未读'; + +-- 把历史上被写成 READ 的记录还原成投递态,读时间用 updated_at 兜底 +UPDATE notify_task SET read_at = updated_at WHERE status = 'READ'; +UPDATE notify_task SET status = 'SENT' WHERE status = 'READ'; + +CREATE INDEX idx_receiver_read ON notify_task (receiver_id, read_at); diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.21__alarm_chain_seq.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.21__alarm_chain_seq.sql new file mode 100644 index 0000000..4fe13f2 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.21__alarm_chain_seq.sql @@ -0,0 +1,31 @@ +-- ============================================================ +-- V1.0.21 告警链序号 +-- 编制:周工 2026-11-09 +-- +-- 解决什么问题 +-- 同一台设备同一条规则连续触发时,现在是"每次新建一条、旧的标 SUPERSEDED"。 +-- 家属在列表里只看到最新那条的时间(比如 14:32), +-- 但风险其实从 07:15 就开始了——中间被替代掉的那几条不在列表里。 +-- 光看 14:32 会让人以为"门是刚开的",这是事故复盘时最容易被质疑的地方。 +-- +-- chain_seq 记录"这是本组的第几次": +-- 1 = 本组首次(上一次已被确认/处置,或者设备一直没动过) +-- N = 本组第 N 次触发 +-- 列表可以直接打"第 5 次",家属一眼就知道前面还有 4 次。 +-- +-- 为什么不用时间窗算 +-- 用"30 分钟内算一组"这种规则,会跟替代逻辑打架: +-- 家属第 29 分钟确认了,第 31 分钟又触发,靠时间窗会算成同组,但实际已经被人处理过、是新一轮。 +-- 组的边界只能由"旧单有没有被人处理过"决定,这个判断在业务代码里,所以序号也在那里算。 +-- +-- 存量数据 +-- 不回填。老数据的链关系本身是"旧逻辑(不新建只看补推)"产生的, +-- 硬编一个序号会伪造"第 N 次",比留 1 更糟。演示库如需干净数据,清空 alarm 重跑即可。 +-- ============================================================ + +ALTER TABLE alarm + ADD COLUMN chain_seq INT NOT NULL DEFAULT 1 + COMMENT '同一事件连续触发中的第几次;1=本组首次。组边界由"旧单是否已被人处理"决定'; + +-- 按设备+规则查本组最新一条,是 raise() 的热路径 +CREATE INDEX idx_alarm_dev_rule ON alarm (dev_id, rule_key, id); diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.22__alarm_source_event.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.22__alarm_source_event.sql new file mode 100644 index 0000000..ae7f128 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.22__alarm_source_event.sql @@ -0,0 +1,10 @@ +-- 告警 <-> 触发它的那条 iot_event +-- +-- 2026-11-09:原来 trigger 事件号是拼在 alarm.content 后面的 +-- ("设备市电供电中断(来源事件 EVT-1109170215-00002)"),家属在手机上 +-- 看到的就是这串内部编号——对客户毫无意义,还把正文挤长了。 +-- 拆成独立字段:家属端只显示正文,运营在后台详情里还能顺着查原始事件。 +-- +-- 存量数据不回填(老 content 里那串留着就留着,不影响新数据)。 +ALTER TABLE alarm + ADD COLUMN source_event_id BIGINT NULL COMMENT '触发本条告警的 iot_event.id,仅运营排查用' AFTER clear_event_id; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.23__command_seven_states.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.23__command_seven_states.sql new file mode 100644 index 0000000..5ee9dd2 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.23__command_seven_states.sql @@ -0,0 +1,49 @@ +-- ============================================================= +-- V1.0.23 命令状态值域迁移:中文六值 → 英文七态 +-- +-- 依据:J-00 V1.7 §3.4「业务层的命令状态机」 +-- GOV-001 §4.3「告警与命令」检查项 +-- +-- 为什么是"刷值"不是"加列改类型": +-- status 是 varchar(16),CANCELLED 只有 9 字符,装得下。 +-- 只扩值域 + 刷存量,不动列类型,索引不受影响。 +-- +-- 七态:PENDING / CLAIMED / SENT / ACKED / FAILED / TIMEOUT / CANCELLED +-- 旧「已下发」把"领走"和"发出"混成一个值,这里按语义拆开: +-- 旧数据里的「已下发」是 IoT 领取时写的(issued_at 同时被写), +-- 所以判为 CLAIMED;若租约已过期,交给 reclaim 任务自然回 PENDING。 +-- ============================================================= + +-- ---------- 1. 新增三列 ---------- + +ALTER TABLE device_command + ADD COLUMN `claim_generation` int NOT NULL DEFAULT '0' + COMMENT 'V1.7:租约代次,每次 CLAIMED +1;回执更新时比对,代次不符即过期回执' + AFTER `reclaim_count`, + ADD COLUMN `raw_result_code` varchar(64) DEFAULT NULL + COMMENT 'V1.7:厂家原始结果码,原样字符串不解释;标准码见 result_code' + AFTER `result_code`, + ADD COLUMN `raw_ack` text DEFAULT NULL + COMMENT 'V1.7:回执原始字节(Base64),可选' + AFTER `raw_result_code`; + +-- ---------- 2. 存量刷值(中文六值 → 英文七态) ---------- + +UPDATE device_command SET status = 'PENDING' WHERE status = '待下发'; +UPDATE device_command SET status = 'CLAIMED' WHERE status = '已下发'; +UPDATE device_command SET status = 'ACKED' WHERE status = '已生效'; +UPDATE device_command SET status = 'FAILED' WHERE status = '失败'; +UPDATE device_command SET status = 'TIMEOUT' WHERE status = '超时'; +UPDATE device_command SET status = 'CANCELLED' WHERE status = '已取消'; + +-- ---------- 3. 列注释与默认值对齐 ---------- + +ALTER TABLE device_command + MODIFY COLUMN `status` varchar(16) NOT NULL DEFAULT 'PENDING' + COMMENT 'V1.7 七态:PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED;CLAIMED 与 SENT 必须分开(领走≠发出)', + MODIFY COLUMN `result_code` varchar(16) DEFAULT NULL + COMMENT 'V1.7 标准码:0 成功/1 不支持/2 参数非法/3 执行失败/4 设备忙;厂家原码见 raw_result_code'; + +-- ---------- 4. 复核(执行后手工跑,Flyway 不吞结果集) ---------- +-- SELECT status, COUNT(*) AS cnt FROM device_command GROUP BY status ORDER BY status; +-- 期望:PENDING / CLAIMED / SENT / ACKED / FAILED / TIMEOUT / CANCELLED 七种之一,无中文值残留 diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.24__region_scope.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.24__region_scope.sql new file mode 100644 index 0000000..5135929 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.24__region_scope.sql @@ -0,0 +1,34 @@ +-- ===================================================================== +-- 后台账号的数据范围:按行政区划 / 小区 / 楼栋(2026-11-10) +-- +-- 为什么不再用 sys_user_scope 按小区逐个勾: +-- 物业覆盖 30 个小区就要配 30 行,新增小区不会自动纳入; +-- 更要紧的是同一套模型没法给机构端复用——机构的服务范围同样是地理区域。 +-- +-- 语义:一行 = 一个覆盖范围;非空字段逐级相等,任一行命中即可。 +-- 只填省 → 全省 +-- 省 + 市 → 全市 +-- 省 + 市 + 区 → 全区 +-- 再加 community_id → 只这个小区 +-- 再加 building_id → 只这一栋 +-- **全字段为空的行不生效**(防止误配成"全放") +-- +-- 数据源是 community.province / city / district 三列(已存在)。 +-- 建户时走地图选点,省市区由地图反查自动带入,所以这三列不会是空的。 +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS `sys_user_region_scope` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL COMMENT 'sys_user.id', + `province` VARCHAR(32) NULL COMMENT '省,如「广东省」;NULL = 不限', + `city` VARCHAR(32) NULL COMMENT '市,如「广州市」', + `district` VARCHAR(32) NULL COMMENT '区/县,如「天河区」', + `community_id` BIGINT NULL COMMENT '小区 id;给了就只覆盖这个小区', + `building_id` BIGINT NULL COMMENT '楼栋 id;给了就只覆盖这栋(同时应给 community_id)', + `remark` VARCHAR(100) NULL COMMENT '备注,如「天河片区」「2026 试点」', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_region_scope_user` (`user_id`), + KEY `idx_region_scope_region` (`province`, `city`, `district`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='后台账号数据范围(按行政区划/小区/楼栋),一行一个覆盖范围'; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.25__raw_ref_64_and_gap.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.25__raw_ref_64_and_gap.sql new file mode 100644 index 0000000..b762cde --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.25__raw_ref_64_and_gap.sql @@ -0,0 +1,66 @@ +-- ============================================================================ +-- V1.0.25 摘要 64 位支持 + 孤儿摘要登记 +-- +-- 背景(评审 v1 阻断项 5 的实测复现): +-- 1) 契约 V1.7 起允许 rawRef 为 32 位 MD5 或 64 位 SHA-256; +-- 但两张原文表的 payload_md5 都是 char(32),严格模式下写 64 位直接 +-- ERROR 1406 Data too long —— 契约允许、实现拒收。 +-- 2) 「rawRef 必填」只能保证字段非空,保证不了它指向的原文真的存在。 +-- I-12(原文)与 I-01(事件)是两次独立请求,接入侧漏发/超时/顺序反了, +-- 事件照样入库成功。这类行要能被查出来,否则「回查链」只是口头承诺。 +-- +-- 本迁移只做结构与登记表,不加任何"拒收"行为: +-- ORPHAN 是软标记,不影响入库。真机上 I-12 可能晚到几十毫秒,硬拒会把 +-- 正常事件挡在门外(V1.7 的 64 位误判就是这么翻车的)。 +-- ============================================================================ + +-- ---------- 1. 两张原文表的摘要列放宽到 64 位 ---------- +-- 用 utf8mb4_bin 与 iot_event.raw_ref 完全一致:JOIN 不做隐式转换,大小写敏感。 +ALTER TABLE `ingest_raw_log` + MODIFY COLUMN `payload_md5` VARCHAR(64) + CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL + COMMENT '原始报文摘要:MD5 32 位 / SHA-256 64 位(小写 hex)。V1.0.25 由 char(32) 放宽'; + +ALTER TABLE `adapter_in_raw` + MODIFY COLUMN `payload_md5` VARCHAR(64) + CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL + COMMENT '原始报文摘要:MD5 32 位 / SHA-256 64 位(小写 hex)。V1.0.25 由 char(32) 放宽'; + +-- ---------- 2. 孤儿摘要登记表 ---------- +CREATE TABLE IF NOT EXISTS `iot_raw_ref_gap` ( + `raw_ref` VARCHAR(64) NOT NULL COMMENT '事件里的 raw_ref,在两张原文表都查不到', + `event_id` VARCHAR(96) NOT NULL COMMENT '首个发现的 event_id,仅作样本定位', + `sensor_id` VARCHAR(64) DEFAULT NULL, + `source_channel` VARCHAR(16) DEFAULT NULL, + `first_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `last_seen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `hit_cnt` INT NOT NULL DEFAULT 1 COMMENT '同一 rawRef 被多少条事件引用(墓碑帧多对一,会 >1)', + `resolved_at` DATETIME(3) DEFAULT NULL COMMENT '原文后到时置位。保留行不删,才能统计孤儿率与自愈率', + PRIMARY KEY (`raw_ref`), + KEY `idx_resolved` (`resolved_at`), + KEY `idx_seen` (`last_seen_at`) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci + COMMENT = '孤儿摘要:事件有 rawRef,原文表没有。软标记,不影响入库'; + +-- ---------- 3. 把已存在的历史孤儿登记进来 ---------- +-- GROUP BY raw_ref:墓碑帧一个 rawRef 对应多条事件,这里合并成一行并记 hit_cnt。 +INSERT INTO `iot_raw_ref_gap` + (`raw_ref`, `event_id`, `sensor_id`, `source_channel`, `first_seen_at`, `last_seen_at`, `hit_cnt`) +SELECT e.raw_ref, + MIN(e.event_id), + MIN(e.sensor_id), + MIN(e.source_channel), + MIN(e.receive_time), + MAX(e.receive_time), + COUNT(*) +FROM `iot_event` e +WHERE e.raw_ref IS NOT NULL + AND e.raw_ref <> '' + AND NOT EXISTS (SELECT 1 FROM `ingest_raw_log` r WHERE r.payload_md5 = e.raw_ref) + AND NOT EXISTS (SELECT 1 FROM `adapter_in_raw` a WHERE a.idem_key = e.raw_ref) +GROUP BY e.raw_ref; + +-- 自检:迁移日志里会打印这几行,便于复核 +SELECT 'ingest_raw_log 摘要最大长度' AS item, MAX(CHAR_LENGTH(payload_md5)) AS v FROM `ingest_raw_log` +UNION ALL SELECT 'adapter_in_raw 摘要最大长度', MAX(CHAR_LENGTH(payload_md5)) FROM `adapter_in_raw` +UNION ALL SELECT '历史孤儿摘要条数', COUNT(*) FROM `iot_raw_ref_gap`; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.26__alarm_retry_deadletter.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.26__alarm_retry_deadletter.sql new file mode 100644 index 0000000..d3a5cff --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.26__alarm_retry_deadletter.sql @@ -0,0 +1,65 @@ +-- ============================================================= +-- V1.0.26 告警规则重试与死信 +-- 2026-11-11 周工 +-- +-- 【为什么加这两列 + 一张表】 +-- 原来 AlarmEngine 只做了「失败不写 processed=1」,看似会重试, +-- 实际重试范围被 event_time > NOW()-60min 这个窗口卡死: +-- 规则连续失败超过 60 分钟,这条事件就永远退出扫描,且没有任何痕迹。 +-- 对老人安全告警来说这是"静默永久漏报"——比当场报错更危险, +-- 因为现场看不出来(其余事件都正常,报表里也只是少一条)。 +-- +-- 现在改成三件事: +-- 1) 窗口判据换成 receive_time(平台收到的时间); +-- 2) 失败按 alarm_try 计数 + alarm_next_at 退避,不再受窗口限制; +-- 3) 连续 FAIL 次仍失败 → 写 alarm_dead_letter + processed=1, +-- 人工处理完可以回放。 +-- +-- 【为什么窗口要换成 receive_time】 +-- 原来卡的是 event_time,那是设备/网关自己打的时间。 +-- G 线是网关打戳(ts_source=3),网关掉电重启后时钟没校准, +-- 一条"刚发生"的事件 event_time 可能是 2 小时前 —— 一入库就在窗口外, +-- 从头到尾没被扫过。换成平台收到时间后,设备时钟再怎么偏都不影响"要不要处理"。 +-- 但为了不让灌历史数据时刷屏,同时要求 event_time 不早于 72 小时 +-- (72 小时也是 device_command 的硬顶,口径统一)。 +-- ============================================================= + +ALTER TABLE `iot_event` + ADD COLUMN `alarm_try` TINYINT NOT NULL DEFAULT 0 + COMMENT '规则处理已失败次数;>=3 进 alarm_dead_letter 并置 processed=1' + AFTER `processed`, + ADD COLUMN `alarm_next_at` DATETIME(3) NULL + COMMENT '下次允许重试的时刻(指数退避);NULL=立即可扫' + AFTER `alarm_try`; + +-- 扫描走 (processed, alarm_next_at)。分区表二级索引不要求带分区键。 +ALTER TABLE `iot_event` + ADD KEY `idx_alarm_scan` (`processed`, `alarm_next_at`); + +-- ------------------------------------------------------------- +-- 死信:规则连续处理失败、已放弃自动重试的事件 +-- event_pk 存 iot_event.id —— 它是分区表主键的一部分,回放必须 id + event_time 一起用 +-- ------------------------------------------------------------- +CREATE TABLE `alarm_dead_letter` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `event_pk` BIGINT NOT NULL COMMENT 'iot_event.id', + `event_time` DATETIME(3) NOT NULL COMMENT '分区键,回放定位用', + `dev_id` VARCHAR(96) COLLATE utf8mb4_bin NOT NULL, + `event_type` VARCHAR(32) NOT NULL, + `tries` TINYINT NOT NULL COMMENT '累计失败次数', + `last_error` VARCHAR(500) NULL COMMENT '最后一次异常信息,截断', + `replayed_at` DATETIME(3) NULL COMMENT '回放时刻;NULL=尚未回放', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dead_event` (`event_pk`, `event_time`) COMMENT '同一事件只进一次死信', + KEY `idx_dead_created` (`created_at`), + KEY `idx_dead_replayed` (`replayed_at`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='告警规则死信'; + +-- ------------------------------------------------------------- +-- alarm_action_log.action 的注释漏了 ACK / CLEARED / REPLACE 之外实际在用的值, +-- 顺手补齐——注释与实现不一致,排障时按注释写 SQL 会漏数据。 +-- ------------------------------------------------------------- +ALTER TABLE `alarm_action_log` + MODIFY COLUMN `action` VARCHAR(20) NOT NULL + COMMENT 'CREATE/PUSH/ACK/CONFIRM/ESCALATE/HANDLE/FALSE/CLOSE/CLEARED/REPLACE'; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.27__receive_time_local_and_audit_fail.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.27__receive_time_local_and_audit_fail.sql new file mode 100644 index 0000000..0992ef5 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.27__receive_time_local_and_audit_fail.sql @@ -0,0 +1,43 @@ +-- ============================================================= +-- V1.0.27 接收时间改由业务层打戳 + 审计失败兜底 +-- 2026-11-11 周工 +-- +-- 【一】为什么 receive_time 不再采信 IoT 传来的 receivedAt +-- 「平台收到这一帧的时刻」这种判据不应该由外部填。 +-- 原实现直接把 J-00 信封里的 receivedAt 落成 receive_time, +-- 业务层既不校验也不打戳。后果是:IoT 只要传一个旧时间, +-- 这条事件在告警扫描里就"从没新鲜过",被时间窗挡在门外 —— 永久不处理。 +-- +-- 现在分两列: +-- receive_time 业务层落库那一刻的 NOW(3),恒等于"现在",可作判据; +-- received_at_remote IoT 自报的接收时刻,只作参考(用来量 IoT 自己延迟多久)。 +-- +-- 【二】审计失败兜底表 +-- 原来审计写失败只在内存里 ++ 一个计数 + 打一行 ERROR。 +-- 重启即清零,也查不出"丢的是哪几条",等于丢了没人知道。 +-- 改成失败时把本该写进 audit_log 的内容先落 audit_fail_log, +-- 后台可列出、可回放补录,计数也从这张表里读,不再依赖内存。 +-- ============================================================= + +ALTER TABLE `iot_event` + ADD COLUMN `received_at_remote` DATETIME(3) NULL + COMMENT 'IoT 自报的接收时刻,仅供参考;判据一律用 receive_time' + AFTER `receive_time`; + +CREATE TABLE `audit_fail_log` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `operator_type` VARCHAR(16) NULL COMMENT '写审计那一刻的操作人类型,取自登录态', + `operator_id` BIGINT NULL, + `operator_name` VARCHAR(50) NULL, + `action_type` VARCHAR(40) NULL COMMENT '本该写进 audit_log 的动作', + `biz_type` VARCHAR(24) NULL, + `biz_key` VARCHAR(96) NULL, + `content` VARCHAR(500) NULL COMMENT '人可读摘要,原样留档', + `error_msg` VARCHAR(500) NULL COMMENT 'audit_log 写入失败的原因,截断', + `replay_status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待补录 1已补录 2已放弃', + `replayed_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_fail_status_time` (`replay_status`, `created_at`), + KEY `idx_fail_biz` (`biz_type`, `biz_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审计写入失败兜底;后台可补录'; diff --git a/aomin-gateway/src/main/resources/db/migration/V1.0.28__command_claim_node.sql b/aomin-gateway/src/main/resources/db/migration/V1.0.28__command_claim_node.sql new file mode 100644 index 0000000..4621495 --- /dev/null +++ b/aomin-gateway/src/main/resources/db/migration/V1.0.28__command_claim_node.sql @@ -0,0 +1,33 @@ +-- ============================================================= +-- V1.0.28 命令领取节点成为可校验事实 +-- 2026-11-12 周工 +-- +-- 【为什么加这两列】 +-- claim_generation 记的是"这条命令被领走第几次",解决的是"第几轮"; +-- 但"这一轮是谁领的"从来没有落过库:I-18 的 nodeId 只在请求里过一下, +-- I-05 回执里的 nodeId 也没人读、没人比。 +-- 结果是一条回执只要命令号和代次对得上,任何调 /internal 的客户端都能改它的流转 +-- ——不是身份问题(身份靠内网白名单 + 签名),是"事实没有落库,就没有东西可比"。 +-- +-- 加了这两列之后,一次领取的完整事实在同一条命令记录上可以直接核对: +-- claimed_node_id + claim_generation + claimed_at + lease_expire_at + status +-- +-- 【为什么不放进 iot_event / 不加新表】 +-- 它天然属于 device_command 的一行。换个地方存就要靠 command_no 再关联, +-- 原子更新的 WHERE 也得拆成两条语句,反而把"一次领取"拆散了。 +-- +-- 【回填口径】 +-- 存量在途命令(CLAIMED/SENT)无法回溯是谁领的,保持 NULL; +-- I-05 遇到 claimed_node_id 为 NULL 的历史在途行,一律按 NODE_UNKNOWN 拒绝采纳 +-- (不猜、不伪造、不拿别的节点顶上);租约到期由 D-01-U4 重派后该行自然有值。 +-- 新领取的命令一律有值。不伪造历史节点。 +-- ============================================================= + +ALTER TABLE `device_command` + ADD COLUMN `claimed_node_id` VARCHAR(64) NULL + COMMENT '谁领走的:I-18 的 nodeId 原值;与 claim_generation 同属一次领取事实' + AFTER `claim_generation`, + ADD COLUMN `claimed_at` DATETIME(3) NULL + COMMENT '本轮领取时刻;与 lease_expire_at 一起判断是否同一轮' + AFTER `claimed_node_id`, + ADD KEY `idx_cmd_node` (`claimed_node_id`, `status`, `lease_expire_at`); diff --git a/aomin-gateway/start-gateway.bat b/aomin-gateway/start-gateway.bat new file mode 100644 index 0000000..577b57d --- /dev/null +++ b/aomin-gateway/start-gateway.bat @@ -0,0 +1,83 @@ +@echo off +chcp 936 >nul 2>&1 +title aomin-gateway :8080 +setlocal EnableDelayedExpansion + +REM ============================================================ +REM aomin-gateway Æô¶¯Æ÷£¨º×ÆÍÔÆ S ÏßһÆÚÑÝʾ°ü£© +REM +REM ÂÌɫ»¯Ҫµ㣺 +REM 1) JAVA_HOME ÓðüÄÚ×ԴøµÄ jdk-21.0.2£¬±ðÈ˻úÆ÷²»ÓÃװ Java£» +REM 2) ÈÕ־Ŀ¼ÓÉ LOG_DIR ´«¸ø Spring Boot£¬²»дËÀÅ̷û£» +REM 3) ÓÐ jar ¾Íֱ½ÓÅܣ¬²»Åö Maven£¨¹¹½¨ֻÔÚû jar ʱ²ŷ¢Éú£©¡£ +REM ============================================================ + +REM ÏîĿ¸ù = ±¾ÎļþÍùÉÏÁ½¼¶£¨app\aomin-gateway -> °ü¸ù£© +pushd "%~dp0..\.." +set "ROOT=%CD%\" +popd + +set "JAVA_HOME=%ROOT%jdk-21.0.2" +set "LOG_DIR=%ROOT%logs" +set "JAR=%ROOT%app\aomin-gateway\target\aomin-gateway-0.0.1-SNAPSHOT.jar" + +if not exist "%JAVA_HOME%\bin\java.exe" ( + echo [ERROR] ÕҲ»µ½°üÄÚ JDK£º%JAVA_HOME% + echo °üÀï±ØÐë×Դø jdk-21.0.2 Ŀ¼£»Èç¹û±»ɾÁˣ¬Çëװ JDK 21 ²¢¸ı¾½ű¾¡£ + exit /b 1 +) +if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" +set "PATH=%JAVA_HOME%\bin;%PATH%" + +echo ================================================ +echo aomin-gateway :8080 +echo JAVA_HOME %JAVA_HOME% +echo LOG_DIR %LOG_DIR% +echo ================================================ +echo. + +cd /d "%ROOT%app\aomin-gateway" + +if not exist "%JAR%" ( + echo [INFO] δÕҵ½ jar£¬¿ªʼ¹¹½¨£¨ÐèҪ°üÄÚ maven Ŀ¼£©... + if not exist "%ROOT%maven\bin\mvn.cmd" ( + echo [ERROR] ûÓÐ jar ҲûÓÐ maven£¬Î޷¨Æô¶¯¡£ + echo Õý³£·¢µİüһ¶¨´ø jar£¬Çë¼ì²é app\aomin-gateway\target\ + exit /b 1 + ) + set "PATH=%ROOT%maven\bin;%PATH%" + call mvn -B -o -Dmaven.repo.local="%ROOT%data\m2repo" clean package -DskipTests + if errorlevel 1 ( + echo [ERROR] ¹¹½¨ʧ°ܣ¬¿´ÉÏÃæµĴíÎóÐÅϢ + exit /b 1 + ) +) + +REM »·¾³Àï¿ÉÄܴæÔÚ SERVER__PORT£¨²¿·ֹ¤¾߻áעÈ룩£¬Spring Boot »á°ÑËü +REM µ±³É server.port ¶Á½øȥ£¬µ¼Ö¶˿ڱä³ÉËæ»úֵ¡£Çåµô£¬²¢ÔÚÃüÁîÐÐÏÔʽָ¶¨¡£ +REM ---- SEC-Z-01: the DB password must come from the environment ---- +REM No default password is built into this project or into this script. +if "%DB_PASS%"=="" ( + echo [ERROR] DB_PASS is not set. Nothing will start. + echo Run: set DB_PASS=your_password + echo Then run this script again. + exit /b 1 +) + +set "SERVER__PORT=" + +REM ±¾µØÑÝʾҪ¹àÑÝʾÊý¾ݣ¨admin / ×ֵä / ʾ·¶СÇø£©¡£ +REM Éú²ú»·¾³²»ҪÉèÕâ¸ö±äÁ¿¡ª¡ªapplication.yml ÀïĬÈÏ false¡£ +set "BIZ_DEMO_ENABLED=true" + +echo [INFO] ½¡¿µ¼ì²é http://127.0.0.1:8080/health +echo [INFO] ÈÕ־ %LOG_DIR%\app.log +echo [INFO] °´ Ctrl+C ֹͣ +echo. + +REM ǰ¶ËĿ¼ҲÓþø¶Ô·¾¶´«½øȥ£¨WebConfig ÀﲻÔÙдËÀÅ̷û£©¡£ +REM ²»´«µĻ°ĬÈÏÕҡ°µ±ǰĿ¼\web¡±£¬¶øÕâ¸ö bat ÒѾ­ cd µ½ app\aomin-gateway£¬»á 404¡£ +java -Dbiz.web.dir="%ROOT%web" -jar "%JAR%" --server.port=8080 + +echo. +echo [INFO] ÒÑֹͣ diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..4d1b715 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,111 @@ +# 前端与设备端源码(演示用) + +> **这不是主干三端。** 按 AM-S1-GOV-020 §6.2(3),正式三端的接入由陈工统一完成。 +> 这份是 M-Z 配套的**演示前端 + 设备模拟器**,用途只有两个:跑通链路、看接口效果。 + +--- + +## 一、目录 + +``` +web/ +├── index.html 首页(三个入口 + 账号与地址) +├── common.js 公共库:token 命名空间、fetch 封装、toast +├── admin/index.html 运营后台(登录、设备、告警、住户、用户、台账…) +└── family/ + ├── index.html 家属端 H5(告警、探视、设置) + ├── map-baidu.html 百度地图选点 + ├── map-offline.html 离线地图(无 AK 时自动降级到这里) + └── map-picker.html 天地图选点(含运行时占位符) +sim/ +└── sim_device.py 设备模拟器:扮演网关 + 7 台设备,只调 I-01/I-02/I-12 +``` + +| 文件 | 行数 | 说明 | +|---|---|---| +| `web/admin/index.html` | 1110 | 体量最大,六个业务页签 | +| `web/family/index.html` | 1763 | 家属端全部功能 | +| `sim/sim_device.py` | 578 | 纯 Python 标准库,无第三方依赖 | + +--- + +## 二、怎么跑 + +### 方式一:跟着后端跑(推荐) + +后端把 `/app/**` 映射到 `biz.web.dir` 指向的目录: + +```bash +set DB_PASS=<口令> +set BIZ_DEMO_ENABLED=true +java -Dbiz.web.dir="<本目录>/web" -jar aomin-gateway/target/aomin-gateway-0.0.1-SNAPSHOT.jar ^ + --server.port=8080 +``` + +打开: + +| 地址 | 页面 | +|---|---| +| `http://127.0.0.1:8080/app/index.html` | 首页 | +| `http://127.0.0.1:8080/app/family/index.html` | 家属端 | +| `http://127.0.0.1:8080/app/admin/index.html` | 运营后台 | + +> 页面里写的接口地址是**相对路径**(`/api/v1/...`),所以必须经后端提供,不能双击 html 直接开——那样会 404。 + +### 方式二:设备模拟器 + +```bash +python sim/sim_device.py # 默认 9901 控制台,往 8080 发事件 +python sim/sim_device.py --fast # 快节奏(每轮 3 秒) +python sim/sim_device.py --seed # 启动先补历史告警 +python sim/sim_device.py --quiet # 只开控制台,不自动产生事件 +``` + +可覆盖的环境变量: + +| 变量 | 默认 | 说明 | +|---|---|---| +| `SIM_BASE` | `http://127.0.0.1:8080` | 目标 M 层地址 | +| `SIM_PORT` | `9901` | 模拟器控制台端口 | + +--- + +## 三、运行时占位符(4 个,**不要改成具体值**) + +| 占位符 | 出现位置 | 部署时替换成 | 不替换会怎样 | +|---|---|---|---| +| `__BAIDU_AK__` | `family/map-baidu.html`(2 处) | 自己的百度地图 AK;或启动前 `window.BAIDU_AK = 'xxx'` 注入 | 地图加载失败,**自动降级到离线地图**,不影响其他功能 | +| `__WB_HTTP_PORT__` | `family/map-picker.html` | 天地图服务端口 | 选点页不可用 | +| `__WB_TMAP_SECRET__` | `family/map-picker.html` | 天地图服务密钥 | 选点页不可用 | +| `__PORT__` | `sim_device.py` 内置控制台页面 | 由脚本自己按 `SIM_PORT` 替换,不用手改 | — | + +> `map-picker.html` 第 10 行原本就写着「`__WB_HTTP_PORT__` / `__WB_TMAP_SECRET__` 是运行时占位符,禁止改成具体值、禁止内嵌任何 key」——按这条执行。 + +--- + +## 四、本次做了什么清理(2026-11-13) + +| 改动 | 原因 | +|---|---| +| 删掉后台登录页「密码」输入框里的预填值 | 原来 `value` 里写死了演示口令,打开页面就明文可见 | +| 两处"演示账号 admin / 口令"的提示改为"口令按部署说明" | 页面里不再出现任何口令字面值 | +| 百度 AK 由真实值改为 `__BAIDU_AK__` 占位符 | 原 AK 是真实密钥,进 git 会永久留痕、可被盗刷配额 | +| 首页"flyway 1.0.18 / 83 表"改为"1.0.28 / 87 表" | 旧数字,与当前迁移不一致 | + +**没动的**:家属端演示账号 `13800000000 / 123456` 的提示保留——那是 `BIZ_DEMO_ENABLED=true` 时库里真实播种的演示数据,不是系统凭据;只在不显示演示数据的环境才会失效。 + +--- + +## 五、边界 + +| 项 | 说明 | +|---|---| +| 不是正式三端 | 演示页,不做兼容性、无障碍、国际化;正式三端由陈工按 §6.2(3) 接入 | +| 接口是相对路径 | 必须由后端 `/app/**` 提供,不能独立部署到别的域名下直接调 | +| 地图依赖外部服务 | 百度/天地图都在公网;内网环境会走离线地图降级 | +| 权限只做前端遮挡 | 真正的数据权限在后端(A-Z-03 / AUTH-01),页面隐藏按钮不等于没权限 | +| 模拟器是"网关+设备" | 它只调 I-01/I-02/I-12,不碰任何业务接口,和真设备走同一条路径 | + +--- + +*配套:后端源码与契约见 `周工_SEC-Z-01清理后_源码与说明_20261113.zip`。* diff --git a/demo/sim/sim_device.py b/demo/sim/sim_device.py new file mode 100644 index 0000000..d0b3b09 --- /dev/null +++ b/demo/sim/sim_device.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +鹤仆云 · 设备模拟器(演示用) + +它扮演的角色是「网关 + 设备」,不是业务层。数据路径和真设备完全一致: + + sim_device.py ──HTTP──→ M 层 /internal/iot/v1/** ──→ iot_event + │ + AlarmEngine(业务层) + ↓ + alarm + notify_task + ↓ + 家属端 H5 + +所以模拟器只调三个内部接口,一个业务接口都不碰: + I-01 POST /internal/iot/v1/events/batch 事件 + I-02 POST /internal/iot/v1/device-status/batch 状态快照(电量/信号/在线) + I-12 POST /internal/iot/v1/raw-frames 原文帧(Base64) + +用法: + python sim_device.py 正常节奏(门磁、水振、电流随机冒出) + python sim_device.py --fast 快节奏,演示时用 + python sim_device.py --seed 启动先补几条历史,页面一打开就有内容 + python sim_device.py --quiet 只开控制台,不自动产生事件 + +控制台:http://127.0.0.1:9901 + —— 按钮按设备类型出现(门磁只有开门/关门,水浸只有漏水/已止), + 选错设备发不出对应的告警——真实设备也是这样。 +""" + +import argparse +import base64 +import hashlib +import json +import os +import random +import threading +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer + +BASE = os.environ.get("SIM_BASE", "http://127.0.0.1:8080") +PORT = int(os.environ.get("SIM_PORT", "9901")) +CST = timezone(timedelta(hours=8)) + +OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) + +# ---------------------------------------------------------------- 设备台账 +# sensor_id / source_channel 必须和 01_初始化_设备台账.sql 对得上, +# 对不上就会被 M 层判成「未建档」丢进 unregistered_device,家属端看不见。 +# +# ★ type 必须和库里 device.type_code 一致(2026-11-09 修): +# 之前 3F7A9C23 在这里写成「厨房水浸」,库里其实是「床头SOS」, +# 结果控制台选门磁点 SOS 也能发出去、还生成告警——纯属模拟器在骗自己。 +# 通道名必须是后端 Channel 枚举的四个之一:OWN_GW / HORN_HOST / HORN_CELL / MIR_CELL。 +# (曾经写成 H_MQTT,后端直接 400 "sourceChannel 非法",断电/来电永远发不出去) +DEVICES = [ + dict(sn="3F7A9C21", dev="T-G-01", name="入户门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="入户门"), + dict(sn="3F7A9C22", dev="T-G-02", name="卧室门磁", type="DOOR", ch="OWN_GW", hh=9003, loc="卧室"), + dict(sn="3F7A9C23", dev="T-G-03", name="床头SOS", type="SOS", ch="OWN_GW", hh=9001, loc="卧室床头"), + dict(sn="3F7A9C24", dev="T-G-04", name="厨房水浸", type="WATER", ch="OWN_GW", hh=9003, loc="厨房水槽下"), + dict(sn="MIR:861234567890123", dev="T-S-01", name="卫生间水浸", type="WATER", ch="MIR_CELL", hh=9001, loc="卫生间"), + dict(sn="HORN:SN12345678", dev="T-H-01", name="客厅烟感", type="SMOKE", ch="HORN_HOST", hh=9001, loc="客厅"), + # 水振(麦乐克那台 HORN:999111 也在库里,型号 VIB)。它报的是"用水活动", + # 不是告警——放进来是为了能演示"这类事件只进基线、不弹窗"。 + dict(sn="HORN:999111", dev="D1794213326404263", name="水振", type="VIB", ch="HORN_HOST", hh=None, loc="厨房水管"), + # 验收专用:一台"已入库、还没人绑"的门磁。账号 A 手填 A1B2C3D4 绑上之后, + # 在控制台把设备选成它再点「门打开了」,就能看见完整闭环。 + dict(sn="A1B2C3D4", dev="T-G-09", name="验收门磁", type="DOOR", ch="OWN_GW", hh=9001, loc="验收"), +] +DEV_BY_SN = {d["sn"]: d for d in DEVICES} + +# 设备类型码(J-00 §1.3 payload.deviceTypeRaw) +TYPE_RAW = {"DOOR": 1, "VIB": 2, "CURRENT": 3, "WATER": 4, "SOS": 5, "SMOKE": 6} + +# 每种设备能干什么。列表里没有的动作,控制台不会显示,后端也会拒。 +# 这不是"模拟器偷懒"——真实设备也一样:门磁没有水浸探针,物理上就发不出漏水。 +ACTIONS = { + "DOOR": [("door", "🚪 门打开了", "warn"), ("door_close", "✅ 门关上了", "ok")], + "WATER": [("leak", "💧 漏水了", "hot"), ("leak_clear", "✅ 漏水已止", "ok")], + "SOS": [("sos", "🆘 按下 SOS", "hot")], + # 烟感:J-00 §1.5.1 是 SMOKE / SMOKE_CLEAR,规则表里 SECOND + URGENT,与漏水同一套 + "SMOKE": [("smoke", "🔥 烟感报警", "hot"), ("smoke_clear", "✅ 烟雾已散", "ok")], + # 水振:J-00 §1.5.3 里它报的是"用水活动" WATER_ACTIVITY / _STOP,不是告警事件。 + # 它喂给基线和「水振+电流」组合研判,本身不弹窗——所以按钮上直接写清楚。 + "VIB": [("vib", "💧 用水活动(不告警)", ""), ("vib_stop", "用水停止", "")], +} +# 任何设备都能做的:断电/来电(网关级事件)、心跳 +COMMON_ACTIONS = [("power_off", "⚡ 设备断电", "warn"), + ("power_on", "🔌 设备来电", "ok"), + ("status", "📊 上报心跳", "")] + + +# 每台设备的运行态。seq 必须单调递增,否则 M 层会判成重传或断档。 +STATE = {} +LOCK = threading.Lock() +COUNTER = {"n": 0} +LOG = [] # 最近 60 条动作,控制台轮询显示 +LOG_CAP = 60 + + +def now_iso(dt=None): + dt = dt or datetime.now(CST) + return dt.strftime("%Y-%m-%dT%H:%M:%S+08:00") + + +def log(msg): + line = time.strftime("%H:%M:%S ") + msg + with LOCK: + LOG.append(line) + del LOG[:-LOG_CAP] + print(line, flush=True) + + +# ---------------------------------------------------------------- HTTP +def post(path, body): + data = json.dumps(body, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request(BASE + path, data=data, method="POST", + headers={"Content-Type": "application/json"}) + try: + with OPENER.open(req, timeout=10) as r: + return json.loads(r.read().decode("utf-8") or "{}") + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8", "ignore")[:200] + return {"code": e.code, "msg": body} + except Exception as e: # noqa: BLE001 + return {"code": -1, "msg": str(e)} + + +def next_seq(dev): + STATE[dev]["seq"] += 1 + return STATE[dev]["seq"] + + +def raw_ref(): + return hashlib.md5(str(random.random()).encode()).hexdigest() + + +# ---------------------------------------------------------------- 发事件 +def emit(dev_sn, event_type, payload=None, when=None, channel=None, with_raw=True): + """发一条事件。when 可传 datetime,用来补历史和制造断档场景。""" + d = DEV_BY_SN[dev_sn] + st = STATE[d["dev"]] + with LOCK: + COUNTER["n"] += 1 + n = COUNTER["n"] + seq = next_seq(d["dev"]) + ref = raw_ref() + ev = { + "eventId": "EVT-%s-%05d" % (time.strftime("%m%d%H%M%S"), n), + "sensorId": dev_sn, + "sourceChannel": channel or d["ch"], + "eventType": event_type, + "deviceTime": now_iso(when), + "receivedAt": now_iso(), + "tsSource": 3, # 3 = 网关打戳(G 线恒定) + "protocolVersion": "gw-1.2", + "seq": str(seq), + "payload": payload or {}, + "rawRef": ref, + } + r = post("/internal/iot/v1/events/batch", {"sourceChannel": channel or d["ch"], "events": [ev]}) + ok = r.get("code") == 0 + acc = (r.get("data") or {}).get("accepted", 0) if ok else 0 + if ok: + if acc: + log("✓ %s(%s)→ %s seq=%s" % (d["name"], dev_sn, event_type, seq)) + else: + reason = json.dumps(r.get("data", {}), ensure_ascii=False)[:300] + log("○ %s(%s)→ %s 未被收下:%s" % (d["name"], dev_sn, event_type, reason)) + else: + log("✗ %s(%s)→ %s 被 M 层拒绝:%s" % (d["name"], dev_sn, event_type, r.get("msg"))) + + if with_raw: + frame = base64.b64encode(b"\xAA\xBB" + bytes([seq % 256]) + dev_sn.encode()[:12]).decode() + post("/internal/iot/v1/raw-frames", {"sourceChannel": channel or d["ch"], "frames": [{ + "rawRef": ref, "sensorId": dev_sn, "sourceChannel": channel or d["ch"], + "receivedAt": now_iso(), "rawFrame": frame, "frameBytes": len(frame), + "gatewayDevId": "GW-SIM-01", "protocolVersion": "gw-1.2", + }]}) + return {"ok": bool(ok and acc), "accepted": acc, + "msg": None if ok else r.get("msg")} + + +def emit_status(): + """状态快照:电量、信号、在线。走 I-02,覆盖写 device_tech_cache。""" + statuses = [] + for d in DEVICES: + st = STATE[d["dev"]] + statuses.append({ + "sensorId": d["sn"], + "sourceChannel": d["ch"], + "online": st["online"], + "lastSeenAt": now_iso(), + "batteryMv": st["battery"], + "csq": st["csq"], + "lastSeq": st["seq"], + "lostCnt": 0, + "protocolVersion": "gw-1.2", + "reportedAt": now_iso(), + }) + post("/internal/iot/v1/device-status/batch", {"sourceChannel": "OWN_GW", "statuses": statuses}) + + +# ---------------------------------------------------------------- 场景 +def init_state(): + for d in DEVICES: + STATE[d["dev"]] = dict( + seq=random.randint(100, 400), + battery=random.randint(3450, 3900), + csq=random.randint(16, 28), + online=True, + door_open_at=None, + leak_at=None, + power_off_at=None, + ) + + +def tick(fast=False): + """一轮随机行为。概率按 5 秒一轮算,故意压低——真设备的动静比演示脚本小得多。""" + speed = 3.0 if fast else 1.0 + g = DEV_BY_SN["3F7A9C21"] + st = STATE["T-G-01"] + + # 门磁:开 → 过一会儿关 + if st["door_open_at"] is None and random.random() < 0.10 * speed: + st["door_open_at"] = time.time() + st["battery"] -= random.randint(1, 3) + emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif st["door_open_at"] and time.time() - st["door_open_at"] > random.randint(20, 50): + st["door_open_at"] = None + emit("3F7A9C21", "DOOR_CLOSE", {"deviceTypeRaw": 1, "rawEventCode": 2, "batteryMv": st["battery"]}) + + # 水浸:漏了就等它自己解除 + w = STATE["T-G-04"] + if w["leak_at"] is None and random.random() < 0.02 * speed: + w["leak_at"] = time.time() + emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": w["battery"]}) + elif w["leak_at"] and time.time() - w["leak_at"] > random.randint(40, 100): + w["leak_at"] = None + emit("3F7A9C24", "WATER_LEAK_CLEAR", {"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": w["battery"]}) + + # S 线水浸:裸 TCP 直连,没有网关打戳,tsSource 恒 2 + s = STATE["T-S-01"] + if s["leak_at"] is None and random.random() < 0.012 * speed: + s["leak_at"] = time.time() + emit("MIR:861234567890123", "WATER_LEAK", + {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": s["battery"]}, channel="MIR_CELL") + elif s["leak_at"] and time.time() - s["leak_at"] > random.randint(40, 90): + s["leak_at"] = None + emit("MIR:861234567890123", "WATER_LEAK_CLEAR", + {"deviceTypeRaw": 4, "rawEventCode": 2, "batteryMv": s["battery"]}, channel="MIR_CELL") + + # SOS:真设备极少误触,这里给 0.4%。必须由 SOS 那台发,门磁发不出来。 + if random.random() < 0.004 * speed: + st_sos = STATE["T-G-03"] + emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": st_sos["battery"]}) + + +def seed_history(): + """补几条历史,让页面一打开就有东西看。时间戳往回拨,走设备时间。""" + now = datetime.now(CST) + log("补历史:3 条(40 分钟前水浸、25 分钟前门磁、8 分钟前 SOS)") + emit("3F7A9C24", "WATER_LEAK", {"deviceTypeRaw": 4, "rawEventCode": 1, "batteryMv": 3610}, + when=now - timedelta(minutes=40)) + emit("3F7A9C21", "DOOR_OPEN", {"deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": 3720}, + when=now - timedelta(minutes=25)) + emit("3F7A9C23", "SOS_PRESS", {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": 3720}, + when=now - timedelta(minutes=8)) + + +# ---------------------------------------------------------------- 控制台 +PAGE = """ + +设备模拟器 · 鹤仆云 +

+

设备模拟器

+
扮演网关和 7 台设备,往 M 层发真实格式的报文 · 控制台 127.0.0.1:__PORT__
+ +

按设备发事件

+
+ 从哪台设备发: + +  验收新绑的设备时记得切到这里 +
+
+
+
+
+ +

设备状态

+
设备设备编号类型电量信号seq
+ +

最近动作

+ +
+ 数据流:本页 → POST /internal/iot/v1/events/batch → M 层入库 iot_event + → 告警引擎(入库即触发,30 秒兜底扫一次)→ alarm → 家属端。
+ 关掉这个窗口不影响已产生的数据;想看新数据得让它继续跑。 +
+
+""" + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): # 关掉默认访问日志,别刷屏 + pass + + def _send(self, code, ctype, body): + b = body.encode("utf-8") if isinstance(body, str) else body + self.send_response(code) + self.send_header("Content-Type", ctype + "; charset=utf-8") + self.send_header("Content-Length", str(len(b))) + self.end_headers() + self.wfile.write(b) + + def do_GET(self): + if self.path in ("/", "/index.html"): + self._send(200, "text/html", PAGE.replace("__PORT__", str(PORT))) + elif self.path.startswith("/api/state"): + with LOCK: + devs = [dict(name=d["name"], sn=d["sn"], type=d["type"], + battery=STATE[d["dev"]]["battery"], + csq=STATE[d["dev"]]["csq"], seq=STATE[d["dev"]]["seq"], + online=STATE[d["dev"]]["online"]) for d in DEVICES] + lg = list(LOG) + self._send(200, "application/json", + json.dumps({"devices": devs, "log": lg}, ensure_ascii=False)) + else: + self._send(404, "text/plain", "not found") + + def do_POST(self): + if not self.path.startswith("/api/trigger"): + return self._send(404, "text/plain", "not found") + k = "" + sn = None + if "k=" in self.path: + k = self.path.split("k=", 1)[1].split("&")[0] + if "sn=" in self.path: + sn = urllib.parse.unquote(self.path.split("sn=", 1)[1].split("&")[0]) + try: + res = trigger(k, sn) + body = json.dumps({"code": 0 if res.get("ok") else 1, + "msg": res.get("msg") or ""}, ensure_ascii=False) + self._send(200, "application/json", body) + except Exception as e: # noqa: BLE001 + self._send(500, "application/json", json.dumps({"code": 1, "msg": str(e)}, ensure_ascii=False)) + + +def trigger(k, sn=None): + """控制台按钮 → 一条事件。全部走真实接口,不直接改库。 + + ★ 必须按设备类型把关(2026-11-09 改):门磁没有水浸探针,物理上就发不出 + WATER_LEAK;SOS 按钮也不该拿门磁的编号发。以前不校验,选「入户门磁」点 SOS + 照样生成 S1 告警——不是系统聪明,是模拟器在骗自己。 + + 返回 {"ok": bool, "msg": 给页面弹的话} + """ + d = DEV_BY_SN.get(sn) if sn else DEVICES[0] + if d is None: + return {"ok": False, "msg": "未知设备编号 " + str(sn)} + st = STATE[d["dev"]] + raw = TYPE_RAW.get(d["type"], 1) + + allowed = [a[0] for a in ACTIONS.get(d["type"], [])] + all_actions = {a[0]: a[1] for a in ACTIONS.get(d["type"], []) + COMMON_ACTIONS} + if k not in allowed and k not in [a[0] for a in COMMON_ACTIONS]: + return {"ok": False, + "msg": "%s 是「%s」,没有这个动作。(%s 只能发:%s)" % ( + d["sn"], d["name"], d["name"], + "、".join(all_actions.get(x, x) for x in allowed) or "只能断电/来电/心跳")} + + if k == "sos": + r = emit(d["sn"], "SOS_PRESS", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif k == "leak": + st["leak_at"] = time.time() + r = emit(d["sn"], "WATER_LEAK", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif k == "leak_clear": + st["leak_at"] = None + r = emit(d["sn"], "WATER_LEAK_CLEAR", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]}) + elif k == "door": + st["door_open_at"] = time.time() + r = emit(d["sn"], "DOOR_OPEN", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif k == "door_close": + st["door_open_at"] = None + r = emit(d["sn"], "DOOR_CLOSE", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]}) + elif k == "power_off": + st["online"] = False + r = emit(d["sn"], "POWER_OFF", {"deviceTypeRaw": raw, "rawEventCode": 5}) + elif k == "power_on": + st["online"] = True + r = emit(d["sn"], "POWER_ON", {"deviceTypeRaw": raw, "rawEventCode": 6}) + elif k == "smoke": + r = emit(d["sn"], "SMOKE", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif k == "smoke_clear": + r = emit(d["sn"], "SMOKE_CLEAR", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]}) + elif k == "vib": + r = emit(d["sn"], "WATER_ACTIVITY", {"deviceTypeRaw": raw, "rawEventCode": 1, "batteryMv": st["battery"]}) + elif k == "vib_stop": + r = emit(d["sn"], "WATER_ACTIVITY_STOP", {"deviceTypeRaw": raw, "rawEventCode": 2, "batteryMv": st["battery"]}) + elif k == "status": + emit_status() + log("○ 心跳已上报(%d 台)" % len(DEVICES)) + return {"ok": True, "msg": "已上报 " + str(len(DEVICES)) + " 台设备的心跳"} + else: + return {"ok": False, "msg": "未知指令 " + k} + + if not r.get("ok"): + return {"ok": False, "msg": "M 层没接收:" + str(r.get("msg") or "看左侧日志")} + # 恢复类事件不新建告警,只关闭"还没清过"的同类告警——没单子可关时看起来"没反应", + # 这里当场说清楚,省得以为是坏了。 + if k in ("door_close", "leak_clear", "power_on", "smoke_clear"): + return {"ok": True, "msg": "已发送(%s)。恢复事件只关闭「%s」这一类、且还没恢复的告警," + "不新建告警,也不会碰别的类型" % (d["name"], d["name"])} + if k in ("vib", "vib_stop"): + return {"ok": True, "msg": "已记录为「用水活动」(%s)。这类事件不产生告警," + "只进基线和「水振+电流」组合研判" % d["name"]} + return {"ok": True, "msg": "已发送(%s · %s)" % (d["name"], d["sn"])} + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--fast", action="store_true", help="快节奏(每轮 3 秒,概率翻倍)") + ap.add_argument("--seed", action="store_true", help="启动先补历史告警") + ap.add_argument("--quiet", action="store_true", help="只开控制台,不自动产生事件") + args = ap.parse_args() + + init_state() + print("=" * 62) + print(" 鹤仆云 · 设备模拟器") + print(" 目标 M 层:%s" % BASE) + print(" 控制台 :http://127.0.0.1:%d" % PORT) + print(" 节奏 :%s" % ("静默(只响应按钮)" if args.quiet else ("快" if args.fast else "正常"))) + print("=" * 62) + + # 先探活。不能用空 statuses 去试 I-02——后端会按"空批"返回 400, + # 那是正确的校验行为,不是服务没起来。 + try: + with OPENER.open(BASE + "/health", timeout=8) as h: + h.read() + except Exception as e: # noqa: BLE001 + print("!! M 层没响应(%s)。先确认 :8080 起来了。" % e) + print(" 启动命令:G:\\aomin-dev\\app\\aomin-gateway\\start-gateway.bat") + return + log("✓ M 层在线:" + BASE) + + if args.seed: + seed_history() + emit_status() + + srv = HTTPServer(("127.0.0.1", PORT), Handler) + threading.Thread(target=srv.serve_forever, daemon=True).start() + + step = 3 if args.fast else 5 + n = 0 + try: + while True: + time.sleep(step) + n += 1 + if not args.quiet: + tick(args.fast) + if n % 6 == 0: + emit_status() + # 电量缓慢下降,模拟真实电池 + for d in DEVICES: + STATE[d["dev"]]["battery"] -= random.randint(0, 1) + STATE[d["dev"]]["csq"] = max(12, min(31, STATE[d["dev"]]["csq"] + random.randint(-1, 1))) + except KeyboardInterrupt: + print("\n已停止。") + + +if __name__ == "__main__": + main() diff --git a/demo/web/admin/index.html b/demo/web/admin/index.html new file mode 100644 index 0000000..b05eecc --- /dev/null +++ b/demo/web/admin/index.html @@ -0,0 +1,1110 @@ + + + + + +鹤仆云 · 运营后台(H5 演示) + + + + + +
+
+
🏠
+

鹤仆云 · 运营后台

+

社区养老 · 守望 S 线一期

+ + + +
演示账号 admin(口令按部署说明,不在页面内写死)
+
+
+ + +
+ +
+
+
+

总览

+
—
+
+
+ + 看家属端 ↗ +
+
+
+
+
+ + + + + + + diff --git a/demo/web/common.js b/demo/web/common.js new file mode 100644 index 0000000..1aeba07 --- /dev/null +++ b/demo/web/common.js @@ -0,0 +1,206 @@ +/* 鹤仆云 H5 演示端 · 共用工具 + * 纯原生,不引框架:老板电脑上双击就能开,不需要 npm。 + */ + +const HB = (() => { + // 家属端和后台端跑在同一个域下,localStorage 是共用的—— + // 不加命名空间的话,在后台登录会把家属端的 token 顶掉。 + let KEY = 'hb_family_token'; + let UKEY = 'hb_family_user'; + + function setNS(ns) { KEY = 'hb_' + ns + '_token'; UKEY = 'hb_' + ns + '_user'; } + + async function req(method, path, body, opt = {}) { + const headers = { 'Content-Type': 'application/json' }; + const t = localStorage.getItem(KEY); + if (t) headers.Authorization = 'Bearer ' + t; + const res = await fetch(path, { + method, + headers, + body: body === undefined || body === null ? undefined : JSON.stringify(body), + }); + let json = {}; + try { json = await res.json(); } catch (e) { /* 空响应 */ } + if (res.status === 401 && !opt.noRedirect) { + localStorage.removeItem(KEY); + location.hash = ''; + location.reload(); + throw new Error('登录已过期'); + } + if (json.code !== 0 && json.code !== undefined) { + throw new Error(json.msg || json.message || '请求失败(' + res.status + ')'); + } + if (!res.ok) throw new Error('请求失败(' + res.status + ')'); + return json.data; + } + + const get = (p) => req('GET', p); + const post = (p, b) => req('POST', p, b); + const put = (p, b) => req('PUT', p, b); + const del = (p) => req('DELETE', p); + + function setSession(token, user) { + localStorage.setItem(KEY, token); + localStorage.setItem(UKEY, JSON.stringify(user || {})); + } + function user() { + try { return JSON.parse(localStorage.getItem(UKEY) || '{}'); } catch (e) { return {}; } + } + function logged() { return !!localStorage.getItem(KEY); } + function logout() { + localStorage.removeItem(KEY); + localStorage.removeItem(UKEY); + location.reload(); + } + + /* ---------------- 展示辅助 ---------------- */ + function ago(v) { + if (!v) return '—'; + const t = new Date(String(v).replace(' ', 'T')).getTime(); + if (isNaN(t)) return String(v); + const d = Math.floor((Date.now() - t) / 1000); + if (d < 0) return '刚刚'; + if (d < 60) return d + ' 秒前'; + if (d < 3600) return Math.floor(d / 60) + ' 分钟前'; + if (d < 86400) return Math.floor(d / 3600) + ' 小时前'; + return Math.floor(d / 86400) + ' 天前'; + } + function dt(v) { + if (!v) return '—'; + return String(v).replace('T', ' ').slice(0, 19); + } + function hhmm(v) { + if (!v) return '—'; + return String(v).replace('T', ' ').slice(11, 16); + } + + const TYPE = { + DOOR: { icon: '🚪', name: '门磁', color: '#4a7ebb' }, + VIB: { icon: '📳', name: '水振', color: '#7a6ad1' }, + CURRENT: { icon: '⚡', name: '电流', color: '#d99a2b' }, + WATER: { icon: '💧', name: '水浸', color: '#2e9bb5' }, + SOS: { icon: '🆘', name: '紧急按钮', color: '#d9534f' }, + SMOKE: { icon: '🔥', name: '烟感', color: '#c25a3c' }, + }; + const typeInfo = (t) => TYPE[t] || { icon: '📦', name: t || '设备', color: '#8a949e' }; + + const LEVEL = { + S1: { name: '紧急', cls: 'lv1' }, + S2: { name: '重要', cls: 'lv2' }, + S3: { name: '提示', cls: 'lv3' }, + }; + const levelInfo = (l) => LEVEL[l] || { name: l || '—', cls: 'lv3' }; + + const STATUS = { + NEW: '新告警', PUSHED: '已推送', CONFIRMED: '已确认', ESCALATED: '已升级', + HANDLED: '已处置', CLOSED: '已关闭', FALSE: '误报', DELAYED: '免打扰', + SUPERSEDED: '已被替代', + }; + // 这几种都不再"待处理":CLOSED 关闭、FALSE 误报、SUPERSEDED 已被新告警替代、 + // HANDLED 家属已经点过"已处理"(后端 OPEN 里仍留 HANDLED 表示"处置未关闭", + // 但家属视角点完就不该再挂角标,否则点了没反应会以为没生效。2026-11-09 修) + const CLOSED = ['CLOSED', 'FALSE', 'SUPERSEDED', 'HANDLED']; + const isOpen = (s) => !CLOSED.includes(s); + + const EVENT = { + DOOR_OPEN: '门打开', DOOR_CLOSE: '门关闭', WATER_LEAK: '检测到漏水', + WATER_LEAK_CLEAR: '漏水已解除', SOS_PRESS: '紧急按钮按下', SOS: '紧急呼叫', + POWER_OFF: '设备断电', POWER_ON: '设备来电', MSG_UNDELIVERED: '补传:曾断开', + POWER_CUT: '设备断电', POWER_RESTORE: '设备来电', + PRE_ALARM_0X04: '断档告警', VIB_ALARM: '震动异常', CURRENT_ALARM: '电流异常', + SMOKE: '烟感报警', SMOKE_CLEAR: '烟雾已散', SOS_CLEAR: '呼叫解除', + WATER_ACTIVITY: '用水活动', WATER_ACTIVITY_STOP: '用水停止', + HEARTBEAT: '心跳', + }; + const evName = (t) => EVENT[t] || t || '事件'; + + function toast(msg, kind) { + let box = document.getElementById('hb-toast'); + if (!box) { + box = document.createElement('div'); + box.id = 'hb-toast'; + document.body.appendChild(box); + } + box.textContent = msg; + box.className = 'show' + (kind === 'err' ? ' err' : ''); + clearTimeout(box._t); + box._t = setTimeout(() => { box.className = ''; }, 2400); + } + + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( + { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] + )); + } + + function maskPhone(p) { + if (!p || p.length < 7) return p || '—'; + return p.slice(0, 3) + '****' + p.slice(-4); + } + + /* ------------------------------------------------------------------ + 命令状态回显 + ------------------------------------------------------------------ + 七态(J-00 V1.7 §3.4)翻成人话。家属端和后台共用一份,避免两边说法不一致。 + + 为什么需要它:下发只是写一条 PENDING,真正到设备要等接入端来领(I-18), + 设备回执再走 I-05。中间可能几秒,也可能几小时(设备离线)。 + 以前点完只弹"已排队",家属永远不知道成没成,按钮看着像摆设。 + */ + const CMD_TEXT = { + PENDING: ['已排队,等设备连上后下发', '#b8860b'], + CLAIMED: ['正在下发…', '#0a6ebd'], + SENT: ['已发出,等设备回应', '#0a6ebd'], + ACKED: ['设备已回应', '#1a7f37'], + FAILED: ['设备没能执行', '#c0392b'], + TIMEOUT: ['超时没有回应', '#c0392b'], + CANCELLED: ['已取消', '#6b7784'], + }; + const CMD_DONE = ['ACKED', 'FAILED', 'TIMEOUT', 'CANCELLED']; + + /** + * 盯一条命令,直到终态或轮询次数用完。 + * + * @param devId 设备 + * @param commandNo 刚下发的命令号(拿不到就取最新一条) + * @param boxId 显示状态的那个 div + * @param times 轮询次数,默认 10 次 × 3 秒 + */ + async function watchCommand(devId, commandNo, boxId, times) { + const max = times || 10; + let n = 0; + const tick = async () => { + n++; + let row = null; + try { + const list = await get('/api/v1/devices/' + devId + '/commands'); + const arr = list || []; + row = arr.find((x) => (x.command_no || x.commandNo) === commandNo) || arr[0]; + } catch (e) { /* 查询失败保持上一行,不要打断用户 */ } + const box = document.getElementById(boxId); + if (!box) return; + if (row) { + const st = String(row.status || ''); + const meta = CMD_TEXT[st] || [st, '#6b7784']; + let html = '' + esc(meta[0]) + ''; + if (row.result_msg) { + html += ' · ' + esc(row.result_msg) + ''; + } + if (st === 'PENDING' && n > 1) { + html += '(不用一直等,设备连上会自动下发)'; + } + box.innerHTML = html; + if (CMD_DONE.includes(st)) return; + } + if (n < max) setTimeout(tick, 3000); + }; + tick(); + } + + return { + req, get, post, put, del, setNS, + setSession, user, logged, logout, + ago, dt, hhmm, typeInfo, levelInfo, STATUS, isOpen, evName, toast, esc, maskPhone, + CMD_TEXT, CMD_DONE, watchCommand, + }; +})(); diff --git a/demo/web/family/index.html b/demo/web/family/index.html new file mode 100644 index 0000000..6a19340 --- /dev/null +++ b/demo/web/family/index.html @@ -0,0 +1,1763 @@ + + + + + +鹤仆云 · 家属端(小程序 H5 演示) + + + +
+ + +
+ +

鹤仆云 · 守望

+

让在外的子女,看得见家里的安稳

+ +
+
📱
+
🔒
+ + + +
+ + + + +
+ + +
+ +
+
+
+
守望 · 安心版
+
家里今天很安静
+
+
+ + +
+
+
+ +
+ +
+
+
+ + + +
+ + + + + diff --git a/demo/web/family/map-baidu.html b/demo/web/family/map-baidu.html new file mode 100644 index 0000000..8dbc228 --- /dev/null +++ b/demo/web/family/map-baidu.html @@ -0,0 +1,252 @@ + + + + + +选择家的位置 + + + + +
+ +

选择家的位置

+ +
+
+
+
+ +
+
拖动地图,把准星对准你家
+
正在加载地图…
+
+
+
拖动地图选择位置
+
地址会自动识别,选不准可以手动改
+
坐标:—(GCJ-02)
+
+ + + +
+ +
+ + + + diff --git a/demo/web/family/map-offline.html b/demo/web/family/map-offline.html new file mode 100644 index 0000000..d7b917c --- /dev/null +++ b/demo/web/family/map-offline.html @@ -0,0 +1,422 @@ + + + + + +选择家的位置(离线) + + + + +
+ +

选择家的位置

+ +
+
+ +
离线示意底图 · 中山市 · GCJ-02
拖动平移 · 滚轮/双指缩放 · 点一下落点
+
+ + +
+
—
+
+
+
点一下地图,落在你家的位置
+
坐标:未选择(GCJ-02)
+
+ + + +
+ +
+ + + diff --git a/demo/web/family/map-picker.html b/demo/web/family/map-picker.html new file mode 100644 index 0000000..efc9ea2 --- /dev/null +++ b/demo/web/family/map-picker.html @@ -0,0 +1,285 @@ + + + + + +选择家的位置 + + + + + +
+ +

选择家的位置

+ +
+ +
+
+
+ +
+
+
点一下地图,或按右上角「定位」用当前位置
+
坐标:未选择(GCJ-02)
+
+ + + +
+
+ +
+
+ + + + diff --git a/demo/web/index.html b/demo/web/index.html new file mode 100644 index 0000000..55a9d1b --- /dev/null +++ b/demo/web/index.html @@ -0,0 +1,146 @@ + + + + + +鹤仆云 · 守望 S 线一期 演示环境 + + + +
+
演示环境 · 2026-11-07
+

鹤仆云 · 守望 S 线一期

+

老人「生活痕迹」监测 · 设备 → 网关 → 平台 → 家属
+ 前中后三段全部可点:设备是模拟的,接口是真的,数据是真的,页面是 H5(暂代小程序)

+
+ +
+ + +
+

三段是怎么串起来的

+
所有箭头都走真实 HTTP 接口,没有一处是页面里写死的假数据。
+
+
① 设备 / 网关 +
sim_device.py
:9901
+
5 台设备按真实帧格式上报;按钮触发 SOS、水浸、断电
+
→
+
② 接入层(M 层) +
/internal/iot/v1/**
:8080
+
I-01 事件 / I-02 状态 / I-12 原文,验签限流幂等后落 iot_event
+
→
+
③ 业务层 +
AlarmEngine + 53 API
:8080
+
把原始事件翻成告警和通知,供家属端、后台查询与处置
+
→
+
④ 前端 +
/api/v1/**
H5
+
家属端看与确认,后台看全量与闭环
+
+
+ +
+

演示动线(8 步,约 7 分钟)

+
+
打开家属端,用「微信一键授权(模拟)」进去。首页能看到老人、在线设备数、待处理告警。验证码那条链路可以点「注册」试:随机 6 位数字直接填好,不真发短信。
+
打开设备模拟器,点「🆘 按下 SOS」或「💧 卫生间漏水」。
+
等 8 秒(告警引擎扫描周期),回家属端点刷新 → 新告警出现 → 点开 → 「我知道了 / 已处理 / 误报」都能点,处理记录会留痕。
+
打开运营后台,总览的 KPI、近 7 天告警柱状、最新告警会跟着变;告警中心可关闭工单,审计日志能看到刚才所有写操作。
+
家属端 →「设备」→ + 绑定设备:扫码(或用演示设备码)/手填编号,能把一台没建档的设备激活并挂到家里。
+
家属端 →「我的」→ + 创建我的家:像美团新增收货地址一样,地图选点或 GPS 定位 → 省市区 → 小区 → 楼栋单元房号 → 提交;建好后顶部 ⌄ 可以在多个家之间切换。
+
填写老人信息:「我的」→ + 填写老人信息 → 姓名/性别/生日/照护等级/是否独居/画像授权 → 保存后点开老人,可以改资料、加紧急联系人(姓名手机号加密,列表只显示打码号码)。
+
一家人怎么进来:户主点「🎟️ 邀请家人」拿 6 位码;另一个浏览器 / 无痕窗口注册新账号 → 「我的 → 加入别人的家」→ 输码直接进,或搜小区房号「申请加入」。回到户主窗口点「📥 审批」,能看到申请并点通过 / 拒绝。
+
+
+ +
+

账号与地址

+
+
家属端 · 演示账号13800000000 / 123456,或直接点微信一键
+
运营后台admin(口令按部署说明)
+
业务服务http://127.0.0.1:8080
+
模拟控制台http://127.0.0.1:9901
+
健康检查/health → flyway 1.0.28 / 87 表
+
本页地址/app/index.html
+
+
+ +
+ 这是演示环境,不是生产版本。三件事还没做:真微信换 openid、真短信通道、告警规则表(目前只有 SOS / 水浸 / 门磁 / 断电 4 条规则,阈值、静默期、升级链要产品定)。 + 角色数据范围也还没收紧——后台账号现在能看全部小区,上线前必须由程序员补。
+ 地图只用腾讯地图 GL JS(SDK 不带 key、走代理、GCJ-02 坐标); + 从 :8080 打开时代理未启用,会自动降级成「演示坐标板」,功能仍可用,但没有真实底图。 +
+ +
鹤仆云 · 守望 S 线一期 · 三段贯通演示
+
+ + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7b3c672 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,75 @@ +# 文档索引 + +本目录只放**交付与契约类**文档,不放产品需求与项目计划(那些在项目文档库,不在代码主干)。 + +三个子目录按性质划分,判定标准是"这份文档过期时,谁能证明它是对的"。 + +## 一、handover/交付与安全清理 + +周工交付的 `SEC-Z-01 清理后` 材料,**原样保留,内容未改**。这是"这批源码从哪来、清理了什么、怎么证明"的凭据。 + +| 文件 | 内容 | 什么时候看 | +|---|---|---| +| `00_包内容与使用说明.md` | 包里有什么、没什么、怎么用 | 第一次接手 | +| `01_SEC-Z-01_最小安全清理差异清单.md` | 15 处明文逐处改成了什么 | 复核清理是否彻底 | +| `02_SEC-Z-01_敏感信息扫描报告.md` | 扫描范围、结果、复算方法、盲区、轮换结论 | 怀疑有凭据泄露时 | +| `03_Z-02_清理后哈希与构建说明.md` | 清理前后指纹、构建步骤、首提交顺序 | 核对版本标识、复现构建 | +| `04_155条证据适用边界.md` | 155 条重跑结果,**以及它不能证明什么** | 有人拿 155 条说"全过了"时 | +| `gitignore_建议.txt` | 交付方给的忽略清单建议 | 调整 `.gitignore` 时对照 | + +> `00` 第二节称"没给演示前端",但实际交付包含 8 个演示文件,已整理到 `demo/`。该说明为早于补包的旧稿,以实际文件为准。 + +## 二、contract/接口契约 + +**唯一的机读契约来源。** 本工程未引入 springdoc,没有运行时 OpenAPI 生成,所以这里的 md 与 yaml 是手工维护的——改了代码不改这里,就是契约漂移,且没有任何工具会报错。 + +| 文件 | 内容 | +|---|---| +| `6_J-00_接入接口规格_V1.8.md` | 接入接口规格正文(J-00 V1.8.1) | +| `6_J-00_接入接口规格_V1.8_OpenAPI.yaml` | 同上的机读形式 | +| `6_J-00_OpenAPI使用说明_V1.8.md` | yaml 怎么用、字段怎么读 | +| `内部五接口定义.md` | I-01/I-02/I-05/I-12/I-18 字段与语义 | +| `状态机.md` | 命令与告警的状态、动作、迁移条件 | +| `Mock样例.md` | 可直接喂给接入侧的请求样例 | + +契约版本号升级时,文件名、正文、yaml 的 `info.version` 三处必须一致;旧版本文件保留不删。 + +## 三、reference/指纹与校验 + +| 文件 | 用途 | +|---|---| +| `原始交付包指纹.md` | 说明各清单的作用范围,并给出 12 条迁移脚本的哈希 | +| `SHA256SUMS_源码树.txt` | 84 个源码文件的逐文件哈希,**仓库根执行 `sha256sum -c` 可直接校验** | +| `SHA256SUMS_原始交付包.txt` | 原交付包的文件哈希清单(**只对原包有效**) | +| `SHA256SUMS_前端与设备端.txt` | 原包内演示前端子清单(**只对原包有效**) | + +前两个清单在目录重排后**已不能直接校验**本仓库,保留是为了证明"交付时的文件与现在仓库里的是同一份"。怎么核对见 `原始交付包指纹.md`。 + +## 四、路径说明 + +`handover/` 里的文档写于交付当时,文中提到的目录名是**原交付包**的结构。对照本仓库: + +| 文中写法 | 本仓库位置 | +|---|---| +| `aomin-gateway/` | `aomin-gateway/`(同名,未变) | +| `01_后端源码/` | `aomin-gateway/`(交付时的旧称) | +| `02_前端与设备端/` | `demo/` | +| `03_数据库与Flyway迁移/` | **未纳入**,见下 | +| `04_OpenAPI与内部IoT契约/` | `docs/contract/` | + +### 迁移镜像为什么没纳入 + +原包的 `03_数据库与Flyway迁移/` 与 `aomin-gateway/src/main/resources/db/migration/` 是同一套 12 条 SQL 的两次出现,用途是"应用与镜像同源核对"。 + +同一套文件在仓库里出现两次,迟早会只改一处。所以仓库只保留工程内一套(Flyway 只认它),核对能力用哈希代替:12 条脚本的 SHA-256 记在 `reference/原始交付包指纹.md`,交付时已核为**逐字节一致**。 + +## 五、新增文档往哪放 + +| 新增内容 | 放这里 | +|---|---| +| 接口字段、状态机、协议变更 | `contract/` | +| 交付清单、安全清理、复验记录 | `handover/` | +| 哈希、版本指纹、校验脚本输出 | `reference/` | +| 都不属于(需求、计划、会议记录) | 不放本仓库 | + +改版不覆盖旧件:同主题新建文件、版本号 +1,旧件保留。 diff --git a/docs/contract/6_J-00_OpenAPI使用说明_V1.8.md b/docs/contract/6_J-00_OpenAPI使用说明_V1.8.md new file mode 100644 index 0000000..05a512c --- /dev/null +++ b/docs/contract/6_J-00_OpenAPI使用说明_V1.8.md @@ -0,0 +1,138 @@ +# J-00 OpenAPI 机器可读版 · 使用说明 + +**文件**:`6_J-00_接入接口规格_V1.8_OpenAPI.yaml`(OpenAPI 3.0.3,v1.8.0)—— **与本说明放在同一目录**,可直接用编辑器或 Swagger Editor 打开 +**对应**:`6_J-00_接入接口规格_V1.8.md`(同一个契约的人读版) +**编制**:周工 | 2026-11-02 + +> **为什么文件名这么长**:这是故意的。同一份契约有两个形态——`.md` 讲"为什么",`.yaml` 讲"长什么样"。两个文件共用 `6_J-00_接入接口规格_V1.8` 前缀,在文件夹里**永远相邻**,谁也漏不掉;仅仅一个叫 `internal-iot.yaml` 的文件,脱离上下文就没人知道它是 J-00 的机读版。 + +--- + +## 一、这是什么 + +一份**机器可读的接口契约**。内容和 J-00 V1.8 一一对应,只是换了一种能被工具直接消费的写法。 + +**两份文件的分工,不要理解成两份契约:** + +| | 管什么 | 举例 | +|---|---|---| +| **本文件(YAML)** | **长什么样** | 路径、方法、字段类型、必填、枚举、长度上限、错误结构 | +| **J-00 V1.8** | **为什么这样** | 告警状态机、四条硬边界、幂等三档算法、断档与 0x04、`tsSource` 为什么不能伪造 | + +有些东西**写不进 YAML**,比如「`deviceTime` 没有就传 null,禁止用 `receivedAt` 顶替——填了等于伪造时源」。所以:**字段定义以本文件为准,业务语义的解释权仍在 J-00。** + +两者不一致时,以本文件为准,同时回改 J-00,两边同版本号。 + +--- + +## 二、里面有什么 + +| 项 | 数量 | 说明 | +|---|---:|---| +| 接口 | **6** | 在役 5 个(I-01/02/05/12/18)+ 二期预留 1 个(I-17) | +| schema | 20 | Event/DeviceStatus/RawFrame/Command/CommandResult 及其批次包装 | +| 公共参数 | 8 | 鉴权头 4 个 + 幂等键 4 个(每接口一个) | +| 公共响应 | 4 | 400/401/429/500 | + +**五个在役接口:** + +| 编号 | 方法 | 路径 | `operationId` | +|---|---|---|---| +| `I-01` | POST | `/events/batch` | `ingestEvents` | +| `I-02` | POST | `/device-status/batch` | `ingestDeviceStatus` | +| `I-12` | POST | `/raw-frames` | `ingestRawFrames` | +| `I-18` | GET | `/commands/pending` | `pullPendingCommands` | +| `I-05` | POST | `/commands/result` | `reportCommandResults` | +| `I-17` | POST | `/commands/{commandId}/dump` | 二期预留,**本期固定 404,不要实现** | + +**枚举已全部写死,且与 J-00 逐值一致:** + +| 枚举 | 值数 | 内容 | +|---|---:|---| +| `eventType` | **29** | 10 对发生/解除 + 9 个单值 | +| `sourceChannel` | 4 | `OWN_GW`/`HORN_HOST`/`HORN_CELL`/`MIR_CELL` | +| `result` | 5 | `ACKED`/`SENT`/`TIMEOUT`/`DEVICE_OFFLINE`/`FAILED` | +| `rejectReason` | 4 | `RATE_LIMITED`/`SCHEMA_INVALID`/`UNKNOWN_CHANNEL`/`SENSOR_ID_INVALID` | +| `cmdType` | 8 | `set_mode`/`set_ack_policy`/`time_sync`/`set_report_cfg`/`del_sensor`/`reboot`/`diag_dump`/`set_ota` | + +--- + +## 三、能拿它做什么(五件事) + +> 下表里的 `$Y` 指本文件名。因为它带中文和版本号,直接敲命令很累,下面给了变量写法,复制即用。 + +| 用途 | 怎么用 | 省了什么 | +|---|---|---| +| **接口校验** | 用 `swagger-cli validate` 或编辑器插件跑一遍 | 字段漏写、枚举越界当场暴露,不用等联调 | +| **生成服务端骨架** | `openapi-generator-cli generate -i "$Y" -g spring` | 五个 Controller 的入参出参自动生成,外包只填业务 | +| **生成前端类型** | `-g typescript-fetch` 或 `typescript-axios` | 陈工不用照表格手敲 TS 类型 | +| **起 Mock 服务** | `prism mock "$Y"` | 前端不用等后端就能联调 | +| **生成测试用例** | `-g jmeter` 或 `schemathesis run` | 必填、枚举、边界自动出用例,比手写清单全 | + +**推荐的命令(外包侧):** + +```bash +# 文件名太长,先存成变量,后面照抄即可 +Y="6_J-00_接入接口规格_V1.8_OpenAPI.yaml" + +# 1. 语法与规范校验 +npx @redocly/cli lint "$Y" + +# 2. 生成 Spring Boot 服务端接口骨架 +npx @openapitools/openapi-generator-cli generate \ + -i "$Y" -g spring \ + -o ./generated --additional-properties=interfaceOnly=true + +# 3. 起 Mock 服务(前端联调用) +npx @stoplight/prism-cli mock "$Y" -p 4010 +``` + +--- + +## 四、与 J-00 V1.8 的逐项核对 + +已用脚本比对,以下全部一致: + +| 项 | J-00 位置 | YAML 位置 | +|---|---|---| +| 五个接口的编号与路径 | §1.1 | `paths` | +| `eventType` 29 值 | §1.5.1 | `schemas.EventType.enum` | +| 事件对象 12 个字段与必填性 | §1.3 | `schemas.Event` | +| I-02 十个字段 | §1.8 | `schemas.DeviceStatus` | +| I-12 八个字段(含 `rawFrame` 定死 Base64) | §1.9.2 | `schemas.RawFrame` | +| `result` 5 值 | §3.3 | `schemas.CommandResult.result` | +| I-18 四个查询参数与上限 | §3.2 | `paths./commands/pending.parameters` | +| 鉴权四层 + 幂等键算法 | §1.2 | `components.parameters`(每个接口的幂等键**分别写**,因为取法不同) | +| 响应结构与 `unregistered` 非错误语义 | §1.7 | `schemas.IngestResponse` | + +**幂等键按接口分开写,这是 V1.6 的重点修正:** + +| 接口 | 幂等键 | 为什么不能统一 | +|---|---|---| +| I-01 | `{sourceChannel}-{首条 eventId}-{条数}` | 有 `eventId` | +| **I-02** | `{sourceChannel}-{首条 sensorId}-{reportedAt}-{条数}` | **I-02 报文里没有 `eventId`** | +| **I-12** | `{sourceChannel}-{首条 rawRef}-{条数}` | **I-12 报文里没有 `eventId`**,`rawRef` 就是 `MD5(原始帧字节)` | +| I-05 | `{nodeId}-{首条 commandId}-{条数}` | 有 `commandId` | +| I-18 | **不带** | GET 拉取,重复拉天然幂等,靠租约去重 | + +--- + +## 五、已知未定项(不藏着) + +| 项 | 现状 | 何时定 | +|---|---|---| +| **错误响应里的 `code` 具体码值** | 只写了 `{code, message}` 结构,**没固定码值** | 待与业务层错误码表统一后回填。当前按 HTTP 段理解:400xx 请求非法/401xx 鉴权失败/429xx 限流 | +| **I-17 的请求体** | 只写了「本期 404」 | 二期启用诊断包时再补 | +| **`params` 的结构** | `additionalProperties: true`,随 `cmdType` 变 | 八种命令字的参数结构**不在本文件里**,由业务层命令模块保证;外包按 `cmdType` 做编码即可 | + +--- + +## 六、对陈工和外包各说一句 + +**给陈工**:这份 YAML 可以生成 TS 类型,`openapi-generator -g typescript-fetch`。你前端**不用照 J-00 的表格手敲字段**,也不用维护第二份接口定义——生成一次即可,接口变了重生成。 + +**给外包(谢工)**:五个接口全在这里。各字段的 `description` 是给你的实现提示,重点是三处最容易做错的: + +1. **I-02 / I-12 的幂等键不是 `eventId`**(报文里没这个字段),按对应 `components.parameters` 的说明算。 +2. **I-18 是 GET,签名时 `sha256(body)` 取空串的 hash,`path` 含 query 一并算。** +3. **`rejectedDetail` 里出现的条目一律不重试**,只有整批 5xx 才重试。 diff --git a/docs/contract/6_J-00_接入接口规格_V1.8.md b/docs/contract/6_J-00_接入接口规格_V1.8.md new file mode 100644 index 0000000..5ed6da0 --- /dev/null +++ b/docs/contract/6_J-00_接入接口规格_V1.8.md @@ -0,0 +1,1198 @@ +# J-00 接入接口规格 + +**版本**:V1.8 | 2026-11-11 | 编制:周工 + +> **V1.8.1 修订(2026-11-12,补命令节点事实)** +> +> 上一版只把"回执必须带代次"落死,却漏了另一半:**这一轮是谁领的**从来没进过库。 +> `claimed_node_id` 补上之后,一次领取的完整事实在 `device_command` 同一行可核: +> `claimed_node_id + claim_generation + claimed_at + lease_expire_at + status`。 +> +> | # | 改什么 | 为什么 | +> |---|---|---| +> | 1 | §3.2 领取时持久化 `claimed_node_id`(V1.0.28) | 代次回答"第几轮",节点回答"这一轮是谁"。只有代次时,代次没变的命令,任何知道命令号的调用方都能改写它的流转 | +> | 2 | §3.3 顶层 `nodeId` **必填**,且必须与 `claimed_node_id` 一致,否则 `NODE_MISMATCH` 拒收 | 回执来源必须和领取事实对得上。历史在途行没有节点值 → `NODE_UNKNOWN` 拒收,不猜不伪造(租约到期重派后即有值) | +> | 3 | OpenAPI `CommandResult.required` 加 `claimGeneration`、取消 `nullable` | 正文说必填、schema 还是 nullable,三端会按旧契约生成,必须同批改 | +> +> **作用边界(写在这里,免得下一轮按安全机制审)**:`nodeId` 是 IoT **自报**的,能伪造。 +> 它证明的是"领取事实一致",不是"调用方身份"——身份由内网来源白名单 + 签名保证。 + +> **V1.8 修订(2026-11-11,兑现 V1.7.2 的预告 + 一处降级)** +> +> | # | 改什么 | 为什么 | +> |---|---|---| +> | 1 | §3.3 I-05 的 `claimGeneration` **必填**(V1.7.2 只说"建议") | 预告兑现。服务端已改为**缺了直接拒**(`rejected` + 原因 `CLAIM_GENERATION_REQUIRED`),不再"拿库里当前值顶上"——那个回退等于让代次校验自己失效 | +> | 2 | §2.1 `receivedAt` 降为**参考值**:落 `iot_event.received_at_remote`,**判据一律用业务层落库时自打的 `receive_time`** | "平台收到这一帧的时刻"不该由外部填。旧实现直接采信,IoT 传个旧时间就能让一条真实告警在扫描里"永远不新鲜"。V1.0.27 起业务层自己打 `NOW(3)` | +> +> **形态说明(免得按集群口径再问一遍)**:本项目一期**不是集群**——同一个业务层只有一套, +> IoT 可以多实例但各自只领自己连的设备(I-18 的 `gatewayIds` 由 IoT 报),MQTT 的 IoT 不会领 TCP 设备的命令。 +> 代次防的只有一件事:**同一条可延迟命令**(`deliveryMode=DEFERRED`)租约到期回收重派后, +> 第一轮的迟到回执把第二轮的流转覆盖掉。实时命令(重启、布撤防)领一次就完,代次恒为 1。 + +> **V1.7.2 修订(2026-11-11,2 处)** +> +> | # | 改什么 | 为什么 | +> |---|---|---| +> | 1 | §3.2 I-18 响应新增 `claimGeneration`(领取代次) | 一条命令可能被领走两次(租约 60 秒到期没回话 → 回收成 `PENDING` → 被另一个节点领走)。第一个节点的回执迟到时,只按 `commandId` 落库会把第二次的流转覆盖掉,而且不报错——报表里表现为"同一条命令走了两条路",查不出原因 | +> | 2 | §3.3 I-05 请求新增 `claimGeneration` 回带 | 只有回带,服务端才能用 `WHERE claim_generation = ?` 挡住旧回执。不回带时服务端只能拿库里当前值自比,等于没有代次校验 | +> +> **兼容性(V1.8 起生效)**:I-18 必返 `claimGeneration`;I-05 **不回带会被拒**——响应计 `rejected=1`,`rejectedDetail` 原因 `CLAIM_GENERATION_REQUIRED`。带旧代次不拒但**不采纳**(更新 0 行,计 `duplicated`,状态不被迟到回执改写)。 +> 代码与用例:`cmd_chain_test.py` 用例 16–19(I-18 返回 1 → 回收重派后 2 → 旧代次被挡 → 新代次生效)。 + +> **V1.7.1 修订(2026-11-10,1 处,需接入段确认后生效)** +> +> | # | 改什么 | 为什么 | +> |---|---|---| +> | 1 | I-01 的 `rawRef` 从「**否**」改为「**是**」,并取消「墓碑帧为 `null`」的例外 | V1.7 当时设想墓碑行来自 `undelivered[]` 数组的一项、没有独立帧。**实际实现是 IoT 把它展开成一条独立 I-01 事件**(`eventType=MSG_UNDELIVERED`)上报,因此它和普通事件一样有自己的原始字节,`rawRef` 完全算得出来 | +> +> **墓碑帧填哪个 `rawRef`**:填**承载这次 `undelivered[]` 的网关上行帧**的摘要。一条网关报文里有 N 个 `undelivered`,就是 N 条墓碑行共享同一个 `rawRef`——**多对一,合法**。 +> 墓碑行的身份是 `sensor_id + seq`(`MsgUndelivered` 按这两个去重),**不是 `rawRef`**;`iot_event` 的幂等键是 `eventId`,也不是 `rawRef`。所以共享 `rawRef` 不影响任何去重逻辑,只是把回指链补全。 +> +> **历史数据**:V1.7.1 之前已入库的墓碑行为 `null`(无原始帧可追,不补造)。业务层对 `null` 保持兼容,不因缺 `rawRef` 拒收。 +> **算法口径(V1.7.2 定稿)**:`MD5`(32 位小写 hex)与 `SHA-256`(64 位小写 hex)**都接受**,按长度识别(`^([a-f0-9]{32}|[a-f0-9]{64})$`)。约束只有一条:**同一条链上 I-01 与 I-12 用同一种**,不能一边 MD5 一边 SHA-256。存储层两列已放宽到 `varchar(64) utf8mb4_bin`,选哪种都能落库。 +**对应 WBS 单元**:`J-00-U1`(SPEC) +**上游依据**:**《奥敏平台总体架构与 S 线一期实施规范 V1.0 定稿版》(2026-11-04)** §4.2 信封字段规则表(本文的上级口径,冲突时以定稿为准并回改本文);《守望系统设计文档 **V1.7**》§6.2–§6.13;《守望数据库设计 **V1.10**》`iot_event` / `ingest_raw_log` / `adapter_in_raw` / `device` / `device_command` / `unregistered_device` + +> **V1.7 对齐定稿 V1.0:改 9 条、留 4 条差异、1 条提请修订定稿。接口集合、路径、字段名、批量大小一个没动**;另有 3 处结构性补充(§2.7 差异表、§2.6 Base64 口径、§9 给陈工/黄工/谢工的事项)。 +> +> **2026-11-06 补记(DB V1.10 到手后)**:四处差异的性质变了——核对后确认是**《数据库设计 V1.10》明确选了另一条路并写了理由**(`iot_event` 分区+`p1900` 哨兵、`link_quality_event` 全册不存在),不是我方实现走样。§2.7 已按此重写,责任归「定稿与 DB 待对齐」,接入层只跟 DB 走。同时 DB 依据由 V1.9 升至 **V1.10**,附录 B 的协议代号补上「≤16 字符」约束。 +> +> | # | 改什么 | 依据(定稿 V1.0 行号) | +> |---|---|---| +> | 1 | `seq` 从「**仅 G 线**,H/S 线传 null」改为「铠湾序列号、bignbiot 帧号及其他协议序号**分别映射**;不得写成 S 线一律 null」 | §4.2 字段表 `seq` 行 | +> | 2 | `rawRef` 加「**新接口优先 SHA-256**」;MD5 仍可用,32 位小写 hex 口径不变 | §4.2 字段表 `rawRef` 行 | +> | 3 | `eventType` 从「枚举外不拒收」细化成「**映射 `UNKNOWN` 并保留原始码**,不静默拒收」 | §4.2 字段表 `eventType` 行 | +> | 4 | 命令状态:领取后是 **`CLAIMED`(已领取/租约中)**,**不得写成「已下发」** | §4.2 末「命令模块」段;V1.0 定稿 L378 | +> | 5 | I-02 批量上限写明 **500 条/批**(I-01/I-12 仍 200) | §4.2 接入层第 1 条 | +> | 6 | `sourceChannel` 加注「**不承担具体物理通路,与 `linkType` 分列**」 | §4.2 字段表 `sourceChannel` 行 | +> | 7 | `protocolVersion` 加「**豪恩不得再写 `tcp-1.10`**」 | §4.2 字段表 `protocolVersion` 行 | +> | 8 | 附录 B 协议清单重写:删「C 契约」残留,改按**五个编解码插件**列(kaiwan_v3/bignbiot/mqtt/aep/mc200) | §4.2「协议编解码插件」段 | +> | 9 | I-05 的 `resultCode` **不得把厂家原码直透到标准字段**,另存 `rawResultCode`/`rawAck` | §4.2 末「命令下发采用拉取模型」段 | +> +> **保留现状、与定稿不一致的 4 处**(按现行实现施工,已记入《提请修订定稿 V1.0》清单,见 §2.7): +> +> | 差异 | 定稿要求 | 现行做法 | 为什么先不改 | +> |---|---|---|---| +> | ① 墓碑帧落表 | 写 `link_quality_event`,标准业务时间字段**禁哨兵** | 落 `iot_event`,`event_time='1900-01-01'`、落 `p1900` 分区 | `link_quality_event` **建表前不存在**(定稿 §7.5 自列「11-12 随基线同步」);过渡期只能这样,否则数据无处可落 | +> | ② 0x04 告警帧落表 | 同上 | 落 `iot_event`,`event_type=PRE_ALARM_0X04` | 同上 | +> | ③ `event_time` 可空 | §6.2「`event_ts` **可为空**」 | `NOT NULL` 且是**分区键** | 分区键不能可空(MySQL 硬约束);改可空=改分区策略,属库结构变更 | +> | ④ `ingest_seq` 稳定接收顺序 | §6.2 要求业务层生成 | 无此列,用自增 `id` | 缺列,等加列 | +> +> **1 条提请修订定稿**:定稿 §6.6 要求「二进制 Base64、JSON 保留原文,**以 encoding 标识来源**」。现行与陈工已核定为**统一 Base64、不加 encoding 列**(选 B),理由见 §2.6。提请把定稿该句改口径,**本文不按定稿执行**。 +> +> **V1.6 的改动继续有效**(I-04→I-18、`tsSource=3` 补空规则、幂等键表拆开重写)。 + +> **V1.6 修三处,接口集合、路径、字段、枚举、状态机一个没动。** +> +> | # | 修什么 | 依据 | +> |---|---|---| +> | 1 | `I-04` → **`I-18`**(领任务),`I-04` 废止、永不复用 | 仇总 11-02 回复函 §二:`I-04` 在陈工《系统设计 V1.4》§6.1 是「映射同步回执」,本轮按 J-00 收口后变成「领取命令」,属"旧编号复用于新含义"。与 `I-13`→`I-17` 同一处理方式 | +> | 2 | §2.3 补「`tsSource=3` 且 `deviceTime` 为空」的取值规则 | 同上 §二点名的遗留项。**原表按字面执行会把 NULL 插进 `event_time`(NOT NULL 分区键),MySQL 直接报 1364** | +> | 3 | §1.2 幂等键表拆开重写(I-02/I-12 不再取 `eventId`) | 同上 §二。I-02(状态快照)与 I-12(原始报文)**报文里没有 `eventId` 字段**,照 V1.5 实现算不出键 | +> +> **11-03 勘误(v1.6 内修正,不升版)**:§1.5.1 原写 `PRE_ALARM_0X04`「**要告警**」、其下说明原写「前者代表设备在求救」,两处都错。0x04 是哨兵报「我丢了 N 条」的**丢失计数**,**只通知平台运营/运维、不通知家属**,用途是到达率指标(第一册 T12「30 天 ≤2%」/T10「≥98%」)。照原文实现,会给家属推一条既没有设备、也没有事件类型的消息。 +> +> **V1.5 的编号订正继续有效**:二期预留的诊断包分片上传为 `I-17`(原 `I-13` 在系统设计 V1.4 曾指「网关台账快照」,故废止不复用)。 + + +--- + +## 谁该读哪几章 + +| 读者 | 必读 | 可跳过 | +| --- | --- | --- | +| **外包(IoT 中间件)** | §〇、§一、§三、§四、§五、§六、§七、§八 | **§二(它不落库)、§九** | +| 我方(业务层 `/internal` 服务端) | 全部,§二是设计与实现依据 | — | +| 陈工(小程序前端 + 库表) | §3.3、§3.4、§九 | §二 | +| 黄工(网关固件) | §3.5 | 其余 | + +**本文只写契约,不写实现。**「报文长什么样、落到哪张表哪一列」在这里;「Controller 怎么拆、事务边界在哪」在各单元的详细设计里,不在这份文件。 + +--- + +## 〇、这份规格解决什么 + +中间件(我方,可外包)和业务层(Java)是两套代码、两个开发方,**唯一的对话方式就是这几个接口**。所以这份文件只写三件事: + +1. **调什么接口**——路径、方向、鉴权、批量、重试; +2. **传什么字段**——含义、必填性、**值从报文的哪里取**; +3. **落到哪张表哪一列**——一条报文变几行、时间字段谁派生、未建档走哪条路。 + +### 0.1 四条硬边界(违反任何一条即返工) + +| # | 边界 | 具体含义 | +| - | ------------------------ | ------------------------------------------- | +| 1 | **IoT 不连数据库** | 不持 DB 账号、无连接串。任何 JDBC/ORM/数据库驱动出现即打回 | +| 2 | **IoT 不判业务** | 不判设备是否建档、不判该不该重试、不判设备支不支持这条命令。所有帧一律上报,所有结果如实回报 | +| 3 | **只允许 IoT 调业务层** | 单向。业务层不保存 IoT 的地址,不提供回调注册,永不反向调用 | +| 4 | **IoT 只报它亲眼看见的事** | 连不上、发出去了、对端回了、回了什么码。业务语义(休眠、不支持、参数非法)由回执原始码或业务层判定 | + +第 4 条是 V1.4 新增,也是最容易被违反的一条:**让中间件判断它看不见的状态,外包只能靠猜,猜出来的就是假数据。假数据比没有数据更坏,因为它看起来是对的。** + +### 0.2 数据怎么走(上行) + +``` +┌──────────────────────────────────────────────┐ +│ 设备侧(三条线形态不同,都只连中间件) │ +│ │ +│ G 线 433 子设备 ─433射频→ 4G 网关 ─MQTT→ │ +│ S 线 NB / 4G 单品 ────裸 TCP 9902────→ │ +│ H 线 厂家云 ───────HTTP 推送──────→ │ +└───────────────────────┬──────────────────────┘ + │ 三条线汇到同一个中间件 + ▼ +┌──────────────────────────────────────────────┐ +│ IoT 中间件(外包,不判身份 / 不判策略) │ +│ J-04 帧解析 / J-05 信封组装 │ +│ J-06 幂等键 / J-07 批量上传 / J-08 命令 │ +└───────────────────────┬──────────────────────┘ + │ 上行 POST 推数据 I-01 / I-02 / I-12 + ▼ +┌──────────────────────────────────────────────┐ +│ 业务层(Java) │ +│ ① 一次 SELECT … WHERE sensor_id IN (…) │ +│ ├ 命中 → 写 iot_event │ +│ └ 未命中 → 写 unregistered_device │ +│ ② EventTimeResolver 派生 event_time │ +│ ③ 规则引擎 / 基线 / 派单 │ +└──────────────────────────────────────────────┘ +``` + +**三条线都只连中间件,没有任何设备直连业务层。** 这是物理事实,不是设计选择:433 子设备没有 IP;S 线单品只会说厂商私有二进制;H 线的厂家云只认一个推送入口。 + +还有一个容易画错的地方:**4G 网关也属于「设备侧」**,它连的是中间件,不是业务层。 + +### 0.3 指令怎么走(下行) + +``` +业务层:用户点【撤防】,目标 = 水浸 3F7A9C21 + │ + ├ ① 查 device:3F7A9C21 的 gateway_dev_id = OWN_GW_A + │ → 路由在这一步算完,结果固化进指令行 + │ + └ ② INSERT device_command + dev_id = 3F7A9C21 ← 给谁(业务身份) + gateway_dev_id = OWN_GW_A ← 从哪个端点进去 + │ + ▼ +IoT:GET /commands/pending?gatewayIds=OWN_GW_A,… + │ + ├ 用 gateway_dev_id 在自己的连接表里找通道 + │ 找不到 → DEVICE_OFFLINE,结束 + ├ 把 targetId 塞进下行报文载荷,发出去 + └ 用 I-05 回报结果 +``` + +**IoT 不知道 `3F7A9C21` 是水浸还是门磁,也不知道它和 `OWN_GW_A` 是父子关系。** 它眼里只有两个字符串:一个用来找连接,一个用来填载荷。 + +这就是「关系是业务数据,执行是机械动作」的分界。一旦 IoT 需要知道设备关系,它就得维护一份会过期的副本,两份状态必然对不上——这跟它当初不该缓存白名单是同一个理由。 + +### 0.4 谁算什么 + +| 判断 | 谁做 | 依据 | +| --- | --- | --- | +| 这台设备建没建档 | 业务层 | 落库时本来就要 `SELECT device`,顺手一个 `if` | +| 这条命令该发给谁 | 业务层 | 写指令行时查 `device.gateway_dev_id` | +| 该不该重试 | 业务层 | `delivery_mode` + 结果 | +| 设备支不支持这条命令 | 业务层 | 能力表,**入口就拦,不发出去** | +| 设备是不是休眠 | 业务层 | 回执码 + 供电方式,IoT 分不清休眠和坏 | +| 链路通不通 | **IoT** | 连接在不在、发没发出去、有没有回执 | + +--- + +## 一、接口定义(外包照着写) + +### 1.1 接口清单 + +**上行(IoT 推数据)** + +| 编号 | 方法 | 路径 | 用途 | 批量上限 | +| -------- | ---- | -------------------------------------- | -------------- | ------: | +| **I-01** | POST | `/internal/iot/v1/events/batch` | 设备事件(含墓碑帧) | 200 条/批 | +| **I-02** | POST | `/internal/iot/v1/device-status/batch` | 技术状态快照(在线/电量/CSQ/lastSeq) | 500 条/批 | +| **I-12** | POST | `/internal/iot/v1/raw-frames` | 原始报文批量留底(**字段见 §1.9**) | 200 条/批 | + +**下行(IoT 主动来领)** + +| 编号 | 方法 | 路径 | 用途 | 批量上限 | +| -------- | ---- | ------------------------------------ | ------------------ | ------: | +| **I-18** | GET | `/internal/iot/v1/commands/pending` | 长轮询领任务 | 50 条/次 | +| **I-05** | POST | `/internal/iot/v1/commands/result` | 批量回报执行结果 | 50 条/批 | + +**五个接口,全部由 IoT 发起。** 业务层一个出站请求都不发。 + +> **二期预留,本版不实现**:`I-17 POST /commands/{id}/dump`(诊断包分片上传)。一期 `diag_dump` 只留命令字、实际不下发,这个接口零调用。等 `set_ota` 和诊断包真正启用时再补。 +> +> **编号台账(V1.6)** +> +> | 编号 | 现状 | 含义 | +> | --- | --- | --- | +> | `I-01` | 在役 | 事件批量上报 | +> | `I-02` | 在役 | 设备状态快照 | +> | `I-05` | 在役 | 命令结果回报 | +> | `I-12` | 在役 | 原始报文上报 | +> | **`I-18`** | 在役 | 长轮询领命令(**V1.6 起,原 `I-04`**) | +> | `I-17` | 二期预留 | 诊断包分片上传 | +> | `I-03`、`I-06`~`I-11`、`I-13`~`I-16`、**`I-04`** | **废止** | 不得复用 | +> +> **两条废止理由要分开记,别混成一条:** +> +> - `I-13`——在系统设计 V1.4 是「网关台账快照」,该功能取消后号一并作废,诊断包另起 `I-17`。 +> - `I-04`——在系统设计 V1.4 是「映射同步回执」(`POST /mapping/ack`),本轮按 J-00 收口后该语义消失;但 **J-00 自 V1.0 起 `I-04` 一直指「领任务」**。同一个号在两份文件里指过两件事,按"旧编号不复用"原则一律废止,领任务改用 `I-18`。 +> +> 宁可空号,也不让同一个编号在不同版本里指两件事。 + +### 1.2 鉴权:四层 + 幂等键 + +| 层 | 头 / 机制 | 值 | +| --- | ------------------------- | -------------------------------------------------------------------------------------------------- | +| 传输 | mTLS | 双向证书,90 天轮换 | +| 身份 | `X-Internal-Token` | 固定长随机串,配置中心下发 | +| 完整性 | `X-Sign` | `HMAC-SHA256(token, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body))` | +| 防重放 | `X-Timestamp` / `X-Nonce` | 偏差 > 300 秒拒;nonce 在 Redis 留 600 秒 | +| 幂等 | `X-Idempotency-Key` | 24 小时内重复请求直接返回首次结果 | + +**幂等键算法定死一个,不要自创**: + +| 接口 | `X-Idempotency-Key` | 为什么取这个 | +| --- | --- | --- | +| **I-01** | `{sourceChannel}-{批内首条 eventId}-{条数}` | I-01 报文里就有 `eventId`(§1.3),取它 | +| **I-02** | `{sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数}` | **I-02 是状态快照,报文里没有 `eventId`**(§1.8)。用「设备 + 快照时刻」定键:同一台设备同一时刻的快照重推,必是同一批 | +| **I-12** | `{sourceChannel}-{批内首条 rawRef}-{条数}` | **I-12 是原始报文,报文里也没有 `eventId`**(§1.9)。`rawRef` 就是 `MD5(原始帧字节)`,重推同一帧必然算出同一个值 | +| **I-05** | `{nodeId}-{批内首条 commandId}-{条数}` | 命令有 `commandId`(§3.3) | +| `I-18` | **不带** | 它是 `GET` 拉取,无 body、不改状态,重复拉取天然幂等(靠租约去重,见 §3.2) | + +**不得用批次号、时间戳、随机数。** 重推同一批必须算出同一个键——用随机值等于每次重推都是一批新数据,幂等白做。 + +> **V1.6 修正**:V1.5 把 `I-01 / I-02 / I-12` 三个接口的幂等键并成一行「`{批内首条 eventId}`」,但 **I-02 和 I-12 的报文里根本没有 `eventId` 这个字段**。照 V1.5 实现,这两条链路算不出键,只能现编——一现编,重推就变成新数据,幂等整条失效。仇总 11-02 回复函 §二 点出的「I-02、I-12 幂等键不能取不存在的 eventId」即此。 + +三处容易做错的地方: + +| # | 情形 | 正确做法 | +| - | --- | --- | +| 1 | **I-18 是 GET,没有 body** | 签名里的 `sha256(body)` 取**空串的 hash**(`e3b0c442…b855`),不要取字面量 `null` | +| 2 | I-18 参与签名的 `path` **含 query** | 按发送时的原始路径算,`?nodeId=…&wait=30` 一并算进去。不带 query 会验签失败 | +| 3 | 重推 | 幂等键必须与首次一致(见上表) | + +### 1.3 事件对象:逐字段 + +```json +{ + "sourceChannel": "OWN_GW", + "events": [ + { + "eventId": "3F7A9C21_10231", + "sensorId": "3F7A9C21", + "sourceChannel": "OWN_GW", + "eventType": "DOOR_OPEN", + "deviceTime": null, + "receivedAt": "2026-10-29T10:19:22+08:00", + "tsSource": 3, + "protocolVersion": "gw-1.2", + "seq": 10231, + "payload": { "deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": 3720, "rssi": -72 }, + "rawRef": "9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6" + } + ] +} +``` + +| 字段 | 必填 | 说明 | +| --------------------- | --------- | -------------------------------------------------------------------------------------------- | +| `eventId` | 是 | 事件级幂等键,**≤96 字符,不得含平台时间戳**。算法见 §2.4 | +| `sensorId` | **是** | 设备物理标识,**必须与 `device.sensor_id` 完全一致(含厂商前缀)**。取值规则见 §1.4,**这是全篇最容易出错的一个字段** | +| `sourceChannel` | 是 | `OWN_GW` / `HORN_HOST` / `HORN_CELL` / `MIR_CELL`。**它是业务来源,不承担具体物理通路**——哪套协议、哪个监听口进来,由接入侧的 `linkType` 描述,两者分列,不合并成一个值 | +| `eventType` | 是 | 枚举,取值见 §1.5。**枚举外的新值映射 `UNKNOWN` 并保留原始码**(写 `payload.rawEventCode`),不拒收、不静默丢弃。见 §1.5.5 | +| `deviceTime` | 否 | 设备侧时间,没有就 `null`。**禁止用 `receivedAt` 顶替**——填了等于伪造时源 | +| `receivedAt` | 是 | 平台接收时刻,ISO-8601 带时区 | +| `tsSource` | 是,无默认 | `0` 设备原始 / `1` 厂家云 / `2` 平台接收 / `3` 网关打戳。漏赋值接口直接拒收 | +| `protocolVersion` | 是,无默认 | 按**具体协议原件**填写(铠湾 V3.0/bignbiot 3.0/NB&WIFI V1.6/MQTT 对接协议版本等),厂商改协议后要能回溯。**豪恩不得再写 `tcp-1.10`**——那是旧口径,已废止 | +| `seq` | 否 | 协议序号。**G 线**取 433 帧 `count`(2 字节);**铠湾 V3.0** 取帧内序列号;**bignbiot** 取帧号(1 字节 1–255);**其他协议**取自带序号。**没有就是 `null`,但不得写成「S 线一律 null」**——S 线两套 TCP 都有序号,丢了对不上账 | +| `payload` | 否 | 事件明细。已知字段逐个列,**厂商新增字段原样保留,不丢** | +| `payload.deviceTypeRaw` | **是** | 设备原始类型码(G 线 `type` 字节、S 线 `DT`、H 线子设备类型)。**任何帧都带,含心跳与 `UNKNOWN`** | +| `payload.rawEventCode` | 否 | 设备原始事件码。**`eventType` 落到 `UNKNOWN` 时必填**,其余情况建议带 | +| `rawRef` | **是**
(V1.7.1 起) | 原始报文摘要,回指 I-12 的 `payload_md5`。**MD5 为 32 位小写 hex;新接口优先 SHA-256**(64 位小写 hex)。摘要只用于关联与幂等,**不作鉴权**。**墓碑帧同样必填**:填承载该 `undelivered[]` 的**网关上行帧**摘要(一帧可对应多条墓碑行,多对一,合法);仅 V1.7.1 之前已入库的历史行为 `null` | + +批量外层带 `sourceChannel` 是缺省值,内层优先——同机多通道部署时有用。 + +### 1.4 `sensorId` 归一化规则(★先看这节再写代码) + +业务层用 `sensor_id` 做等值匹配。如果中间件传的标识和库里存的差一个前缀,**结果是全场设备都被判成未建档**,数据一条都进不了库,而且日志上一切"正常"。 + +所以前缀由**适配器按通道拼**,不靠猜: + +| `sourceChannel` | 前缀 | `sensorId` 写法 | 示例 | +| --- | --- | --- | --- | +| `OWN_GW` | 无 | 433 帧 byte4–7,**大写 8 位 hex** | `3F7A9C21` | +| `HORN_HOST` / `HORN_CELL` | `HORN:` | 前缀 + 豪恩子设备 SN,**原样不改大小写** | `HORN:SN12345678` | +| `MIR_CELL` | `MIR:` | 前缀 + IMEI(15 位数字) | `MIR:861234567890123` | + +三条纪律: + +1. **不截断、不补零、不转小写**。超 64 字符整帧拒收,不静默截断(截断会产生一个"看起来很对"的假设备)。 +2. **前缀只在适配器加一次**。若厂商原始报文里已自带前缀,先剥掉再按上表重新加,避免 `HORN:HORN:xxx`。 +3. **上线前必做一次抽样比对**:从 `device` 表随机取 20 台在役设备,用其 `sensor_id` 反查适配器会生成什么值,逐个对齐。这一步省不得。 + +### 1.5 `eventType` 枚举 + +#### 1.5.1 事件家族与枚举全表 + +**29 个值,按家族成对出现。** 少一个"解除",告警就永远不闭环——界面一直红着,规则引擎也判不出"已恢复"。 + +| 家族 | 发生 | 解除 / 停止 | +| --- | --- | --- | +| 门磁 | `DOOR_OPEN` | `DOOR_CLOSE` | +| 用水活动 | `WATER_ACTIVITY` | `WATER_ACTIVITY_STOP` | +| 用电活动 | `CURRENT_ACTIVITY` | `CURRENT_ACTIVITY_STOP` | +| 漏水 | `WATER_LEAK` | `WATER_LEAK_CLEAR` | +| 燃气 | `GAS_LEAK` | `GAS_LEAK_CLEAR` | +| 烟感 | `SMOKE` | `SMOKE_CLEAR` | +| 跌倒 | `FALL` | `FALL_CLEAR` | +| 呼救 | `SOS` | `SOS_CLEAR` | +| 防拆 | `TAMPER` | `TAMPER_CLEAR` | +| 电源 | `POWER_CUT` | `POWER_RESTORE` | + +单值事件: + +| 值 | 语义 | 当前已知来源 | +| --- | --- | --- | +| `VALVE_CLOSED` | 关阀执行结果 | S | +| `TEMP_HUMIDITY` | 温湿度采样 | S | +| `DEVICE_ONLINE` / `DEVICE_OFFLINE` | 上下线 | G / S / H | +| `LOW_BATTERY` | 低电量 | G / S / H | +| `HEARTBEAT` | 心跳 | G / S / H | +| `PRE_ALARM_0X04` | 0x04 未送达告警帧(**只通知平台运营/运维,不通知家属**;分钟级) | G | +| `MSG_UNDELIVERED` | 墓碑行(只算质量证据,**不告警**) | G | +| `UNKNOWN` | 未知帧,原样转供排查 | 任意 | + +**「当前已知来源」这一列不作接收校验依据。** 它只回答"截至本版本我们确认过哪些线会产生这个事件"。任何线上报任何合法枚举值一律接收——设备以后扩展出新能力是常态。 + +`PRE_ALARM_0X04` 与 `MSG_UNDELIVERED` **必须分开**:前者是**哨兵自己报「我丢了 N 条」**(`lost` 字段是唯一来源,只通知平台,不通知家属);后者是**网关按序号推算出的缺失帧记录**(不告警)。混成一个值,要么丢掉设备侧上报的丢失计数,要么天天误报。 + +> **`PRE_ALARM_0X04` 的用途是到达率指标,不是用户告警。** 第一册 T12「到达率 30 天 ≤2%(排除 `is_backfill` 与离线)」与 T10「到达率 ≥98%」是同一个指标的两面;`lost` 是**必握手事件**(水浸/SOS)丢失的唯一设备侧来源,非握手型(门磁/水振/电流)的丢失由网关 count 断档覆盖。所以它进运营/运维视图,**不进家属通知**。 + +#### 1.5.2 命名规范 + +| 家族 | 格式 | 例 | +| --- | --- | --- | +| 开关型 | `XXX_OPEN` / `XXX_CLOSE` | `DOOR_OPEN` / `DOOR_CLOSE` | +| 报警型 | `XXX` / `XXX_CLEAR` | `WATER_LEAK` / `WATER_LEAK_CLEAR` | +| 活动型 | `XXX_ACTIVITY` / `XXX_ACTIVITY_STOP` | `WATER_ACTIVITY` / `WATER_ACTIVITY_STOP` | +| 通用 | 无主语 | `HEARTBEAT` / `LOW_BATTERY` / `TAMPER` | + +`DOOR_CLOSE` 不用 `DOOR_CLEAR`——门磁的语义是"开关",不是"报警/解除"。 +`POWER_RESTORE` 不用 `POWER_CLEAR`——来电是独立发生的正常事件,不是"某个异常解除了"。 + +#### 1.5.3 G 线 `(type, event)` 完整映射矩阵 + +`type` 和 `event` 是两个独立维度,**必须二维查表**。 + +| `type` | event=1 触发 | event=2 恢复 | event=0 心跳 | event=3 低电 | event=4 未送达 | +| --- | --- | --- | --- | --- | --- | +| 1 门磁 | `DOOR_OPEN` | `DOOR_CLOSE` | `HEARTBEAT` | `LOW_BATTERY` | `PRE_ALARM_0X04` | +| 2 水振 | `WATER_ACTIVITY` | `WATER_ACTIVITY_STOP` | 同上 | 同上 | 同上 | +| 3 电流 | `CURRENT_ACTIVITY` | `CURRENT_ACTIVITY_STOP` | 同上 | 同上 | 同上 | +| 4 水浸 | `WATER_LEAK` | `WATER_LEAK_CLEAR` | 同上 | 同上 | 同上 | +| 5 SOS | `SOS` | `SOS_CLEAR` | 同上 | 同上 | 同上 | + +两条读法,别弄反: + +- `event = 0 / 3 / 4`(心跳、低电、未送达)**与 type 无关**,落同一个枚举值,所以 `HEARTBEAT` 不带主语; +- `event = 1 / 2` **必须看 type**。同是"触发",门磁是开门、水浸是漏水。 + +**只按 type 查会把"不漏了"也映射成"漏水"**——这是最容易犯的错误,因为一维写法更短。 + +#### 1.5.4 `deviceTypeRaw` 为什么任何帧都要留 + +不是"0x04 特殊要留",而是**所有帧都留**,规则写成一条,代码里不用一堆 `if`。三条理由: + +| 场景 | 没有它会怎样 | +| --- | --- | +| 野设备发帧 | `sensorId` 库里查不到,不知道是水浸求救还是门磁误报,只能当垃圾扔掉 | +| SN 复用/换绑 | 同一 `sensorId` 前后是不同设备,靠 type 变化才能发现绑错 | +| 排障 | 现场说"这设备没报警",原文里能看出是 type 配错了还是真没触发 | + +#### 1.5.5 遇到枚举外的新值(不拒收) + +| 步 | 中间件做什么 | +| - | --- | +| 1 | `eventType` 填 `UNKNOWN` | +| 2 | 原始事件码**必填**写入 `payload.rawEventCode`,完整帧照常进 I-12 | +| 3 | 本地计数 `UNKNOWN` 占比;同一 `sourceChannel + rawEventCode` 超过 50 次,产生一条自检告警提示补枚举 | + +业务层收到 `UNKNOWN` 只落库、不进规则引擎、不告警。这样新协议第一天就能全量进库跑起来,补枚举只是把历史数据重新分类,不用重放报文。 + +### 1.6 批量、重试、限流 + +| 项 | 值 | +| ---- | --------------------------------------------- | +| 批量上限 | 事件 **200** 条/批;状态 **500** 条/批;原文 **200** 条/批(三档不同,别统一成一个数) | +| 攒批策略 | 满 200 条 或 200ms,先到先发 | +| **免攒批** | `SOS`、`WATER_LEAK`、`PRE_ALARM_0X04`、`GAS_LEAK` **立即单发**,不等批量 | +| 超时 | 请求 10 秒 | +| 重试 | 1s / 5s / 30s / 2m / 10m,共 5 次;仍失败落本地死信并计数 | +| **单设备限流** | 同一 `sourceChannel + sensorId` **60 帧/分钟**,超出丢弃并计 `RATE_LIMITED` | +| **单通道限流** | 同一 `sourceChannel` 5000 帧/分钟,超出丢弃并计 `RATE_LIMITED` | + +关于限流:撤销白名单后,野设备也会走 I-01,所以要防的是**帧量本身**,不是设备身份。一台被拆下来反复上电的网关会持续发心跳,该拦的是频率。限流是初值,联调后按实测调,改配置不改代码。 + +限流丢弃**必须计数**,不能静默丢。 + +### 1.7 响应与错误 + +成功(含部分失败): + +```json +{ + "code": 0, + "accepted": 198, + "rejected": 2, + "duplicated": 3, + "unregistered": 5, + "rejectedDetail": [ + { "eventId": "3F7A9C21_10230", "reason": "SCHEMA_INVALID" } + ] +} +``` + +`reason` 只保留两个处置动作,**不要按原因做分支**: + +| `reason` | 含义 | 外包处置 | +| ---------------------------- | ---------------------------- | ------------------- | +| `RATE_LIMITED` | 超出 §1.6 限流 | 计数,**不重试**(重试还是被限) | +| `SCHEMA_INVALID` / `UNKNOWN_CHANNEL` / `SENSOR_ID_INVALID` | 字段缺失、通道不认识、标识非法 | 落死信,**不重试**(重试也不会变) | + +也就是说:**收到 `rejectedDetail` 一律不重试,只落死信 + 计数。** 需要重试的只有整批失败(网络、5xx),按 §1.6 那五档退避。 + +**`unregistered` 是计数字段,不是错误。** 一批里 5 条设备未建档,业务层已经写进 `unregistered_device`,接入侧**不做任何动作**——不重报、不换接口、不缓存。 + +**单条失败不整批回滚**:一批 200 条里 1 条非法,其余 199 条照常入库。整批回滚会让接入侧重推全部,重推又撞去重键,最后一条都进不来。 + +### 1.8 I-02 状态快照字段 + +**批量上限 500 条/批**(I-01 与 I-12 是 200)。三档不同是有原因的:一条状态快照只有一个设备一行的量级,而一条事件要带 `payload`;统一成一个数会让状态上报被无谓拆批。 + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `sensorId` | 是 | 同 §1.4 规则 | +| `sourceChannel` | 是 | 同上 | +| `online` | 是 | `true` / `false`。**只表示"最近是否有报文",不表示设备好坏** | +| `lastSeenAt` | 是 | 最后一次收到有效报文的时刻 | +| `batteryMv` | 否 | 毫伏整数。电压不换算成百分比(换算式会变,在业务层) | +| `csq` | 否 | 信号质量 0–31,`99` = 未知 | +| `lastSeq` | 否 | 仅 G 线,最后收到的 433 序号 | +| `lostCnt` | 否 | 仅 G 线,窗口内未送达计数,`255` 表示饱和 | +| `protocolVersion` | 是 | 同 I-01 | +| `reportedAt` | 是 | 快照生成时刻 | + +I-02 是**并发上报**的状态,业务层按 `sensorId` 覆盖写(谁最新谁覆盖),不做时间排序。它不参与告警判定,只用于界面展示与离线判断的原料。 + +### 1.9 I-12 原始报文字段 + +**I-12 是「全集的口子」,不是 I-01 的附件。** 所有上行帧都从这里进,其中「能变成业务事件」的那部分,另由 I-01 再报一次。同一帧走两条链路不是重复——一条存档,一条产生业务后果。 + +``` +设备/厂商云 ──I-12──→ ingest_raw_log(G 线,30 天)/ adapter_in_raw(H·S 线,90 天) + └─I-01──→ iot_event(仅业务事件,25 个月) +``` + +**为什么必须独立成接口**,三条理由按重要性排: + +| 层 | 问题 | +| --- | --- | +| **覆盖不全(决定性)** | `iot_event` 只收「有 `eventType` 的业务事件」。心跳、鉴权帧、解析失败的未知帧、**被限流丢弃的帧**根本进不了 I-01,但原文必须留:没有心跳原文,「这台设备还在不在」没有证据;没有未知帧原文,新固件加个字段就永远查不出为什么解析失败 | +| 体积 | 原文表 30 天滚动 3600 万行。整帧塞进 I-01 要转义、要膨胀,能把请求体撑到几 MB,直接毁掉 100ms 的 ACK 目标 | +| 生命周期 | 事件表按月分区、留 25 个月;原文表 30 天删。两张表保留策略差一个数量级,同一条报文里就意味着只能同寿 | + +#### 1.9.1 报文 + +```http +POST /internal/iot/v1/raw-frames +X-Idempotency-Key: OWN_GW-3F7A9C21_10231-2 +Content-Encoding: gzip +``` + +```json +{ + "sourceChannel": "OWN_GW", + "frames": [ + { + "rawRef": "9f2c1b7e5a0d4c88b3e6f1a2d5c9b047", + "sensorId": "3F7A9C21", + "sourceChannel": "OWN_GW", + "receivedAt": "2026-10-29T11:05:00.123+08:00", + "rawFrame": "AA0BQ0hJUA==", + "frameBytes": 42, + "gatewayDevId": "OWN_GW_3F7A9C21", + "protocolVersion": "gw-1.2" + } + ] +} +``` + +#### 1.9.2 逐字段 + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `rawRef` | **是** | 原始帧字节的摘要。**MD5 为 32 位小写 hex;新接口优先 SHA-256**(64 位小写 hex)。**它就是幂等键**,也是 I-01 事件里 `rawRef` 回指的目标(对 `ingest_raw_log.payload_md5`)。两个接口必须用**同一种算法**,不能 I-01 用 MD5、I-12 用 SHA-256 | +| `sensorId` | 否(**建议带**) | 解析出设备标识时带上,规则同 §1.4。**未建档设备也要带**——有值才追得回是哪个 SN;实在解析不出可留空,原文照样落库 | +| `sourceChannel` | **是** | 决定落哪张表:`OWN_GW` → `ingest_raw_log`;`HORN_HOST`/`HORN_CELL`/`MIR_CELL` → `adapter_in_raw`(厂商原文,归一化之前) | +| `receivedAt` | **是** | 平台收到这一帧的时刻,ISO-8601 带时区。**不是设备时间**,设备时间只走 I-01 的 `deviceTime` | +| `rawFrame` | **是** | **Base64 字符串,定死这一种表示**。理由见 §2.6:十六进制把体积翻倍,而 200 条/批本来就是按字节数撑上限的;`payload_md5` 对解码后的字节算,两种表示算出来一样,体积差一倍。**写「hex 或 Base64」等于没定**,两边各选一种,MD5 就永远对不上 | +| `frameBytes` | 否 | 解码后的字节数。用于核对——**对不上说明 Base64 编码有问题**,这是最省事的一道自检 | +| `gatewayDevId` | 否 | 接入端点标识(网关 SN,或独立设备自身标识)。排障时用来回答「这帧从哪个口进来的」 | +| `protocolVersion` | **是** | 本次解析用的协议版本号,同 I-01。厂商改协议后要能回溯是哪一版解析的 | + +#### 1.9.3 三条纪律 + +1. **`rawFrame` 必须是原始字节。** 不允许「先解析成对象再序列化」——那样排障时对不上设备实际发的内容,`payload_md5` 也算不稳,I-01 的 `rawRef` 回指就断了。 +2. **解析失败、限流丢弃的帧也要上报。** I-12 的职责是「把所有看见的都留下」,判断谁该被业务处理是 I-01 的事。这里做过滤,等于把事故现场擦掉。 +3. **单帧失败不整批回滚。** 一批 200 帧里 1 帧 `rawRef` 重复(重推),按幂等跳过即可,其余 199 帧照常入库,并在响应里计 `duplicated`。 + +响应结构与错误码同 §1.7(`accepted` / `rejected` / `duplicated` / `rejectedDetail`)。**I-12 没有 `unregistered` 计数字段**——未建档判定只发生在 I-01。 + +--- + +## 二、落库映射(外包可跳过;我方 `/internal` 服务端的设计依据) + +### 2.1 字段 → `iot_event` 列 + +| 接口字段 | 表列 | 备注 | +| --------------------- | ------------------ | ------------------------------------------------ | +| `eventId` | `event_id` | 唯一键 `uk_event_id(event_id, event_time)` | +| `sensorId` | `sensor_id` | 与 `seq` 组唯一键 `uk_sensor_seq` | +| `sourceChannel` | `source_channel` | | +| `eventType` | `event_type` | 允许 `UNKNOWN` | +| `deviceTime` | `device_time` | 可空 | +| `tsSource` | `ts_source` | **NOT NULL 无默认**,漏传直接拒收 | +| `receivedAt` | `receive_time` | 接口叫 `receivedAt`、库列叫 `receive_time`,**故意的,不是笔误** | +| `seq` | `seq` | 仅 G 线 | +| `payload` | `payload` | JSON 原样落;**必含 `deviceTypeRaw`**,`UNKNOWN` 时另必含 `rawEventCode` | +| `rawRef` | `raw_ref` | 回指 `ingest_raw_log.payload_md5` | +| `protocolVersion` | `protocol_version` | | +| — | `dev_id` | **业务层按 `sensor_id` 查 `device` 补**,接入侧不传 | +| — | `household_id` | **业务层补**,接入侧不传 | +| — | `is_backfill` | 墓碑/补传/回灌 = 1,其余 = 0 | +| — | `lost_cnt` | 仅心跳行:该窗口未送达计数 | +| — | `processed` | 0 待处理 / 1 已处理 | +| (派生) | `event_time` | **分区键,业务层派生,接入侧不传** | + +### 2.2 一条事件变几行 + +| 情况 | 落几行 | 说明 | +| ------------- | ------: | ----------------------------------------------------------------------------------- | +| 普通事件 | **1 行** | `is_backfill=0` | +| 0x04 提前告警帧 | **1 行** | `event_type=PRE_ALARM_0X04`、`is_backfill=0`;是**丢失计数**,只通知平台 | +| 墓碑帧 | **1 行** | `event_type=MSG_UNDELIVERED`、`is_backfill=1`、`event_time='1900-01-01'`,落 `p1900` 分区 | +| **一次丢失(G 线)** | **2 行** | 墓碑(seq=33)+ 告警帧(seq=34),两行都要写,按 `sensor_id+seq` 去重只告警一次 | +| 断网补传 / 建档回灌 | 各 1 行 | `is_backfill=1`,`event_time` 用报文里的**真实时间** | +| **未建档设备的帧** | **不落 `iot_event`** | 落 `unregistered_device`,见 §2.5 | + +### 2.3 `event_time` 派生 + +`event_time` 是 `iot_event` 的**分区键、NOT NULL**——这是本节所有规则的硬约束。所以「没有可信设备时间」不能靠插 NULL 解决,只能落一个值、再把精度降下来。 + +| 情形 | `event_time` 落什么 | `device_time` | 实际精度(取两者更差) | +| --- | --- | --- | --- | +| `tsSource ∈ {0,1}` 且 `deviceTime` 有值 | `device_time` | 原值 | 按 `device.ts_precision`,最高 A | +| **`tsSource = 3` 且 `deviceTime` 为 null** | **`receive_time`** | **NULL** | **恒 C** | +| `tsSource = 2`(S 线裸 TCP 恒此值) | `receive_time` | 恒 NULL | 按 `device.ts_precision`,恒 B 或 C | +| 墓碑帧(网关按 `count` 缺口补出,**原始报文里走 `undelivered[]`**) | 恒 `1900-01-01` | 恒 NULL | 不适用 | + +> **关于 `undelivered[]` 的说明**:那是**设备/网关原始报文**里的数组,属 IoT 侧与厂家协议之间的事,**不是本接口(I-01/I-12)的字段**。本接口只有 `events[]`,网关补出的墓碑帧在这里表现为 `eventType=MSG_UNDELIVERED` 的一条。两者不矛盾,读的时候别把两层的字段混成一件事。 + +**`tsSource=3` 且 `deviceTime` 为空——本版新增的规则**(仇总 11-02 回复函 §二 点名要关闭的一条): + +`tsSource=3` 是「网关打戳」,指网关替设备打了个时间。但 433 上行帧 17 字节里没有时间字段,网关只能打**自己收到帧的那一刻**;若网关固件没带这个戳、或厂家没透传,`deviceTime` 就是 null。 + +处置三条: + +1. **`event_time` 落 `receive_time`**,`device_time` 保持 NULL。不能落 NULL(违反 NOT NULL),**更不能落 `1900-01-01`**——那是墓碑帧的专用值,也是 `is_backfill=1` 的判据之一,拿来做「未知」会让墓碑行和未知时间行混成一堆,断档统计直接算错。 +2. **精度强制降 C**。判据是 `device_time IS NULL AND ts_source = 3`,由 `EventTimeResolver` 写入时判定,**接入侧不参与**。C 级按陈工《系统设计 **V1.7**》§6.11 的禁令使用:不进基线采样、不参加任何「时间间隔」类判定(活动画像、无活动告警、到点巡检全部排除)。 +3. **界面不得显示为发生时间**。`device_time IS NULL` 的行,按 `device.ts_precision` 走 B 显示「约 HH:mm」,走 C 显示「**时间未知**」,C 级不得给出具体时分。 + +派生逻辑只在一处实现(`EventTimeResolver`),**接入侧不传这个字段**。 + +### 2.4 幂等:三档,按有无序号选 + +| 场景 | `eventId` 怎么算 | 兜底唯一键 | +| ------------- | ------------------------------ | ------------------------------------------- | +| 有 `seq`(G 线) | `{sensorId}_{seq}` | `uk_sensor_seq(sensor_id, seq, event_time)` | +| 有厂商报文 ID | `{sensorId}_{厂商msgId}` | `uk_event_id` | +| 两种都没有(H/S 线) | `{sensorId}_{sha256(原始帧字节)前16位}` | `uk_event_id`(内容相同视为同一帧,正是要的语义) | + +**三档都必须满足一条:重推同一批,`eventId` 必须不变。** 所以 hash 要对**原始字节**算,不能对"反序列化后再序列化"的 JSON 算——后者字段顺序、空格、数字格式都可能变,重推一次就多一条重复数据。 + +### 2.5 未建档设备:用现成的 `unregistered_device` + +**V1.4 更正**:V1.3 这里编了 4 个不存在的列。V1.4 已有 `unregistered_device` 表,实际字段是: + +| 列 | 用途 | +| --- | --- | +| `sensor_id` | 报上来的标识 | +| `source_channel` | 哪条线 | +| `first_seen_at` / `last_seen_at` | 首见 / 末见 | +| `report_count` | 累计帧数 | +| `last_payload` | 最后一帧原文(`deviceTypeRaw`、`rawEventCode` 都在里面) | +| `status` | 待认领 / 已认领 / 已停用 | +| `dev_id` | 认领建档后回填 | + +处理流程: + +``` +收到 I-01(200 条) + │ + ├ 一次 SELECT sensor_id, dev_id, household_id FROM device WHERE sensor_id IN (…200个…) + │ + ├ 命中 → 组 iot_event 批量 INSERT + │ + └ 未命中 → upsert unregistered_device + 同一 sensor_id 24h 内只保留一行,report_count 累加 + last_payload 覆盖为最新一帧 + → 响应里计入 unregistered,不返回 rejectedDetail +``` + +三条要点: + +1. **查询是批量的**(`IN (…)` 一次),不是 200 次单查。这是方案能成立的前提,写代码时别退化成循环。 +2. **`last_payload` 必须存原始 JSON**,认领时靠它判断"这是什么设备"。 +3. **待认领记录不参与任何告警**。唯一出口是"有人认领(扫码/人工)→ 建档 → 触发回灌 Job 把历史帧补进 `iot_event`(`is_backfill=1`)"。回灌不在本规格范围。 + +### 2.6 原文怎么带 + +| 表 | 谁写 | 从哪来 | 保留 | +| ---------------- | --- | ------------------- | ------------------------------------ | +| `ingest_raw_log` | 业务层 | I-12 | 30 天,`payload_md5` 与事件的 `raw_ref` 对应 | +| `adapter_in_raw` | 业务层 | I-12(H/S 线归一化前厂商原文) | 90 天 | + +字段定义、`rawFrame` 为什么定死 Base64、哪些帧必须上报——**见 §1.9**,本节只说落库。 + +`payload_md5` 对**解码后的原始字节**算,与 `rawFrame` 用什么表示无关;但表示不统一,两边算出来就对不上。 + +**统一 Base64,不加 `encoding` 列。** 定稿 V1.0 曾要求「二进制存 Base64、JSON 保留原文、以 `encoding` 标识来源」;核对后定为**一律 Base64 文本**,理由三条: + +1. `payload` / `raw_payload` 是 `MEDIUMTEXT + utf8mb4`,塞二进制在严格模式报 `Incorrect string value`、非严格模式**静默变成 `?`**——原文永久损坏还不报错。Base64 是唯一稳妥的存法。 +2. 加 `encoding` 列只是省一次 `FROM_BASE64()`,却让**每张表多一列、每个写入点多一个分支**,而且那一列的值由写入方自己填,填错了照样读不出来。 +3. 排障代价可接受:`CONVERT(FROM_BASE64(payload) USING utf8mb4)` 一行就还原;二进制本来就要 `HEX(FROM_BASE64(...))`,两种场景都要解码,不存在"多花一步"。 + +**提请修订定稿 §6.6 该句口径**(已列入《提请修订定稿 V1.0》清单),本文不按定稿执行。 + +### 2.7 与定稿 V1.0 的四处差异:**分歧在定稿与 DB 之间,不在我方实现** + +定稿 V1.0 是本文的上级口径,但有四处它要求的做法**与数据库设计 V1.10 明确写下的结构相反**。这一节把三方的原话摆在一起,读的人不必再猜是哪一方漏了: + +| # | 定稿 V1.0 要求 | **DB V1.10 明确写的** | 本文怎么执行 | 分歧的性质 | +|---|---|---|---|---| +| ① | 墓碑帧写 `link_quality_event`,标准业务时间字段**禁哨兵** | **墓碑行进 `iot_event`,`event_time='1900-01-01'`,落 `p1900` 分区**(§4.4 第 1721 行,且给了"分区键不能为 NULL""为什么选 1900 不选 1970"两段论证) | 按 DB:落 `iot_event` + `p1900` | **DB 主动否决了定稿**,不是遗漏——`link_quality_event` 全册 0 次出现 | +| ② | 0x04 告警帧也写 `link_quality_event`,「不占告警九态」 | **`PRE_ALARM_0X04` 是 `iot_event.event_type` 的正式取值**(§4.4 第 1687 行注释、第 2030 行「要告警」),与墓碑靠 `is_backfill`/`retry_cnt` 区分 | 按 DB:落 `iot_event` | 同上 | +| ③ | `event_ts` **可为空** | **`event_time DATETIME(3) NOT NULL COMMENT '分区键'`**(§4.4 第 1700 行) | 按 DB:`NOT NULL` | MySQL 分区键不能可空。DB 选了"分区 + 哨兵",定稿选了"可空"——**两者只能取一,取定稿就要放弃 `iot_event` 分区** | +| ④ | 业务层生成稳定接收顺序 `ingest_seq` | **全册无此列**(0 次出现),§4.4 用自增 `id` 排序 | 按 DB:用 `id` | 纯缺列,加一列即可,不牵动分区 | + +**为什么本文这次写得比上一版硬**:这四条此前被写成「我方按现行实现施工」,读起来像接入层为了省事没照定稿做。核对 DB V1.10 后可以确认,**是数据库设计在 ①②③ 上明确选了另一条路并且写了理由,接入层只是跟着执行**。真正的分歧在《定稿 V1.0》与《数据库设计 V1.10》之间,不在接口层。 + +**因此本文的责任边界只有一条**:DB 怎么定,我按它写;DB 改了,本文一节跟着改。**接入层不对这四条自行选择**。 + +**关闭条件(按定稿让步 / 我方让步分开写)**: + +| # | 若按定稿改,要动什么 | 代价 | 建议 | +|---|---|---|---| +| ① ② | 建 `link_quality_event` 表 + 把已有墓碑行迁过去 + I-01 增加一个落表分支 | 新表 + 一次数据迁移 + 代码分支;收益是"断档记录"与"业务事件"物理分开 | 可做;本文暂不采纳,仍按 DB V1.10 的墓碑行方案施工(见 §2.7) | +| ③ | 放弃 `iot_event` 分区 → 或改用 `received_at` 分区 | 4.4 亿行/年的主表放弃按事件时间分区,冷热分离与按月 `DROP PARTITION` 全部失效 | **建议不定稿让步,改为定稿修订**:把"可空"改成"墓碑行用哨兵值" | +| ④ | 加 `ingest_seq BIGINT` + 回填历史 | 只加列,代价最小;但回填 4.4 亿行不现实,只能新行起算 | 二期做,本期用 `id` | + +> 四处**本文一律按 DB V1.10 施工**——数据库结构是已执行的事实,契约必须与可执行的库一致。 +> 是否单开 `link_quality_event` 属二期表结构调整,不改本期接口集合与字段名。 + +--- + +## 三、指令下行 + +### 3.1 两类命令,判断权在业务层 + +分类在**创建时**就定死在 `delivery_mode` 上,不由 IoT 运行时判断: + +| | `REALTIME` 实时 | `DEFERRED` 可延迟 | +| --- | --- | --- | +| 例 | 布防/撤防、重启 | 校时、上报配置、ACK 策略、删子设备 | +| 用户在等吗 | 在等,盯着屏幕 | 不等 | +| 晚到 20 分钟 | **有害**(人已经出门了才撤防) | 无害 | +| 离线时 | **当场失败**,明确告诉用户 | 挂起,等上线再发 | +| 失败后 | 终态,不重排 | 重排,有次数上限和 TTL | + +### 3.2 I-18 拉取命令(长轮询 + 租约) + +``` +GET /internal/iot/v1/commands/pending + ?nodeId=iot-node-1 IoT 实例标识 + &gatewayIds=OWN_GW_A,OWN_GW_B 该实例当前在线的接入端点,逗号分隔,单次 ≤100 + &wait=30 长轮询最长挂起秒数,0 = 立即返回 + &limit=50 单次最多返回条数 +``` + +| 参数 | 必填 | 说明 | +| --- | --- | --- | +| `nodeId` | 是 | IoT 实例标识。多实例时业务层按它记租约 | +| `gatewayIds` | 是 | **只传当前在线的接入端点**。离线端点的命令因此一条都不会被拉走,天然实现"等上线再发" | +| `wait` | 否 | 默认 30。有新命令时立即返回,不等满 | +| `limit` | 否 | 默认 50,上限 100 | + +响应: + +```json +{ + "nodeId": "iot-node-1", + "commands": [ + { + "commandId": "CMD-20261030-000123", + "gatewayId": "OWN_GW_3F7A9C21", + "targetId": "3F7A9C21", + "cmdType": "set_mode", + "deliveryMode": "REALTIME", + "params": { "mode": "ARMED" }, + "leaseExpireAt": "2026-10-30T08:46:00+08:00", + "expireAt": "2026-11-02T08:44:00+08:00", + "retryCount": 0, + "claimGeneration": 1 + } + ], + "hasMore": false +} +``` + +**`gatewayId` 是「这条命令从哪个端点进去」,不是「网关 SN」。** + +| 设备形态 | `gatewayId` 填什么 | IoT 怎么找通道 | +| --- | --- | --- | +| 连网关的哨兵 | 网关 SN | MQTT session | +| 独立设备(无绑定关系) | **设备自身标识** | 裸 TCP 连接池 / 云 API | +| H 线(云代理) | 厂家云节点标识 | 云 API | + +对 IoT 来说三种完全一样:拿 `gatewayId` 去自己的连接表里查,查到就发,查不到报 `DEVICE_OFFLINE`。**它不需要知道这个 id 代表网关还是单品**,也不需要知道 `targetId` 和它是什么关系——路由在业务层写指令时就算完了(见 §0.3)。 + +**租约机制(拉走不等于拿走)**: + +| 步 | 动作 | +| - | --- | +| 1 | 业务层返回命令时置 **`CLAIMED`**(已领取/租约中),`lease_expire_at = now + 60s`。**这一步不能写「已下发」**——命令还在 IoT 手里没发出去,写「已下发」等于把「领走」和「发出」两件事的状态混成一个 | +| 2 | IoT 在租约内回报 → 按结果流转(§3.4) | +| 3 | 租约到期没回报 → 自动回 `待下发`,**`reclaim_count + 1`,`retry_count` 不变** | +| 4 | `reclaim_count` 超过 5 → 说明这个 `nodeId` 反复失联,告警,人工看 | + +两个计数器必须分开:`retry_count` 记"设备侧失败了几次",`reclaim_count` 记"IoT 拿走了却没回话几次"。混成一个,就分不清是设备坏了还是中间件坏了。 + +**`claimGeneration`(领取代次,V1.7.2 新增):IoT 必须原样保存,并在 I-05 里回带。** + +它是"这条命令被领走第几次"。每次 `PENDING → CLAIMED` 时 +1。 + +为什么需要:一条命令可能被领走两次——第一次租约 60 秒到期没回话,被自动回收成 `PENDING`,然后被另一个节点领走。此时**第一个节点的回执才迟到**。如果只按 `commandId` 落库,这条旧回执会把第二次的流转覆盖掉,而且不报错、日志里也看不出来。 + +回带了才能拦住:服务端拿回带值当 `WHERE claim_generation = ?` 条件,旧代次更新 0 行,计入 `duplicated`。 +**V1.8 起不回带直接拒**(`CLAIM_GENERATION_REQUIRED`)——不再"拿库里当前值顶上",那种回退等于让这道校验自己失效。 + +**`claimed_node_id`(领取节点,V1.8.1 新增):I-18 落库,I-05 必读必比。** + +代次只回答"第几轮",不回答"这一轮是谁"。没有节点列时,一条代次没变的命令, +任何知道 `commandId` 的调用方都能改写它的流转状态。所以领取时刻必须把 `nodeId` 一并写进命令记录 +(`claimed_node_id` + `claimed_at`,与 `claim_generation` 同一条 UPDATE), +I-05 的原子更新条件同时约束**节点 + 代次 + 允许来源状态**。 + +> 接口演进:V1.7.2 起 I-18 必返 `claimGeneration`;**V1.8 起 I-05 必带**(缺了拒收); +> **V1.8.1 起 I-18 落 `claimed_node_id`、I-05 必须带 `nodeId` 且与之一致**(不符 `NODE_MISMATCH`)。 + +### 3.3 I-05 回报结果:5 个值 + +``` +POST /internal/iot/v1/commands/result +``` + +```json +{ + "nodeId": "iot-node-1", + "results": [ + { + "commandId": "CMD-20261030-000123", + "result": "ACKED", + "resultCode": "0", + "resultMsg": "OK", + "ackAt": "2026-10-30T08:45:03+08:00", + "rawAck": "020A1F3C…", + "claimGeneration": 1 + } + ] +} +``` + +| `result` | 含义 | IoT 怎么得出 | 业务层处置 | +| --- | --- | --- | --- | +| `ACKED` | 设备有回应 | 收到回执帧 | 看 `resultCode` 分流 | +| `SENT` | 已发出,本协议无回执通道 | 发完就返回 | 置 **`SENT`**,设 `timeout_at = now+24h`,到期无回执置 `FAILED` | +| `TIMEOUT` | 已发出,等不到回执 | 超时 | 实时=失败;可延迟=重排 | +| `DEVICE_OFFLINE` | 连接不在,**没有发出去** | 发前查连接 | 实时=失败;可延迟=重排 | +| `FAILED` | IoT 自身发送失败 | 编码/写通道异常 | 同上 | + +**`resultCode` 是标准字典值,不是厂家原码。** 定稿 V1.0 §4.2 末段:`resultCode` / `channel_result` 按**统一字典**填写,厂家原始结果码另存 `rawResultCode`/`rawAck`,**不得把厂家原码直接透传到标准字段**。 + +| 字段 | 谁填 | 内容 | +|---|---|---| +| `resultCode` | **IoT 翻译后填** | 标准码:`0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙 | +| `rawResultCode` | IoT 填(**新增,可选**) | 厂家原始返回码,**原样字符串**,不解释 | +| `rawAck` | IoT 填(可选) | 回执原始字节(Base64) | +| `claimGeneration` | **必填(V1.8 起)** | 领取时 I-18 给的值,原样回传。服务端用它挡住旧代次的迟到回执;**不带直接拒**(`CLAIM_GENERATION_REQUIRED`),不再退化为库里当前值 | +| `nodeId`(顶层) | **必填(V1.8.1 起)** | 来源节点标识,必须等于 I-18 领取时落库的 `claimed_node_id`。不符 → `NODE_MISMATCH`;历史在途行无节点值 → `NODE_UNKNOWN`。**它不是身份认证**(值可伪造),证明的是"领取事实一致" | + +原因很直接:厂家原码各不一样(铠湾 `08` 是水浸、bignbiot `08` 是白蚁这种坑在命令侧同样存在),直透到标准列以后,业务层与前端就得按每个厂家各写一套判断,等于把适配器的活摊到全平台。 + +> `rawResultCode` 是 **V1.7 新增的可选字段**,不填也能跑(`null` 时业务层只看 `resultCode`)。加它零成本:DTO 加一个 `String` 字段,Jackson 收不到就是 `null`,老版本 IoT 不受影响。 + +#### 3.3.1 参考实现(外包照抄这个分支就够了) + +```python +send(cmd): + conn = my_connections.get(cmd.gatewayId) # 自己的内存,不查任何设备表 + if conn is null: + return DEVICE_OFFLINE # 没发 + + try: + frame = encode(cmd.type, cmd.targetId, cmd.params) + conn.write(frame) + except EncodeError | WriteError as e: + return FAILED(e) # 发了但发不出去 + + if not conn.supportAck(cmd.type): # S 线裸 TCP 无回执 + return SENT + + raw = conn.readAck(cmd.cmdSeq, timeout=5s) + if raw is null: + return TIMEOUT # 发了没回 + return ACKED(code=parseCode(raw), msg=raw) # 设备回了,原样搬运 +``` + +#### 3.3.2 「设备不支持」「设备休眠」这两个状态在哪 + +它们**没有消失**,只是不由 IoT 填: + +| 场景 | 谁发现 | 填在哪 | +| --- | --- | --- | +| 这型号压根不支持下发 | 业务层入口 | 能力表里没标记 → **命令根本不生成**,界面按钮置灰 | +| 设备老固件,真发过去才说不支持 | 设备回执 | `ACKED` + `resultCode='1'` | +| 电池设备在休眠 | 设备/网关回执,或干脆没回 | `TIMEOUT`,业务层按"电池设备 + 可延迟"翻译成「待唤醒」 | + +业务层翻译表: + +| IoT 回的 | 业务层再补什么 | 界面显示 | +| --- | --- | --- | +| `ACKED` code=0 | — | 已生效 | +| `ACKED` code=1 | **顺手把该型号的「支持下发」标记摘掉** | 设备不支持(以后不再发) | +| `ACKED` code=2/3 | — | 参数不合法 / 执行失败 | +| `TIMEOUT` | 电池设备 → 待唤醒(**不判失败**) | 待送达 | +| `TIMEOUT` | 插电设备 → 真失败 | 设备无响应 | +| `DEVICE_OFFLINE` | — | 离线,未发送 | +| `SENT` | — | 已送出(无回执,不做承诺) | +| `FAILED` | — | 通道异常 | + +`ACKED` code=1 那一行有额外好处:**能力表能自愈**。老固件不支持时,第一次发过去就被降级,以后不再发——不需要谁去维护一张静态表。 + +### 3.4 业务层的命令状态机 + +**七态,英文枚举**(定稿 V1.0 §4.2 末段:`PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED`,由业务层维护)。**`CLAIMED` 与 `SENT` 必须分开**——「IoT 领走了」和「已经发出去了」是两件事,混成一个值,租约回收就判不出该不该重派。 + +| 状态 | 说明 | 进这个状态的时刻 | +| --- | --- | --- | +| `PENDING` | 待领取 | 创建命令 | +| **`CLAIMED`** | **已领取/租约中**(`lease_expire_at` 有效) | IoT 通过 I-18 领走 | +| `SENT` | 已发出(`timeout_at` 有效) | I-05 回报 `SENT`(无回执协议) | +| `ACKED` | 设备回执成功 | I-05 回报 `ACKED` + `resultCode=0` | +| `FAILED` | 失败(终态) | 实时类任意失败;`SENT` 超 24h 无回执 | +| `TIMEOUT` | 已过期(终态) | 重排 10 次 或 过 `expire_at`(创建+72h) | +| `CANCELLED` | 已取消(终态) | 用户撤单/设备已解绑 | + +**迁移状态:已完成**(2026-11-10)。`device_command.status` 原先用中文六值(`待下发/已下发/已生效/失败/超时/已取消`),「领走」和「发出」被挤在「已下发」一个值里,IoT 侧分不清命令是"被拿走了"还是"真发出去了"。迁移脚本 `V1.0.23__command_seven_states.sql` 已刷完存量并扩了值域: + +| 本文 | 迁移前库值 | 迁移后 | +|---|---|---| +| `PENDING` | 待下发 | `PENDING` | +| `CLAIMED` | **无此值** | `CLAIMED`(新增,租约中) | +| `SENT` | 已下发 | `SENT` | +| `ACKED` | 已生效 | `ACKED` | +| `FAILED` | 失败 | `FAILED` | +| `TIMEOUT` | 超时 | `TIMEOUT` | +| `CANCELLED` | 已取消 | `CANCELLED` | + +> 是**值域迁移,不是加列**:`varchar(16)` 装得下 `CANCELLED`(9 字符),`status` 上的索引不受影响。 +> 迁移同时补了三列:`claim_generation`(租约代次,每次 `CLAIMED` +1,回执比对不上就按过期丢弃)、 +> `raw_result_code`(厂家原始结果码,原样存不解释)、`raw_ack`(回执原始字节)。 +> **现状**:库里 `status` 已全是英文七态,无中文残留;`V1.0.23` 已进 Flyway 序列(checksum `-1404977223`)。 + +``` + ┌──────── 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(终态) +``` + +| 结果 | `REALTIME` 处置 | `DEFERRED` 处置 | +| --- | --- | --- | +| `ACKED` code=0 | `ACKED` | `ACKED` | +| `ACKED` code≠0 | `FAILED`(终态),提示取 `resultCode` 语义 | `FAILED`(终态),最多重排 3 次 | +| `SENT` | **保持 `SENT`,`timeout_at = now+24h`**;到期无回执置 `FAILED` | 同 | +| `TIMEOUT` | `FAILED`(终态),电池设备提示"待唤醒" | 回 `PENDING`,`retry+1` | +| `DEVICE_OFFLINE` | `FAILED`(终态),提示"离线,命令未下发" | 回 `PENDING`,`retry+1` | +| `FAILED` | `FAILED`(终态) | 回 `PENDING`,最多 3 次后终态 | + +**`SENT` 不是终态。** V1.3 写"成功待确认(终态),靠快照兜底"——终态就再也改不了了,兜底是空话。正确做法是留在 `SENT` 并设 24 小时超时,到期由 `D-01-U4` 扫描置 `FAILED`。 + +**重排上限**:10 次或 72 小时,先到哪个算哪个,置 `TIMEOUT`。不删除记录,留痕供界面显示"已过期未执行"。 + +**两列两义,别混**:`expire_at` = 创建 + 72h,整体 TTL,到了置 `TIMEOUT`;`timeout_at` = `SENT` + 24h,单次兜底,到了置 `FAILED`。扫描任务**先判 `expire_at` 再判 `timeout_at`,同一次只判一个**——两条都中时按 TTL 优先,否则同一行会被判两次、状态在两列之间来回跳。 + +**实时命令的等待**:接口同步等结果,最长 5 秒(见 §9.4)。超过 5 秒仍无回报,降级返回"指令已送出,结果稍后通知"。 + +### 3.5 契约 B 下行硬要求(给黄工,外包按此实现 MQTT 客户端) + +| 项 | 要求 | 为什么 | +| --- | --- | --- | +| QoS | **0** | QoS1 + 持久会话时,网关断线期间的 PUBLISH 会进离线队列,20 分钟后上线就执行了——而用户早已被告知"离线,未生效" | +| Retain | `false` | 命令是一次性动作,不能留在 broker 上给下一个连上来的设备重放 | +| 会话 | 下行命令主题**不做离线消息排队** | 同上 | +| 在线判据 | 以 **MQTT 连接存活**为准,不以"PUBLISH 返回成功"为准 | 在有离线队列的配置下,PUBLISH 成功毫无意义 | +| 重复下发 | 网关侧按 `commandId` 去重 | 租约回收可能导致同一条命令被下发两次,`set_mode` 重放还算幂等,`reboot` 会重启两次 | + +最后一条尤其重要:**IoT 侧按 `commandId` 去重只能在单实例内存里做**,跨实例要靠业务层的租约。所以网关固件也要能识别重复 `commandId`。 + +--- + +## 四、中间件不做什么(写进外包合同) + +| 不做 | 为什么 | +| ------------------ | ---------------------------------- | +| **不连数据库** | 拿不到 DB 账号;任何 JDBC/连接串/密码出现即打回 | +| **不判断设备是否建档** | 不持白名单、不缓存白名单、不查"设备在不在库里"。所有帧一律上报 | +| **不判命令该不该重试** | 只发一次、如实回报。重排还是终态由业务层状态机决定 | +| **不判设备支不支持命令** | 能力表在业务层,入口就拦了,发不出来的命令到不了 IoT | +| **不判设备供电方式** | 分不清休眠和坏。只报 `TIMEOUT`,怎么解读是业务层的事 | +| **不查设备状态** | 唯一的 GET 是 I-18「领任务」,不是"查这设备合不合法、在不在线"(见 §五) | +| **不判告警、不算基线、不派单** | 全在业务层,中间件只搬数据 | +| **不写业务表** | 落库在业务层,中间件只调接口 | +| **不改写时间** | `deviceTime` 没有就传 `null`,不许用接收时间顶替 | +| **不丢未知字段** | 厂商加字段是常态,`payload` 原样保留 | +| **不静默丢包** | 限流、解析失败、死信,全部计数并暴露指标 | + +--- + +## 五、调用方向(合同硬条款) + +| 条款 | 内容 | +| --- | -------------------------- | +| 方向 | **只允许 IoT 主动发起 HTTP 请求到业务层** | +| 上行 | 用 `POST` 推(I-01 / I-02 / I-12 / I-05) | +| 下行 | 用 `GET` 领(I-18),领到后自己下发,再 `POST` 回报 | +| 业务层不持有 | IoT 的任何地址、端口、域名、回调 URL | +| 业务层不提供 | 任何形式的"变更推送""回调注册""反向通知" | +| 原因 | IoT 部署位置不定(外包机房、客户内网、NAT 之后),没有固定公网入口 | +| 违反后果 | 一旦要求业务层能反向调用,外包就得暴露公网端口,`/internal/**` 那套 mTLS 等于白做 | + +**判断标准**:如果某个方案需要业务层知道 IoT 在哪、需要 IoT 开一个端口等业务层敲门,那就是设计错误。 + +**「拉」和「查」的区别,是这一节的全部要点**: + +| | 拉(I-18,允许) | 查(禁止) | +| --- | --- | --- | +| 语义 | 有活给我干吗 | 这台设备合法吗 / 在线吗 | +| 返回 | 待办任务,拉走就归我 | 状态快照,用来做判断 | +| 判断发生在 | 业务层(给不给这个任务) | 中间件(要不要处理这帧) | +| 违反后果 | — | 中间件持有一份会过期的业务状态,两份数据必然对不上 | + +--- + +## 六、外包开工依赖 + +**这不是工期表,是依赖表。** 缺哪一项就卡哪一段,其余照常推进。 + +| 要干的活 | 必须先有 | 谁给 | 缺了会怎样 | +|---|---|---|---| +| G 线帧解析(`J-04`) | 第一册契约 B | 已有 | — | +| S 线帧解析(`J-04`) | 铠湾 V3.0(`WTK…END`)+ bignbiot 3.0(`0x0E00…0x7F`)+ NB&WIFI V1.6 | 已有 | `tcp-1.10` 已废止,不得再用 | +| 信封组装(`J-05`)、幂等键(`J-06`)、攒批(`J-07`) | 本文 §1.3–§1.6 + 附录 A | 已有 | — | +| H 线帧解析(`J-03`) | 豪恩云推送格式 | 豪恩 | **这条线写不了**,只能留空壳 | +| 真跑一次 HTTP 请求 | 测试域名 + mTLS 证书 | 我方 | 代码能写完,但跑不通、没法验证 | +| 自测字段偏移对不对 | Mock 服务(固定返回 `{"code":0}`) | 我方 | 只能自己造数据,测不出偏移错 | +| 正式联调 | 业务层 `/internal` 部署完成(M 域) | 我方 | 停在"自测通过" | +| 命令下发(`J-08`) | I-18 / I-05 两个接口 | 我方 | 上行能通,下行跑不起来 | + +**五个可以立刻开工的单元**(纯本地逻辑,不依赖服务端): + +1. `J-04` 帧解析与 CRC —— S 线两套协议文档已到手,G 线契约 B 已定; +2. `J-05` 信封组装 —— 按本文 §1.3 / §1.4 / 附录 A; +3. `J-06` 幂等键生成 —— 按本文 §2.4; +4. `J-07` 攒批与重试 —— 按本文 §1.6; +5. `J-08` 命令下发与回执 —— 按本文 §三。 + +--- + +## 七、附录 A:适配器映射模板 + +每条协议一份 YAML,**新增协议只加文件,不改本文正文**。适配器读配置取值,代码里不写死任何字节偏移。 + +```yaml +# adapter/own-gw.yaml +channel: OWN_GW +protocolVersion: gw-1.2 +sensorIdPrefix: "" +# 17B 帧:前导4 + ID4 + type1 + 电量1 + event1 + count2 + 标志1 + lost1 + CRC2 +fields: + sensorId: { from: frame, path: "hex(bytes[4:8])", upper: true } + eventId: { from: frame, path: "sensorId + '_' + seq" } + tsSource: { const: 3 } + deviceTime: { const: null } + seq: { from: frame, path: "uint16BE(bytes[11:13])" } + payload.deviceTypeRaw: { from: frame, path: "bytes[8]" } + payload.rawEventCode: { from: frame, path: "bytes[10]" } + payload.batteryMv: { from: frame, path: "bytes[9]", note: "单位换算见第一册 §4.3" } + +# eventType 必须按 (type, event) 二维查表,不能只看 type +eventType: + by: [deviceTypeRaw, rawEventCode] + map: + "1/1": DOOR_OPEN + "1/2": DOOR_CLOSE + "2/1": WATER_ACTIVITY + "2/2": WATER_ACTIVITY_STOP + "3/1": CURRENT_ACTIVITY + "3/2": CURRENT_ACTIVITY_STOP + "4/1": WATER_LEAK + "4/2": WATER_LEAK_CLEAR + "5/1": SOS + "5/2": SOS_CLEAR + "*/0": HEARTBEAT + "*/3": LOW_BATTERY + "*/4": PRE_ALARM_0X04 + fallback: UNKNOWN + +unbatchable: [SOS, WATER_LEAK, PRE_ALARM_0X04] +``` + +```yaml +# adapter/mir-nb.yaml +channel: MIR_CELL +protocolVersion: nb-1.6 +sensorIdPrefix: "MIR:" +fields: + sensorId: { from: payload, path: "SN" } + eventId: { from: payload, path: "sensorId + '_' + sha256(rawFrame)[0:16]" } + tsSource: { const: 2 } + deviceTime: { const: null } + seq: { const: null } + payload.deviceTypeRaw: { from: payload, path: "DT" } + payload.rawEventCode: { from: payload, path: "AR" } + +# 具体码值按 NB 协议 V1.6 的 DT + 报警位逐项填,模式同 own-gw.yaml +eventType: + by: [deviceTypeRaw, rawEventCode] + map: + # "
/<报警位>": + # 门磁、水浸、烟感、燃气、SOS 各配"发生/解除"两项 + fallback: UNKNOWN +``` + +四条纪律: + +1. **`fallback: UNKNOWN` 是默认配法**,不是可选项。映射表没覆盖到的值不能抛异常、不能丢帧。 +2. **`eventType` 必须二维查表**(设备类型 × 事件码)。 +3. **每个适配器启动自检**:加载 YAML → 校验必填字段齐全(`sensorId` / `tsSource` / `protocolVersion` / `eventType` / `payload.deviceTypeRaw` / `receivedAt`)→ 缺任一则**启动失败**,不允许带病运行。 +4. **上线前跑样例帧**:用协议文档里的真实样例帧跑一遍,断言每个必填字段都取到了值;取不到就报出字段名和偏移。 + +--- + +## 八、附录 B:当前已接入协议清单 + +**按编解码插件列,不按"线"列。** 定稿 V1.0 §4.2 定了五个插件,每个插件一个独立模块,输出**同一个 J-00 事件信封**。加协议=加一个插件,不改信封、不改接口。 + +| 插件 | 协议 | 供应商 | 设备型号 | 入口 | `sourceChannel` | `protocolVersion` | 状态 | +|---|---|---|---|---|---|---|---| +| `kaiwan_v3_decoder` | 铠湾智能无线终端通信协议 **V3.0**(帧头 `0x57544B`=WTK,CRC16-XMODEM) | 麦乐克 | 按 S 线选型 | 裸 TCP **24002** | `MIR_CELL` | `WTK-V3.0` | 可开工(端口待安全评审冻结) | +| `bignbiot_decoder` | 麦乐克 **物联网云平台设备协议 3.0**(帧头 `0x0E00`,帧尾 `0x7F`,无 CRC) | 麦乐克 | MIR-WA301 等 | 裸 TCP **24003** | `MIR_CELL` | `bignbiot-3.0` | 可开工(**文件名 3.0/正文到 2.0,待供应商确认**) | +| `mqtt_decoder` | 豪恩 MQTT 对接协议(JSON 负载,逐型号 topic 与字段) | 豪恩 | LH-608CT1/LH-302CT1/HO-01LCT1 | **MQTT TLS 8883** | `HORN_HOST` | 按各型号协议文件版本 | 方案已定,**待豪恩书面确认 Broker 地址可配** | +| `aep_adapter` | 豪恩 NB 三款,经电信 AEP 平台推送(DT=Dmg/Hmt/Gas),按 **NB&WIFI V1.6** 解析 | 豪恩 | HO-032NB(II)/LH-336NB/JT-LH-806NB | HTTP 回调/订阅 | `HORN_CELL` | `nb-1.6` | 待 AEP 回调参数 | +| `mc200_callback_adapter` | MC200 平台 HTTP 推送(LwM2M 物模型,按物联网卡归属路由 OneNET/AEP) | MC200 | 按选型 | HTTP 回调 | `HORN_CELL` | 待定 | **阻塞**(平台、物模型、回调均待确认) | +| (无插件) | **契约 B**:自研 433 四件套 + 自研网关(17B 帧,前导 4 + ID 4 + type 1 + 电量 1 + event 1 + count 2 + 标志 1 + lost 1 + CRC 2) | 自研 | G 线四件套 | **MQTT TLS 8883** | `OWN_GW` | `gw-1.2` | 可开工 | + +**`protocol_version` 的取值就是本表最后一列的「协议代号」,另有两条硬约束**(DB V1.10 §1.5.11 第 3 条): + +- **≤16 字符**:列宽 `VARCHAR(16)`,与 `iot_event.protocol_version` 一致;那列在 4.4 亿行/年的分区大表上,不能为加宽去 `MODIFY COLUMN`。`bignbiot-v3.0.2` 已 15 字符贴边,厂商再升一版就写不进去,所以用短代号 `bignbiot-3.0`。 +- **必须写协议代号,不写厂商产品名**:`tcp-1.10` 之所以废止,就是它既是旧口径、又答不出"哪家的哪一版"。 + +**已废止、不得恢复的旧口径**:`0x5D6C` 豪恩 V1.10 帧头、`0x7B`/`0x5B` 跌倒 JSON 帧头、`horn_decoder`、`fall_json_decoder`、"C 契约"。**`tcp-1.10` 这个 `protocolVersion` 值同废止**——它是旧版把豪恩 TCP 与 S 线混在一起时的产物,现在每条线各有各的协议原件。 + +**S 线两套 TCP 同属 `MIR_CELL`**,靠 `link_type`(落 `adapter_in_raw`)区分,不靠 `sourceChannel`。**别按 `sourceChannel` 猜协议**:麦乐克两套帧格式完全不同,同一个 `sourceChannel` 底下压着两种解释(铠湾 `08` 是水浸、bignbiot `08` 是白蚁)。 + +**三个入口原则**(定稿 §4.2 第 1 条):24002 / 24003 是我方**规划端口**,由部署与安全评审后冻结,**不是全部设备的统一入口**;未知协议走 I-12 留原文**不拒收**;非法帧留档+计数。 + +> **H 线与 MC200 阻塞不影响 G/S 开工**——各插件互不依赖,共用的只有信封组装和五个接口。 + +## 九、给我方与配合方的事项 + +### 9.1 给陈工(库表与小程序侧) + +| # | 事项 | 需要他做什么 | +| - | ------------------------------------------ | -------------------------------------------- | +| 1 | **`sensor_id` 前缀规则写死**(§1.4) | 确认 `device.sensor_id` 的存储值与 §1.4 完全一致。**这一条不一致会导致全场设备判成未建档**,是风险最高的一处 | +| 2 | **`eventType` 扩到 29 个值**(§1.5.1) | 规则引擎与告警配置要按新枚举配规则。重点是 8 个"解除/停止"事件,没有它们告警状态不闭环 | +| 3 | **`device_command` 加 5 列** | `delivery_mode`、`reclaim_count`、`lease_expire_at`、`expire_at`、`dump_path`。(`retry_count` 已有,不用加) | +| 4 | **`gateway_dev_id` 改为一律非空** | 现在注释是"NULL 表示不经网关"。改为"命令的接入端点,独立设备填自身标识"。**因为 `uk_gw_seq(gateway_dev_id, cmd_seq)` 是回执匹配键,NULL 不参与唯一约束,留空等于这一行逃出保护。** | +| 5 | **新增 I-18 / I-05 两个接口**(§三) | 指令下行链路。I-18 长轮询(`wait` 最长 30s,用 `DeferredResult` 挂起)+ 租约发放;I-05 批量回报 | +| 6 | **实时命令改同步返回**(§9.4) | 用户点【撤防】不要"异步 + 前端轮询",接口内挂起等 I-05,最长 5s | +| 7 | ~~**`device_command.status` 值域迁移:中文六值 → 英文七态**(§3.4)~~ ✅ **已完成(2026-11-10)** | 原状态:`待下发/已下发/已生效/失败/超时/已取消`,缺 `CLAIMED`。已由 `V1.0.23__command_seven_states.sql` 刷为 `PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED`,并补 `claim_generation` / `raw_result_code` / `raw_ack` 三列。库里已无中文残留 | +| 8 | **索引 `device_command(status, expire_at, timeout_at)`** | `D-01-U4` 扫描是 `status IN ('CLAIMED','SENT') AND 到期 < now`,没有这个组合索引就是每轮全表扫 | + +`unregistered_device` 表 V1.4 已有,不用新增(V1.3 的待确认项已关闭)。 + +### 9.2 给黄工(网关固件) + +见 §3.5 那五条。其中第 1 条(QoS 0 + 禁离线队列)不确认,会出现"用户被告知失败、20 分钟后指令真执行了"——这是安全事故,不是体验问题。 + +另有一条**比 8883 更前置**的问题(V1.7 补):**固件里的 Broker 地址(域名+端口)能不能改?** 只能连豪恩云的话,附录 B 的 `mqtt_decoder` 整条作废,改走 AEP 或他们云的适配。这一条不成立,后面 TLS/证书全都白讨论。 + +### 9.3 给谢工(J 域实现方) + +| # | 事项 | 依据 | +|---|---|---| +| 1 | 六个插件的**边界**:`kaiwan_v3_decoder`/`bignbiot_decoder`/`mqtt_decoder`/`aep_adapter`/`mc200_callback_adapter`+契约 B(自研网关) | 定稿 §4.2;附录 B | +| 2 | 输出**同一个 J-00 信封**,不许为某条线定制字段 | §1.3 | +| 3 | `deviceTypeRaw` **任何帧都带**(含心跳与 `UNKNOWN`);`eventType` 枚举外**映射 `UNKNOWN` 并填 `rawEventCode`** | §1.5.4/§1.5.5 | +| 4 | `sourceChannel` 按**入口配置**决定,**不得根据到达报文自动改写**;前缀由适配器按通道拼 | §1.4;定稿 §4.2「通道路由纪律」 | +| 5 | `resultCode` 填**标准字典**,厂家原码进 `rawResultCode`/`rawAck` | §3.3 | +| 6 | I-12 的 `rawFrame` **一律 Base64**,`payload_md5` 对**解码后字节**算,与 I-01 的 `rawRef` **同一种算法** | §1.9/§2.6 | + +### 9.4 实时命令的同步等待(业务层实现,给陈工参考) + +用户点【撤防】不应该看到"点了转圈、再轮询几秒、再弹窗"。 + +``` +小程序 POST /api/v1/devices/{id}/commands(delivery_mode=REALTIME) + ↓ 业务层写 device_command,长轮询立即返回 + ↓ IoT 判断端点在线与否(<100ms) + ↓ 业务层收到 I-05 回报 → 唤醒挂起的请求 + ↓ 返回最终结果 +小程序弹出「已撤防」或「网关离线,撤防未生效」 +``` + +| 情形 | 耗时 | 返回 | +| --- | --- | --- | +| 正常 | 200ms ~ 2s | 成功 / 失败,直接给结论 | +| 端点离线 | **<200ms** | 「网关离线,命令未下发」 | +| 设备无响应 | 5s | 「设备无响应」 | +| 5s 仍无回报 | 5s | 「指令已送出,结果稍后通知」(降级,前端再短轮询 1s×5) | + +实现上用 `DeferredResult` 挂起请求等回调。**JDK 21 虚拟线程让挂起 5 秒的成本接近零**,不必改成异步接口 + 前端轮询——那会把一次点击拆成三跳。 + +--- diff --git a/docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml b/docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml new file mode 100644 index 0000000..9b91678 --- /dev/null +++ b/docs/contract/6_J-00_接入接口规格_V1.8_OpenAPI.yaml @@ -0,0 +1,970 @@ +openapi: 3.0.3 + +info: + title: 鹤仆云 · IoT 接入接口(internal) + version: 1.8.1 + description: | + 本文件是 `J-00 接入接口规格 V1.7` 的机器可执行版。 + + **两者关系(不要理解成两份契约):** + - **本文件管「长什么样」**:路径、方法、字段类型、必填、枚举、错误码——工具可执行、可校验、可生成代码。 + - **J-00 管「为什么这样」**:业务语义、告警状态机、四条硬边界、幂等三档、断档与 0x04。这些写不进 YAML。 + - 二者不一致时,以本文件为准,同时回改 J-00;但**业务语义的解释权仍在 J-00**。 + + **V1.8.1 变更(2026-11-12):** + - `CommandResult.claimGeneration` 进 `required`、取消 `nullable`; + `nodeId` 补必填语义(与 I-18 落库的 `claimed_node_id` 比对,不符按 `NODE_MISMATCH` 拒)。 + - `RejectReason` 补命令侧四个原因码。 + + **V1.7.2 变更(2026-11-11):** + - `I-18` 响应新增 `claimGeneration`(领取代次),`I-05` 新增同名字段用于回带。 + 用途:一条命令租约到期被回收重派后,第一个节点的**迟到回执**会覆盖第二次的流转且不报错。 + 回带代次后服务端能用 `WHERE claim_generation = ?` 挡住它(更新 0 行、计 `duplicated`)。 + - **兼容性**:`I-18` 必返;`I-05` 不回带也不会被拒,但等于没有代次校验。**V1.8 起 `I-05` 必填。** + + **V1.7.1 变更(2026-11-10,需接入段确认后生效):** + - `Event.rawRef` 由**可选改必填**,并取消「墓碑帧为 `null`」的例外。 + 墓碑帧同样是独立的一条 I-01 事件(`eventType=MSG_UNDELIVERED`),有自己的原始字节, + 摘要填**承载该 `undelivered[]` 的网关上行帧**,多对一共享同一 `rawRef` 是合法的。 + 墓碑行身份是 `sensorId + seq`,事件幂等键是 `eventId`,都不依赖 `rawRef`,去重逻辑不受影响。 + V1.7.1 之前已入库的历史墓碑行保持 `null`,业务层兼容。 + + **V1.7 变更(对齐《总体架构与 S 线一期实施规范 V1.0 定稿版》):** + 1. `protocolVersion` 不再接受 `tcp-1.10`(旧口径已废止)。 + 2. `seq` 不再是「仅 G 线」——铠湾序列号、bignbiot 帧号等分别映射;没有才传 `null`。 + 3. `rawRef` 允许 **MD5(32 位)或 SHA-256(64 位)**,但 **I-01 与 I-12 必须同一算法**。 + 4. `eventType` 枚举外的新值 → 映射 `UNKNOWN` 并填 `payload.rawEventCode`,不静默丢弃。 + 5. `resultCode` 改为**标准字典值**(`0/1/2/3/4`),厂家原码进新增的可选字段 `rawResultCode`。 + 6. 命令状态七态英文(`PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED`), + **领取后是 `CLAIMED`,不是「已下发」**。 + + **五条不可协商的约束(J-00 §0.1 / §五):** + 1. 全部接口由 **IoT 侧发起**,业务层一个出站请求都不发。 + 2. IoT 侧**不持有数据库账号**,不直连库。 + 3. IoT 侧**不判设备是否建档**——未建档设备照常上报,由业务层分流。 + 4. IoT 侧**不判业务策略**,只报亲眼看见的事实。 + 5. `deviceTime` 没有就传 `null`,**禁止用 `receivedAt` 顶替**。 + + **编号台账(J-00 §1.1):** 在役 `I-01`/`I-02`/`I-05`/`I-12`/`I-18`;二期预留 `I-17`。 + 废止永不复用:`I-03`、`I-04`、`I-06`~`I-11`、`I-13`~`I-16`。 + +servers: + - url: https://{host}/internal/iot/v1 + description: 内网地址,由部署时确定。**不走公网、不做域名备案**。 + variables: + host: + default: 127.0.0.1:8080 + description: 业务层内网入口 + +tags: + - name: 上行 + description: IoT → 业务层,推送设备数据 + - name: 下行 + description: IoT → 业务层,领取命令与回报结果 + - name: 二期预留 + description: 本期不实现 + +security: + - mtls: [] + internalToken: [] + +paths: + + /events/batch: + post: + tags: [上行] + operationId: ingestEvents + summary: I-01 设备事件批量上报 + description: | + 上报「能变成业务事件」的帧。心跳、鉴权帧、解析失败的未知帧**不从这里进**,走 I-12。 + + **限流**:单实例 200 条/批,1000 条/秒。 + **幂等键**:`{sourceChannel}-{批内首条 eventId}-{条数}`。重推同一批必须算出同一个键。 + **单条失败不整批回滚**:200 条里 1 条非法,其余 199 条照常入库。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/Sign' + - $ref: '#/components/parameters/Timestamp' + - $ref: '#/components/parameters/Nonce' + - $ref: '#/components/parameters/IdempotencyKeyEvent' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EventBatchRequest' + responses: + '200': + description: 受理完成(含部分失败)。**HTTP 200 只表示受理,不表示处理完**。 + content: + application/json: + schema: + $ref: '#/components/schemas/IngestResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/ServerError' + + /device-status/batch: + post: + tags: [上行] + operationId: ingestDeviceStatus + summary: I-02 设备状态快照批量上报 + description: | + 并发上报「在线/电量/CSQ/最后报文时刻」。业务层按 `sensorId` **覆盖写**(谁最新谁覆盖), + **不做时间排序**,也**不参与告警判定**——只作界面展示与离线判断的原料。 + + **限流**:500 条/批。 + **幂等键**:`{sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数}`。 + ⚠️ 注意:**I-02 报文里没有 `eventId`**,不能用它算幂等键。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/Sign' + - $ref: '#/components/parameters/Timestamp' + - $ref: '#/components/parameters/Nonce' + - $ref: '#/components/parameters/IdempotencyKeyStatus' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StatusBatchRequest' + responses: + '200': + description: 受理完成 + content: + application/json: + schema: + $ref: '#/components/schemas/IngestResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/ServerError' + + /raw-frames: + post: + tags: [上行] + operationId: ingestRawFrames + summary: I-12 原始报文批量上报 + description: | + **全集的口子**——所有上行帧都从这里进一次,其中「能变成业务事件」的那部分另由 I-01 再报一次。 + 同一帧走两条链路不是重复:一条存档,一条产生业务后果。 + + **限流**:200 条/批。 + **幂等键**:`{sourceChannel}-{批内首条 rawRef}-{条数}`。 + ⚠️ 注意:**I-12 报文里没有 `eventId`**;`rawRef` 就是 `MD5(原始帧字节)`,重推同一帧必然算出同一个值。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/Sign' + - $ref: '#/components/parameters/Timestamp' + - $ref: '#/components/parameters/Nonce' + - $ref: '#/components/parameters/IdempotencyKeyRaw' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RawFrameBatchRequest' + responses: + '200': + description: 受理完成 + content: + application/json: + schema: + $ref: '#/components/schemas/IngestResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/ServerError' + + /commands/pending: + get: + tags: [下行] + operationId: pullPendingCommands + summary: I-18 长轮询领取命令 + description: | + ⚠️ **本接口编号是 I-18**(原 `I-04` 已废止、永不复用)。老文档里的 `I-04` 不要再实现。 + + **长轮询**:`wait` 是最长挂起秒数,不是轮询间隔。有新命令立即返回,不等满。 + 服务端用 `DeferredResult` 挂起,**不占线程**。 + + **`gatewayIds` 只传当前在线的接入端点**——离线端点的命令因此一条都不会被拉走, + 天然实现「等上线再发」。IoT 不需要知道这个 id 代表网关还是单品。 + + **租约**:拉走不等于拿走。业务层返回时置 **`CLAIMED`**、`leaseExpireAt = now + 60s`; + 租约内不回报会自动回 `PENDING`,`reclaimCount + 1`。 + + ⚠️ **领取这一步不能写「已下发」**——命令还在 IoT 手里没发出去。`CLAIMED`(领走)与 + `SENT`(已发出)是两个状态,混成一个值,租约回收就判不出该不该重派。 + + **签名注意**:本接口是 GET,无 body —— `X-Sign` 里的 `sha256(body)` 取**空串的 hash**; + 参与签名的 `path` **含 query**,`?nodeId=…&wait=30` 一并算进去。 + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/Sign' + - $ref: '#/components/parameters/Timestamp' + - $ref: '#/components/parameters/Nonce' + - name: nodeId + in: query + required: true + description: IoT 实例标识。多实例时业务层按它记租约。 + schema: + type: string + maxLength: 64 + - name: gatewayIds + in: query + required: true + description: | + 该实例**当前在线**的接入端点,逗号分隔,单次 ≤100。 + **离线端点不要传**——传了等于让业务层以为能发。 + schema: + type: string + example: OWN_GW_A,OWN_GW_B + - name: wait + in: query + required: false + description: 长轮询最长挂起秒数。0 = 立即返回。默认 30,上限 60。 + schema: + type: integer + default: 30 + minimum: 0 + maximum: 60 + - name: limit + in: query + required: false + description: 单次最多返回条数。默认 50,上限 100。 + schema: + type: integer + default: 50 + minimum: 1 + maximum: 100 + responses: + '200': + description: 返回命令列表。无命令时 `commands` 为空数组(长轮询到期的情况)。 + content: + application/json: + schema: + $ref: '#/components/schemas/PendingResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /commands/result: + post: + tags: [下行] + operationId: reportCommandResults + summary: I-05 命令结果批量回报 + description: | + 回报命令执行结果。**`resultCode` 是标准字典值(`0` 成功/`1` 不支持/`2` 参数非法/ + `3` 执行失败/`4` 设备忙),不是厂家原码**——厂家原始返回码请放 `rawResultCode`。 + 把原码直透到标准字段,业务层与前端就得按每个厂家各写一套判断。 + + **幂等键**:`{nodeId}-{批内首条 commandId}-{条数}`。 + + **五个 `result` 值的含义(不要合并):** + - `ACKED` 设备有回应,看 `resultCode` 分流 + - `SENT` 已发出,本协议无回执通道(S 线裸 TCP)——业务层置 `SENT` + 24h 超时兜底 + - `TIMEOUT` 已发出,等不到回执 + - `DEVICE_OFFLINE` **没有发出去**(连接不在) + - `FAILED` IoT 自身发送失败(编码/写通道异常) + parameters: + - $ref: '#/components/parameters/InternalToken' + - $ref: '#/components/parameters/Sign' + - $ref: '#/components/parameters/Timestamp' + - $ref: '#/components/parameters/Nonce' + - $ref: '#/components/parameters/IdempotencyKeyResult' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ResultBatchRequest' + responses: + '200': + description: 受理完成 + content: + application/json: + schema: + $ref: '#/components/schemas/IngestResponse' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/ServerError' + + /commands/{commandId}/dump: + post: + tags: [二期预留] + operationId: uploadDiagDump + summary: 【二期预留,本期不实现】I-17 诊断包分片上传 + description: | + **本期零调用,不要实现。** 一期 `diag_dump` 只留命令字,实际不下发。 + 等 `set_ota` 与诊断包真正启用时再补。 + + 编号说明:原 `I-13` 在系统设计 V1.4 曾指「网关台账快照」,该功能取消后号一并作废, + 诊断包另起 `I-17`,不沿用旧号。 + parameters: + - name: commandId + in: path + required: true + schema: + type: string + maxLength: 64 + responses: + '404': + description: | + 本期不实现:**这条路由没有注册**。 + 请求由 Spring 默认错误处理返回 404,`body` 是框架的错误结构, + **不是本文档其他接口那种 `{code,msg,data}` 统一信封**,不要按信封解析。 + 留这一条只是占住 `I-17` 这个号,等二期真做时再补正式响应定义。 + +components: + + securitySchemes: + mtls: + type: mutualTLS + description: 双向证书,90 天轮换。**没有它,其余四层都不必看**。 + internalToken: + type: apiKey + in: header + name: X-Internal-Token + description: 固定长随机串,配置中心下发。 + + parameters: + InternalToken: + name: X-Internal-Token + in: header + required: true + schema: + type: string + Sign: + name: X-Sign + in: header + required: true + description: | + `HMAC-SHA256(token, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body))` + + - `path` **含 query**(GET 时尤其注意) + - GET 无 body 时,`sha256(body)` 取**空串的 hash** `e3b0c442…b855`,不要取字面量 `null` + schema: + type: string + Timestamp: + name: X-Timestamp + in: header + required: true + description: Unix 秒。与服务器偏差 > 300 秒直接拒。 + schema: + type: integer + format: int64 + Nonce: + name: X-Nonce + in: header + required: true + description: 随机串。Redis 留 600 秒做防重放。 + schema: + type: string + maxLength: 64 + IdempotencyKeyEvent: + name: X-Idempotency-Key + in: header + required: true + description: '`{sourceChannel}-{批内首条 eventId}-{条数}`' + schema: + type: string + example: OWN_GW-3F7A9C21_10231-2 + IdempotencyKeyStatus: + name: X-Idempotency-Key + in: header + required: true + description: '`{sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数}`。**不要用 eventId,I-02 没有这个字段**' + schema: + type: string + example: HORN_CELL-868123456789012-2026-10-29T10:19:22+08:00-500 + IdempotencyKeyRaw: + name: X-Idempotency-Key + in: header + required: true + description: '`{sourceChannel}-{批内首条 rawRef}-{条数}`。**不要用 eventId,I-12 没有这个字段**' + schema: + type: string + example: MIR_CELL-9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6-200 + IdempotencyKeyResult: + name: X-Idempotency-Key + in: header + required: true + description: '`{nodeId}-{批内首条 commandId}-{条数}`' + schema: + type: string + example: iot-node-1-CMD-20261030-000123-50 + + responses: + BadRequest: + description: 请求体不符合本文件定义。**不重试**,字段问题重试也不会变。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + Unauthorized: + description: mTLS 失败、token 错、签名不匹配、时间戳超窗、nonce 重放。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + TooManyRequests: + description: 超出限流。**不重试**(重试还是被限),计入死信即可。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + ServerError: + description: 服务端异常。**这是唯一需要重试的**,按 J-00 §1.6 的五档退避。 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + + SourceChannel: + type: string + description: | + 接入通道四值,**不是产品线**。同一户可以存在多个通道的设备。 + 它决定三件事:`dev_id` 生成规则、`ts_precision` 默认值、原文落哪张表。 + + ⚠️ **它是业务来源,不承担具体物理通路。** 哪套协议、哪个监听口进来,由接入侧的 + `linkType` 描述(落 `adapter_in_raw`,不进本信封),两者分列。 + 例:S 线两套 TCP 同属 `MIR_CELL`,靠 `linkType` 区分,不能靠这个字段猜协议。 + enum: [OWN_GW, HORN_HOST, HORN_CELL, MIR_CELL] + + TsSource: + type: integer + description: | + 时源,**没有默认值,漏传直接拒收**(故意不设 default,让漏赋值当场暴露)。 + `0` 设备原始 / `1` 厂家云 / `2` 平台接收 / `3` 网关打戳。 + enum: [0, 1, 2, 3] + + EventType: + type: string + description: | + 29 个值,按家族成对出现。**少一个「解除」,告警就永远不闭环。** + 枚举外的新值**映射 `UNKNOWN` 并必填 `payload.rawEventCode`**——不拒收、不静默丢弃。 + enum: + # 成对:发生 / 解除 + - DOOR_OPEN + - DOOR_CLOSE + - WATER_ACTIVITY + - WATER_ACTIVITY_STOP + - CURRENT_ACTIVITY + - CURRENT_ACTIVITY_STOP + - WATER_LEAK + - WATER_LEAK_CLEAR + - GAS_LEAK + - GAS_LEAK_CLEAR + - SMOKE + - SMOKE_CLEAR + - FALL + - FALL_CLEAR + - SOS + - SOS_CLEAR + - TAMPER + - TAMPER_CLEAR + - POWER_CUT + - POWER_RESTORE + # 单值 + - VALVE_CLOSED + - TEMP_HUMIDITY + - DEVICE_ONLINE + - DEVICE_OFFLINE + - LOW_BATTERY + - HEARTBEAT + - PRE_ALARM_0X04 + - MSG_UNDELIVERED + - UNKNOWN + + EventPayload: + type: object + description: 事件明细。已知字段逐个列;**厂商新增字段原样保留,不丢**(见 `additionalProperties`)。 + required: [deviceTypeRaw] + properties: + deviceTypeRaw: + type: integer + description: | + 设备原始类型码(G 线 `type` 字节、S 线 `DT`、H 线子设备类型)。 + **任何帧都带,含心跳与 UNKNOWN。** 不要在这里做归一化——归一化由业务层按设备类型表做。 + rawEventCode: + type: integer + description: 设备原始事件码。**`eventType` 落到 `UNKNOWN` 时必填**,其余情况建议带。 + batteryMv: + type: integer + description: 电池电压,**毫伏整数**。不换算成百分比(换算式会变,在业务层)。 + rssi: + type: integer + description: 信号强度 dBm。 + csq: + type: integer + minimum: 0 + maximum: 99 + description: 信号质量 0–31,`99` = 未知。 + additionalProperties: true + + Event: + type: object + required: + - eventId + - sensorId + - sourceChannel + - eventType + - receivedAt + - tsSource + - protocolVersion + - rawRef + - payload + properties: + eventId: + type: string + maxLength: 96 + description: | + 事件级幂等键,**≤96 字符,不得含平台时间戳**。 + 算法见 J-00 §2.4 三档:有 `seq` 用 `{sensorId}_{seq}`;有厂商报文 ID 用它; + 两者都没有用 `{sensorId}_{sha256(原始帧字节)前16位}`。 + **hash 要对原始字节算**,不能对反序列化后再序列化的 JSON 算。 + example: 3F7A9C21_10231 + sensorId: + type: string + maxLength: 64 + description: | + 设备物理标识,**必须与 `device.sensor_id` 完全一致(含厂商前缀)**。 + H/S 线必带 `HORN:`/`MIR:` 前缀;G 线 8 位 hex 不加前缀。 + **这是全篇最容易出错的一个字段**——不一致会导致全场设备判成未建档。 + example: 3F7A9C21 + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + eventType: + $ref: '#/components/schemas/EventType' + deviceTime: + type: string + format: date-time + nullable: true + description: | + 设备侧时间,**没有就传 `null`**。 + ⚠️ **禁止用 `receivedAt` 顶替**——填了等于伪造时源,界面会把平台收到的时刻当事情发生的时刻。 + receivedAt: + type: string + format: date-time + description: 平台接收时刻,ISO-8601 带时区。 + example: '2026-10-29T10:19:22+08:00' + tsSource: + $ref: '#/components/schemas/TsSource' + protocolVersion: + type: string + maxLength: 16 + description: | + 按**具体协议原件**填写,厂商改协议后要能回溯。 + 例:`gw-1.2`(契约 B)/`WTK-V3.0`(铠湾)/`bignbiot-3.0`/`nb-1.6`(NB&WIFI)。 + **豪恩不得再写 `tcp-1.10`**——旧口径已废止。 + example: gw-1.2 + seq: + type: integer + format: int64 + nullable: true + description: | + 协议序号。**G 线**取 433 帧 `count`(2 字节);**铠湾 V3.0** 取帧内序列号; + **bignbiot** 取帧号(1 字节 1–255);其他协议取自带序号。 + **没有才传 `null`,不得写成「S 线一律 null」**——S 线两套 TCP 都有序号。 + payload: + $ref: '#/components/schemas/EventPayload' + rawRef: + type: string + pattern: '^([a-f0-9]{32}|[a-f0-9]{64})$' + description: | + 原始报文摘要,回指 I-12 的 `rawRef`。 + **MD5(32 位小写 hex)与 SHA-256(64 位小写 hex)都接受**,按长度识别。 + **约束只有一条:I-01 与 I-12 用同一种算法**,否则回指链断开。 + 摘要只用于关联与幂等,不作鉴权。 + + **必填(V1.7.1 起)。墓碑帧也必填**:填承载该 `undelivered[]` 的网关上行帧摘要。 + 一条网关报文里的 N 个 `undelivered` → N 条墓碑行共享同一 `rawRef`,多对一合法。 + 墓碑行身份是 `sensorId + seq`、事件幂等键是 `eventId`,都**不是** `rawRef`,故不影响去重。 + V1.7.1 之前已入库的历史墓碑行为 `null`,业务层保持兼容。 + example: 9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6 + + EventBatchRequest: + type: object + required: [sourceChannel, events] + properties: + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + events: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/Event' + + DeviceStatus: + type: object + required: [sensorId, sourceChannel, online, lastSeenAt, protocolVersion, reportedAt] + properties: + sensorId: + type: string + maxLength: 64 + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + online: + type: boolean + description: | + 只表示「最近是否有报文」,**不表示设备好坏**。 + 是否「离线告警」由业务层按阈值判定,IoT 不判。 + lastSeenAt: + type: string + format: date-time + description: 最后一次收到有效报文的时刻。 + batteryMv: + type: integer + nullable: true + description: 毫伏整数。 + csq: + type: integer + nullable: true + minimum: 0 + maximum: 99 + lastSeq: + type: integer + format: int64 + nullable: true + description: 仅 G 线,最后收到的 433 序号。 + lostCnt: + type: integer + nullable: true + minimum: 0 + maximum: 255 + description: 仅 G 线,窗口内未送达计数,`255` 表示饱和。 + protocolVersion: + type: string + maxLength: 16 + reportedAt: + type: string + format: date-time + description: 快照生成时刻。**幂等键要用它**。 + + StatusBatchRequest: + type: object + required: [sourceChannel, statuses] + properties: + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + statuses: + type: array + maxItems: 500 + items: + $ref: '#/components/schemas/DeviceStatus' + + RawFrame: + type: object + required: [rawRef, sourceChannel, receivedAt, rawFrame, protocolVersion] + properties: + rawRef: + type: string + pattern: '^[a-f0-9]{32}$' + description: '`MD5(原始帧字节)`,32 位小写 hex。**它就是幂等键**,也是 I-01 里 `rawRef` 回指的目标。' + sensorId: + type: string + maxLength: 64 + nullable: true + description: | + 解析出设备标识时带上,规则同 I-01。**未建档设备也要带**——有值才追得回是哪个 SN。 + 实在解析不出可留空,原文照样落库。 + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + receivedAt: + type: string + format: date-time + description: 平台收到这一帧的时刻。**不是设备时间。** + rawFrame: + type: string + format: byte + description: | + **Base64,定死这一种表示。** + 十六进制会把体积翻倍,而 200 条/批本来就是按字节数撑上限。 + `rawRef` 对解码后的字节算——两种表示算出来一样,所以「写 hex 或 Base64 等于没定」。 + 写 hex 的实现方,MD5 永远对不上业务层。 + frameBytes: + type: integer + nullable: true + description: 解码后的字节数。用于自检——**对不上说明 Base64 编码有问题**。 + gatewayDevId: + type: string + maxLength: 64 + nullable: true + description: 接入端点标识(网关 SN,或独立设备自身标识)。排障时回答「这帧从哪个口进来的」。 + protocolVersion: + type: string + maxLength: 16 + + RawFrameBatchRequest: + type: object + required: [sourceChannel, frames] + properties: + sourceChannel: + $ref: '#/components/schemas/SourceChannel' + frames: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/RawFrame' + + DeliveryMode: + type: string + enum: [REALTIME, DEFERRED] + description: | + 由业务层在创建命令时定,IoT 不判。 + `REALTIME` 实时命令(布撤防、重启):设备离线必须当场失败,不能「等上线再发」。 + `DEFERRED` 可延迟命令(配置下发):失败可重排。 + + CmdType: + type: string + description: 下行命令字 C1–C8。**IoT 只负责编码下发,不解释语义。** + enum: [set_mode, set_ack_policy, time_sync, set_report_cfg, del_sensor, reboot, diag_dump, set_ota] + + Command: + type: object + required: [commandId, gatewayId, targetId, cmdType, deliveryMode, leaseExpireAt, expireAt, retryCount] + properties: + commandId: + type: string + maxLength: 64 + description: 命令唯一标识。**IoT 与网关都要按它去重**——租约回收可能导致同一条被下发两次。 + example: CMD-20261030-000123 + gatewayId: + type: string + maxLength: 64 + description: | + **「这条命令从哪个端点进去」,不是「网关 SN」。** + 连网关的哨兵填网关 SN;独立设备填设备自身标识;H 线填厂家云节点标识。 + IoT 拿它去自己的连接表里查,查不到报 `DEVICE_OFFLINE`。 + targetId: + type: string + maxLength: 64 + description: 目标设备标识。路由在业务层写指令时就算完了,IoT 不需要知道它与 `gatewayId` 的关系。 + cmdType: + $ref: '#/components/schemas/CmdType' + deliveryMode: + $ref: '#/components/schemas/DeliveryMode' + params: + type: object + nullable: true + additionalProperties: true + leaseExpireAt: + type: string + format: date-time + description: 租约到期时刻(拉走 + 60s)。到期不回报会自动回「待下发」,`reclaimCount + 1`。 + expireAt: + type: string + format: date-time + description: 命令最终有效期。过期即终态「已过期」。 + retryCount: + type: integer + description: 设备侧已失败次数。**与 `reclaimCount` 分开记**——混成一个就分不清是设备坏了还是中间件坏了。 + claimGeneration: + type: integer + minimum: 1 + description: | + **领取代次(V1.7.2 新增):这条命令被领走第几次。** 每次 `PENDING → CLAIMED` 时 +1。 + + IoT 必须原样保存,并在 I-05 里回带。用途:一条命令租约到期被回收重派后, + 第一个节点的迟到回执会覆盖第二次的流转且不报错——回带代次后服务端能把它挡住。 + + 不回带也不会被拒(兼容未升级的接入端),但等于没有代次校验。 + example: 1 + + PendingResponse: + type: object + required: [nodeId, commands, hasMore] + properties: + nodeId: + type: string + commands: + type: array + items: + $ref: '#/components/schemas/Command' + hasMore: + type: boolean + description: 还有更多(本次达到 `limit` 上限)。IoT 可立即再拉一次。 + + CommandResult: + type: object + required: [commandId, result, claimGeneration] + properties: + commandId: + type: string + maxLength: 64 + result: + type: string + enum: [ACKED, SENT, TIMEOUT, DEVICE_OFFLINE, FAILED] + description: | + - `ACKED` 设备有回应(看 `resultCode`) + - `SENT` 已发出,本协议无回执通道 + - `TIMEOUT` 已发出,等不到回执 + - `DEVICE_OFFLINE` **没有发出去** + - `FAILED` IoT 自身发送失败 + resultCode: + type: string + enum: ['0', '1', '2', '3', '4'] + nullable: true + description: | + **标准字典值,由 IoT 翻译后填,不是厂家原码。** + `0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙。 + 厂家原始返回码请放 `rawResultCode`——直透到标准字段,业务层与前端就得按每个厂家 + 各写一套判断,等于把适配器的活摊到全平台。 + + 过渡期说明:服务端按字符串接收,**不因非标准值拒整批**(避免联调期因为一个码值 + 整批 400),非标准值会记日志并在 `rawResultCode` 为空时兜底采用。 + rawResultCode: + type: string + maxLength: 32 + nullable: true + description: | + 厂家原始返回码,**原样字符串,不解释**。 + V1.7 新增的**可选**字段:不填也能跑(`null` 时业务层只看 `resultCode`)。 + resultMsg: + type: string + maxLength: 256 + nullable: true + ackAt: + type: string + format: date-time + nullable: true + description: 收到回执的时刻。`SENT`/`DEVICE_OFFLINE`/`FAILED` 时为 `null`。 + rawAck: + type: string + maxLength: 512 + nullable: true + description: 回执帧原样(hex)。排障用。 + claimGeneration: + type: integer + minimum: 1 + description: | + 领取时 I-18 给的 `claimGeneration`,**原样回带。V1.8 起 required,不可为空。** + + 服务端用它当 `WHERE claim_generation = ?` 的条件,挡住旧代次的迟到回执 + (更新 0 行、计 `duplicated`)。**缺失直接拒**,原因 `CLAIM_GENERATION_REQUIRED`—— + 旧实现"缺了就拿库里当前值顶上",等于让代次校验自己失效,已取消。 + + 与 `nodeId` 一起构成一次领取的唯一事实: + `claim_generation` 回答"第几轮",`nodeId` 回答"这一轮是谁"。 + example: 1 + + ResultBatchRequest: + type: object + required: [nodeId, results] + properties: + nodeId: + type: string + maxLength: 64 + description: | + **来源节点标识,必填。** 必须与 I-18 领取时落库的 `claimed_node_id` 一致, + 否则整条回执按 `NODE_MISMATCH` 拒收;I-18 时未落节点的历史行按 `NODE_UNKNOWN` 拒收。 + + **作用边界**:它不是身份认证——值由 IoT 自报,同样可以伪造; + "调用方是谁"由内网来源白名单 + 签名保证。这里校验的是**领取事实的一致性**: + 这条结果是不是领走这一轮的那个节点回的。 + results: + type: array + maxItems: 50 + items: + $ref: '#/components/schemas/CommandResult' + + RejectReason: + type: string + enum: [RATE_LIMITED, SCHEMA_INVALID, UNKNOWN_CHANNEL, SENSOR_ID_INVALID, + COMMAND_NOT_FOUND, CLAIM_GENERATION_REQUIRED, NODE_MISMATCH, NODE_UNKNOWN] + description: | + 前四个用于 I-01/I-02/I-12(事件侧),后四个用于 I-05(命令回执侧): + `COMMAND_NOT_FOUND` 命令号不存在;`CLAIM_GENERATION_REQUIRED` 缺 `claimGeneration`; + `NODE_MISMATCH` 回执节点与领取节点不符;`NODE_UNKNOWN` 历史在途行没有领取节点可核。 + + **不要按原因做分支。** 收到 `rejectedDetail` 一律**不重试**,落死信 + 计数即可。 + 唯一例外是 `NODE_UNKNOWN`:那条命令会在 60 秒租约到期后被回收重派,重领后节点列即有值。 + 需要重试的只有整批失败(网络、5xx)。 + + RejectedItem: + type: object + required: [reason] + properties: + eventId: + type: string + nullable: true + description: 事件类批次用 `eventId` 定位。 + rawRef: + type: string + nullable: true + description: 原文类批次用 `rawRef` 定位。 + sensorId: + type: string + nullable: true + description: 状态类批次用 `sensorId` 定位。 + commandId: + type: string + nullable: true + description: 结果类批次用 `commandId` 定位。 + reason: + $ref: '#/components/schemas/RejectReason' + + IngestResponse: + type: object + required: [code, accepted, rejected, duplicated, unregistered] + description: | + **HTTP 200 只表示受理,不表示处理完。** + `unregistered` 是**计数字段,不是错误**——业务层已写进 `unregistered_device`, + 接入侧不做任何动作:不重报、不换接口、不缓存。 + properties: + code: + type: integer + description: 0 = 受理完成。非 0 见 `message`。 + example: 0 + accepted: + type: integer + description: 成功入库条数。 + example: 198 + rejected: + type: integer + description: 拒收条数。 + example: 2 + duplicated: + type: integer + description: 命中幂等跳过的条数(重推)。 + example: 3 + unregistered: + type: integer + description: 未建档设备条数。**不是错误。** + example: 5 + rejectedDetail: + type: array + items: + $ref: '#/components/schemas/RejectedItem' + + ErrorResponse: + type: object + required: [code, message] + properties: + code: + type: integer + description: | + 错误码。**本文件暂不固定具体码值**——待与业务层错误码表统一后回填, + 当前按 HTTP 状态码区段理解:400xx 请求非法、401xx 鉴权失败、429xx 限流。 + message: + type: string + description: 人读的错误说明。**不要用它做分支判断**,用 `code`。 diff --git a/docs/contract/Mock样例.md b/docs/contract/Mock样例.md new file mode 100644 index 0000000..718e44c --- /dev/null +++ b/docs/contract/Mock样例.md @@ -0,0 +1,156 @@ +# Mock 样例(可直接拿去打) + +> 六组报文全部取自 2026-11-09 20:21 的**真实一次运行**,不是手编的示例。 +> 对应证据:`06_统一闭环题自测证据/05_SOS链路.json` + +--- + +## 1. I-01 事件上报 · 请求 + +```http +POST /internal/iot/v1/events/batch HTTP/1.1 +Content-Type: application/json +X-Idempotency-Key: batch-20261109-2021-001 +``` + +```json +{ + "sourceChannel": "OWN_GW", + "events": [{ + "eventId": "EVT-SOS-3B9B2CDB9CD4", + "sensorId": "3F7A9C23", + "sourceChannel": "OWN_GW", + "eventType": "SOS_PRESS", + "deviceTime": null, + "receivedAt": "2026-11-09T20:21:12+08:00", + "tsSource": 3, + "protocolVersion": "gw-1.2", + "seq": 16872, + "payload": {"deviceTypeRaw": 5, "rawEventCode": 1, "batteryMv": 3610}, + "rawRef": "e1abbc2d6515f118ac9e7e113c68d677" + }] +} +``` + +**三个刻意的点** + +- `deviceTime: null` —— 433 帧没有时间字段,按 J-00 必须传 null,不许拿 `receivedAt` 顶替 +- `tsSource: 3` —— 网关打戳,界面不能显示成"发生时间" +- `payload.deviceTypeRaw: 5` —— 任何帧都必须带,缺了整条拒收 + +## 2. I-01 响应 + +```json +{"code":0,"msg":"ok","data":{ + "accepted":1, "rejected":0, "duplicated":0, "unregistered":0, "rejectedDetail":[]}} +``` + +## 3. I-01 失败样例(四种) + +```jsonc +// ① 缺 deviceTypeRaw +{"code":0,"data":{"accepted":0,"rejected":1,"rejectedDetail":[ + {"eventId":"EVT-BAD-01","reason":"SCHEMA_INVALID"}]}} + +// ② SN 与通道不符(OWN_GW 传带前缀的 SN) +{"code":0,"data":{"accepted":0,"rejected":1,"rejectedDetail":[ + {"eventId":"EVT-BAD-02","reason":"SENSOR_ID_INVALID"}]}} + +// ③ tsSource 越界 +{"code":0,"data":{"accepted":0,"rejected":1,"rejectedDetail":[ + {"eventId":"EVT-BAD-03","reason":"SCHEMA_INVALID"}]}} + +// ④ 批级通道非法 → 整批 400 +{"code":400,"msg":"sourceChannel 非法:H_MQTT"} +``` + +## 4. I-02 设备状态快照 + +```json +{ + "sourceChannel": "OWN_GW", + "items": [{ + "sensorId": "3F7A9C23", + "sourceChannel": "OWN_GW", + "batteryMv": 3610, + "csq": 22, + "online": true, + "reportedAt": "2026-11-09T20:21:10+08:00" + }] +} +``` + +## 5. I-12 原始报文留档 + +```json +{ + "sourceChannel": "OWN_GW", + "frames": [{ + "sensorId": "3F7A9C23", + "rawRef": "e1abbc2d6515f118ac9e7e113c68d677", + "frameHex": "A55A213F7A9C2350100001000000E8B4", + "frameLen": 17, + "receivedAt": "2026-11-09T20:21:12+08:00" + }] +} +``` + +> `rawRef` 与 I-01 必须同一种算法(MD5 32 位或 SHA-256 64 位),否则回指链断。 + +## 6. I-18 / I-05 下行命令 + +> 编号按 J-00 V1.8 §三接口表:领取是 **I-18**、回执是 **I-05**(原写 I-20/I-21,已作废)。 + +```jsonc +// I-18 拉取(长轮询,wait 最长 30s) +GET /internal/iot/v1/commands/pending?nodeId=iot-node-1&wait=30 +{"code":0,"data":{"nodeId":"iot-node-1","hasMore":false,"commands":[ + {"commandId":"CMD17942268075877","gatewayId":"GW-0001","targetId":"3F7A9C23", + "cmdType":"time_sync","deliveryMode":"REALTIME", + "params":{"ts":"2026-11-09T20:21:12+08:00"}, + "leaseExpireAt":"2026-11-09T20:22:12+08:00","expireAt":"2026-11-12T20:21:12+08:00", + "retryCount":0}]}} + +// I-05 回执(result 五值;resultCode 是标准码,不是厂家原码) +POST /internal/iot/v1/commands/result +{"nodeId":"iot-node-1","results":[ + {"commandId":"CMD17942268075877","result":"ACKED","resultCode":"0","resultMsg":"OK", + "ackAt":"2026-11-09T20:21:14+08:00","rawResultCode":"00"}]} +{"code":0,"msg":"ok"} +``` + +## 7. 服务端推给家属端(SSE) + +``` +event: alarm +data:{"type":"alarm","alarmId":2796,"alarmNo":"AL20261109202112922", + "householdId":9001,"level":"S1","tier":"T1","ruleKey":"SOS", + "title":"紧急呼叫","content":"老人按下紧急按钮,请立即联系", + "deviceName":"床头SOS","deviceSn":"3F7A9C23", + "triggerAt":"2026-11-09T20:21:12","delayed":false} +``` + +--- + +## 8. 怎么用这些样例自测 + +```bash +# 登录拿 token(账号由 DemoDataInitializer 在空库首次启动时生成) +curl -X POST http://127.0.0.1:8080/api/v1/auth/wx/password-login \ + -H "Content-Type: application/json" \ + -d '{"phone":"13800000000","password":"123456"}' + +# 打一条 SOS(把上面第 1 节的 body 存成 sos.json) +curl -X POST http://127.0.0.1:8080/internal/iot/v1/events/batch \ + -H "Content-Type: application/json" \ + -H "X-Idempotency-Key: batch-local-001" \ + -d @sos.json + +# 查告警是否生成 +curl "http://127.0.0.1:8080/api/v1/alarms?size=3" -H "Authorization: Bearer " +``` + +预期:1 秒内家属端 SSE 收到 `event: alarm`,列表首条是刚才那条 SOS。 + +> **注意**:`sensorId` 必须已经入库(后台「设备入库」录入或跑 `03` 的虚构台账数据), +> 否则会返回 `unregistered`,不建档也不告警 —— 这是 A 方案的刻意设计。 diff --git a/docs/contract/内部五接口定义.md b/docs/contract/内部五接口定义.md new file mode 100644 index 0000000..0f8b3d3 --- /dev/null +++ b/docs/contract/内部五接口定义.md @@ -0,0 +1,224 @@ +# 内部 IoT 五接口定义(实现侧) + +> J-00 V1.8 定义了内部接口契约,本文是**我方实现的落地版**:路径、字段、幂等键、拒绝码,逐条对应代码。 +> 五个接口,三类上行(I-01 / I-02 / I-12)+ 两类下行(I-18 / I-05)。**业务层不向 IoT 侧发起任何调用**,只有 IoT 调业务层。 +> +> **编号一次说清**:I-01、I-02、I-12、I-18、I-05 就是这五个接口,与 J-00 V1.8 §三接口表逐字一致。 +> 早期稿把下行两个写成 I-20/I-21,**该编号已作废**,本文与 `Mock样例.md`、`状态机.md` 均已统一。 +> +> **与代码的一致性**:早期稿在这里登记过两条差距,两条都已修掉,本文与代码现在一致: +> ① 命令状态值域 —— `V1.0.23__command_seven_states.sql` 已把存量中文六值刷成英文七态(含 `CLAIMED`),代码读写全用英文值; +> ② `resultCode` —— `CommandService.standardCode()` 已把上报值收敛到标准码 `0`–`4`,厂家原码另存 `raw_result_code`,不再直透。 + +--- + +## 0. 公共约束(五个接口全适用) + +| 项 | 约定 | +|---|---| +| 传输 | HTTP/1.1,`Content-Type: application/json`,**仅内网**(来源 IP 有代码校验,见文末附录) | +| 鉴权 | `X-Internal-Token` + `X-Timestamp`(±300s)+ `X-Nonce`(Redis 600s 不复用)+ `X-Sign`(HMAC-SHA256) | +| 签名原文 | `token\nmethod\npath\ntimestamp\nnonce\nsha256(body)` | +| 幂等 | `X-Idempotency-Key`,同一 key 在 TTL 内返回首次结果 | +| 限流 | 通道维度按批次、设备维度按条;超限 429 | +| 响应包封 | `{"code":0,"msg":"ok","data":{...}}`,业务失败用 `code!=0` | +| 校验顺序 | Token(最便宜)→ 时间戳 → nonce → 签名(最贵),前面挡掉的不付后面代价 | + +> **当前配置** `GATEWAY_AUTH_ENABLED=false`(联调态,签名不校验)。机制完整实现于 +> `security/SignatureVerifier` + `security/NonceStore` + `interceptor/AuthInterceptor`, +> 打开方式:环境变量 `GATEWAY_AUTH_ENABLED=true`,并同步配好 `GATEWAY_TOKEN`。 +> **必须与 IoT 侧同批切换**,单边打开会导致全部请求 401。 + +--- + +## I-01 事件批量上报 + +``` +POST /internal/iot/v1/events/batch +X-Idempotency-Key: <批次键> +``` + +**请求** + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `sourceChannel` | enum | ✅ | `OWN_GW` / `HORN_HOST` / `HORN_CELL` / `MIR_CELL` | +| `events[]` | array | ✅ | ≤200 条 | + +**`events[]` 单条**(字段与 J-00 `Event` 逐项一致,详见 `04` 目录下字段差异表) + +`eventId`(≤96,不含平台时间戳) / `sensorId`(≤64,含厂商前缀) / `sourceChannel` / `eventType` / +`deviceTime`(可 null) / `receivedAt`(ISO-8601 带时区) / `tsSource`(0–3) / `protocolVersion` / +`seq`(可 null) / `payload`(必须含 `deviceTypeRaw`) / `rawRef`(32 或 64 位小写 hex,可 null) + +**响应** + +```json +{"code":0,"msg":"ok","data":{ + "accepted":1, "rejected":0, "duplicated":0, "unregistered":0, "rejectedDetail":[]}} +``` + +四个计数各有含义,**不能只看 accepted**: + +| 字段 | 含义 | +|---|---| +| `accepted` | 本批收下的条数(含幂等命中,**不等于新建条数**) | +| `duplicated` | 事件级幂等命中(同 `event_id` 已存在,未重复落库) | +| `unregistered` | 设备未入库:**不建档**,写 `unregistered_device` 待后台确认 | +| `rejected` | 条目级校验失败条数,明细在 `rejectedDetail` | + +**拒绝码** + +| 码 | 触发 | HTTP | +|---|---|---| +| `SCHEMA_INVALID` | 必填缺失 / 长度超限 / `tsSource` 越界 / `rawRef` 格式错 / 缺 `deviceTypeRaw` | 200 内逐条拒 | +| `UNKNOWN_CHANNEL` | 通道不在 4 个枚举内(批级错 → 整批 400) | 400 | +| `SENSOR_ID_INVALID` | SN 超 64 位,或前缀与通道不符(如 `OWN_GW` 传 `HORN:xxx`) | 200 内逐条拒 | + +--- + +## I-02 设备状态快照 + +``` +POST /internal/iot/v1/device-status/batch +``` + +上报电量、信号、在线状态。字段:`sensorId` / `sourceChannel` / `batteryMv` / `csq` / `online` / `reportedAt`。 +用于设备列表的「电量 / 信号 / 最近上报」,不产生告警。 + +--- + +## I-12 原始报文留档 + +``` +POST /internal/iot/v1/raw-frames +``` + +把原始帧字节摘要与原文留档,供 I-01 的 `rawRef` 回指。 +`rawRef` 与 I-01 **必须同一种算法**(MD5 32 位或 SHA-256 64 位),否则回指链断开。 +`sensorId` 允许为空(实在解析不出时),原文照样落库。 + +--- + +## I-18 待下发命令拉取(长轮询) + +``` +GET /internal/iot/v1/commands/pending?nodeId=<节点ID>&wait=30 +``` + +> **编号说明**:本节原写作 `I-20`,与 J-00 V1.7 §三接口表的 `I-18` 不一致,现已统一为 **I-18**(I-20/21 作废)。 +> 参数名也按 J-00 用 `nodeId`,不是 `gwId`。 + +网关/单品长轮询取下行命令,返回按创建时间与优先级排序。`wait` 最长 30s(用 `DeferredResult` 挂起,不占线程)。 + +```json +{"code":0,"msg":"ok","data":{ + "nodeId":"iot-node-1", + "commands":[{ + "commandId":"CMD-20261030-000123", + "gatewayId":"OWN_GW_3F7A9C21", + "targetId":"3F7A9C21", + "cmdType":"set_mode", + "deliveryMode":"REALTIME", + "params":{"mode":"ARMED"}, + "leaseExpireAt":"2026-10-30T08:46:00+08:00", + "expireAt":"2026-11-02T08:44:00+08:00", + "retryCount":0 + }], + "hasMore":false}} +``` + +**拉走不等于拿走**:业务层返回命令时同时置 `CLAIMED`、`lease_expire_at = now + 60s`。 +60 秒没回报 → 回 `PENDING`,`reclaim_count + 1`(`retry_count` 不动)。 + +**命令类型 C1–C8**:`set_mode` / `set_ack_policy` / `time_sync` / `set_report_cfg` / +`del_sensor` / `reboot` / `diag_dump` / `set_ota`。全部带 `seq`。 + +--- + +## I-05 命令执行结果回执 + +``` +POST /internal/iot/v1/commands/result +``` + +> **编号说明**:本节原写作 `I-21`,现已统一为 J-00 V1.7 的 **I-05**。 + +```json +{"code":0,"msg":"ok","data":{ + "nodeId":"iot-node-1", + "results":[{ + "commandId":"CMD-20261030-000123", + "result":"ACKED", + "resultCode":"0", + "resultMsg":"OK", + "ackAt":"2026-10-30T08:45:03+08:00", + "rawResultCode":"00", + "rawAck":"020A1F3C…" + }]}} +``` + +| 字段 | 必填 | 说明 | +|---|---|---| +| `commandId` | ✅ | I-18 返回的命令 ID | +| `result` | ✅ | `ACKED` / `SENT` / `TIMEOUT` / `DEVICE_OFFLINE` / `FAILED`(**五值,见 J-00 §3.3**) | +| `resultCode` | ✅ | **标准字典值**:`0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙 | +| `resultMsg` | | 可读说明 | +| `ackAt` | | 设备侧回执时刻 | +| `rawResultCode` | | **厂家原始码,原样字符串,不解释**(V1.7 新增可选) | +| `rawAck` | | 回执原始字节(Base64,可选) | + +**`result` 与 `resultCode` 的关系(写死,避免误读)**: + +| `result` | 含义 | 业务层处置 | +|---|---|---| +| `ACKED` | **设备有回应**(收到回执帧),**不代表执行成功** | 看 `resultCode`:`0` → `ACKED`;非 `0` → `FAILED` | +| `SENT` | 已发出,本协议无回执通道 | 置 `SENT`,`timeout_at = now+24h` | +| `TIMEOUT` | 已发出,等不到回执 | 实时=失败;可延迟=重排 | +| `DEVICE_OFFLINE` | **没有发出去**(连接不在) | 实时=失败;可延迟=重排 | +| `FAILED` | IoT 自身发送失败 | 同上 | + +> **`resultCode` 不得直透厂家原码**。厂家原码各不一样(铠湾 `08` 是水浸、bignbiot `08` 是白蚁,命令侧同样有这种坑), +> 直透到标准列以后,业务层和前端就得按每个厂家各写一套判断,等于把适配器的活摊到全平台。厂家原码只能进 `rawResultCode`。 + +回执落 `device_command.status`(七态见 `状态机.md` §二),后台命令记录页可见。 + +--- + +## 与 J-00 的关系 + +| 项 | 结论 | +|---|---| +| 字段命名 | 11 个字段与 J-00 `Event` schema **完全一致**,0 处改名 | +| 枚举 | `sourceChannel` 4 值、`tsSource` 4 值完全一致 | +| 接口编号 | **I-01 / I-02 / I-12 / I-18 / I-05**,与 J-00 §三逐字一致 | +| 事件类型 | J-00 29 个值全部接受;额外兼容 3 个别名(`SOS_PRESS` / `POWER_OFF` / `POWER_ON`),建议对外以 J-00 为准 | +| 命令状态 | 契约七态(J-00 §3.4),**代码同值**;存量中文六值已由 `V1.0.23` 刷平,库里无残留 | +| 命令结果码 | 契约 `resultCode` 标准码 `0`–`4`,**代码已按此收敛**(`standardCode()`),厂家原码落 `raw_result_code` 不参与判断 | +| 回执来源节点 | I-05 顶层 `nodeId` **必填**(V1.8.1),与领取时落库的 `claimed_node_id` 比对:不符 `NODE_MISMATCH`、历史在途行 `NODE_UNKNOWN`,均不采纳。原子 `WHERE` = 节点 + 代次 + 来源状态(`V1.0.28`) | +| 未实现 | `undelivered[]` 墓碑帧数组(表结构与写法已通,H/S 线不产生,仅 G 线用) | + +字段级逐项对照见 `04_OpenAPI与内部IoT契约` 目录内说明,或 20261109 提交包中的《J-00 字段差异表》。 + +--- + +## 附:`/internal/**` 的两道闸(2026-11-11 补) + +五个接口都挂 `/internal/**`,进业务逻辑之前有**两层**,顺序不能换: + +| 顺序 | 闸 | 拦什么 | 开关 | 默认 | +|---|---|---|---|---| +| 1 | `InternalNetworkGuard` | 来源 IP 不在白名单 → 403 | `gateway.auth.network-guard-enabled` | **开** | +| 2 | `AuthInterceptor` | Token / 时间戳 / nonce / 签名 | `gateway.auth.enabled` | 关(联调态) | + +**为什么要分两个开关**:签名要和 IoT 侧同批切(单边打开全部 401),所以联调期只能关着。 +但签名关着的时候如果什么都没有,`/internal/**` 就是任何能连 8080 的人都能调—— +文档写的"仅内网"变成一句空话,谁都能伪造一条水浸事件。所以网络闸独立于签名开关,**默认就开着**。 + +白名单默认值:`127.0.0.0/8`、`::1/128`、`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`。 +覆盖本机自测、局域网演示、同机 Nginx 反代三种场景。要收紧就配 `gateway.auth.allowed-cidrs`, +配了之后以配置为准(默认值不再参与)。 + +只认 `getRemoteAddr()`,**不认 `X-Forwarded-For`**——后者是请求头,调用方随便写。 +部署在 Nginx 后面时 `remoteAddr` 是 Nginx 那台机(本机或内网),仍在白名单内; +真正的公网边界由 Nginx 做,这一层兜的是"绕过 Nginx 直连 8080"。 diff --git a/docs/contract/状态机.md b/docs/contract/状态机.md new file mode 100644 index 0000000..8f6f508 --- /dev/null +++ b/docs/contract/状态机.md @@ -0,0 +1,289 @@ +# 状态机:告警 与 下行命令 + +> 状态字段全部是 `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,再改本文与代码。 diff --git a/docs/handover/00_包内容与使用说明.md b/docs/handover/00_包内容与使用说明.md new file mode 100644 index 0000000..15f3bc0 --- /dev/null +++ b/docs/handover/00_包内容与使用说明.md @@ -0,0 +1,79 @@ +# 包内容与使用说明 + +> 本包是 **M-Z 业务底座的受控源码**(SEC-Z-01 清理后),只覆盖中段业务层,不含其他段的东西。 +> 用途:陈工按 AM-S1-GOV-020 §6.2(1) 接收此源码,建立唯一主干。 + +--- + +## 一、包里有什么(108 个条目) + +| 目录 / 文件 | 数量 | 是什么 | +|---|---|---| +| `aomin-gateway/` | 87 | **M-Z 业务层完整 Maven 工程**:`pom.xml`、`src/main`(84 个文件:controller / service / dao / biz / engine / 迁移脚本 / 配置)、`README.md`、`start-gateway.bat` | +| `03_数据库与Flyway迁移/` | 13 | **迁移镜像**:12 条 SQL(V1.0.0~V1.0.28)+ 迁移说明。与 `aomin-gateway/src/main/resources/db/migration` **逐字节一致**,供"应用与镜像同源"核对 | +| `04_OpenAPI与内部IoT契约/` | 6 | J-00 接入接口规格(md + OpenAPI yaml)、内部五接口定义、状态机、Mock 样例、使用说明 | +| `01_SEC-Z-01_最小安全清理差异清单.md` | 1 | 15 处明文逐处改了什么 | +| `02_SEC-Z-01_敏感信息扫描报告.md` | 1 | 扫描范围、结果、复算方法、盲区、轮换结论 | +| `03_Z-02_清理后哈希与构建说明.md` | 1 | 清理前后指纹、构建步骤、入库顺序(**先清后推**) | +| `04_155条证据适用边界.md` | 1 | 155 条重跑结果,以及**它不能证明什么** | +| `gitignore_建议.txt` | 1 | 建主干时直接用的 `.gitignore` | +| `SHA256SUMS_包内.txt` | 1 | 包内文件哈希,解压后在包根执行 `sha256sum -c` 即可校验 | + +--- + +## 二、包里**没有**什么,为什么 + +| 没给 | 原因 | +|---|---| +| `02_前端与设备端源码`(8 文件,演示前端) | 三端由陈工按 §6.2(3) 统一接入。这是评审用演示前端,给了反而分不清哪套是主干。**需要对照可以单独要** | +| `06_统一闭环题自测证据/raw`(12 份结果 JSON、11 份日志) | 体积大、每轮重跑全变。证据按受控目录交付,不进代码主干;要复跑见 `04` 里的命令 | +| `99_应急恢复(非正式路径)` | 明确是应急手段,不是主干迁移序列。给进去容易被当成常规路径 | +| `评审用虚构台账数据.sql` | 演示数据,不属于主干 | +| `09_代码与版本/SHA256SUMS.txt`(131 项全包清单) | 那是整个评审包的清单,路径以评审包为根;本包用 `SHA256SUMS_包内.txt`,路径与包内一致,可直接校验 | +| 明文口令 | 已按 SEC-Z-01 清空,**一个字符都没有** | + +--- + +## 三、怎么用(三步) + +```bash +# 1) 校验(在解压后的包根) +sha256sum -c SHA256SUMS_包内.txt + +# 2) 编译(JDK 21;离线仓库按你们环境改) +cd aomin-gateway +mvn -o -q -s -DskipTests clean package +# 产物 target/aomin-gateway-0.0.1-SNAPSHOT.jar,约 43.5 MB + +# 3) 启动(必须带 DB_PASS,否则启动中止、退出码 1) +set DB_PASS=<口令> +java -jar target/aomin-gateway-0.0.1-SNAPSHOT.jar --server.port=8080 --server.address=0.0.0.0 +curl http://127.0.0.1:8080/health +# 预期 db=connected、tables=87、flyway=1.0.28、status=UP +``` + +--- + +## 四、建主干前必读的两条 + +**① 顺序不能反:先清后推。** +当前目录**不是 git 仓库**,是干净起点。`git init` 必须在 15 处清理完成之后——一旦推上去,即使删除文件,第一条提交里仍有明文,只能重写全部历史。详见 `03_Z-02_清理后哈希与构建说明.md` §4.2。 + +**② 迁移只有一套序列。** +`aomin-gateway/src/main/resources/db/migration` 与 `03_数据库与Flyway迁移/` 是同一套 12 条的两次出现,**不是两套**。主干只保留一套默认序列;若两者路径必须二选一,以应用内那份为准,镜像目录作对照。 + +--- + +## 五、已知边界(不让接收方误判) + +| 项 | 状态 | +|---|---| +| A-Z-01/02/03 | **未关闭**。作者自测通过,仍需接收方复验签认;本包不构成关闭 | +| 并发 PULL | 未覆盖(`v20` 是串行 10 条),由谢工按完整矩阵补测 | +| 真机 | 未覆盖,等黄工真机窗口 | +| 前端三端 | 不在本包范围 | +| 口令轮换 | **未轮换**,登记为待确认(见 `02` §4) | + +--- + +*对应 GOV-020:Z-01(差异+扫描+轮换结论)、Z-02(哈希+构建说明+155 条边界)。* diff --git a/docs/handover/01_SEC-Z-01_最小安全清理差异清单.md b/docs/handover/01_SEC-Z-01_最小安全清理差异清单.md new file mode 100644 index 0000000..d0e4fe3 --- /dev/null +++ b/docs/handover/01_SEC-Z-01_最小安全清理差异清单.md @@ -0,0 +1,126 @@ +# SEC-Z-01 最小安全清理差异清单(Z-01) + +| 项 | 内容 | +|---|---| +| 依据 | AM-S1-GOV-020 §4.2「第一优先级:完成 SEC-Z-01 最小安全清理」 | +| 执行人 | 周工 | +| 执行时间 | 2026-11-13 11:20–11:45 | +| 范围 | **15 处明文** + **1 处启动守卫**(为满足"缺参明确失败") | +| 是否升版 | **否**,V1.9 内做最小安全清理,未升 V1.10,无无关重构 | +| 口令复述 | **本件不含原口令任何字符**,差异表中一律写作「(原值已脱敏)」 | + +--- + +## 一、清理前后总览 + +| 项 | 清理前 | 清理后 | +|---|---|---| +| 含明文口令的文件 | **15 个** | **0** | +| 明文出现次数 | 15 处(每文件 1 处) | 0 | +| 配置层(yml) | 带非空默认值 | 无默认值,缺参启动中止 | +| 脚本层(py) | 带非空默认值 | 无默认值,缺参直接退出 | +| 文档层(md) | 写出明文示例 | 改为占位符 | +| 历史包 | 6 个 zip 内含旧凭据 | 已移入受控归档目录,标注禁止外发 | + +--- + +## 二、逐处差异(15 处) + +### A 类:后端配置(1 处) + +| # | 文件 | 位置 | 改前 | 改后 | +|---|---|---|---|---| +| 1 | `01_后端源码/src/main/resources/application.yml` | `spring.datasource.password` | `${DB_PASS:<原值已脱敏>}` | `${DB_PASS}` | + +同时把上方两行自相矛盾的注释("包里不再有第二处明文"——实际上还有 14 处)改成实际口径。 + +### B 类:Python 证据脚本(12 处) + +12 个脚本写法相同,改法相同。以一处示意: + +| 改前 | 改后 | +|---|---| +| `DB_PASS = os.environ.get("AOMIN_DB_PASS", "<原值已脱敏>")` | `DB_PASS = os.environ.get("AOMIN_DB_PASS")`
加 4 行守卫:为空则 `raise SystemExit(...)`,打印设置方法 | + +| # | 文件 | +|---|---| +| 2 | `06_统一闭环题自测证据/alarm_retry_test.py` | +| 3 | `06_统一闭环题自测证据/closed_loop_test.py` | +| 4 | `06_统一闭环题自测证据/cmd_chain_test.py` | +| 5 | `06_统一闭环题自测证据/run_evidence.py` | +| 6 | `06_统一闭环题自测证据/scope_test.py` | +| 7 | `06_统一闭环题自测证据/v15_fix_test.py` | +| 8 | `06_统一闭环题自测证据/v16_fix_test.py` | +| 9 | `06_统一闭环题自测证据/v17_merge_test.py` | +| 10 | `06_统一闭环题自测证据/v18_fix_test.py` | +| 11 | `06_统一闭环题自测证据/v19_gov008_test.py` | +| 12 | `06_统一闭环题自测证据/v20_node_test.py` | +| 13 | `06_统一闭环题自测证据/stamp_evidence.py`(特殊:原为内联调用 `"-p" + os.environ.get(...)`,改为在模块顶部定义 `DB_PASS` 并加同样的守卫,调用处改引用常量) | + +### C 类:说明文档(2 处) + +| # | 文件 | 位置 | 改前 | 改后 | +|---|---|---|---|---| +| 14 | `05_启动构建与测试说明/启动构建与测试说明.md` | §5 启动 | `export DB_PASS=<原值已脱敏>` | `export DB_PASS=<口令>`,注释改为"必需:配置里已无默认值,未设置时启动直接失败" | +| 15 | `09_代码与版本/README.md` | 环境变量表 | 默认值列写出明文 | `root` / **必需,无默认值**;说明未设置时脚本直接退出 | + +--- + +## 三、新增 1 处启动守卫(为什么必须有) + +**这是本次唯一的代码改动,不是重构,是为满足 §4.2「缺少必需口令时明确失败」。** + +### 问题:只删默认值,并没有"明确失败" + +先按最省事的方式改(`password: ${DB_PASS}` 不设默认值)实测了一次,结果不是报"缺少 DB_PASS",而是: + +``` +Access denied for user 'root'@'localhost' (using password: YES) +``` + +原因:Spring Boot 的占位符解析在“解析不到”时是**忽略**而不是报错,于是拿空口令去连库。表现出来像"口令错了",实际是"根本没配"——这正是不允许的静默回退。 + +### 处理:在 `main` 里提前拦住 + +| 文件 | 改动 | +|---|---| +| `src/main/java/cn/aomin/gateway/GatewayApplication.java` | `main()` 首行调用 `requireEnv("DB_PASS", "数据库口令")`;新增私有方法 `requireEnv`,为空则打印提示并 `System.exit(1)` | + +改后实测输出(退出码 **1**): + +``` +[启动中止] 缺少必需的环境变量 DB_PASS(数据库口令)。 + SEC-Z-01:程序不再内置任何默认口令,也不会回退到包内明文。 + 设置方法:Windows set DB_PASS=<口令> Linux/macOS export DB_PASS=<口令> +``` + +**只校验"有没有",不校验"对不对",也不打印任何取值。** + +--- + +## 四、构建产物与缓存清理 + +| 对象 | 处理 | +|---|---| +| `target/classes/application.yml` | 旧副本含明文;`mvn clean package` 后已由新文件覆盖,实测 jar 内配置 **0 命中** | +| `__pycache__` / `*.pyc` | 已删除(编译产物里会残留字符串常量) | +| 应用日志 `G:/aomin-dev/logs` | 实测 **0 命中**,保留(不含凭据,且是运行证据) | +| `target/` 其余内容 | 43 MB 构建产物,已随 `clean` 重建;**不入代码仓库**(见 `03` 的 `.gitignore`) | + +--- + +## 五、历史包处置 + +`_历史包/` 下 6 个 zip 都含旧凭据(解开后逐成员扫描,命中 13~15 处)。直接按字节搜 zip 查不出来,因为内容被 deflate 压缩过。 + +| 处理 | 说明 | +|---|---| +| 目录改名 | `_历史包/` → **`_历史包_含旧凭据_禁止外发/`** | +| 登记 | 同目录 `_清单_含旧凭据.md` 记录每个 zip 的字节数、SHA-256、含旧口令文件数 | +| 约束 | **禁止外发、禁止入代码库、禁止上传公共 AI**;仅作内部留档 | + +> 这是"轮换结论"要覆盖的一部分:口令一旦进过包,就不能假设它只存在于一个地方。 + +--- + +*后续:清理后哈希、构建说明、155 条证据边界见同目录 `03_…` 与 `04_…`。* diff --git a/docs/handover/02_SEC-Z-01_敏感信息扫描报告.md b/docs/handover/02_SEC-Z-01_敏感信息扫描报告.md new file mode 100644 index 0000000..d539045 --- /dev/null +++ b/docs/handover/02_SEC-Z-01_敏感信息扫描报告.md @@ -0,0 +1,113 @@ +# SEC-Z-01 敏感信息扫描报告(Z-01 附件) + +| 项 | 内容 | +|---|---| +| 扫描时间 | 2026-11-13 11:45 | +| 扫描人 | 周工 | +| 结论 | 包内、源码树、构建产物、日志 **全部 0 命中**;历史归档包 6 个含旧凭据,已隔离 | +| 模式 | 原口令字面值(大小写不敏感)+ URL 编码形式 `%40` | + +--- + +## 一、扫描范围与结果 + +| # | 位置 | 类型 | 命中 | +|---|---|---|---| +| 1 | `候选模型评审包/`(全部文件,含 md / yml / py / json / xml) | 文本 | **0** | +| 2 | `候选模型评审包/`(全部文件,**按字节**,含 pyc 等二进制) | 二进制 | **0** | +| 3 | `01_后端源码/src` + `pom.xml`(工作目录 `G:/aomin-dev/app/aomin-gateway`) | 文本 | **0** | +| 4 | `aomin-gateway-0.0.1-SNAPSHOT.jar` → `BOOT-INF/classes/application.yml` | 二进制 | **0** | +| 5 | `target/`(全部,43 MB,构建产物) | 二进制 | **0** | +| 6 | `G:/aomin-dev/logs`(全部,6.1 MB) | 二进制 | **0** | +| 7 | `_历史包_含旧凭据_禁止外发/`(6 个 zip,**解压后逐成员**) | 文本 | **13~15 处/包,已隔离** | + +### 第 7 项明细(历史包) + +| 文件 | 含旧口令文件数 | +|---|---| +| 周工_V1.9定点修复包_20261112_上午版.zip | 15 | +| 周工_V1.9定点修复包_20261112_第二版.zip | 15 | +| 周工_候选模型评审去敏精简包_V1.6_20261111.zip | 13 | +| 周工_候选模型评审去敏精简包_V1.8_20261111.zip | 15 | +| 周工_候选模型评审去敏精简包_V1.9.1_20261112.zip | 15 | +| 周工_候选模型评审去敏精简包_V1.9_20261111.zip | 13 | + +**处置**:整个目录改名为 `_历史包_含旧凭据_禁止外发`,同目录 `_清单_含旧凭据.md` 登记每个包的字节数与 SHA-256。禁止外发、禁止入代码库、禁止上传公共 AI。 + +> 这一条要特别说明:**直接对 zip 做字节搜索是查不出来的**,因为成员内容被 deflate 压缩过,第一遍按字节扫全部返回"否",是错的。必须解开逐成员扫。 + +--- + +## 二、复算方法(陈工可原样执行) + +```python +import os, zipfile + +SECRET = b"<原口令>" # 自行填入,不要写进任何文件 +hits = [] + +for root in [r"<包根>", r"<源码根>/src", r"<源码根>/target", r"<日志目录>"]: + for dp, dn, fn in os.walk(root): + for f in fn: + p = os.path.join(dp, f) + try: + if os.path.getsize(p) > 30 * 1024 * 1024: + continue + if SECRET in open(p, "rb").read(): + hits.append(p) + except Exception: + pass + +# 压缩包必须解开再扫 +for zp in [r"<_历史包>/*.zip"]: + import glob + for p in glob.glob(zp): + with zipfile.ZipFile(p) as z: + for nm in z.namelist(): + try: + if SECRET in z.read(nm): + hits.append(p + "!" + nm) + except Exception: + pass + +print("命中:", len(hits)) +for h in hits: + print(" ", h) +``` + +判读规则: + +- **文本命中和字节命中分开看**。只 grep 文本会漏掉 pyc、jar、zip; +- **zip / jar 必须解压后扫**,压缩态下的字节搜索没有意义; +- 只扫"当前工作副本",不扫 git 历史——**本次交付时尚无 git 仓库**(见下节)。 + +--- + +## 三、盲区声明(扫描覆盖不到什么) + +| 盲区 | 说明 | +|---|---| +| Git 历史 | 当前 `G:/aomin-dev/app/aomin-gateway` **不是 git 仓库**,包内也没有 `.git`。所以不存在历史污染;这也是必须在首次 `git init` **之前**完成清理的原因 | +| 数据库内部 | 本扫描只查文件。数据库用户表里的口令哈希不属于"明文口令",另行按鉴权设计处理 | +| 其他凭据 | 本次只针对 SEC-Z-01 指出的那一处数据库口令。若后续新增 token、AK/SK,需另做一次扫描 | +| 聊天记录与截图 | 不在本次范围。已在回执中说明"不得在回复、截图、差异表或本通知中复述原口令" | +| 已外发的旧包 | 6 个历史包若曾通过群聊或邮件发出,**接收方副本无法回收**——按 GOV-020 §4.2,这是"检查历史包、受控配置和访问记录"要求的部分,如实列出,不隐瞒 | + +--- + +## 四、轮换结论(要求 Z-01 必须给的一句) + +**结论:不轮换,但登记为"待确认项",由仇总/陈工判断是否升级。** + +理由(如实写,不美化): + +1. 该口令是 **评审机本地 MySQL 演示库** 的 `root` 口令(库名 `aomin_dev`),不是生产凭据,也不是任何对外服务的入口; +2. 该库仅监听 `127.0.0.1:3306`,未对公网开放; +3. **但**:它已经进过 6 个历史包,若这些包曾被外发(群聊、邮件、他人机器),则等于该口令已离开本机; +4. 因此本件的表述是:**本地场景不需要轮换;一旦确认历史包曾外发,立即轮换并重跑 155 条**(重跑约 13 分钟,成本可控)。 + +> 不写"已轮换"——因为确实没换,换了就要重跑全部证据,那是另一件事,不能混在这次清理里糊过去。 + +--- + +*扫描与复算脚本、结果均可在本机重现;如需基线比对,见 `03_Z-02_清理后哈希与构建说明.md`。* diff --git a/docs/handover/03_Z-02_清理后哈希与构建说明.md b/docs/handover/03_Z-02_清理后哈希与构建说明.md new file mode 100644 index 0000000..9b56b6e --- /dev/null +++ b/docs/handover/03_Z-02_清理后哈希与构建说明.md @@ -0,0 +1,175 @@ +# Z-02 清理后源码树哈希、包哈希与构建说明 + +| 项 | 内容 | +|---|---| +| 依据 | AM-S1-GOV-020 §4.4「Z-02」 | +| 交付人 | 周工 | +| 时间 | 2026-11-13 | +| 结论 | 清理后指纹已重算;155 条在本基线**真实重跑并全过**,不是沿用旧结果 | + +--- + +## 一、清理前后指纹对比 + +| 指纹 | 清理前(V1.9 第二版) | 清理后(本件) | 变化原因 | +|---|---|---|---| +| 源码树摘要(`01_后端源码/src`,84 文件) | `60FA6938…`(上批) | **`5E9F894B0D1071969A968F2A9B1451279ADF39D423148186886BFD064398F286`** | 改了 1 个 java(启动守卫)+ yml 注释与默认值 | +| 可执行 jar | `43,516,447 B` / `652E1602…` | **`43,517,256 B`** / **`1F38F527605F09AA45E9E54528AFB11230469E1B197FA001C06E8725FD102192`** | 重新构建,含新配置与新类 | +| 契约版本 | `1.8.1` | `1.8.1` | **未变** | +| 迁移版本 | `1.0.28`(12 条) | `1.0.28`(12 条) | **未变**(本次不动 DDL) | +| 明文口令 | 15 处 | **0 处** | 本件清理目标 | + +> 源码树摘要算法:把 `01_后端源码/src` 下每个文件的「相对路径 + 该文件 SHA-256」按路径排序后拼接,再整体算一次 SHA-256。改一个字节即变,等效于"这一棵树"的指纹(不是 Git commit)。 + +--- + +## 二、本次改动文件逐个哈希(16 个) + +| # | 文件 | 字节 | SHA-256(改后) | +|---|---|---|---| +| 1 | `01_后端源码/src/main/resources/application.yml` | 9,636 | `3912006F4C6432965DAF2F590D229509363A1FEAFF9D7FB60DF6F93E5D6C16C9` | +| 2 | `01_后端源码/src/main/java/cn/aomin/gateway/GatewayApplication.java` | 2,639 | `2C315FCBABD70D5282AE33CA00DCEAFB352D14253C6B3D33D9C2A3FC717DC1B6` | +| 3 | `05_启动构建与测试说明/启动构建与测试说明.md` | 16,116 | `D4AAF430598FD1DB121697B5519F0206D7CFA5552C6C400292CABF18730A8BC1` | +| 4 | `09_代码与版本/README.md` | 5,845 | `50F128904869F1F56676DA06A42794C77106154C946D91AEA95BF24A02CFA365` | +| 5 | `06_统一闭环题自测证据/alarm_retry_test.py` | 6,708 | `24F062EAE16BF449170FA5D7A9404364F16950262108C9247C378DC2C9966E2A` | +| 6 | `06_统一闭环题自测证据/closed_loop_test.py` | 9,579 | `F17E59D6B9F7B63CE7818108D4D574EEF60EB16FEB0E8E708EA91F15B5054C8E` | +| 7 | `06_统一闭环题自测证据/cmd_chain_test.py` | 17,290 | `1C4FD7CF30D7A4ADBA4D61683449B8954D2FA630E5FEB079C97E9BCC3958035B` | +| 8 | `06_统一闭环题自测证据/run_evidence.py` | 21,551 | `478D672726193874CD9544EAEDB92D6FA887A2BAAB7D211F753290204822CA37` | +| 9 | `06_统一闭环题自测证据/scope_test.py` | 11,005 | `ACE8150FBC566D5AF796D4FADA14CC9927E4C8D6114DB919C883F307309E0651` | +| 10 | `06_统一闭环题自测证据/stamp_evidence.py` | 10,670 | `DA33B7B431279FE5064420AD6C8BA7F48CED6E4B7EF79BC7AE0B3A0171D63035` | +| 11 | `06_统一闭环题自测证据/v15_fix_test.py` | 11,575 | `C0C073AE6495717AF902F94552D9E40A69DCC67A8D840ECC3C130F322F6518EA` | +| 12 | `06_统一闭环题自测证据/v16_fix_test.py` | 16,120 | `08C425D9620E414B56E2FEF59B9C632054F18BBA9EEBA92F640FB447CC3F51E7` | +| 13 | `06_统一闭环题自测证据/v17_merge_test.py` | 11,748 | `A2BCBED23683554B1E661BD85304F056B55F2149272A7CBC72D7EE92546F7603` | +| 14 | `06_统一闭环题自测证据/v18_fix_test.py` | 7,375 | `48EC29ACFA0F9E9EAE28971973B1095FBA77B7CF42C8027FBAC615D66C7197A4` | +| 15 | `06_统一闭环题自测证据/v19_gov008_test.py` | 15,881 | `8599CBCF41E81E670598310D003FC03BAAD21CB0B0130EADAAB7A15A8858A4DC` | +| 16 | `06_统一闭环题自测证据/v20_node_test.py` | 10,843 | `8DDBF8996B989D446D667D454DB222F2EAA15D0918F28C0033E4DAF53931088C` | + +> 除以上 16 个,包内其余文件的哈希都在 `09_代码与版本/SHA256SUMS.txt` 里,本件已重新生成(原 131 项 → 现 131 项,内容更新)。 + +--- + +## 三、构建说明(可复现,约 21 秒) + +### 环境 + +| 项 | 要求 | +|---|---| +| JDK | 21.0.2(`G:/aomin-dev/jdk-21.0.2`) | +| Maven | 3.x,离线仓库 `G:/aomin-dev/downloads/settings.xml` | +| 依赖 | 已预置在本地仓库,`-o` 离线模式可用 | + +### 构建命令 + +```bash +cd <源码根>/aomin-gateway +mvn -o -q -s -DskipTests clean package +# 产物:target/aomin-gateway-0.0.1-SNAPSHOT.jar +``` + +实测:**21 秒**,`exit=0`,产物 `43,517,256 B`。 + +### 启动(**必须带 `DB_PASS`**) + +```bash +# Windows +set DB_PASS=<口令> +# Linux / macOS +export DB_PASS=<口令> + +java -jar target/aomin-gateway-0.0.1-SNAPSHOT.jar --server.port=8080 --server.address=0.0.0.0 +``` + +**缺少 `DB_PASS` 会立即中止**(退出码 1),不会带着空口令去连库: + +``` +[启动中止] 缺少必需的环境变量 DB_PASS(数据库口令)。 + SEC-Z-01:程序不再内置任何默认口令,也不会回退到包内明文。 + 设置方法:Windows set DB_PASS=<口令> Linux/macOS export DB_PASS=<口令> +``` + +### 启动后自检 + +```bash +curl http://127.0.0.1:8080/health +``` + +本机实测返回: + +```json +{"code":0,"msg":"ok","data":{"app":"aomin-gateway","db":"connected","tables":87, + "schema":"aomin_dev","redis":"connected","flyway":"1.0.28","alarmBacklog":0, + "alarmStale":0,"alarmDeadLetter":0,"auditFailures":0,"warnings":[],"status":"UP"}} +``` + +要点:`db: connected`、`tables: 87`、`flyway: 1.0.28`、`status: UP`。 + +--- + +## 四、入库建议(给陈工建主干时用) + +### 4.1 `.gitignore` + +见同目录 `gitignore_建议.txt`,核心是三条: + +- `target/`、`*.jar` —— 构建产物不入库; +- `.env`、`*.local.yml`、`application-local.yml` —— 本地配置可能含口令; +- `06_统一闭环题自测证据/raw|out|screenshots/` —— 证据原始数据体积大、每轮变化,按受控目录单独交付,不进代码历史。 + +### 4.2 首次提交顺序(**顺序不能反**) + +``` +① 确认 15 处已清、扫描 0 命中 +② git init +③ 放入 .gitignore +④ git add -A && git status ← 人工看一眼,确认没把 target/ 和 .env 加进来 +⑤ git commit -m "初始提交:M-Z 业务底座 V1.9 基线(SEC-Z-01 清理后)" +⑥ git remote add origin <陈工给的地址> && git push -u origin main +``` + +**为什么必须先清后推**:git 记录每一版。今天删掉明文、明天再提交一次,第一条提交里照样有。要彻底清只能重写全部历史(`filter-repo`),成本远高于现在清 15 处。 + +**好消息**:当前 `G:/aomin-dev/app/aomin-gateway` **不是 git 仓库**,包内也没有 `.git`,不存在历史污染,属于干净起点。 + +### 4.3 交付目录建议 + +``` +主干/ +├── src/main/java/… 业务代码(M-Z) +├── src/main/resources/ +│ ├── application.yml 已无默认口令 +│ └── db/migration/ Flyway 12 条,最新 1.0.28 +├── pom.xml +└── .gitignore +``` + +证据材料(`06_…/raw`、`06_…/out`、截图)**不进主干**,走受控目录交付。 + +--- + +## 五、版本标识一致性(四项对齐) + +| 标识 | 值 | +|---|---| +| 模型版本 | V1.9(已冻结,本次仅为 SEC-Z-01 最小安全清理,**未升 V1.10**) | +| 源码树摘要 | `5E9F894B0D1071969A968F2A9B1451279ADF39D423148186886BFD064398F286` | +| 可执行 jar | `1F38F527605F09AA45E9E54528AFB11230469E1B197FA001C06E8725FD102192`(43,517,256 B) | +| 契约版本 | J-00 `1.8.1` | +| 迁移版本 | Flyway `1.0.28`,12 条 | +| 证据批次 | `R20261113-112443`,11 组 / 155 条 / 失败 0 | + +--- + +## 六、交付包自检(打出包后实测) + +| 项 | 结果 | +|---|---| +| 条目数 | 93(源码 87 + 4 份说明 + `gitignore_建议.txt` + `SHA256SUMS_源码契约迁移脚本.txt`) | +| 解压后 `aomin-gateway/src` | 84 个文件,与包内 `01_后端源码/src` **逐文件哈希完全一致** | +| 解压后含明文口令 | **0** | +| 独立编译 | 解压到空目录后 `mvn -o -q -s -DskipTests compile` → **exit 0**,`GatewayApplication.class` 正常生成 | + +> 包自身的 SHA-256 只在交付渠道给出,**不写进包内**(写进去包一改哈希就变,形成死循环)。 + +--- + +*配套材料:`01` 差异清单、`02` 扫描报告、`04` 155 条证据适用边界、`gitignore_建议.txt`。* diff --git a/docs/handover/04_155条证据适用边界.md b/docs/handover/04_155条证据适用边界.md new file mode 100644 index 0000000..1063923 --- /dev/null +++ b/docs/handover/04_155条证据适用边界.md @@ -0,0 +1,109 @@ +# 155 条作者证据的适用边界(Z-02 附件) + +| 项 | 内容 | +|---|---| +| 依据 | AM-S1-GOV-020 §4.4「Z-02:……现有 155 条作者证据的适用边界;若作为清理后当前结果,必须真实重跑」 | +| 处理结论 | **已真实重跑**,不是沿用清理前结果 | +| 批次号 | `R20261113-112443` | +| 结果 | **155 条 / 通过 155 / 未过 0**,11 组脚本 **失败 0 组** | + +--- + +## 一、这 155 条是什么 + +| 证据文件 | 条数 | 覆盖内容 | 耗时 | +|---|---|---|---| +| `05_SOS链路.json` | —(非用例) | 单次闭环的原始请求/响应记录,不作计数 | 8.9s(与下一项同组) | +| `06_命令链.json` | 23 | 命令七态、回执、幂等、状态机边界 | 58.6s | +| `06_异常与权限.json` | 28 | 异常分支与对象级权限 | 同上组 | +| `07_告警失败不吞事件.json` | 5 | 故障注入:规则异常 / 库异常 / 重试 / 死信 | 84.6s | +| `08_闭环后半段.json` | 13 | 通知、家属确认、处置关闭 | 22.0s | +| `09_数据范围.json` | 16 | 数据权限与可见范围 | 19.9s | +| `10_摘要与状态防护.json` | 15 | rawRef 摘要、状态不可回退 | 34.0s | +| `11_超窗与死信.json` | 15 | 时间源分流、退避、死信、审计回滚 | 286.2s | +| `12_合并与替代.json` | 6 | 连续触发的合并与 superseded | 77.7s | +| `13_GOV008整改.json` | 16 | GOV-008 定点整改项 | 105.8s | +| `13_分区与一致性.json` | 8 | 分区维护、计数一致性 | 18.4s | +| `14_命令节点闭环.json` | 10 | 领取节点、代次、租约、错节点拒收 | 42.7s | +| **合计** | **155** | — | 约 13 分钟 | + +--- + +## 二、为什么必须重跑(不是"为了好看") + +清理动了三处会影响执行的东西: + +1. **`application.yml` 的 `password`** 由「带默认值」改成「无默认值 + 缺参中止」——启动条件变了; +2. **12 个证据脚本** 的口令来源由「环境变量或内置默认」改成「只认环境变量」——脚本启动条件变了; +3. **`GatewayApplication`** 增加启动守卫——可执行产物变了。 + +换句话说,**清理前的 155 条是在"另一套启动条件"下跑出来的**,直接拿来当"清理后的当前结果"属于沿用旧结果。所以按 §4.4 那句"必须真实重跑"执行。 + +重跑后源码树摘要、jar 摘要、批次号全部更新,见 `03`。 + +--- + +## 三、这批证据能证明什么 + +| 能证明 | 依据 | +|---|---| +| 155 条在**这一棵源码树**(`5E9F894B…`)、**这一个 jar**(`1F38F527…`)、契约 `1.8.1`、迁移 `1.0.28` 上真实执行过 | 每份结果 JSON 的 `_meta` + `raw/_execution_manifest.json` | +| 每组是哪个脚本、什么参数、几点开始几点结束、耗时多少、退出码几 | `raw/_run_logs/*.log`(11 份完整 stdout+stderr) | +| 失败项不会被藏起来 | 清单外的结果自动标「历史结果补充索引」,**不计入通过数**;本次该数为 **0** | +| 清理没有破坏原有功能 | 与清理前同为 155/155,无新增失败 | + +--- + +## 四、这批证据**不能**证明什么(必须写明,否则就是夸大) + +| 不能证明 | 说明 | +|---|---| +| **不是接收方复验** | 这是**作者自测**。按 §9.2 第 4 条,收到文件 ≠ 接收通过;A 类关闭要谢工、陈工签认,本件不构成关闭 | +| **不含并发** | `v20_node_test.py` 是串行 10 条,**没有 Thread / 线程池 / 并发 PULL**。并发领取、节点与租约不匹配、重复/乱序/迟到回执,由谢工按完整矩阵补测 | +| **不含真机** | 全部是接口级与故障注入。真实设备的上报、断连、无恢复帧场景,要等黄工真机窗口 | +| **不代表生产性能** | 单机、本地 MySQL、单实例;没有压测、没有长稳 | +| **不覆盖前端与小程序** | 只覆盖 M-Z 业务层与内部契约 | +| **不是重复性统计** | 每项跑一次。偶发失败只能靠重跑发现,本批失败 0 不代表永不失败 | +| **不覆盖 A-Z-02「停机超窗」的完整场景** | 有故障注入与死信验证,但"停服 60 分钟以上再起、事件被处理"需在共同环境由谢工/陈工复验 | + +--- + +## 五、怎么复跑(陈工/谢工可按原样执行) + +```bash +# 1) 先起服务(必须带口令) +set DB_PASS=<口令> +java -jar aomin-gateway-0.0.1-SNAPSHOT.jar --server.port=8080 --server.address=0.0.0.0 + +# 2) 跑全部 11 组(约 13 分钟) +cd 06_统一闭环题自测证据 +python run_all_evidence.py # 产出 raw/_execution_manifest.json + raw/_run_logs/*.log +python stamp_evidence.py # 给每份结果写 _meta.execution +python count_evidence.py # 对账,exit 0 = 155 条一致 +``` + +前置环境变量(缺一个都会失败或跳项): + +| 变量 | 用途 | 缺失后果 | +|---|---|---| +| `DB_PASS` | 应用启动 | **启动中止**(退出码 1) | +| `AOMIN_DB_PASS` | 12 个证据脚本 | **脚本退出**,提示设置方法 | +| `AOMIN_MYSQL` | mysql 客户端路径 | 找不到命令,相关断言失败 | +| `AOMIN_LOG` | 应用日志路径(`v17` 读横幅合并日志) | 误报"跳过 0 次" | +| `NO_PROXY` | 本机回环不走代理 | 报 `10061`,与用例无关 | + +--- + +## 六、复验入口(对应 GOV-011 / GOV-016 的 A 类) + +| 关注点 | 脚本 | 建议核对 | +|---|---|---| +| A-Z-01 命令节点与代次 | `v20_node_test.py`(10 条)+ `cmd_chain_test.py`(23 条) | 领取后 `claimed_node_id` 落库、错节点 `NODE_MISMATCH`、历史空节点 `NODE_UNKNOWN`、旧节点+新代次仍被拒 | +| A-Z-02 超窗补偿 | `v16_fix_test.py`(15 条)+ `alarm_retry_test.py`(5 条) | 无时间窗过滤、`receive_time` 库端打戳、退避与死信、人工回放 | +| A-Z-03 审计可靠性 | `v16_fix_test.py` 的审计回滚段 | 审计写失败 → 业务整条回滚;`audit_fail_log` 可查可补录;重启后不清零 | + +复验请填:**结论(通过/失败/未执行/不适用)+ 证据位置 + 日期**。作者不单方面宣布关闭。 + +--- + +*关联:`01` 差异清单、`02` 扫描报告、`03` 哈希与构建说明。* diff --git a/docs/handover/gitignore_建议.txt b/docs/handover/gitignore_建议.txt new file mode 100644 index 0000000..85a343d --- /dev/null +++ b/docs/handover/gitignore_建议.txt @@ -0,0 +1,51 @@ +# ───────────────────────────────────────────── +# 代码主干 .gitignore(M-Z 业务底座) +# 原则:源码、迁移、契约、测试脚本进仓库; +# 构建产物、本地配置、日志、证据原始数据不进。 +# ───────────────────────────────────────────── + +# 构建产物 +target/ +build/ +out/ +*.class +*.jar +*.war + +# 本地配置(可能含口令,绝不入库) +.env +.env.* +*.local.yml +application-local.yml +application-*-local.yml +application-secret.yml + +# 日志与临时 +logs/ +*.log +*.tmp +*.bak +*.swp + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + +# Python +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ + +# 证据原始数据(体积大、每轮变化;证据按受控目录单独交付,不进代码历史) +06_统一闭环题自测证据/raw/ +06_统一闭环题自测证据/out/ +06_统一闭环题自测证据/screenshots/ + +# 系统 +.DS_Store +Thumbs.db diff --git a/docs/reference/SHA256SUMS_前端与设备端.txt b/docs/reference/SHA256SUMS_前端与设备端.txt new file mode 100644 index 0000000..775639c --- /dev/null +++ b/docs/reference/SHA256SUMS_前端与设备端.txt @@ -0,0 +1,12 @@ +# 包内文件 SHA-256 清单(路径相对包根) +# 校验:在本包根目录执行 sha256sum -c SHA256SUMS_包内.txt + +848de973be78a75f47f1e7d78242a9d2862ffa94699c5405b06a4e4408123572 README.md +55dfa815e9c2801a37d2dcff0ab095c975402ab66ef7ffe33a13af54bebbf994 sim/sim_device.py +72eaa3997aab635fea28b79480922c7e8162e650602665d32c1f13eff68e489f web/admin/index.html +98cee85935be9a55af85cd194aaa105ae9d4d3b931ef6faea5b7423314aec439 web/common.js +6166979b12559a690747f0190b2f8882ecd6086e0696527b700b0341891e062c web/family/index.html +e0200f12ac905c295ec760439756b330639a4e922ab19918d76d65319a0809a1 web/family/map-baidu.html +a68d78beb94fff53d91eb474081c4c907e3dcfb769609d6bf9212773bd7e06c0 web/family/map-offline.html +2a13542045932ecd4e8b2a5e64cd6a14215d49e3da22975c6db147994bb5650a web/family/map-picker.html +6328a6aa17f9a67bd55d96bf944bbd60a0fcb27c2b9dcd3a4a8366b7a01edbb9 web/index.html diff --git a/docs/reference/SHA256SUMS_原始交付包.txt b/docs/reference/SHA256SUMS_原始交付包.txt new file mode 100644 index 0000000..633698f --- /dev/null +++ b/docs/reference/SHA256SUMS_原始交付包.txt @@ -0,0 +1,116 @@ +# 包内文件 SHA-256 清单(路径相对包根) +# 校验:在本包根目录执行 sha256sum -c SHA256SUMS_包内.txt +# 说明:03_数据库与Flyway迁移 与 aomin-gateway/src/main/resources/db/migration 为同一套迁移的两次出现,不是两套。 + +71d8471d07c4c067f375de16bbc5920820ea30a1bf05a60898c5ee71043c0267 00_包内容与使用说明.md +65dff0ffaba7ab185e7b4b7f3bd2c0a6782ad9faeab3669cbc6aeb1ede74dd05 01_SEC-Z-01_最小安全清理差异清单.md +2f88825dbd03c828ce19ac7736138a46d1ba9badee018087dde60ab012096cdb 02_SEC-Z-01_敏感信息扫描报告.md +f01f5a40a1423b0cdb69a0ff56227652da58bfc87aa9fd3860ca5ada5168978b 03_Z-02_清理后哈希与构建说明.md +07e5263e3d755535a5fadaa18b8529d6244af975daa06faef9b3e908bcf0c7cc 03_数据库与Flyway迁移/V1.0.0__baseline_schema.sql +8be5a9816f1f661d0a54c2a23c56079b76df88f47bd153609a94c659e42e7299 03_数据库与Flyway迁移/V1.0.18__raw_link_columns.sql +45e64f88796d46267b8f4f67f76d9ea5ec3ad34fbbe62736c4768216cbaf23a2 03_数据库与Flyway迁移/V1.0.19__household_geo.sql +cde7f6df804cdc1a827b0a109815c0af3a52d7efa4f35a170f262d8ce429418d 03_数据库与Flyway迁移/V1.0.20__notify_read_at.sql +7751f42076e39874a85f147cf1ce5c45653161207cb06e6042d60f7888d90710 03_数据库与Flyway迁移/V1.0.21__alarm_chain_seq.sql +3306548d3c02f6750cfb93c535e5c9e98062710ed6d4d099f69e0fc7a7b8edc1 03_数据库与Flyway迁移/V1.0.22__alarm_source_event.sql +9c941b1675d5a82daef81d7f297d03b206a028deb9923b78cd92f473a9ca8272 03_数据库与Flyway迁移/V1.0.23__command_seven_states.sql +ec0b7e0f23f74f52bd373fc0fb7d84bacdda9be62c19e9b6163bf669da442af2 03_数据库与Flyway迁移/V1.0.24__region_scope.sql +e480a143b8a079971c285d63bf51252eecc2034054459f83e2104c877fa51d40 03_数据库与Flyway迁移/V1.0.25__raw_ref_64_and_gap.sql +61e8c5f2167de4588e8aa337c9d45dfa12015680d6b12d1d456b3f159770c29e 03_数据库与Flyway迁移/V1.0.26__alarm_retry_deadletter.sql +778141cf45e9b3f62afc9bdbb3f7079794daedfba9f0a20caf84fe90eb3503dd 03_数据库与Flyway迁移/V1.0.27__receive_time_local_and_audit_fail.sql +2d6e90a3e6a2db9771b7820df4dfc7e58c211627ac02271612e2511764e83286 03_数据库与Flyway迁移/V1.0.28__command_claim_node.sql +94fc66069dabce35327e4ab2af724f7269bae6ced4f9c4de2e6681ca3498b36c 03_数据库与Flyway迁移/数据库与迁移说明.md +981c79f28b85d56a225c524e297af1cf16d63acdbf763f520a17fa65454feeb6 04_155条证据适用边界.md +1cc027d867b7538becd0e566666d772c09f092db2b81d6363abc643a03ec4821 04_OpenAPI与内部IoT契约/6_J-00_OpenAPI使用说明_V1.8.md +80b3ff9468fb71ed47b5378658e3354a0ac2f1338a253edf45f07343d0ffba1f 04_OpenAPI与内部IoT契约/6_J-00_接入接口规格_V1.8.md +99a659435512909e0b48ede82114325ba562958f65ff0d49b3772b672645fab7 04_OpenAPI与内部IoT契约/6_J-00_接入接口规格_V1.8_OpenAPI.yaml +6d16a685bb765b3aa1ad54039686c8f5936de22bb83eaba276cd5be022382877 04_OpenAPI与内部IoT契约/Mock样例.md +89c492c591e4c591351b5f3ddabe24de5566c9e19f2dbb26312dedb3e81a8484 04_OpenAPI与内部IoT契约/内部五接口定义.md +5272bb1d4d31bc7677cf12d44aafde4e2ec8e4eeb1bb09fe5a444da49cac6a5e 04_OpenAPI与内部IoT契约/状态机.md +1ddf2498987f80336b568efff85d0964264a06435d5c84e76fce115bcf7c83dd aomin-gateway/README.md +dbc64823853fef475d8735475a62f92e95df841e8a82fc8ab13de573b0438270 aomin-gateway/pom.xml +2c315fcbabd70d5282ae33ca00dceafb352d14253c6b3d33d9c2a3fc717dc1b6 aomin-gateway/src/main/java/cn/aomin/gateway/GatewayApplication.java +8fa464b5a4dc0c246b5585bbaabd7ad4fea5271d524eb6e9d4b7b91faf72c011 aomin-gateway/src/main/java/cn/aomin/gateway/biz/DemoDataInitializer.java +116a3b56bbb3b2b051ed239a5a4b82111c9c010b0a620efdbfe59a2528fcdc35 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AdminController.java +a7f7f7f3129a23d09d40f5589bb65c3cb7d933a8ceba8a929c20501b05b45432 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AlarmController.java +58fadae221f2763f2f51e267ddefdf8478661a15eb1583686205119c6bb9e434 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AuthController.java +d27df1ed1695ffcc75b9c2e00fa1111088694ab4ddfc04af9d29aad8b9d2b0cb aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/DeviceController.java +95b432a2c7a84b0f834c21b721e4131a7c71edb30f42cbb49dc014182e0201f3 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/FamilyController.java +cb04548d6386555e7df9070e19ebe0b007c2e825646c685ecfa80e78b6204d7c aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyController.java +2c40eaf77abd7a73d19022c85faf8dc319ccff7948fbd8553b595c4653e3c324 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyStreamController.java +84829224a845f88986683c4c87dd37b9b4dae4b27405e558b78b5e8fe34bbdbf aomin-gateway/src/main/java/cn/aomin/gateway/biz/dao/BizDao.java +f44f3cc9af74c6639241633aa30039e1baa22fad40487c05c5b3a8f172ba93bf aomin-gateway/src/main/java/cn/aomin/gateway/biz/dto/Dtos.java +e936b9acf37ef2f4d61840665329fc503df72d646480cc8b5a6fd438d1cd574b aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmEngine.java +2de186fdede0cd58e2d2f54d08616436a760557b9e9f53a13518d69cf7359954 aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmTrigger.java +26e417d5d41adf35d50d0917f2e0a17ac6f7d688bc18e159c4080ea3ba63f872 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AdminService.java +30b0f7d7ce7a753e24b78e252743e9524d7e11f5ec846655e72b7964f702bc33 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AlarmService.java +d296c463aa6416cc7a4df87c0e821f3989b4cc9bd5b9e3d06e06f5e3fc549894 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AuthService.java +4bcf644491cd6d0b924fd2f42e07b6583ad9499d06a8beed7d28bc9c89fc768e aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/BizDeviceService.java +84fb58da94f2a9395e372746b850ccf6f03b8f3c71bd1be1dc2400898a62989b aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/FamilyService.java +f7f6bd7e73741b2bbc774e8e05f91e344f00ba893727209fdfb22cf908201448 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/NotifyService.java +18ba93498aad6ffab1680e6cb780eb934dbd85f29625b2dd688beb362894d970 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/PiiService.java +18191289464412e7b1408662421b83291f1a95378a868c5f9892489964652a5b aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/SmsGateway.java +5d4d8c17959734e7c969c681369850be76fee08f0cc50910d5344f2832f9c65e aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AdminScope.java +00378b6a55265fb2adce15480faa80a20e85bfc7268efd074467427f607df560 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuditGuard.java +88b74c167b8e7b3f594d82a501072eb2dc736e07ee52e9e5ba87e05f3a5d03e2 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuthContext.java +e77c7b1cad576b5ee23ee05acb27f62af6a5d1cb80fbc955ce6c25db62a668cb aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/BizAuthInterceptor.java +fc12b2e26e2e39012479df1f4a22a2a4605ffe67d1793edbe01baef0aa2ac7b5 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/Crypto.java +65994b8ccfba75de35fe6a35f1632cf7d3a3a5e3d9412018facd6c2ecd66126e aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PageResult.java +e255bc089e83bd35fdce6d5d5521bd0f05c78c84919775459fb0a2f4ba26f88a aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PartitionMaintainer.java +ecc3e56a10b9e02a828a9c54314b9724f107f11506a354dd842c783b2ab69be8 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SseHub.java +46e4c0987cfa35f2067ccb1951dbd9f04321dfb01d22f9392242521d8d35e1d7 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SuppressGate.java +b068a6413895de569ec33d9b967c5aca13818258abc5747adf16d5b25adcf31f aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/TokenService.java +5d3b73cb52245ea2fcfe301fe6d794350d7f0bebd32851627eef5c3a8507ea42 aomin-gateway/src/main/java/cn/aomin/gateway/common/ApiResponse.java +6ef484310f27e7b951a490d7ae32786fd27e9d1fe14e38487b0451c62fbaf4a3 aomin-gateway/src/main/java/cn/aomin/gateway/common/BizException.java +3aa57442b289ed4f7a27120fbb69153dc670a94efbcccf361860e7e02a857613 aomin-gateway/src/main/java/cn/aomin/gateway/common/GlobalExceptionHandler.java +3fdb0e845a3b7d1b5bc025b5ef784b0b5635a514df522761973afc7c4a6226ed aomin-gateway/src/main/java/cn/aomin/gateway/config/GatewayProperties.java +615c60e33233c424764d4a1e4082375ca2244a836aa04619d7706033280efa76 aomin-gateway/src/main/java/cn/aomin/gateway/config/RedisConfig.java +879e6341ee07c5b933c5f3cda0dcf04196eea89189feacbdb048af5f723b2cfd aomin-gateway/src/main/java/cn/aomin/gateway/config/WebConfig.java +745e893fc2606ff4c4b694a9b9ca1a4f51e14076878adfb19e2a5863eca8923e aomin-gateway/src/main/java/cn/aomin/gateway/controller/CommandController.java +bf40814adbc2e5b708ac8bd521f4f4e28b58f18230b234ba539d0c72cc7584a9 aomin-gateway/src/main/java/cn/aomin/gateway/controller/HealthController.java +087b82f0f4b49bfbfadfab31b9354c5355e28acdca2d1354afd887432a01bf90 aomin-gateway/src/main/java/cn/aomin/gateway/controller/IngestController.java +bc872eef7be69cbab1b3b014efaf3c519aae4a37d9e071932b91f52673c2c94f aomin-gateway/src/main/java/cn/aomin/gateway/dao/CommandDao.java +9f495136f8a13420ce0746783873d437a875fd15d422e55816080c30f6d32dd0 aomin-gateway/src/main/java/cn/aomin/gateway/dao/DeviceDao.java +d9410152ba5940bb2b8c75d45466d94effdc0e3417ecfdbc4e913007a9778438 aomin-gateway/src/main/java/cn/aomin/gateway/dao/IotEventDao.java +e8140a24b1d0f037bdae70dc6e49b4481b9ce8ce60b24f9e46df13232bd9efcf aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawFrameDao.java +61fdbf0f9bca0e7d86e9f0cd37bece1c90412de0107faf21df8beee0a3c9be80 aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawRefGapDao.java +d81e3276b697cde3c024bcc64f5c8420f91cdeadc290247750cd0e2be883877e aomin-gateway/src/main/java/cn/aomin/gateway/dao/TechCacheDao.java +17e6215e0b9868369bfe28d623c9a18f619114df79590cddfc8b57590281e1cf aomin-gateway/src/main/java/cn/aomin/gateway/dao/UnregisteredDao.java +c1b4fd06e88dd1085396455396e3d68bfabfbacbfc59a71bed993270c3824a5e aomin-gateway/src/main/java/cn/aomin/gateway/domain/Channel.java +dc142f281fab1a31ae0c6040baef02e10ef2359d97809b95ceae5bd189ea45c2 aomin-gateway/src/main/java/cn/aomin/gateway/domain/DeviceRow.java +bccd9cfba21c63449734a1d1585ab02c0dd36257e5b238d409ddee6a1a4491f5 aomin-gateway/src/main/java/cn/aomin/gateway/domain/TimeResult.java +c3a4d760ab8596e58eeb8ca9ad08e55066c99b4867a24a003d8f63fab814b465 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/CommandResultRequest.java +17b9924b78175c83ef20c2f390b04106ffeb6bbf1770fddcb061b7cf62f01a72 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/EventBatchRequest.java +fad99f362b82adb7a4f06eace7583711295ffae4aa6d12e4b20965b0acc77f6b aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/IngestResponse.java +07ff87965099f00c9ba0f8adcf1a4649e7359d71d380e6b7350687cd35d1a3ab aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/PendingCommandResponse.java +ec23b4048a1a505e712c68f4e8bc941c3cb9d83168db18487930f8fdd457c4fd aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/RawFrameBatchRequest.java +954eb9c474eba1a75fa10df8310616a6892d80ff2f9ed5efbbc70890f7a9e391 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/StatusBatchRequest.java +ab1222707fdc4e8f5ed059fae8135cb676af0053987a29bf992eb915d1c7be99 aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/AuthInterceptor.java +b99a9bb97500b5b2e1096a3049375cd2348b8b478f1700878968410946c1fc2b aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/InternalNetworkGuard.java +368726ecbd86aec46a0c145ab47eb79eedddad3e7e3e16c35e2eade681aa67b7 aomin-gateway/src/main/java/cn/aomin/gateway/security/CachedBodyFilter.java +6efcd0422225e4d2835225847ded8c4382efa638307d866b8252e11882f71f3f aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotencyStore.java +474c3aea6bee182ed96fbac6eec478a1d3bc025e2923736a07bf45b59d0ce61b aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotentExecutor.java +8452ee990a69d223ae4d16d5b1dea152823d10fb2b59169190965ef8e9c9a33f aomin-gateway/src/main/java/cn/aomin/gateway/security/NonceStore.java +8cc16695275e6a69615b50c4a8d88a3e78302d528c8f4f795da1c276099c95de aomin-gateway/src/main/java/cn/aomin/gateway/security/RateLimiter.java +96e0c685a04299f854b7586383eee28c31007def56f692f426bedb258ab4554b aomin-gateway/src/main/java/cn/aomin/gateway/security/SignatureVerifier.java +c38d62b372c8aac7f4f9bb27d8c3d9652539bd441d68f35b4b2c718dec8b9a87 aomin-gateway/src/main/java/cn/aomin/gateway/service/CommandService.java +bfbdec7c215746a09bebfb8b2594d025b1db4c931bbff11c90a2b6c2689d16b0 aomin-gateway/src/main/java/cn/aomin/gateway/service/IngestService.java +66d22c25a1b2871c5802b686a2c47836264b15d3cde89d723b037865ca2ff380 aomin-gateway/src/main/java/cn/aomin/gateway/service/RawFrameService.java +2f9abbae9b5c1c227b2b7816845e2145f9169ac2098f64e0017076c55e56beb0 aomin-gateway/src/main/java/cn/aomin/gateway/service/StatusService.java +77387e73bb73e7c687da953a83851d9654c204171aa874254a72fe521f6178e7 aomin-gateway/src/main/java/cn/aomin/gateway/support/EventTimeResolver.java +e77267248d3aa7ecc9eab37ca039902c82e9f3879936ad742035c2993b8d25fd aomin-gateway/src/main/java/cn/aomin/gateway/support/EventValidator.java +f4172bca391f5b4ebb90bf74a11ae2c998ef763c2b2908cd9c8b13bc56f86150 aomin-gateway/src/main/java/cn/aomin/gateway/support/LinkTypeResolver.java +3912006f4c6432965daf2f590d229509363a1feaff9d7fb60df6f93e5d6c16c9 aomin-gateway/src/main/resources/application.yml +07e5263e3d755535a5fadaa18b8529d6244af975daa06faef9b3e908bcf0c7cc aomin-gateway/src/main/resources/db/migration/V1.0.0__baseline_schema.sql +8be5a9816f1f661d0a54c2a23c56079b76df88f47bd153609a94c659e42e7299 aomin-gateway/src/main/resources/db/migration/V1.0.18__raw_link_columns.sql +45e64f88796d46267b8f4f67f76d9ea5ec3ad34fbbe62736c4768216cbaf23a2 aomin-gateway/src/main/resources/db/migration/V1.0.19__household_geo.sql +cde7f6df804cdc1a827b0a109815c0af3a52d7efa4f35a170f262d8ce429418d aomin-gateway/src/main/resources/db/migration/V1.0.20__notify_read_at.sql +7751f42076e39874a85f147cf1ce5c45653161207cb06e6042d60f7888d90710 aomin-gateway/src/main/resources/db/migration/V1.0.21__alarm_chain_seq.sql +3306548d3c02f6750cfb93c535e5c9e98062710ed6d4d099f69e0fc7a7b8edc1 aomin-gateway/src/main/resources/db/migration/V1.0.22__alarm_source_event.sql +9c941b1675d5a82daef81d7f297d03b206a028deb9923b78cd92f473a9ca8272 aomin-gateway/src/main/resources/db/migration/V1.0.23__command_seven_states.sql +ec0b7e0f23f74f52bd373fc0fb7d84bacdda9be62c19e9b6163bf669da442af2 aomin-gateway/src/main/resources/db/migration/V1.0.24__region_scope.sql +e480a143b8a079971c285d63bf51252eecc2034054459f83e2104c877fa51d40 aomin-gateway/src/main/resources/db/migration/V1.0.25__raw_ref_64_and_gap.sql +61e8c5f2167de4588e8aa337c9d45dfa12015680d6b12d1d456b3f159770c29e aomin-gateway/src/main/resources/db/migration/V1.0.26__alarm_retry_deadletter.sql +778141cf45e9b3f62afc9bdbb3f7079794daedfba9f0a20caf84fe90eb3503dd aomin-gateway/src/main/resources/db/migration/V1.0.27__receive_time_local_and_audit_fail.sql +2d6e90a3e6a2db9771b7820df4dfc7e58c211627ac02271612e2511764e83286 aomin-gateway/src/main/resources/db/migration/V1.0.28__command_claim_node.sql +8f2bf8c6287bdf169546c664c2af98b8153412247c5c2e65b68570bfec412f54 aomin-gateway/start-gateway.bat +84ce77a2d36ce08f0807d9c5476af5aa9490daee70c5f9f233f7bf115a52ac89 gitignore_建议.txt diff --git a/docs/reference/SHA256SUMS_源码树.txt b/docs/reference/SHA256SUMS_源码树.txt new file mode 100644 index 0000000..19008a0 --- /dev/null +++ b/docs/reference/SHA256SUMS_源码树.txt @@ -0,0 +1,84 @@ +2c315fcbabd70d5282ae33ca00dceafb352d14253c6b3d33d9c2a3fc717dc1b6 aomin-gateway/src/main/java/cn/aomin/gateway/GatewayApplication.java +8fa464b5a4dc0c246b5585bbaabd7ad4fea5271d524eb6e9d4b7b91faf72c011 aomin-gateway/src/main/java/cn/aomin/gateway/biz/DemoDataInitializer.java +116a3b56bbb3b2b051ed239a5a4b82111c9c010b0a620efdbfe59a2528fcdc35 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AdminController.java +a7f7f7f3129a23d09d40f5589bb65c3cb7d933a8ceba8a929c20501b05b45432 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AlarmController.java +58fadae221f2763f2f51e267ddefdf8478661a15eb1583686205119c6bb9e434 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/AuthController.java +d27df1ed1695ffcc75b9c2e00fa1111088694ab4ddfc04af9d29aad8b9d2b0cb aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/DeviceController.java +95b432a2c7a84b0f834c21b721e4131a7c71edb30f42cbb49dc014182e0201f3 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/FamilyController.java +cb04548d6386555e7df9070e19ebe0b007c2e825646c685ecfa80e78b6204d7c aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyController.java +2c40eaf77abd7a73d19022c85faf8dc319ccff7948fbd8553b595c4653e3c324 aomin-gateway/src/main/java/cn/aomin/gateway/biz/controller/NotifyStreamController.java +84829224a845f88986683c4c87dd37b9b4dae4b27405e558b78b5e8fe34bbdbf aomin-gateway/src/main/java/cn/aomin/gateway/biz/dao/BizDao.java +f44f3cc9af74c6639241633aa30039e1baa22fad40487c05c5b3a8f172ba93bf aomin-gateway/src/main/java/cn/aomin/gateway/biz/dto/Dtos.java +e936b9acf37ef2f4d61840665329fc503df72d646480cc8b5a6fd438d1cd574b aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmEngine.java +2de186fdede0cd58e2d2f54d08616436a760557b9e9f53a13518d69cf7359954 aomin-gateway/src/main/java/cn/aomin/gateway/biz/engine/AlarmTrigger.java +26e417d5d41adf35d50d0917f2e0a17ac6f7d688bc18e159c4080ea3ba63f872 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AdminService.java +30b0f7d7ce7a753e24b78e252743e9524d7e11f5ec846655e72b7964f702bc33 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AlarmService.java +d296c463aa6416cc7a4df87c0e821f3989b4cc9bd5b9e3d06e06f5e3fc549894 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/AuthService.java +4bcf644491cd6d0b924fd2f42e07b6583ad9499d06a8beed7d28bc9c89fc768e aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/BizDeviceService.java +84fb58da94f2a9395e372746b850ccf6f03b8f3c71bd1be1dc2400898a62989b aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/FamilyService.java +f7f6bd7e73741b2bbc774e8e05f91e344f00ba893727209fdfb22cf908201448 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/NotifyService.java +18ba93498aad6ffab1680e6cb780eb934dbd85f29625b2dd688beb362894d970 aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/PiiService.java +18191289464412e7b1408662421b83291f1a95378a868c5f9892489964652a5b aomin-gateway/src/main/java/cn/aomin/gateway/biz/service/SmsGateway.java +5d4d8c17959734e7c969c681369850be76fee08f0cc50910d5344f2832f9c65e aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AdminScope.java +00378b6a55265fb2adce15480faa80a20e85bfc7268efd074467427f607df560 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuditGuard.java +88b74c167b8e7b3f594d82a501072eb2dc736e07ee52e9e5ba87e05f3a5d03e2 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/AuthContext.java +e77c7b1cad576b5ee23ee05acb27f62af6a5d1cb80fbc955ce6c25db62a668cb aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/BizAuthInterceptor.java +fc12b2e26e2e39012479df1f4a22a2a4605ffe67d1793edbe01baef0aa2ac7b5 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/Crypto.java +65994b8ccfba75de35fe6a35f1632cf7d3a3a5e3d9412018facd6c2ecd66126e aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PageResult.java +e255bc089e83bd35fdce6d5d5521bd0f05c78c84919775459fb0a2f4ba26f88a aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/PartitionMaintainer.java +ecc3e56a10b9e02a828a9c54314b9724f107f11506a354dd842c783b2ab69be8 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SseHub.java +46e4c0987cfa35f2067ccb1951dbd9f04321dfb01d22f9392242521d8d35e1d7 aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/SuppressGate.java +b068a6413895de569ec33d9b967c5aca13818258abc5747adf16d5b25adcf31f aomin-gateway/src/main/java/cn/aomin/gateway/biz/support/TokenService.java +5d3b73cb52245ea2fcfe301fe6d794350d7f0bebd32851627eef5c3a8507ea42 aomin-gateway/src/main/java/cn/aomin/gateway/common/ApiResponse.java +6ef484310f27e7b951a490d7ae32786fd27e9d1fe14e38487b0451c62fbaf4a3 aomin-gateway/src/main/java/cn/aomin/gateway/common/BizException.java +3aa57442b289ed4f7a27120fbb69153dc670a94efbcccf361860e7e02a857613 aomin-gateway/src/main/java/cn/aomin/gateway/common/GlobalExceptionHandler.java +3fdb0e845a3b7d1b5bc025b5ef784b0b5635a514df522761973afc7c4a6226ed aomin-gateway/src/main/java/cn/aomin/gateway/config/GatewayProperties.java +615c60e33233c424764d4a1e4082375ca2244a836aa04619d7706033280efa76 aomin-gateway/src/main/java/cn/aomin/gateway/config/RedisConfig.java +879e6341ee07c5b933c5f3cda0dcf04196eea89189feacbdb048af5f723b2cfd aomin-gateway/src/main/java/cn/aomin/gateway/config/WebConfig.java +745e893fc2606ff4c4b694a9b9ca1a4f51e14076878adfb19e2a5863eca8923e aomin-gateway/src/main/java/cn/aomin/gateway/controller/CommandController.java +bf40814adbc2e5b708ac8bd521f4f4e28b58f18230b234ba539d0c72cc7584a9 aomin-gateway/src/main/java/cn/aomin/gateway/controller/HealthController.java +087b82f0f4b49bfbfadfab31b9354c5355e28acdca2d1354afd887432a01bf90 aomin-gateway/src/main/java/cn/aomin/gateway/controller/IngestController.java +bc872eef7be69cbab1b3b014efaf3c519aae4a37d9e071932b91f52673c2c94f aomin-gateway/src/main/java/cn/aomin/gateway/dao/CommandDao.java +9f495136f8a13420ce0746783873d437a875fd15d422e55816080c30f6d32dd0 aomin-gateway/src/main/java/cn/aomin/gateway/dao/DeviceDao.java +d9410152ba5940bb2b8c75d45466d94effdc0e3417ecfdbc4e913007a9778438 aomin-gateway/src/main/java/cn/aomin/gateway/dao/IotEventDao.java +e8140a24b1d0f037bdae70dc6e49b4481b9ce8ce60b24f9e46df13232bd9efcf aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawFrameDao.java +61fdbf0f9bca0e7d86e9f0cd37bece1c90412de0107faf21df8beee0a3c9be80 aomin-gateway/src/main/java/cn/aomin/gateway/dao/RawRefGapDao.java +d81e3276b697cde3c024bcc64f5c8420f91cdeadc290247750cd0e2be883877e aomin-gateway/src/main/java/cn/aomin/gateway/dao/TechCacheDao.java +17e6215e0b9868369bfe28d623c9a18f619114df79590cddfc8b57590281e1cf aomin-gateway/src/main/java/cn/aomin/gateway/dao/UnregisteredDao.java +c1b4fd06e88dd1085396455396e3d68bfabfbacbfc59a71bed993270c3824a5e aomin-gateway/src/main/java/cn/aomin/gateway/domain/Channel.java +dc142f281fab1a31ae0c6040baef02e10ef2359d97809b95ceae5bd189ea45c2 aomin-gateway/src/main/java/cn/aomin/gateway/domain/DeviceRow.java +bccd9cfba21c63449734a1d1585ab02c0dd36257e5b238d409ddee6a1a4491f5 aomin-gateway/src/main/java/cn/aomin/gateway/domain/TimeResult.java +c3a4d760ab8596e58eeb8ca9ad08e55066c99b4867a24a003d8f63fab814b465 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/CommandResultRequest.java +17b9924b78175c83ef20c2f390b04106ffeb6bbf1770fddcb061b7cf62f01a72 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/EventBatchRequest.java +fad99f362b82adb7a4f06eace7583711295ffae4aa6d12e4b20965b0acc77f6b aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/IngestResponse.java +07ff87965099f00c9ba0f8adcf1a4649e7359d71d380e6b7350687cd35d1a3ab aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/PendingCommandResponse.java +ec23b4048a1a505e712c68f4e8bc941c3cb9d83168db18487930f8fdd457c4fd aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/RawFrameBatchRequest.java +954eb9c474eba1a75fa10df8310616a6892d80ff2f9ed5efbbc70890f7a9e391 aomin-gateway/src/main/java/cn/aomin/gateway/domain/dto/StatusBatchRequest.java +ab1222707fdc4e8f5ed059fae8135cb676af0053987a29bf992eb915d1c7be99 aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/AuthInterceptor.java +b99a9bb97500b5b2e1096a3049375cd2348b8b478f1700878968410946c1fc2b aomin-gateway/src/main/java/cn/aomin/gateway/interceptor/InternalNetworkGuard.java +368726ecbd86aec46a0c145ab47eb79eedddad3e7e3e16c35e2eade681aa67b7 aomin-gateway/src/main/java/cn/aomin/gateway/security/CachedBodyFilter.java +6efcd0422225e4d2835225847ded8c4382efa638307d866b8252e11882f71f3f aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotencyStore.java +474c3aea6bee182ed96fbac6eec478a1d3bc025e2923736a07bf45b59d0ce61b aomin-gateway/src/main/java/cn/aomin/gateway/security/IdempotentExecutor.java +8452ee990a69d223ae4d16d5b1dea152823d10fb2b59169190965ef8e9c9a33f aomin-gateway/src/main/java/cn/aomin/gateway/security/NonceStore.java +8cc16695275e6a69615b50c4a8d88a3e78302d528c8f4f795da1c276099c95de aomin-gateway/src/main/java/cn/aomin/gateway/security/RateLimiter.java +96e0c685a04299f854b7586383eee28c31007def56f692f426bedb258ab4554b aomin-gateway/src/main/java/cn/aomin/gateway/security/SignatureVerifier.java +c38d62b372c8aac7f4f9bb27d8c3d9652539bd441d68f35b4b2c718dec8b9a87 aomin-gateway/src/main/java/cn/aomin/gateway/service/CommandService.java +bfbdec7c215746a09bebfb8b2594d025b1db4c931bbff11c90a2b6c2689d16b0 aomin-gateway/src/main/java/cn/aomin/gateway/service/IngestService.java +66d22c25a1b2871c5802b686a2c47836264b15d3cde89d723b037865ca2ff380 aomin-gateway/src/main/java/cn/aomin/gateway/service/RawFrameService.java +2f9abbae9b5c1c227b2b7816845e2145f9169ac2098f64e0017076c55e56beb0 aomin-gateway/src/main/java/cn/aomin/gateway/service/StatusService.java +77387e73bb73e7c687da953a83851d9654c204171aa874254a72fe521f6178e7 aomin-gateway/src/main/java/cn/aomin/gateway/support/EventTimeResolver.java +e77267248d3aa7ecc9eab37ca039902c82e9f3879936ad742035c2993b8d25fd aomin-gateway/src/main/java/cn/aomin/gateway/support/EventValidator.java +f4172bca391f5b4ebb90bf74a11ae2c998ef763c2b2908cd9c8b13bc56f86150 aomin-gateway/src/main/java/cn/aomin/gateway/support/LinkTypeResolver.java +3912006f4c6432965daf2f590d229509363a1feaff9d7fb60df6f93e5d6c16c9 aomin-gateway/src/main/resources/application.yml +07e5263e3d755535a5fadaa18b8529d6244af975daa06faef9b3e908bcf0c7cc aomin-gateway/src/main/resources/db/migration/V1.0.0__baseline_schema.sql +8be5a9816f1f661d0a54c2a23c56079b76df88f47bd153609a94c659e42e7299 aomin-gateway/src/main/resources/db/migration/V1.0.18__raw_link_columns.sql +45e64f88796d46267b8f4f67f76d9ea5ec3ad34fbbe62736c4768216cbaf23a2 aomin-gateway/src/main/resources/db/migration/V1.0.19__household_geo.sql +cde7f6df804cdc1a827b0a109815c0af3a52d7efa4f35a170f262d8ce429418d aomin-gateway/src/main/resources/db/migration/V1.0.20__notify_read_at.sql +7751f42076e39874a85f147cf1ce5c45653161207cb06e6042d60f7888d90710 aomin-gateway/src/main/resources/db/migration/V1.0.21__alarm_chain_seq.sql +3306548d3c02f6750cfb93c535e5c9e98062710ed6d4d099f69e0fc7a7b8edc1 aomin-gateway/src/main/resources/db/migration/V1.0.22__alarm_source_event.sql +9c941b1675d5a82daef81d7f297d03b206a028deb9923b78cd92f473a9ca8272 aomin-gateway/src/main/resources/db/migration/V1.0.23__command_seven_states.sql +ec0b7e0f23f74f52bd373fc0fb7d84bacdda9be62c19e9b6163bf669da442af2 aomin-gateway/src/main/resources/db/migration/V1.0.24__region_scope.sql +e480a143b8a079971c285d63bf51252eecc2034054459f83e2104c877fa51d40 aomin-gateway/src/main/resources/db/migration/V1.0.25__raw_ref_64_and_gap.sql +61e8c5f2167de4588e8aa337c9d45dfa12015680d6b12d1d456b3f159770c29e aomin-gateway/src/main/resources/db/migration/V1.0.26__alarm_retry_deadletter.sql +778141cf45e9b3f62afc9bdbb3f7079794daedfba9f0a20caf84fe90eb3503dd aomin-gateway/src/main/resources/db/migration/V1.0.27__receive_time_local_and_audit_fail.sql +2d6e90a3e6a2db9771b7820df4dfc7e58c211627ac02271612e2511764e83286 aomin-gateway/src/main/resources/db/migration/V1.0.28__command_claim_node.sql diff --git a/docs/reference/原始交付包指纹.md b/docs/reference/原始交付包指纹.md new file mode 100644 index 0000000..96b1677 --- /dev/null +++ b/docs/reference/原始交付包指纹.md @@ -0,0 +1,89 @@ +# 原始交付包指纹 + +本文件记录周工交付的 `SEC-Z-01 清理后` 源码包的指纹,用途只有一个:**证明仓库里这份代码与交付确认过的那份是同一份**。 + +## 一、两个 SHA256SUMS 的作用范围(重要) + +`docs/reference/` 下的两个清单是交付当时生成的,路径以**原交付包根**为基准。 + +| 清单 | 覆盖 | 现在还能直接校验吗 | +|---|---|---| +| `SHA256SUMS_原始交付包.txt` | 原包全部 108 个条目 | **不能**。仓库目录已重排(`02_前端与设备端/` → `demo/` 等),路径对不上 | +| `SHA256SUMS_前端与设备端.txt` | 原包内演示前端 8 个文件 | **不能**,同上 | + +保留它们不是为了"跑一下 `sha256sum -c`",而是为了在需要追溯时能回答:**交付当天每个文件的哈希是多少**。路径变了,文件内容没变——逐文件比对哈希即可证明。 + +## 二、版本标识(与 `handover/03` 第五节一致) + +| 标识 | 值 | +|---|---| +| 模型版本 | V1.9(已冻结;本次仅 SEC-Z-01 最小安全清理,未升 V1.10) | +| 内部契约 | J-00 1.8.1 | +| 迁移序列 | Flyway 1.0.28,12 条 | +| 源码树摘要 | `5E9F894B0D1071969A968F2A9B1451279ADF39D423148186886BFD064398F286` | +| 可执行 jar | `1F38F527605F09AA45E9E54528AFB11230469E1B197FA001C06E8725FD102192`(43,517,256 字节) | +| 证据批次 | `R20261113-112443`,11 组 / 155 条 / 失败 0 | + +**源码树摘要**的描述(`handover/03` 第一节)是:把 `src` 下每个文件的「相对路径 + 该文件 SHA-256」按路径排序后拼接,再整体算一次 SHA-256。它等效于"这一棵树"的指纹,**不是** Git commit 哈希。 + +但这段描述**不足以复现该值**——分隔符、哈希大小写、路径是否带前缀都没写。本仓库按 60 余种组合(分隔符 `""`/`\n`/`\r\n`/空格/`|`/`:`/`\t`,大小写两种,路径前缀 `""`/`src/`/`01_后端源码/src/`,拼接顺序两种,末尾换行两种)逐一复算,**均未得到 `5E9F894B…`**。 + +结论:这个值只能作为"交付方声称的标识"引用,不能作为可校验凭据。可校验的替代物是 `SHA256SUMS_源码树.txt`(84 行,`sha256sum -c` 直接可用),见第四节。 + +## 三、12 条迁移脚本指纹 + +原包的 `03_数据库与Flyway迁移/` 与工程内 `aomin-gateway/src/main/resources/db/migration/` 是同一套文件的两次出现。仓库只保留工程内一套,交付时已核为**逐字节一致**。下表可直接用于复核。 + +| # | 迁移脚本 | 字节 | SHA-256 | +|---|---|---|---| +| 1 | `V1.0.0__baseline_schema.sql` | 101,859 | `07E5263E3D755535A5FADAA18B8529D6244AF975DAA06FAEF9B3E908BCF0C7CC` | +| 2 | `V1.0.18__raw_link_columns.sql` | 5,134 | `8BE5A9816F1F661D0A54C2A23C56079B76DF88F47BD153609A94C659E42E7299` | +| 3 | `V1.0.19__household_geo.sql` | 1,211 | `45E64F88796D46267B8F4F67F76D9EA5EC3AD34FBBE62736C4768216CBAF23A2` | +| 4 | `V1.0.20__notify_read_at.sql` | 1,140 | `CDE7F6DF804CDC1A827B0A109815C0AF3A52D7EFA4F35A170F262D8CE429418D` | +| 5 | `V1.0.21__alarm_chain_seq.sql` | 1,803 | `7751F42076E39874A85F147CF1CE5C45653161207CB06E6042D60F7888D90710` | +| 6 | `V1.0.22__alarm_source_event.sql` | 656 | `3306548D3C02F6750CFB93C535E5C9E98062710ED6D4D099F69E0FC7A7B8EDC1` | +| 7 | `V1.0.23__command_seven_states.sql` | 2,678 | `9C941B1675D5A82DAEF81D7F297D03B206A028DEB9923B78CD92F473A9CA8272` | +| 8 | `V1.0.24__region_scope.sql` | 2,099 | `EC0B7E0F23F74F52BD373FC0FB7D84BACDDA9BE62C19E9B6163BF669DA442AF2` | +| 9 | `V1.0.25__raw_ref_64_and_gap.sql` | 3,951 | `E480A143B8A079971C285D63BF51252EECC2034054459F83E2104C877FA51D40` | +| 10 | `V1.0.26__alarm_retry_deadletter.sql` | 3,793 | `61E8C5F2167DE4588E8AA337C9D45DFA12015680D6B12D1D456B3F159770C29E` | +| 11 | `V1.0.27__receive_time_local_and_audit_fail.sql` | 2,452 | `778141CF45E9B3F62AFC9BDBB3F7079794DAEDFBA9F0A20CAF84FE90EB3503DD` | +| 12 | `V1.0.28__command_claim_node.sql` | 1,989 | `2D6E90A3E6A2DB9771B7820DF4DFC7E58C211627AC02271612E2511764E83286` | + +**这 12 条的哈希只用于确认"与交付时相同"。** 迁移脚本一旦被修改,即使只改注释,Flyway 也会报校验和不符;那时要做的是 `flyway repair` 加会签记录,不是回头改这张表。 + +## 四、怎么复核 + +### 单条脚本 + +```bash +cd aomin-gateway/src/main/resources/db/migration +sha256sum V1.0.28__command_claim_node.sql +# 与上表第 12 行比对,应完全相同 +``` + +### 全部 12 条 + +```bash +cd aomin-gateway/src/main/resources/db/migration +sha256sum V1.0.*.sql | sort -k2 +``` + +### 源码树(84 个文件) + +```bash +# 在仓库根执行 +sha256sum -c docs/reference/SHA256SUMS_源码树.txt +# 84 行全部 "OK" 即为一致 +``` + +`SHA256SUMS_源码树.txt` 覆盖 `aomin-gateway/src` 下全部 84 个文件,格式是标准 `sha256sum` 清单(哈希 + 相对仓库根的路径),生成于本次导入、自校验 84/84 通过。 + +这就是"逐字节一致"的可校验形式:交付包的 `01_后端源码/src` 与本仓库 `aomin-gateway/src` 只差一层目录名,逐个文件比对即可。行尾差异(CRLF/LF)会直接改变哈希,所以比对不上时先查行尾——`.gitattributes` 已设为 `* -text`,不会自动转换。 + +## 五、这张表什么时候需要更新 + +| 情况 | 处理 | +|---|---| +| 新增迁移脚本 `V1.0.29` | 追加一行;不修改已有 12 行 | +| 契约版本升级(J-00 1.8.1 → 更高) | 更新第二节的契约版本与 `03` 的对应项 | +| 代码发生实质变更 | 源码树摘要与 jar 哈希随之失效,**应记入 `CHANGELOG.md` 而不是改这张表**——这张表描述的是交付那一刻 |