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),不做归一化以免整树摘要失效。
This commit is contained in:
aomin committed 2026-11-13 17:32:37 +08:00
commit a7605ef8b8
121 files changed
+23629

No files matched your search

+28
View File
@@ -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
+31
View File
@@ -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
+62
View File
@@ -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
+56
View File
@@ -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 与真机未覆盖。
+142
View File
@@ -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.<n>__<英文小写下划线>.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' # 有没有硬编码凭据
```
第三条命中不一定是问题(变量名也会命中),但每一条都要能说清为什么不是问题。
+22
View File
@@ -0,0 +1,22 @@
版权所有 © 2026 奥敏 保留所有权利
本仓库及其全部内容(包括但不限于源代码、数据库迁移脚本、接口契约、设计文档、演示素材与设备模拟器)为奥敏的专有财产与商业秘密。
一、权利保留
本文件不构成任何形式的许可、授权或权利转让。未取得奥敏的书面许可,任何人不得复制、修改、翻译、汇编、反向工程、分发、公开传播、再许可,或将本仓库内容用于鹤仆·守望项目之外的任何用途。
二、保密
本仓库内容按"内部"密级管理,含服务器地址、账号信息与接口设计。访问者因工作需要获得的访问权限不构成对外披露的授权;访问权限不得转让、不得共享账号、不得将内容复制到公开平台或公共 AI 服务。
三、第三方组件
本仓库引用的第三方库、框架与工具(如 Spring Boot、MyBatis-Plus、Flyway、MySQL 驱动等)遵循其各自的许可证,不在本声明范围内。第三方组件清单见 `aomin-gateway/pom.xml`。
四、无担保
本仓库内容按"现状"提供,不附带任何明示或默示的担保,包括但不限于适销性、特定用途适用性与不侵权的担保。
五、终止
违反上述任一条款即自动终止访问权限,已获得的副本应予销毁并书面告知。
---
*正式授权文本以奥敏出具的书面文件为准;本文件为仓库内的权利声明,替代开源许可证使用。*
+122
View File
@@ -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 <settings.xml>)
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 主干。*
+80
View File
@@ -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 时补做一次扫描 | 提交人 | 按第三节顺序处置 |
---
*仓库托管侧的安全事项(访问控制、证书、备份)不在本文件范围。*
+210
View File
@@ -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`。
+103
View File
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>cn.aomin</groupId>
<artifactId>aomin-gateway</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>aomin-gateway</name>
<description>鹤仆·守望 业务层接入网关(M 域)</description>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
</properties>
<dependencies>
<!-- Web 容器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 参数校验 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Redis(幂等键、nonce 防重放) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 连接池 lettuce 依赖 commons-pool2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<!-- 健康检查端点 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- MyBatis-Plus(Spring Boot 3 专用 starter) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL 驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Flyway:MySQL 8 需要额外的 flyway-mysql 模块 -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>
</build>
</project>
@@ -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 域)启动类。
*
* <p>承载 J-00 接入接口规格中 /internal/iot/v1/ 下五个接口:
* M-01 公共拦截层、M-02 事件批量接收、M-03 状态快照、M-04 原文接收、M-05 命令拉取。
*
* <p>数据访问全部走 {@code JdbcTemplate},<b>没有 MyBatis Mapper</b>,
* 所以不需要 {@code @MapperScan}。pom 里保留 mybatis-plus 只是为后续业务模块留口子,
* 它不会自动扫描任何东西。
*
* <p>{@code @EnableScheduling} 是必须的,有两个定时任务靠它:
* 命令长轮询的 500ms 扫描(M-05),以及租约回收/72h 硬顶的 10s 维护(M-05)。
*
* <p>{@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)。
*
* <p>{@code application.yml} 里写的是 {@code password: ${DB_PASS}},不设默认值。
* 但 Spring Boot 的宽松绑定在占位符解析不到时是"忽略"而不是报错,结果会拿空口令去连库,
* 报出来的是一句 {@code Access denied}——看着像口令错了,实际是根本没配。
* 所以在这里提前拦住,给一句能照着做的提示,而不是让人去猜。
*
* <p>只校验"有没有",不校验"对不对",也不打印任何取值。
*/
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);
}
}
}
@@ -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;
/**
* 演示数据初始化。
*
* <p><b>只在 {@code sys_user} 为空时执行一次</b>,跑过就不再动——
* 所以它既能保证新环境一启动就能登录,又不会反复覆盖你改过的数据。
*
* <p>造的东西:3 个角色、1 个后台账号、1 个家属账号、若干字典项,
* 以及一栋楼 + 两户 + 一位老人的最小闭环(并挂到已有的 9001/9002 户上)。
*
* <p><b>上线前删掉这个类</b>,或者在配置里把 {@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<String, Object> 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++);
}
}
/**
* 两条演示告警,让告警列表和角标不是空的。
* <b>只动 alarm 表</b>,不碰 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
@@ -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;
/**
* 后台与物业:小区 / 楼栋 / 单元 / 户 / 老人建档 / 账号 / 字典 / 工单 / 审计。
*
* <p>这一组只给 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<List<Map<String, Object>>> communities(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(admin.communities(page, size));
}
@PostMapping("/communities")
public ApiResponse<Map<String, Object>> createCommunity(@RequestBody Dtos.CommunityCreate req) {
return ApiResponse.ok(Map.of("id", admin.createCommunity(req)));
}
// ---------------------------------------------------------------- 楼栋 / 单元
@GetMapping("/buildings")
public ApiResponse<List<Map<String, Object>>> buildings(
@RequestParam(required = false) Long communityId) {
return ApiResponse.ok(admin.buildings(communityId));
}
@PostMapping("/buildings")
public ApiResponse<Map<String, Object>> createBuilding(@RequestBody Dtos.BuildingCreate req) {
return ApiResponse.ok(Map.of("id", admin.createBuilding(req)));
}
@GetMapping("/units")
public ApiResponse<List<Map<String, Object>>> units(
@RequestParam(required = false) Long buildingId) {
return ApiResponse.ok(admin.units(buildingId));
}
@PostMapping("/units")
public ApiResponse<Map<String, Object>> createUnit(@RequestBody Dtos.UnitCreate req) {
return ApiResponse.ok(Map.of("id", admin.createUnit(req)));
}
// ---------------------------------------------------------------- 户 / 老人
@GetMapping("/households")
public ApiResponse<List<Map<String, Object>>> 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<Map<String, Object>> createHousehold(@RequestBody Dtos.HouseholdCreate req) {
return ApiResponse.ok(Map.of("id", admin.createHousehold(req)));
}
@PostMapping("/elders")
public ApiResponse<Map<String, Object>> createElder(@RequestBody Dtos.ElderCreate req) {
return ApiResponse.ok(Map.of("id", admin.createElder(req)));
}
// ---------------------------------------------------------------- 账号
@GetMapping("/users")
public ApiResponse<List<Map<String, Object>>> users(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(admin.sysUsers(page, size));
}
@PostMapping("/users")
public ApiResponse<Map<String, Object>> createUser(@RequestBody Dtos.SysUserCreate req) {
return ApiResponse.ok(Map.of("id", admin.createSysUser(req)));
}
@GetMapping("/roles")
public ApiResponse<List<Map<String, Object>>> roles() {
return ApiResponse.ok(admin.roles());
}
// ---------------------------------------------------------------- 数据范围(按区域)
/** 范围配置的下拉选项:库里已有的省市区树 + 小区 + 楼栋。 */
@GetMapping("/scope/options")
public ApiResponse<Map<String, Object>> scopeOptions() {
return ApiResponse.ok(admin.scopeOptions());
}
/** 某个账号当前配的范围。 */
@GetMapping("/users/{id}/scopes")
public ApiResponse<List<Map<String, Object>>> userScopes(@PathVariable long id) {
return ApiResponse.ok(admin.userScopes(id));
}
/** 覆盖保存某个账号的范围。只有运营账号能调。 */
@PutMapping("/users/{id}/scopes")
public ApiResponse<Map<String, Object>> saveUserScopes(@PathVariable long id,
@RequestBody Dtos.ScopeSave req) {
return ApiResponse.ok(Map.of("saved", admin.saveUserScopes(id, req)));
}
// ---------------------------------------------------------------- 字典
@GetMapping("/dicts")
public ApiResponse<List<Map<String, Object>>> dicts() {
return ApiResponse.ok(admin.dicts());
}
@GetMapping("/dicts/{type}")
public ApiResponse<Object> dictItems(@PathVariable String type) {
return ApiResponse.ok(admin.dictItems(type));
}
// ---------------------------------------------------------------- 工单
@GetMapping("/work-orders")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> createWorkOrder(@RequestBody Dtos.WorkOrderCreate req) {
return ApiResponse.ok(Map.of("id", admin.createWorkOrder(req)));
}
// ---------------------------------------------------------------- 设备入库 / 野设备
/** 出厂录入。家属能不能绑,全看这里有没有先录。 */
@PostMapping("/devices/register")
public ApiResponse<Map<String, Object>> registerDevice(@RequestBody Dtos.DeviceRegister req) {
return ApiResponse.ok(admin.registerDevice(req));
}
/** 野设备:通电联网了、库里没有,先在这儿等着运营认领。 */
@GetMapping("/devices/unregistered")
public ApiResponse<List<Map<String, Object>>> unregisteredDevices() {
return ApiResponse.ok(admin.unregisteredDevices());
}
/** 野设备转正:补一个类型,进库等家属来绑。 */
@PostMapping("/devices/unregistered/adopt")
public ApiResponse<Map<String, Object>> adoptUnregistered(@RequestBody Dtos.UnregAdopt req) {
return ApiResponse.ok(admin.adoptUnregistered(req));
}
// ---------------------------------------------------------------- 告警死信
/**
* 规则连续处理失败、已放弃自动重试的事件。
*
* <p>只列未回放的。要看历史把它改成 {@code ?onlyOpen=false} 也行,
* 但运维日常只关心"现在还有几条没人管"。
*/
@GetMapping("/dead-letters")
public ApiResponse<List<Map<String, Object>>> 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<Map<String, Object>> replayDeadLetter(@PathVariable long id) {
return ApiResponse.ok(admin.replayDeadLetter(id));
}
// ---------------------------------------------------------------- 审计
@GetMapping("/audit-logs")
public ApiResponse<Map<String, Object>> auditLogs(
@RequestParam(required = false) String bizType,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(admin.auditLogs(bizType, page, size));
}
/**
* 审计写失败、待补录的清单。
*
* <p>{@code /health} 里的 {@code auditFailures} 就是这张表的条数——
* 非 0 意味着"有些操作查不到是谁做的",补录之前一直是待办。
*/
@GetMapping("/audit-failures")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> replayAuditFailure(@PathVariable long id) {
return ApiResponse.ok(admin.replayAuditFailure(id));
}
}
@@ -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<Map<String, Object>> 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));
}
/**
* 告警链:这一组「同一次风险」连续触发的全部记录。
*
* <p>前端点开详情时用它把「最初几点开始的」和「现在这次」并排显示,
* 免得家属只看到最新时间,误以为风险是刚发生的。
*/
@GetMapping("/{id}/chain")
public ApiResponse<Map<String, Object>> chain(@PathVariable long id) {
return ApiResponse.ok(alarms.chain(id));
}
/** 首页角标:未关闭数、今日新增、按级别分布。 */
@GetMapping("/stats")
public ApiResponse<Map<String, Object>> stats(@RequestParam(required = false) Long householdId) {
return ApiResponse.ok(alarms.stats(householdId));
}
@GetMapping("/{id}")
public ApiResponse<Map<String, Object>> detail(@PathVariable long id) {
return ApiResponse.ok(alarms.detail(id));
}
/** 弹窗已读:只记回执,不改告警状态。 */
@PostMapping("/{id}/ack")
public ApiResponse<Map<String, Object>> ack(@PathVariable long id,
@RequestParam(required = false) String ackType) {
return ApiResponse.ok(alarms.ack(id, ackType));
}
/** 确认:有人接了。 */
@PostMapping("/{id}/confirm")
public ApiResponse<Map<String, Object>> confirm(@PathVariable long id,
@RequestBody(required = false) Dtos.AlarmAction req) {
return ApiResponse.ok(alarms.confirm(id, req));
}
/** 处置:处理完了,但还没关。 */
@PostMapping("/{id}/handle")
public ApiResponse<Map<String, Object>> handle(@PathVariable long id,
@RequestBody(required = false) Dtos.AlarmAction req) {
return ApiResponse.ok(alarms.handle(id, req));
}
/** 关闭:终态,必须有人。 */
@PostMapping("/{id}/close")
public ApiResponse<Map<String, Object>> close(@PathVariable long id,
@RequestBody(required = false) Dtos.AlarmAction req) {
return ApiResponse.ok(alarms.close(id, req));
}
/** 误报:单独终态 FALSE,不占用 CLOSED。 */
@PostMapping("/{id}/false")
public ApiResponse<Map<String, Object>> markFalse(@PathVariable long id,
@RequestBody(required = false) Dtos.AlarmAction req) {
return ApiResponse.ok(alarms.markFalse(id, req));
}
@GetMapping("/{id}/actions")
public ApiResponse<List<Map<String, Object>>> actions(@PathVariable long id) {
return ApiResponse.ok(alarms.actions(id));
}
}
@@ -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<Map<String, Object>> wxLogin(@RequestBody Dtos.WxLogin req) {
return ApiResponse.ok(auth.wxLogin(req));
}
/** 发短信验证码。开发态响应里带 code,上线把 biz.sms.echo 关掉。 */
@PostMapping("/wx/sms")
public ApiResponse<Map<String, Object>> sms(@RequestBody Dtos.SmsReq req) {
return ApiResponse.ok(auth.sendSms(req));
}
/** 手机号 + 密码 + 短信码注册(openid 必须先拿到)。 */
@PostMapping("/wx/register")
public ApiResponse<Map<String, Object>> register(@RequestBody Dtos.WxRegister req) {
return ApiResponse.ok(auth.register(req));
}
/** 手机号 + 密码登录。 */
@PostMapping("/wx/password-login")
public ApiResponse<Map<String, Object>> pwdLogin(@RequestBody Dtos.PwdLogin req) {
return ApiResponse.ok(auth.passwordLogin(req));
}
/** 后台登录(运营 / 物业)。没有注册接口,账号只能由运营建。 */
@PostMapping("/console/login")
public ApiResponse<Map<String, Object>> consoleLogin(@RequestBody Dtos.ConsoleLogin req) {
return ApiResponse.ok(auth.consoleLogin(req));
}
/** 当前登录人 + 名下户 / 角色。 */
@GetMapping("/me")
public ApiResponse<Map<String, Object>> me() {
return ApiResponse.ok(auth.me());
}
}
@@ -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<Map<String, Object>>> 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<Map<String, Object>> detail(@PathVariable String devId) {
return ApiResponse.ok(devices.detail(devId));
}
@PutMapping("/{devId}")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> bindBySn(@RequestBody Dtos.DeviceBindBySn req) {
return ApiResponse.ok(devices.bindBySn(req));
}
/**
* 按 SN 预查:家属填完编号,页面直接把类型显示出来。
*
* <p>放在 /devices 下是因为它就是"这台机器是什么",不走后台权限。
* 只回类型和能不能绑,不回户归属。
*/
@GetMapping("/by-sn/{sn}")
public ApiResponse<Map<String, Object>> bySn(@PathVariable String sn) {
return ApiResponse.ok(devices.lookupBySn(sn));
}
@PostMapping("/{devId}/bind")
public ApiResponse<Map<String, Object>> bind(@PathVariable String devId,
@RequestBody Dtos.DeviceBind req) {
return ApiResponse.ok(devices.bind(devId, req));
}
@PostMapping("/{devId}/unbind")
public ApiResponse<Map<String, Object>> unbind(@PathVariable String devId) {
return ApiResponse.ok(devices.unbind(devId));
}
/** 设备事件流(分页)。时间用 event_time,不是接收时间。 */
@GetMapping("/{devId}/events")
public ApiResponse<Map<String, Object>> 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<Map<String, Object>> command(@PathVariable String devId,
@RequestBody Dtos.CommandReq req) {
return ApiResponse.ok(devices.sendCommand(devId, req));
}
@GetMapping("/{devId}/commands")
public ApiResponse<List<Map<String, Object>>> commands(@PathVariable String devId) {
return ApiResponse.ok(devices.commands(devId));
}
}
@@ -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<List<Map<String, Object>>> households() {
return ApiResponse.ok(family.myHouseholds());
}
/** 家属自助建档:地址 + 地图选点/GPS 坐标,建完自动把当前微信用户设为户主。 */
@PostMapping("/households")
public ApiResponse<Map<String, Object>> createHousehold(@RequestBody Dtos.HouseholdSelfCreate req) {
return ApiResponse.ok(family.createHousehold(req));
}
/** 家属端自助填写老人信息。 */
@PostMapping("/elders")
public ApiResponse<Map<String, Object>> createElder(@RequestBody Dtos.ElderSelfCreate req) {
return ApiResponse.ok(family.createElder(req));
}
/** 按小区名 / 房号找户(只回地址,申请阶段不暴露老人与设备)。 */
@GetMapping("/households/search")
public ApiResponse<List<Map<String, Object>>> searchHouseholds(@RequestParam String keyword) {
return ApiResponse.ok(family.searchHouseholds(keyword));
}
/** 凭邀请码加入一个户。 */
@PostMapping("/households/join")
public ApiResponse<Map<String, Object>> join(@RequestBody Dtos.HouseholdJoin req) {
return ApiResponse.ok(family.joinByCode(req));
}
/** 申请加入一个户,落 PENDING 等户主审批。 */
@PostMapping("/households/apply")
public ApiResponse<Map<String, Object>> apply(@RequestBody Dtos.HouseholdApply req) {
return ApiResponse.ok(family.apply(req));
}
/** 当前有效的邀请码。 */
@GetMapping("/households/{id}/invite-info")
public ApiResponse<Map<String, Object>> inviteInfo(@PathVariable long id) {
return ApiResponse.ok(family.inviteInfo(id));
}
/** 户成员(正式 + 待审批)。 */
@GetMapping("/households/{id}/members")
public ApiResponse<List<Map<String, Object>>> members(@PathVariable long id) {
return ApiResponse.ok(family.members(id));
}
/** 待审批申请,只有户主能看。 */
@GetMapping("/households/{id}/applications")
public ApiResponse<List<Map<String, Object>>> applications(@PathVariable long id) {
return ApiResponse.ok(family.applications(id));
}
@PostMapping("/households/{id}/applications/{wxUserId}/approve")
public ApiResponse<Map<String, Object>> approve(@PathVariable long id, @PathVariable long wxUserId) {
return ApiResponse.ok(family.review(id, wxUserId, true));
}
@PostMapping("/households/{id}/applications/{wxUserId}/reject")
public ApiResponse<Map<String, Object>> reject(@PathVariable long id, @PathVariable long wxUserId) {
return ApiResponse.ok(family.review(id, wxUserId, false));
}
/** 老人停用 / 启用 / 标记过世(不删行,保留历史回指)。 */
@PostMapping("/elders/{id}/status")
public ApiResponse<Map<String, Object>> elderStatus(@PathVariable long id,
@RequestParam String value) {
return ApiResponse.ok(family.setElderStatus(id, value));
}
/** 户详情:老人 + 设备 + 未关闭告警数,一次给全。 */
@GetMapping("/households/{id}")
public ApiResponse<Map<String, Object>> household(@PathVariable long id) {
return ApiResponse.ok(family.householdDetail(id));
}
@GetMapping("/households/{id}/elders")
public ApiResponse<List<Map<String, Object>>> elders(@PathVariable long id) {
family.assertHousehold(id);
return ApiResponse.ok(family.eldersOf(id));
}
@GetMapping("/households/{id}/devices")
public ApiResponse<List<Map<String, Object>>> devices(@PathVariable long id) {
family.assertHousehold(id);
return ApiResponse.ok(family.devicesOf(id));
}
/** 生成 6 位邀请码,24 小时有效。用于把另一位家属拉进同一户。 */
@PostMapping("/households/{id}/invite")
public ApiResponse<Map<String, Object>> invite(@PathVariable long id,
@RequestParam(required = false) String relation) {
return ApiResponse.ok(family.invite(id, relation));
}
@GetMapping("/elders/{id}")
public ApiResponse<Map<String, Object>> elder(@PathVariable long id) {
return ApiResponse.ok(family.elderDetail(id));
}
@PutMapping("/elders/{id}")
public ApiResponse<Map<String, Object>> updateElder(@PathVariable long id,
@RequestBody Dtos.ElderUpdate req) {
return ApiResponse.ok(family.updateElder(id, req));
}
@GetMapping("/elders/{id}/contacts")
public ApiResponse<List<Map<String, Object>>> contacts(@PathVariable long id) {
family.elderDetail(id); // 顺带做越权校验
return ApiResponse.ok(family.contacts(id));
}
@PostMapping("/elders/{id}/contacts")
public ApiResponse<Map<String, Object>> addContact(@PathVariable long id,
@RequestBody Dtos.ContactCreate req) {
long cid = family.addContact(id, req);
return ApiResponse.ok(Map.of("id", cid));
}
}
@@ -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<Map<String, Object>> 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<Map<String, Object>> unread() {
return ApiResponse.ok(notify.unreadCount());
}
@PostMapping("/notifications/read-all")
public ApiResponse<Map<String, Object>> readAll() {
return ApiResponse.ok(notify.readAll());
}
@PostMapping("/notifications/{id}/read")
public ApiResponse<Map<String, Object>> read(@PathVariable long id) {
return ApiResponse.ok(notify.read(id));
}
@GetMapping("/notify/preferences")
public ApiResponse<Object> prefs(@RequestParam(required = false) Long householdId) {
return ApiResponse.ok(notify.preferences(householdId));
}
@PutMapping("/notify/preferences")
public ApiResponse<Object> savePrefs(@RequestParam(required = false) Long householdId,
@RequestBody Dtos.PrefUpdate req) {
return ApiResponse.ok(notify.savePreferences(householdId, req));
}
}
@@ -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;
/**
* 家属端实时通道。
*
* <pre>
* GET /api/v1/notifications/stream?token=xxx 长连接,服务端单向推送
* GET /api/v1/notifications/online 在线连接数(排障/看板用)
* </pre>
*
* <p><b>为什么要用 query 传 token</b>:浏览器的 {@code EventSource} <b>不能自定义请求头</b>,
* 带不了 {@code Authorization: Bearer}。好在 {@code BizAuthInterceptor} 本来就支持
* {@code ?token=}(当初是为小程序下载图片加的),这里直接复用,不用改拦截器。
*
* <p><b>为什么要关缓冲</b>:nginx、cpolar 这类代理默认会攒够一批再发,
* 结果就是"服务端 10:00:00 发了,浏览器 10:00:30 才收到",实时性被吃掉。
* {@code X-Accel-Buffering: no} 是 nginx 认的标准头,加上它才能真正逐条下发。
*
* <p><b>断线由浏览器自己重连</b>:{@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<String, Object> online() {
return Map.of(
"users", sse.onlineUsers(),
"connections", sse.onlineConnections());
}
}
@@ -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 入口。
*
* <p>只用 {@link JdbcTemplate} 不引 ORM:83 张表里业务查询大多是"带条件的列表 + 少量联表",
* 写 SQL 更直观,也省掉实体映射的维护成本。
*
* <p><b>纪律</b>:所有对外查询必须带 {@code deleted = 0}(有软删的表),
* 分页必须带 {@code LIMIT},不允许出现无界查询。
*/
@Repository
public class BizDao {
private final JdbcTemplate jdbc;
public BizDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public List<Map<String, Object>> list(String sql, Object... args) {
return jdbc.queryForList(sql, args);
}
public Map<String, Object> one(String sql, Object... args) {
List<Map<String, Object>> 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);
}
}
@@ -0,0 +1,156 @@
package cn.aomin.gateway.biz.dto;
/**
* 业务层请求体集合。
*
* <p>全部用 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<String, Object> params) {
}
// ---------------------------------------------------------------- 告警
public record AlarmAction(String remark, String reason) {
}
// ---------------------------------------------------------------- 通知
public record PrefUpdate(String quietStart, String quietEnd,
java.util.List<String> types, java.util.List<String> 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) {
}
/**
* 一行数据范围。字段全空的行不生效(不会误配成"全放")。
*
* <p>由宽到窄:只给 province = 全省;三个省市区都给 = 那个区;
* 再给 communityId = 那个小区;再给 buildingId = 那一栋。
*/
public record ScopeRow(String province, String city, String district,
Long communityId, Long buildingId, String remark) {
}
public record ScopeSave(java.util.List<ScopeRow> 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) {
}
/**
* 出厂录入(预注册)。
*
* <p>这是绑定的前置:<b>没录进库的设备,家属扫不出来也绑不上</b>。
* 类型必须由录入人明确指定,系统不猜——猜错一次,这台设备就一辈子是错的。
*/
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) {
}
}
@@ -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;
/**
* 告警规则引擎(最简可演示版)。
*
* <p><b>它在整条链路里的位置</b>:M 层把帧解析成 {@code iot_event} 就结束了,
* 不判告警——这是 J-00 定死的边界。业务层这边用 {@code iot_event.processed} 这个
* 字段做"已消费"标记,定时把这批原始事件翻译成 {@code alarm}。
*
* <pre>
* 设备 ──433/MQTT──→ 网关 ──HTTP──→ M 层 ──→ iot_event(processed=0)
* │
* AlarmEngine(本类)
* ↓
* alarm(active_key 唯一) + notify_task
* </pre>
*
* <p><b>触发方式(2026-11-09 改)</b>:原来是纯轮询"最多晚 8 秒",对 SOS 这种 S1 级告警太慢。
* 现在主链路改成<b>入库即触发</b>——I-01 返回后由 {@link AlarmTrigger} 异步扫一轮,
* 正常情况下从事件入库到告警落库在 1 秒内。本类的 {@code tick()} 退为 <b>30 秒兜底</b>,
* 只负责捡"进程重启时压着的历史事件"和"某批触发失败"的漏。
*
* <p><b>为什么不在入库事务里直接调 scanOnce</b>:入库是 M 层的热路径,31 条用例都在测它。
* 规则一旦抛异常会把整批事件回滚,把接入能力拖下水。所以走"事务外 + 异步 + 失败只记日志"。
*
* <p><b>规则目前只有 4 条</b>(SOS / 水浸 / 门磁 / 断电)+ 3 条恢复。
* 真正的规则表(阈值、静默期、升级链、免打扰)还没批——见《业务层实现说明》第五节待改清单。
*/
@Component
public class AlarmEngine {
private static final Logger log = LoggerFactory.getLogger(AlarmEngine.class);
/**
* event_type → 告警规则。level 用字典 alarm_level 的 S1/S2/S3。
*
* <p>{@code deviceType} 是这条规则要求的设备类型(device.type_code),
* {@code null} 表示任何设备都能发(断电/来电是网关级事件)。
* <b>为什么要这层校验</b>(2026-11-09 加):上报侧没有强校验时,一台门磁发
* {@code SOS_PRESS} 也能生成一条 S1 紧急呼叫——家属会被拉起来,出事后还查不出是谁发的。
* 现在对不上就不生成告警,但<b>原始事件照旧入库</b>,留痕不丢。
*/
private record Rule(String key, String tier, String level, String title, String content,
String deviceType) {
}
private static final Map<String, Rule> 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<String, String> 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"
);
/**
* 没人动过的状态。命中这些的旧单会被新一次触发<b>替代</b>(SUPERSEDED)。
* 一旦有人确认/处置/升级过,就进"保持结果"那一档,不许被新告警盖掉。
*/
private static final java.util.Set<String> 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;
/**
* 规则连续失败几次后放弃自动重试、转死信。
*
* <p>不做无限重试:一条规则处理不了的事件,每 30 秒重试一次只会刷满日志,
* 而且掩盖了"规则本身有 bug"这个事实。3 次(含退避约 2.5 分钟)足够躲过
* 数据库瞬时抖动,再失败就该有人看了。
*/
@org.springframework.beans.factory.annotation.Value("${biz.alarm.max-try:3}")
private int maxTry;
/**
* S1(SOS / 水浸 / 烟感 / 燃气)的实时横幅合并窗口,**默认 1 秒**。
*
* <p>为什么是 1 秒而不是 60 秒:I-01 一批最多 200 条、攒批 200 ms,所以
* "一次抖动"或"补一次数据"产生的重复记录,几乎都落在<b>同一批</b>里、
* 几百毫秒内处理完。1 秒足够覆盖"同一批",又不会把两次真正独立的告警合成一次。
*
* <p>紧急告警宁可多弹,不能少弹——所以 S1 的窗口比 S2/S3 更短。
*/
@org.springframework.beans.factory.annotation.Value("${biz.alarm.push-merge-s1-seconds:1}")
private int pushMergeS1Seconds;
/**
* S2 / S3 的实时横幅合并窗口,**默认 5 秒**。
*
* <p>次级告警(门磁、电流、水振)抖动更常见,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 表示已经扫完)。
*
* <p><b>{@code processed=1} 只在"真的处理过"时才写</b>(2026-11-10 修):
* 以前无论成功失败都标已消费,规则一抛异常这条事件就永远不会再被扫到——
* 对老人安全告警来说,这就是<b>永久漏报</b>,而且自测看不出来(其余事件都正常)。
*
* <p><b>没有时间窗</b>(2026-11-11 二次修,GOV-008 §3.2):
* 历史上这里有两道闸——60 分钟扫描窗、72 小时老化闸。两个都在问同一个问题:
* "这条事件够不够新,值不值得处理"。
*
* <p>但这个问题的前提是错的。{@code receive_time} 现在由业务层落库时自己打
* {@code NOW(3)}(见 {@code IotEventDao}),恒等于"现在"——事件只要进了库,
* 它就是刚收到的。旧实现的错在于把 J-00 信封里 IoT 自报的 {@code receivedAt}
* 直接当判据,外部传个旧时间就能让一条真实告警永远"不新鲜"。
*
* <p>所以闸拆掉:{@code processed=0} 的全部要处理,按 {@code id} 升序分批。
* 打扰不靠入口挡,靠出口收——同户同设备同规则连续触发由新的替代旧的,
* 横幅按 S1 1 秒 / S2S3 5 秒合并,短信 60 秒合并。补 3000 条历史进来,
* 家属最后看到的也只是几条待处理,不会被弹 3000 次。
*
* <p><b>失败重试</b>:失败写 {@code alarm_next_at} 做退避,连续 {@code max-try}
* 次才转死信并置 {@code processed=1},全程留痕。这也是拆掉时间窗后
* 唯一还在限制"什么时候扫这条"的条件——它和事件新不新无关。
*/
public int scanOnce() {
// alarm_next_at 非空且未到点的先跳过,其余全部扫。没有任何基于 event_time /
// receive_time 的条件——那两条正是会被外部时间戳绕过的判据。
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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");
}
/**
* 处理一条事件,返回"能不能标已消费"。
*
* <p>返回 {@code false} 只有一种情况:真的处理出错,需要重试。
* 其余(包括"不认识的事件类型""设备类型对不上")都返回 {@code true}——
* 那些是<b>确定不需要处理</b>,一直挂着只会每轮重扫同一批。
*/
private boolean handleOne(Map<String, Object> 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; // 与告警无关的事件(心跳、状态上报等),直接消费
}
/**
* 触发一条告警。这里是<b>告警覆盖逻辑</b>的唯一入口(2026-11-09 定)。
*
* <p><b>为什么不能"旧单还在就不建新单"</b>:那样家属永远只看到一条记录,
* 看不出今天到底触发了几次;而且 {@code trigger_at} 被反复刷新之后,
* 「最早什么时候开始的」这个信息就永久丢了——出事后追责要靠它。
*
* <p><b>三条分支</b>(判据只看旧单状态,不看时间间隔):
* <table border="1">
* <tr><th>旧单状态</th><th>旧单</th><th>新单</th></tr>
* <tr><td>NEW / PUSHED / DELAYED(没人管过)</td>
* <td>置 SUPERSEDED,{@code superseded_by} 指向新单</td><td>新建</td></tr>
* <tr><td>CONFIRMED / ESCALATED / HANDLED(人处理过)</td>
* <td><b>原状态原结果不动</b>,只释放抑制键</td><td>新建</td></tr>
* <tr><td>CLOSED / FALSE / SUPERSEDED(已终结)</td><td>不动</td><td>新建</td></tr>
* </table>
*
* <p>「处理过就保留结果」这条是关键:新告警只能盖掉<b>没人理的</b>旧告警,
* 不能盖掉已经有人确认或处置过的记录,否则「我明明处理过了」会变成查无此事。
*
* <p><b>顺序不能换</b>:先释放旧单的 {@code active_key}(它是唯一索引),
* 再插新单,最后回填 {@code superseded_by}——新单 id 要插完才知道。
* 反过来做会直接撞 {@code uk_alarm_active}。
*/
private int raise(String devId, Number householdId, Rule rule, Map<String, Object> ev) {
// ① 只读准备放事务外:查旧单、取收件人、判静默(会读 Redis),都不占数据库事务
Map<String, Object> 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<Long> receivers = receiverIds(householdId);
boolean isS1 = "S1".equals(rule.level());
// 每个人是否处于免打扰:APP 记录人人都有,推不推分开算
java.util.Map<Long, Boolean> quietMap = new java.util.LinkedHashMap<>();
java.util.List<Long> 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 修)。
//
// <p>以前这里只改告警状态,通知任务照旧 PENDING,发送端会一条条发。
// 结果是:列表里每个设备只剩最新一条(替代生效,看着很正常),
// 家属手机却按"事件条数"响——补一次 300 条历史事件就是 300 次推送。
// 列表和通知必须表现一致:既然旧单被替代了,它那条提醒就没有发送的意义。
//
// <p>只撤 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 通知任务(含被静默的——静默只是不响铃,列表里还得看得见)。
*
* <p><b>失败必须抛</b>:这个方法在事务里,抛出去整条告警回滚,事件留待重试。
* 吞掉的后果是"有告警、没人收到通知",且事后无从发现。
*/
private void insertNotifyTasks(long alarmId, String alarmNo, Rule rule,
List<Long> receivers, java.util.Map<Long, Boolean> 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<Long> receiverIds(Number householdId) {
if (householdId == null) {
return List.of();
}
List<Map<String, Object>> users = dao.list(
"SELECT wx_user_id FROM user_house WHERE household_id=? AND status='ACTIVE'", householdId);
List<Long> ids = new java.util.ArrayList<>();
for (Map<String, Object> u : users) {
Object v = u.get("wx_user_id");
if (v instanceof Number n) {
ids.add(n.longValue());
}
}
return ids;
}
/**
* 设备侧恢复。只写 {@code cleared_at},不动 status——设备自己好了不等于有人处理过。
*
* <p><b>为什么不再按 active_key 找</b>(2026-11-09 改):改成"每次触发新建单"之后,
* 旧单的抑制键都要释放(否则新单撞唯一索引),所以已经没单子攥着 active_key 了。
* 改成按 {@code dev_id + rule_key + 未终结 + 未清过} 找,语义更准,也防重复清。
*
* <p>SUPERSEDED 不在范围内:被替代的单子代表"当时确实发生过",但现在的风险事实
* 由最新那条承载,不该被这次恢复顺手抹掉。
*/
private int recover(String devId, String ruleKey, Map<String, Object> 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);
}
/**
* 通知一户口的所有家属。三个动作,任何一步失败都不影响告警本身落库。
*
* <ol>
* <li><b>APP 记录</b>:一条 {@code notify_task}(receiver_type=WX),家属端通知中心用;</li>
* <li><b>SSE 实时推</b>:只推<b>当前不在免打扰时段</b>的在线家属;</li>
* <li><b>短信</b>:<b>只有 S1</b>(SOS / 水浸)发,S2/S3 一律不发。</li>
* </ol>
*
* <p><b>免打扰是按人算的</b>(2026-11-09 定):爸爸设了 22:00–07:00,妈妈没设,
* 那么半夜那条开门告警妈妈手机照响、爸爸手机不响。不做"按户取最松"。
* 静默的人<b>不是收不到</b>——{@code notify_task} 照建,打开列表就在那儿,只是不弹不响。
*
* <p><b>短信为什么只给 S1</b>:短信是按条计费的,而且短信响一声跟 APP 弹窗在
* 紧急程度上的差别只在"手机没网/APP 没开"。S2/S3 每天几十条,短信全发会让家属
* 直接把号码拉黑——真出事那天反而收不到。这条成本边界是产品定的,不是技术限制。
*/
/**
* 事务外的实时投递:SSE 推送 + 短信。
*
* <p><b>为什么必须在事务外</b>:这两件事都是不可回滚的外部副作用。
* 放进事务里,一旦后面回滚,消息已经发出去了,数据库却当没发生过——
* 家属收到一条"查不到"的告警,比晚几秒更糟。
*
* <p><b>失败只记日志不回滚</b>:告警主记录和 APP 通知任务已经在库里了,
* 家属打开列表仍然能看到。推送是"更快知道",不是"唯一渠道"。
*
* <p><b>短信为什么只给 S1</b>:短信是按条计费的,而且短信响一声跟 APP 弹窗在
* 紧急程度上的差别只在"手机没网/APP 没开"。S2/S3 每天几十条,短信全发会让家属
* 直接把号码拉黑——真出事那天反而收不到。这条成本边界是产品定的,不是技术限制。
*/
private void pushLive(long alarmId, String alarmNo, Rule rule, long hhId, String devId,
List<Long> 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<String, Object> 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());
}
}
}
/**
* 推送合并窗口:这个「户 + 设备 + 规则」在窗口内是否还没推过。
*
* <p>窗口按级别分两档:**S1 1 秒、S2/S3 5 秒**。都压得很短,因为
* I-01 一批最多 200 条、攒批 200 ms —— 一次抖动产生的重复记录几乎都在同一批,
* 几百毫秒内就处理完了。1 秒挡得住"同一批",挡不掉两次真正独立的告警。
*
* <p>用 Redis {@code SET NX EX} 而不是"先 GET 再 SET":后者在并发下两条都能读到
* "还没有",于是两条都推出去,等于没合并。
*
* <p><b>Redis 不可用时返回 true(照推)</b>:宁可多弹一次,也不能因为缓存挂了
* 让告警不出现在手机上——合并只是体验优化,不是告警的前置条件。
*/
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<String, Object> 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;
}
}
@@ -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;
/**
* 告警触发闸门:入库完就扫,不再等定时器。
*
* <p><b>为什么要有这个类,而不是让 Controller 直接调 AlarmEngine</b>:
* 三个理由,缺一不可——
* <ol>
* <li><b>必须异步</b>:{@code @Async} 只有在"从别的 Bean 调进来"时才生效。
* 如果把 {@code @Async} 方法写在 AlarmEngine 里、由它自己调自己,
* Spring 代理被绕过,实际还是同步执行,接入接口会被规则引擎拖慢。</li>
* <li><b>必须防抖</b>:模拟器或真实网关一次批量推 200 条事件,是一条 I-01 请求,
* 触发一次扫描;但如果 IoT 侧连续推 10 个批次,就会连开 10 次扫描,
* 彼此抢同一批 {@code processed=0} 的行。</li>
* <li><b>必须单飞</b>:同一时刻只允许一轮扫描在跑。撞车不会产生重复告警
* ({@code alarm.uk_active} 兜得住),但会白白多查几次库。</li>
* </ol>
*
* <p><b>它不是唯一入口</b>:{@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;
}
/**
* 有事件入库了,去扫一轮。
*
* <p>调用方({@code IngestController})只在本批次 {@code accepted > 0} 时调,
* 全被拒收或全重复的批次不必惊动规则引擎。
*
* <p><b>为什么不用"太频繁就丢掉"的防抖</b>:演示时就是会快速连按几次 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);
}
}
}
}
@@ -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;
/**
* 后台与物业:小区、楼栋、单元、户、老人建档、账号、字典、工单、审计。
*
* <p>这一组是<b>运营/物业用</b>的,家属端不会调。凡是写操作都落 {@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<Map<String, Object>> communities(int page, int size) {
int p = BizDao.page(page);
int s = BizDao.size(size);
List<Object> args = new ArrayList<>();
String scopeSql = scope.sql("c.id", args); // 物业只看到自己名下的小区
List<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> households(Long communityId, int page, int size) {
int p = BizDao.page(page);
int s = BizDao.size(size);
List<Object> args = new ArrayList<>();
args.add(communityId);
args.add(communityId);
// 用户级过滤而不是"小区 IN":物业若只配了某栋楼,住户列表不能把同小区其他栋也放出来
String scopeSql = scope.householdFilter("h.id", args);
List<Map<String, Object>> 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<String, Object> 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<Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> roles() {
return dao.list("SELECT role_code, role_name, data_scope_type FROM sys_role ORDER BY id");
}
// ---------------------------------------------------------------- 数据范围(按区域)
/**
* 范围配置下拉用的选项。
*
* <p><b>不引国标行政区划码表</b>:选项直接从库里已有小区反推。
* 好处是选出来的值必然和 {@code community.province/city/district} 字面一致,
* 不会出现"选了广东省、库里写广东"这种匹配不上的鬼事;数据量也就几十个。
*/
public Map<String, Object> scopeOptions() {
List<Map<String, Object>> comms = dao.list(
"SELECT id, name, community_code, province, city, district FROM community "
+ "ORDER BY province, city, district, id");
List<Map<String, Object>> 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<String, java.util.LinkedHashMap<String, java.util.LinkedHashSet<String>>> tree =
new java.util.LinkedHashMap<>();
for (Map<String, Object> 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<Map<String, Object>> regions = new ArrayList<>();
for (var pe : tree.entrySet()) {
List<Map<String, Object>> cities = new ArrayList<>();
for (var ce : pe.getValue().entrySet()) {
Map<String, Object> city = new java.util.LinkedHashMap<>();
city.put("city", ce.getKey());
city.put("districts", new ArrayList<>(ce.getValue()));
cities.add(city);
}
Map<String, Object> prov = new java.util.LinkedHashMap<>();
prov.put("province", pe.getKey());
prov.put("cities", cities);
regions.add(prov);
}
Map<String, Object> out = new java.util.LinkedHashMap<>();
out.put("regions", regions);
out.put("communities", comms);
out.put("buildings", blds);
return out;
}
/** 某个账号当前配的范围。 */
public List<Map<String, Object>> 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);
}
/**
* 覆盖保存某个账号的范围。
*
* <p>整体覆盖(先删后插)而不是逐行 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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);
}
// ---------------------------------------------------------------- 审计失败兜底
/**
* 审计写失败、还没补录的清单。
*
* <p>这些行是"某个后台操作成功了,但查不到是谁做的"。补录之前,
* 对应业务在审计上就是空白——所以这张表不是日志,是<b>待办</b>。
*/
public Map<String, Object> 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<Map<String, Object>> 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},并标记已补录。
*
* <p>只写回内容,不改 {@code created_at}——那是"操作实际发生的时间",
* 补录只是把当时漏掉的那一行补上,不能让审计时间线错位。
*/
public Map<String, Object> replayAuditFailure(long id) {
if (!AuthContext.isSys()) {
throw new BizException(403, "只有后台账号可以补录审计");
}
Map<String, Object> 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<String, String> DEVICE_TYPES = Map.of(
"DOOR", "门磁", "WATER", "水浸", "VIB", "水振",
"CURRENT", "电流", "SOS", "紧急按钮", "SMOKE", "烟感");
private static final java.util.Set<String> VENDORS = java.util.Set.of("OWN", "HORN", "MIR");
/**
* 出厂录入:把一批设备先录进库,家属之后才能绑。
*
* <p><b>这是"能不能绑"的唯一开关。</b>没有这一步,家属扫码填了 SN 也绑不上——
* 因为 SN 本身不含类型信息(8 位十六进制是流水号),光看编号永远不知道是门磁还是水浸。
* 想让它绑上,就得有人告诉系统它是什么。
*
* <p>录进来是 {@code status=已入库}、{@code household_id=NULL}:在库但还没归谁。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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");
}
/**
* 野设备转正:补一个类型,让它进库。
*
* <p>和"出厂录入"的区别只在入口——机器是它自己先报上来的。
* 落库后一样是 {@code 已入库 / 未绑户},等家属来绑。
*/
@Transactional
public Map<String, Object> adoptUnregistered(Dtos.UnregAdopt req) {
if (req == null || req.sensorId() == null || req.sensorId().isBlank()) {
throw BizException.badRequest("设备编号不能为空");
}
String sn = req.sensorId().trim();
Map<String, Object> 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<String, Object> 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";
}
// ---------------------------------------------------------------- 告警死信
/**
* 未回放的死信。
*
* <p>死信不是垃圾桶:它代表"这条事件确实发生过,但规则连续 {@code max-try} 次都没处理成"。
* 必须有人能看到、能重放,否则和直接丢掉没有区别。
*/
public List<Map<String, Object>> 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);
}
/**
* 回放一条死信:把原事件还给告警引擎重跑一次。
*
* <p>扫描条件只剩两条:{@code processed=0}、{@code alarm_next_at} 为空或已到点。
* 这里置 {@code NOW(3)} 是为了立刻可扫(不必等下一轮退避),置 NULL 也能扫到——
* 时间窗已经拆掉,回放一条几小时前的死信不会再被"不够新"挡住。
*
* <p>{@code alarm_try} 归零:回放是人工判断"规则已经修好了,再给一次机会",
* 不归零的话它一进扫描就再次满足 {@code tries >= max-try},直接又进死信。
*/
@Transactional
public Map<String, Object> replayDeadLetter(long id) {
if (!AuthContext.isSys()) {
throw new BizException(403, "只有后台账号可以回放死信");
}
Map<String, Object> 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<String, Object> 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}。
*
* <p><b>失败不再静默</b>(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;
}
}
@@ -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;
/**
* 告警。
*
* <p><b>四个词必须分开</b>(M05 口径):
* <ul>
* <li><b>已读</b>({@code alarm_popup_ack}):弹窗看过,只是 UI 行为,不动主表状态;</li>
* <li><b>确认</b>(CONFIRMED):有人接了,写 {@code confirmed_by/at};</li>
* <li><b>处置</b>(HANDLED):人处理完了,还没关;</li>
* <li><b>关闭</b>(CLOSED):写 {@code closed_by/at},终态。</li>
* </ul>
*
* <p><b>设备恢复不等于关闭</b>:恢复只写 {@code cleared_at} / {@code clear_event_id},
* 安全告警(水浸、SOS)不得因为设备自己好了就自动关单——这条松了,
* 出事时说不清到底有没有人看过。
*/
@Service
public class AlarmService {
private static final List<String> 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<String, Object> 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<Object> 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<Object> q = new ArrayList<>(args);
q.add(s);
q.add((p - 1) * s);
List<Map<String, Object>> 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<String, Object> detail(long id) {
Map<String, Object> a = mustGet(id);
Map<String, Object> 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;
}
/**
* 告警链:这一组「同一次风险」里的所有记录。
*
* <p><b>要解决的问题</b>:门磁今天开了 8 次,库里是 8 条记录——1 被 2 替代、2 被 3 替代……
* 家属点开最新那条只看到 14:32,会以为"刚开的门",其实风险从 07:15 就开始了。
* 所以详情页必须能把"最初的告警时间"和"现在这次"摆在一起。
*
* <p><b>分组规则</b>(跟 {@code AlarmEngine.raise} 的替代逻辑严格对应):
* 沿 {@code superseded_by} <b>反向</b>递归——被本节点替代过的,都是本组的。
* 有人确认/处置过的地方链会自然断开(那条没被替代,{@code superseded_by} 为空),
* 正好就是"新的一组"的起点。
*
* <pre>
* 1 ←2 ←3(已处置) 4 ←5(当前)
* 点 5 → {4,5} 点 3 → {1,2,3} 点 1/2/4(已被替代)→ {自己}
* </pre>
*
* <p><b>为什么点被替代的单只返回它自己</b>:它不是这一组的代表,点它说明是从历史里翻出来的。
* 前端拿 {@code latestId} 给一个"查看最新"的跳转就够了,不把整组铺开——
* 否则同一个组在列表里有多个入口,家属会以为是好几件事。
*/
public Map<String, Object> chain(long id) {
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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。
*
* <p><b>为什么把状态判断写进 WHERE</b>:原来是"先读状态、内存里判、再按 id 更新"。
* 两个人在同一秒点"确认"时,两次读都看到 NEW、两次都过判断,审计会写两条,
* 后一条覆盖前一条的操作人。改成条件更新后,第二个 affected=0,直接 409 让他刷新。
*
* <p><b>为什么加 DELAYED</b>:免打扰时段生成的单就是这个状态,家属端按钮
* 本来就把它算作可确认({@code family/index.html}),后端却不在白名单里 ——
* 家属点了会收到 400「当前状态 DELAYED 不能确认」。前后端口径必须一致。
*
* <p>事务边界覆盖"状态更新 + 动作日志 + 审计":审计写不进去就整体回滚,
* 不能出现"告警关了但查不到谁关的"。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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);
}
/**
* 关闭:必须写人;不允许"系统自动关"。
*
* <p>已经是 CLOSED 时幂等返回、不重复写审计(同一个人重复点不该留两条记录)。
* FALSE / SUPERSEDED 是另外两种终态,不能从它们转成 CLOSED。
*/
@Transactional
public Map<String, Object> close(long id, Dtos.AlarmAction req) {
Map<String, Object> 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<String, Object> 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, "设备侧恢复");
}
/**
* 处理记录(时间轴)。
*
* <p><b>必须自己校验归属</b>(2026-11-10 修):这个方法对外暴露成
* {@code GET /alarms/{id}/actions},如果只靠 {@link #detail} 那条路径校验,
* 直接调这个接口就能读到别人家的处理记录——包括谁在什么时候确认、写了什么备注。
* 所以校验放在这里,任何入口都绕不过去。
*/
public List<Map<String, Object>> actions(long alarmId) {
Map<String, Object> 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<String, Object> stats(Long householdId) {
StringBuilder scope = new StringBuilder();
List<Object> 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<String, Object> 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 定:只有最新那条可以编辑状态)。
*
* <p>要堵的场景:家属从历史里翻到第 2 条「房门已打开」,点"我知道了"。
* 如果放行,那条会变成 CONFIRMED,而真正待处理的第 5 条还是 NEW——
* 家属以为处理完了,其实没处理。所以这里必须卡死,并告诉他去处理哪一条。
*/
/**
* 条件更新没命中时,回去看一看到底为什么,给一句能照着做的话。
*
* <p>只在失败路径多查一次,成功路径不受影响。
*
* <p>要堵的场景(2026-11-09 定):家属从历史里翻到第 2 条「房门已打开」,
* 点"我知道了"。如果放行,那条会变成 CONFIRMED,而真正待处理的第 5 条还是 NEW——
* 家属以为处理完了,其实没处理。所以这里必须卡死,并告诉他去处理哪一条。
*/
private BizException conflictAfterNoop(long id) {
Map<String, Object> 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<String, Object> mustGet(long id) {
// 带出设备别名和机身编号:家属看到的是"SOS 床头 3F7A9C23",
// 不是内部 dev_id(T-G-03 / D1794…)——那个号客户手上没有。2026-11-09
Map<String, Object> 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}(合规审计)。
*
* <p><b>审计失败必须抛,不能再吞</b>(2026-11-11 修):原来 {@code audit_log} 的 insert
* 包在 {@code catch (Exception ignore) {}} 里,事务照样提交。结果是
* 「告警被关掉了、时间轴上有这条,但 {@code audit_log} 查不到谁关的」——
* 而且没有任何报错,事后只有对账才发现。评审把它列为阻断项是对的。
*
* <p>现在:本方法只被 {@code @Transactional} 的人工动作调用,insert 抛出去
* 整条动作回滚,宁可让用户重试,也不留"操作了但无审计"的记录。
*
* <p>系统自动动作走 {@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}。
*
* <p>起因:{@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);
}
}
@@ -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;
/**
* 账户与登录。
*
* <p><b>两条链彻底分开</b>(10-29 定稿):
* <ul>
* <li>{@code wx_user}:小程序自助注册,微信授权拿 openid → 手机号 + 密码 + 短信码注册,
* 之后密码 / 短信 / 微信三种都能登。只放家属(FAMILY)、探视人(VISITOR)、机构主管(ORG_MANAGER)。</li>
* <li>{@code sys_user}:运营和物业,<b>后台建的账号,没有注册接口</b>,只认账号密码。</li>
* </ul>
*
* <p><b>微信授权 ≠ 登录</b>:没注册过的 openid 一律返回 {@code needRegister=true},
* 让前端跳注册页,绝不自动建号——自动建号会产生一堆没人认领的空账号。
*
* <p>短信码存 Redis 5 分钟,<b>不落库</b>。爆破锁定本期不做(字段留着,逻辑不写)。
*/
@Service
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final BizDao dao;
private final TokenService tokens;
private final RedisTemplate<String, String> redis;
private final boolean smsEcho;
private final String piiKey;
public AuthService(BizDao dao, TokenService tokens,
@org.springframework.beans.factory.annotation.Qualifier("redisTemplate")
RedisTemplate<String, String> 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<String, Object> wxLogin(Dtos.WxLogin req) {
if (req == null || req.code() == null || req.code().isBlank()) {
throw BizException.badRequest("code 不能为空");
}
String openid = devOpenid(req.code());
Map<String, Object> u = dao.one(
"SELECT id, nickname, user_type, status FROM wx_user WHERE openid=?", openid);
if (u == null) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("sent", true);
out.put("expireSeconds", 300);
if (smsEcho) {
out.put("code", code); // 仅开发态
}
return out;
}
/**
* 校验短信码。
*
* <p>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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> passwordLogin(Dtos.PwdLogin req) {
if (req == null || !Crypto.isMobile(req.phone())) {
throw BizException.badRequest("手机号格式不对");
}
Map<String, Object> 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<String, Object> 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<String, Object> consoleLogin(Dtos.ConsoleLogin req) {
if (req == null || req.username() == null || req.username().isBlank()) {
throw BizException.badRequest("账号不能为空");
}
Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> me() {
AuthContext.Principal p = AuthContext.require();
Map<String, Object> 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<String, Object> 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<String, Object> 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;
}
}
@@ -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;
/**
* 设备管理(业务视角)。
*
* <p>与 M 域那几个接收接口的分工:接收接口只管"收到什么就落什么",这里管"人怎么用"——
* 列表、改名、绑定、解绑、查事件、下发命令。
*
* <p><b>绑定是唯一会写 {@code household_id} 的地方</b>(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<Map<String, Object>> 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<Object> 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 查一眼:这台机器入没入库、是什么类型、有没有被占。
*
* <p>家属填完编号就调它,把类型直接显示出来——这样他不用选,也不会选错。
* <b>不返回 household_id</b>:已经被别人绑走的设备,只告诉他"已被绑定",
* 不能把他的邻居住哪户也漏出去。
*/
public Map<String, Object> lookupBySn(String sn) {
Map<String, Object> out = new LinkedHashMap<>();
out.put("sensorId", sn);
if (sn == null || sn.isBlank()) {
out.put("registered", false);
return out;
}
Map<String, Object> 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<String, Object> detail(String devId) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> update(String devId, Dtos.DeviceUpdate req) {
Map<String, Object> 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<String, Object> bind(String devId, Dtos.DeviceBind req) {
if (req == null || req.householdId() == null) {
throw BizException.badRequest("householdId 不能为空");
}
Map<String, Object> 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 绑定(家属端「+ 绑定设备」)。
*
* <p>与 {@link #bind} 的区别:那个按 {@code dev_id} 操作已有台账;这个按用户扫到的
* <b>原始 SN</b> 操作,设备可能压根没建过档。业务层是唯一有权建档的地方——
* M 层收到未建档设备只做软拒(落 {@code unregistered_device}),不会自己建。
*
* <p>身份三元组({@code sensor_id / vendor / source_channel})只在建档时写一次,
* 之后任何绑定动作都不改写。{@code household_id} 依旧只由绑定写。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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<String, Object> unbind(String devId) {
Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("devId", devId);
out.put("unbound", true);
out.put("previousHouseholdId", old);
return out;
}
// ---------------------------------------------------------------- 事件
public Map<String, Object> 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<Object> 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<Object> q = new java.util.ArrayList<>(args);
q.add(s);
q.add((p - 1) * s);
List<Map<String, Object>> 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<String> 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<String> GATEWAY_ONLY = java.util.Set.of(
"set_mode", "set_report_cfg", "del_sensor", "set_ota");
/**
* 家属端(小程序)可用的两条命令。
*
* <p>{@code diag_dump}:只读,让设备回报一次当前状态,对应界面的「测试」。
* {@code reboot}:对应「重启」,点击时前端弹一次确认(会断约 1 分钟)。
*
* <p><b>两个按钮始终显示</b>,不按在线状态隐藏。理由:家属判断不了"该不该显示",
* 按钮时有时无反而让人以为界面坏了;真正的保护放在点击确认和服务端白名单这两层。
*
* <p>其余六个不给家属:{@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<String> FAMILY_ALLOWED = java.util.Set.of(
"diag_dump", "reboot");
/** 人在界面等着看结果的,走实时;其余允许设备上线后补发 */
private static final java.util.Set<String> REALTIME_CMDS = java.util.Set.of(
"reboot", "set_mode", "time_sync");
/**
* 下发命令:入库即 {@code PENDING},由 IoT 长轮询(I-18)领走。业务层不直接碰设备。
*
* <p><b>四道闸,缺一道就是越权</b>:
* <ol>
* <li><b>归属闸</b>:先 {@code assertDevice}——家属只能碰自己家的设备,
* 后台按区域范围判断。这一道必须在角色闸之前,否则家属可以对自己家
* 之外的设备发命令;</li>
* <li><b>白名单闸</b>:只认 C1–C8,杜绝把任意 cmdType 透传给接入层;</li>
* <li><b>角色闸</b>:家属端只放「测试」(diag_dump) 和「重启」(reboot),
* 其余六个是运维语义。不能做成"家属一律禁止"——家属端确实有这两个按钮,
* 一律禁止的结果是按钮点了必然弹 403;</li>
* <li><b>能力闸</b>:网关专属命令不能发给单品设备,否则 IoT 那边必然报错,
* 错误却会记成"设备故障"。</li>
* </ol>
*/
public Map<String, Object> sendCommand(String devId, Dtos.CommandReq req) {
Map<String, Object> 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<String, Object> 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 条)。
*
* <p>状态值走 J-00 V1.7 七态。库里若还有没刷成英文的存量行,
* 这里按"中文旧值 → 英文"做一次读时映射,避免界面出现两套值。
*/
public List<Map<String, Object>> commands(String devId) {
Map<String, Object> d = mustGet(devId);
assertDevice(d);
List<Map<String, Object>> 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<String, Object> 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<String, String> CMD_STATUS_ALIAS = Map.of(
"待下发", "PENDING",
"已下发", "CLAIMED",
"已生效", "ACKED",
"失败", "FAILED",
"超时", "TIMEOUT",
"已取消", "CANCELLED");
// ---------------------------------------------------------------- 内部
private Map<String, Object> mustGet(String devId) {
Map<String, Object> 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<String, Object> m) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(m);
} catch (Exception e) {
return "{}";
}
}
static Map<String, Object> page(List<Map<String, Object>> rows, long total, int p, int s) {
Map<String, Object> 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;
}
}
@@ -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;
/**
* 家庭与老人。
*
* <p><b>数据范围只有一条规则</b>:家属(WX)只能看 {@code user_house} 里 status=ACTIVE 的户;
* 运营/物业(SYS)本期全放,等 C 组把 {@code sys_user_scope} 落到接口上再收紧。
*
* <p>每次访问单个户/老人/设备前都要过 {@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<Map<String, Object>> 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());
}
/**
* 家属自助「创建我的家」(类似美团新增收货地址)。
*
* <p>组织树按需补齐:小区 / 楼栋 / 单元先按名字找,找不到才建,
* 避免同名小区被重复建档。建完立刻写 {@code user_house},
* 否则 {@link #assertHousehold} 会把刚建好的户判成无权访问。
*
* <p>坐标是 GCJ-02,前端负责把 GPS 的 WGS-84 换算好再提交;
* 这里只校验范围,不做坐标系转换——服务端猜坐标系比不做更危险。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> householdDetail(long id) {
assertHousehold(id);
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> eldersOf(long householdId) {
List<Map<String, Object>> 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<Map<String, Object>> out = new ArrayList<>(rows.size());
for (Map<String, Object> r : rows) {
out.add(withName(r));
}
return out;
}
public Map<String, Object> elderDetail(long elderId) {
Map<String, Object> 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<String, Object> 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<String, Object> withName(Map<String, Object> row) {
Map<String, String> p = pii.read((Long) row.get("pii_id"));
Map<String, Object> 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<String, Object> updateElder(long elderId, Dtos.ElderUpdate req) {
Map<String, Object> 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<String, String> 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<Map<String, Object>> contacts(long elderId) {
List<Map<String, Object>> rows = dao.list(
"SELECT id, contact_order, pii_id, relation, remark FROM elder_contact "
+ "WHERE elder_id=? ORDER BY contact_order, id", elderId);
List<Map<String, Object>> out = new ArrayList<>(rows.size());
for (Map<String, Object> r : rows) {
Map<String, String> p = pii.read((Long) r.get("pii_id"));
Map<String, Object> 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;
}
/**
* 家属端自助填写老人信息。
*
* <p>姓名手机号一律进 {@code pii_person} 加密,主表只留 pii_id;
* 建完同步 {@code household.elder_count},否则首页那个「登记老人」数字永远不动。
*/
@Transactional
public Map<String, Object> 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);
}
/**
* 老人停用 / 启用 / 标记过世。
*
* <p>不删行——删了历史事件、告警、设备归属全部失去回指。
* 停用后不再算进 {@code household.elder_count},也不再出现在家属端首屏。
*/
@Transactional
public Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("inviteCode", code);
out.put("expireHours", 24);
out.put("relation", relation);
return out;
}
/** 当前这个户还有效的邀请码(没有就返回空串)。 */
public Map<String, Object> inviteInfo(long householdId) {
assertHousehold(householdId);
Map<String, Object> 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")));
}
/**
* 凭邀请码加入一个户。
*
* <p>邀请码挂在邀请人的那条 {@code user_house} 上({@code uk_invite} 保证全局唯一),
* 所以查到码就等于查到了是哪个户、谁邀请的。加入者 is_owner=0、perm_bits=7(看+告警+设备),
* 成员管理和结算留给户主。
*/
@Transactional
public Map<String, Object> joinByCode(Dtos.HouseholdJoin req) {
if (req == null || req.code() == null || req.code().isBlank()) {
throw BizException.badRequest("邀请码不能为空");
}
String code = req.code().trim().toUpperCase();
Map<String, Object> 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<Map<String, Object>> 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},等户主审批。
*
* <p>{@code uk_user_house} 的生成列让「同一人同一户只保留一条有效关系」,
* 所以重复申请会被挡住,不用自己写去重。
*/
@Transactional
public Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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();
}
}
@@ -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;
/**
* 通知。
*
* <p>只做"看和读":真正发送在 {@code notify_task} → 渠道网关那一段,
* 业务层负责决定"该通知谁",不负责"怎么发出去"。
*
* <p>通知偏好(免打扰时段、只收哪几类)落在 {@code notify_preference},
* 一个人一个户一条;查不到就按默认全收。
*/
@Service
public class NotifyService {
private final BizDao dao;
public NotifyService(BizDao dao) {
this.dao = dao;
}
/**
* 通知列表。
*
* <p>{@code status} 是**投递状态**(PENDING/SENT/FAILED/CANCELLED);
* 已读与否看 {@code read_at},两者不再混在一个字段里。
*/
public Map<String, Object> 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<Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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);
}
/**
* 读当前这个人的免打扰设置。
*
* <p><b>返回字段一律驼峰</b>(2026-11-09 修):原来"没记录"走 Map.of 返回 {@code quietStart},
* "有记录"直接把数据库行返回成 {@code quiet_start}。前端读 {@code pref.quietStart}
* 在后一种情况拿到 undefined,于是回退到默认 22:00——用户明明设成 00:00–23:59,
* 再打开页面看到的是 22:00,以为没保存成功,再点一次保存就真被覆盖回去了。
* 这种"读和写两种字段名"的坑,只要有一个分支没走到就永远发现不了。
*
* <p><b>时间格式统一 HH:mm</b>:{@code <input type="time">} 只认这个,
* 数据库里是 {@code 22:00:00},直接在页面上显示会多两个冒号。
*/
public Object preferences(Long householdId) {
long hid = householdId == null ? 0L : householdId;
Map<String, Object> 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<String, Object> 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;
}
/**
* 保存免打扰设置。
*
* <p><b>留空 = 关闭免打扰</b>,不是"用默认值"。{@code SuppressGate} 里
* "记录存在但 quiet_start 为 NULL"就表示这个人明确不要静默,两边语义必须一致——
* 否则用户在页面上关掉了,后端还在按默认 22:00 吞他的告警。
*
* <p><b>开始和结束填成一样</b>也按关闭处理(零长度区间没有意义),
* 免得用户以为设了个"从 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<String> 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<List<String>>() {});
} catch (Exception e) {
return List.of();
}
}
private static String toJson(List<String> list) {
try {
return new com.fasterxml.jackson.databind.ObjectMapper()
.writeValueAsString(list == null ? List.of() : list);
} catch (Exception e) {
return "[]";
}
}
}
@@ -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 读写。姓名、手机号这类字段按库设计是<b>独立加密表</b>({@code pii_person}),
* 业务表只存 {@code pii_id}。
*
* <p>为什么不图省事直接塞进业务表:一旦明文字段落进 {@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<String, String> read(Long piiId) {
if (piiId == null) {
return Map.of();
}
Map<String, Object> 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", ""));
}
}
@@ -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;
/**
* 短信网关(一期,<b>演示模式不真发</b>)。
*
* <p><b>现在是什么</b>:把"该发给谁、发什么内容"落成一条 {@code notify_task}(receiver_type=SMS),
* 状态直接写 SENT,同时在日志里打一行。这样家属端和后台都能看到"短信已发出"这个事实,
* 但手机上不会真收到——演示时不要拿它当"短信通了"的证据。
*
* <p><b>接真通道要改哪里</b>:只改 {@link #deliver} 一个方法,把日志换成供应商 SDK 调用
* (阿里云 / 腾讯云短信),成功写 SENT、失败写 FAILED 并把错误码塞进 payload。
* 上层(AlarmEngine)不用动,这是刻意把边界画在这里。
*
* <p><b>为什么手机号只落脱敏串</b>:{@code wx_user.phone_enc} 是 AES 加密的,
* 通知表里再存一份明文等于多一个泄露面。演示只需要"138****0001"这种可辨认形态,
* 真发时再在网关内部解密、用完即弃,不落库。
*
* <p>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;
/**
* 合并窗口(秒):同一户、同一设备、同一规则,在这个窗口内只发一条短信。
*
* <p>为什么必须有:短信是<b>同步发</b>的,发出去就收不回来,替代机制救不了它。
* 补一次历史数据(10 条同设备 SOS)会变成 3 个家属 × 10 条 = 30 条短信,
* 手机上连着响 30 次,而列表里只有 1 条告警——这跟"替代"给人的观感完全矛盾。
*
* <p>窗口内合并是安全的:第一条已经告诉家属"出事了",后续重复触发不会再提供新信息,
* 次数记在 {@code chain_seq} 里,打开小程序能看到"又触发了 N 次"。
* 老人隔一分钟再按一次,窗口变了,照样会再发——不会被永久压掉。
*
* <p>设 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;
}
/**
* 给指定的家属发告警短信。
*
* <p><b>只有 S1(SOS / 水浸)会调到这里</b>,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<Long> onlyUsers) {
List<Map<String, Object>> 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<Long> allow = onlyUsers == null ? null : new java.util.HashSet<>(onlyUsers);
int n = 0;
for (Map<String, Object> 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("\"", "\\\"");
}
}
@@ -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;
/**
* 后台账号的数据范围。
*
* <p>规则只有一条:<b>没配就是全放,配了就收窄</b>。只对 SYS(运营/物业/审计)生效,
* 家属走自己的 {@code user_house} 逻辑。
*
* <p>范围有两种来源,新表优先:
* <ol>
* <li>{@code sys_user_region_scope}(2026-11-10 起):<b>按行政区划配</b>,一行一个覆盖范围,
* 可宽可窄——只填省就是全省,加市区逐级收窄,再给 {@code community_id} / {@code building_id}
* 就细到小区/楼栋。物业配几行覆盖一片,新小区落在已选区域内自动纳入。</li>
* <li>{@code sys_user_scope}(老):按小区逐个勾,只为兼容一期已配的账号保留。</li>
* </ol>
*
* <p><b>为什么放宽到区域</b>:物业覆盖 30 个小区就要配 30 行,新增小区不会自动纳入;
* 而且同一套"按小区"模型没法给机构端复用——机构的服务范围同样是地理区域。
*
* <p><b>为什么不缓存进令牌</b>:范围随时可能被管理员改,缓存进去就会出现
* "改了权限还得等 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<Long> communities, Set<Long> 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<Map<String, Object>> 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<Long> blds = new LinkedHashSet<>();
for (Map<String, Object> r : rows) {
if (r.get("building_id") instanceof Number n) {
blds.add(n.longValue());
}
}
// 没指定楼栋的行,按 省/市/区/小区 反查命中的小区。
// **全字段为空的行必须排除**:不排除就等于 "province IS NULL" 命中所有小区 → 误配成全放。
List<Map<String, Object>> 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<Long> comms = new LinkedHashSet<>();
for (Map<String, Object> r : hit) {
if (r.get("cid") instanceof Number n) {
comms.add(n.longValue());
}
}
return new RegionScope(false, comms, blds);
}
// ---------- ② 老表:按小区勾的(兼容) ----------
List<Map<String, Object>> 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<Long> ids = new LinkedHashSet<>();
for (Map<String, Object> 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。
*
* <p><b>只配了楼栋时,这里会把楼栋所属的小区也算进来</b>——小区维度的列表
* (小区下拉、按小区统计)没法表达"只一栋",只能宽到小区。
* 户/告警这些能表达楼栋的查询请用 {@link #householdFilter}。
*/
public Set<Long> allowedCommunities() {
RegionScope rs = resolve();
if (rs.all()) {
return null;
}
Set<Long> ids = new LinkedHashSet<>(rs.communities());
if (!rs.buildings().isEmpty()) {
List<Map<String, Object>> parents = dao.list(
"SELECT DISTINCT community_id AS cid FROM building WHERE id IN ("
+ BizDao.in(rs.buildings().size()), rs.buildings().toArray());
for (Map<String, Object> r : parents) {
if (r.get("cid") instanceof Number n) {
ids.add(n.longValue());
}
}
}
return ids;
}
/** 拼 SQL 用:没有范围返回空串,有范围返回 " AND 列 IN (?,?,?)"。 */
public String sql(String column, List<Object> args) {
Set<Long> 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 <column> IN (SELECT id FROM household hh WHERE ...)"};不限时返回空串
*/
public String householdFilter(String column, List<Object> args) {
RegionScope rs = resolve();
if (rs.all()) {
return "";
}
List<String> 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<String, Object> 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<Long> ids = allowedCommunities();
if (ids == null || communityId == null) {
return;
}
if (!ids.contains(communityId)) {
throw new cn.aomin.gateway.common.BizException(403, "无权查看该小区");
}
}
}
@@ -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;
/**
* 审计写失败的兜底:把"该写的审计"先存下来,事后再补。
*
* <p><b>为什么不是"失败就抛"这么简单</b>:审计和业务能不能一起回滚,
* 取决于调用方在不在事务里,两种情况必须分开处理,混成一个策略一定有一头是错的。
*
* <table>
* <tr><th>场景</th><th>做法</th><th>理由</th></tr>
* <tr>
* <td>告警处置({@code AlarmService},全程 {@code @Transactional})</td>
* <td><b>直接抛,整条回滚</b></td>
* <td>告警关了但查不到谁关的,比让用户重试一次严重得多</td>
* </tr>
* <tr>
* <td>后台建档 / 改房号 / 账号({@code AdminService},非事务)</td>
* <td><b>落 {@code audit_fail_log} + 记日志,业务照常完成</b></td>
* <td>业务已经提交了,抛出去只是给用户一个报错——他重试就会多建一户。
* 正确做法是让这条审计<b>留得住、能补回</b></td>
* </tr>
* </table>
*
* <p><b>为什么必须落表,不能只计数</b>(2026-11-11 二次修,GOV-008 §3.3):
* 上一版只有一个 {@code AtomicLong} + ERROR 日志。三个硬伤:
* ① 重启清零——昨天丢了 30 条,今天打开 {@code /health} 是 0;
* ② 只知道次数,不知道"丢的是哪几条",补不回来;
* ③ 只有日志,没人盯日志就等于没发生。
* 现在每条失败的审计都是 {@code audit_fail_log} 里的一行:谁、什么时候、对什么、
* 做了什么、为什么没写进去,全在。后台可列出,可一键补录回 {@code audit_log}。
*
* <p><b>兜底表也写不进去时</b>(数据库整体不可用):退回 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<Map<String, Object>> 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<String, Object> 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);
}
}
@@ -0,0 +1,58 @@
package cn.aomin.gateway.biz.support;
import java.util.List;
/**
* 当前登录人(线程内)。
*
* <p>只放三样: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<Principal> 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<Long> householdScope() {
return List.of();
}
}
@@ -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 令牌。
*
* <p>与 {@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<String> 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<String, Object> 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();
}
}
@@ -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、手机号检索哈希。
*
* <p>三件事各用各的算法,别混:
* <ul>
* <li><b>口令</b>:PBKDF2-HmacSHA256,10 万次迭代,随机盐。只验证、不还原。
* 格式 {@code pbkdf2$迭代次数$盐$摘要},一列存完,将来换 BCrypt 也能兼容判断。</li>
* <li><b>PII 姓名/手机号</b>:AES-256-GCM,随机 12 字节 IV 前置。密文可还原——这是和口令的根本区别。</li>
* <li><b>手机号检索</b>:SHA-256 十六进制,落 {@code mobile_hash}。用哈希做等值查询,不用解密。</li>
* </ul>
*
* <p><b>密钥只在配置里,不进代码、不进库。</b>本地默认值仅供开发,上线前必须换成环境变量注入。
*/
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));
}
}
@@ -0,0 +1,14 @@
package cn.aomin.gateway.biz.support;
import java.util.List;
/**
* 分页返回体。字段名和前端习惯一致:{@code list / total / page / size / pages}。
*/
public record PageResult<T>(List<T> list, long total, int page, int size, int pages) {
public static <T> PageResult<T> of(List<T> 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);
}
}
@@ -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;
/**
* 事件表分区维护。
*
* <p><b>为什么必须有这个任务</b>:基线里 {@code iot_event} 按 {@code event_time} 月分区,
* 但只建到 {@code p202612},后面挂一个 {@code pmax} 兜底。
* 没有这个任务的话,2027 年 1 月起的数据全进 {@code pmax},越堆越厚,
* 到时候既 {@code DROP PARTITION} 收不掉(它装着全部新数据),也 {@code ADD PARTITION} 加不进去——
* 只能重建表。分区表的意义是"回收靠删分区",不是"建好了就完事"。
*
* <p><b>做法</b>:每天检查一次,永远保持未来 {@code ahead-months} 个月的分区已就位。
* 加分区用 {@code REORGANIZE PARTITION pmax} 切一刀,把 {@code pmax} 的下界往后推一个月。
* 这是在线 DDL,不锁全表读,也不搬数据(切出来的新区间本来就是空的)。
*
* <p><b>为什么放在应用里而不是让运维每月手跑</b>:手跑一定会忘。忘了不会立刻报错,
* 等发现时数据已经堆进 {@code pmax} 几个月了。这种"不报错但慢慢烂"的事必须自动化。
*
* <p><b>删旧分区默认关着</b>({@code auto-drop=false})。删数据是不可逆动作,
* 保留期算错一次就没了。默认只在日志里提示"哪些分区已超期、可以手工 DROP",
* 要自动回收就把 {@code biz.partition.auto-drop} 打开。
*
* <p>多实例部署时用 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<Target> 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 跑一次。错开整点——整点是各种备份、统计任务的拥挤时段。
*
* <p>启动时也跑一次({@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<Partition> 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<String> 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 里有没有数据。
*
* <p>正常情况下 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<Partition> listPartitions(String table) {
List<Map<String, Object>> 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<Partition> out = new ArrayList<>();
for (Map<String, Object> 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) {
}
/**
* 一个分区的边界视图。
*
* <p>{@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");
}
}
}
@@ -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 连接池:谁在线,就往谁的连接上推。
*
* <p><b>为什么是 SSE 不是 WebSocket</b>:这条链路只需要"服务端单向推给家属端",
* 家属端回话走普通 HTTP(确认、处置)。WebSocket 要额外维护握手、心跳、重连和跨域配置,
* 而 SSE 就是一个不结束的 GET 响应,浏览器自带 {@code EventSource} 和断线重连,
* 穿透(cpolar / nginx)也只需要关掉缓冲即可,运维面小得多。
*
* <p><b>为什么按 userId 存而不是按 householdId</b>:同一个人可能在多个户里(自己的家、父母家),
* 按人存一份连接,推送时再按户筛人,避免一个人开两个页面建两条连接。
*
* <p><b>连接会消失得毫无征兆</b>:关页面、手机锁屏、代理超时都算。所以三个回调
* (完成/超时/错误)都要挂清理,另外每 25 秒发一次注释心跳,让中间的代理别把它当空闲连接掐掉。
*/
@Component
public class SseHub {
private static final Logger log = LoggerFactory.getLogger(SseHub.class);
/** userId → 该用户的全部连接(同一个人多标签页会有多条)。 */
private final Map<Long, CopyOnWriteArrayList<SseEmitter>> byUser = new ConcurrentHashMap<>();
/**
* 建立一条连接。
*
* <p>{@code timeout=0} 表示不设超时——家属端可能整天挂着。真正断开靠心跳失败和浏览器回收,
* 不靠服务端设时限(设了反而会出现"到点必断、断了必重连"的无谓抖动)。
*/
public SseEmitter register(Long userId) {
SseEmitter emitter = new SseEmitter(0L);
CopyOnWriteArrayList<SseEmitter> list =
byUser.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>());
list.add(emitter);
Runnable remove = () -> {
CopyOnWriteArrayList<SseEmitter> 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<Long> userIds, String eventName, Object data) {
if (userIds == null || userIds.isEmpty()) {
return 0;
}
int sent = 0;
for (Long uid : userIds) {
List<SseEmitter> 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;
}
/**
* 唯一的发送出口,必须加锁。
*
* <p><b>为什么</b>:{@code SseEmitter.send()} <b>不是线程安全的</b>。
* 告警推送在业务线程、心跳在调度线程,两边同时写同一条异步响应,
* 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();
}
/**
* 心跳。发的是注释行(冒号开头),浏览器不会当作事件派发,只用来保活。
*
* <p>15 秒一次:Tomcat 的 {@code connectionTimeout} 默认 20 秒,
* 心跳必须比它短,否则空闲 20 秒就被容器判定超时、连接作废。
*/
@Scheduled(fixedDelay = 15000, initialDelay = 15000)
public void heartbeat() {
for (Map.Entry<Long, CopyOnWriteArrayList<SseEmitter>> en : byUser.entrySet()) {
CopyOnWriteArrayList<SseEmitter> 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);
}
}
}
}
@@ -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;
/**
* 免打扰闸门。
*
* <p><b>按人各管各的手机</b>(2026-11-09 定):{@code notify_preference} 是
* {@code wx_user_id + household_id} 维度,同一条告警对不同家属可以一个响一个不响。
* 不做"按户取最松"——那等于让最能熬夜的人替所有人决定。
*
* <p><b>谁能穿透静默</b>:只有 S1。SOS 和水浸不管几点都弹、都发短信。
* S2/S3 在静默期只落库进列表,不弹窗、不响铃、不发短信。
*
* <p><b>配置读法</b>:
* <ul>
* <li>有本户记录 → 用本户的;</li>
* <li>只有 {@code household_id=0} 的记录 → 用全局默认;</li>
* <li>一条都没有 → 用代码默认(22:00–07:00)。</li>
* </ul>
* 记录存在但 {@code quiet_start} 为 NULL,表示这个人自己关掉了免打扰,此时 <b>不静默</b>。
* 这跟"没配置"是两件事:前者是他明确表示不要静默,后者是他还没设过。
*
* <p><b>一期没做</b>:{@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<String, Object> 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<Long> quietUsers(List<Long> userIds, long householdId) {
java.util.Set<Long> 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;
}
}
@@ -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 库)。
*
* <p>格式:{@code base64url(payload).base64url(签名)}。payload 只放四个字段:
* 用户 id、类型(WX/SYS)、显示名、过期时间戳。
*
* <p><b>为什么不用标准 JWT</b>:这里只需要"防篡改 + 有过期",不需要跨系统互认。
* 自己签 30 行就能看懂,出问题好查;换成 nimbus 反而多一层依赖。
*
* <p>密钥从 {@code biz.token.secret} 读,本地有默认值,<b>上线必须换成环境变量</b>。
*/
@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<String, Object> 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<String, Object> 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<String, Object> 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);
}
}
}
@@ -0,0 +1,62 @@
package cn.aomin.gateway.common;
/**
* 统一返回体。
*
* <p>五个 /internal 接口的返回格式统一为 {code, msg, data},
* 前端和 IoT 侧只看 code:0 表示成功,非 0 表示业务失败。
*
* <p>与 J-00 V1.6 §2 的错误码约定保持一致:
* 400 参数/信封不合法、401 鉴权失败、409 幂等冲突、500 服务内部错误。
*/
public class ApiResponse<T> {
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 <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "ok", data);
}
public static ApiResponse<Void> ok() {
return new ApiResponse<>(0, "ok", null);
}
public static <T> ApiResponse<T> 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;
}
}
@@ -0,0 +1,44 @@
package cn.aomin.gateway.common;
/**
* 业务异常。抛出后由 GlobalExceptionHandler 统一转成 ApiResponse,不打印完整堆栈。
*
* <p>约定: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);
}
}
@@ -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;
/**
* 全局异常处理。
*
* <p>原则:HTTP 状态码与 body 里的 code 保持一致,接入侧只判断一个维度即可。
* 业务异常不打印堆栈(噪音大),未知异常打印完整堆栈。
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> handleUnreadable(HttpMessageNotReadableException e) {
log.warn("报文无法解析: {}", e.getMostSpecificCause().getMessage());
return ResponseEntity.badRequest().body(ApiResponse.fail(400, "报文格式错误"));
}
/**
* 缺必填 query 参数(如 I-18 的 {@code nodeId})。
*
* <p>J-00 §1.7 把"参数不合法"归 400。不接这一手,Spring 会把它扔进下面的
* {@code Exception} 兜底,变成 500——接入侧看到 500 会当成我方故障去重试,
* 而实际上重试一百次也一样错。
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> 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<ApiResponse<Void>> 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<ApiResponse<Void>> handleOther(Exception e) {
log.error("未捕获异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail(500, "服务内部错误"));
}
/**
* 路径不存在。
*
* <p>Spring Boot 3.2 起,找不到 handler 会抛 NoResourceFoundException,
* 如果不单独接住,就会落到下面的 Exception 兜底里变成 500。
* 那时候对接方看到"服务内部错误",会去查我们的代码,实际是他自己路径写错了。
*/
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(NoResourceFoundException e) {
log.warn("接口不存在: {} {}", e.getHttpMethod(), e.getResourcePath());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.fail(404, "接口不存在:" + e.getResourcePath()));
}
}
@@ -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.*} 读。
*
* <p><b>{@code auth.enabled} 默认 false</b>:联调阶段先不拦,
* 等 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))}
*
* <p>生产环境必须从环境变量或配置中心注入,<b>不要写死在 yml 里提交</b>。
*/
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} 无关。
*
* <p>{@code enabled=false}(联调态)时签名全跳过,如果这时网络闸也关着,
* {@code /internal/**} 就是完全裸奔——文档写的"仅内网"变成一句空话。
* 所以这两件事分开关:签名可以晚点开,网络闸必须一开始就有。
*/
private boolean networkGuardEnabled = true;
/**
* 允许访问 {@code /internal/**} 的网段,CIDR 写法({@code 192.168.1.0/24})。
*
* <p>**留空即用内置默认**:{@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}。
* 一旦配了值就<b>以配置为准</b>,默认值不再参与——要收紧就只写 IoT 服务器那一个地址。
*/
private java.util.List<String> allowedCidrs = new java.util.ArrayList<>();
public boolean isNetworkGuardEnabled() {
return networkGuardEnabled;
}
public void setNetworkGuardEnabled(boolean networkGuardEnabled) {
this.networkGuardEnabled = networkGuardEnabled;
}
public java.util.List<String> getAllowedCidrs() {
return allowedCidrs;
}
public void setAllowedCidrs(java.util.List<String> 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})取值来源。
*
* <p><b>{@code link_type} 不发在 J-00 报文里,也不由 {@code sourceChannel} 推</b>——
* 麦乐克两套 TCP(bignbiot 与铠湾 V3.0)同属 {@code MIR_CELL},推不出来。
* 它是部署事实:哪个监听端口接哪套协议,只有部署的人知道,所以放配置。
*
* <p>两级查找,先精确后兜底,都查不到就留 NULL(列可空,不编):
* <ol>
* <li>{@code linkTypes}:按接入端点(I-12 的 {@code gatewayDevId})精确匹配;</li>
* <li>{@code channelLinkTypes}:按 {@code sourceChannel} 兜底,只适合"一个通道只有一套协议"的线,
* 例如 {@code HORN_HOST} 恒 MQTT。</li>
* </ol>
*/
public static class Raw {
/** 接入端点 id → link_type。优先于 channelLinkTypes */
private Map<String, String> linkTypes = new LinkedHashMap<>();
/** sourceChannel → link_type,兜底用 */
private Map<String, String> channelLinkTypes = new LinkedHashMap<>();
public Map<String, String> getLinkTypes() {
return linkTypes;
}
public void setLinkTypes(Map<String, String> linkTypes) {
this.linkTypes = linkTypes == null ? new LinkedHashMap<>() : linkTypes;
}
public Map<String, String> getChannelLinkTypes() {
return channelLinkTypes;
}
public void setChannelLinkTypes(Map<String, String> channelLinkTypes) {
this.channelLinkTypes = channelLinkTypes == null ? new LinkedHashMap<>() : channelLinkTypes;
}
}
}
@@ -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 配置。
*
* <p>默认的 RedisTemplate 用 JDK 序列化,写进去的 key 会带二进制前缀,
* redis-cli 里看到的是乱码、TTL 也不好核对。这里统一改成字符串序列化,
* 幂等键和 nonce 都是纯字符串,用 String 序列化最合适。
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> 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;
}
}
@@ -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 配置。
*
* <p>只做一件事:把 M-01 拦截层挂到 /internal/** 上。
*
* <p>两层,顺序不能颠倒:
* <ol>
* <li>{@link InternalNetworkGuard}(order -1):来源必须是内网。**和签名开关无关**,
* {@code gateway.auth.enabled=false} 时它照样拦——否则联调态下谁都能伪造事件;</li>
* <li>{@link AuthInterceptor}(order 0):Token / 时间戳 / nonce / 签名。</li>
* </ol>
*
* <p>为什么路径是 /internal:J-00 V1.6 的约定是"只允许 IoT 调业务层、
* 业务层不持 IoT 端点",这五个接口必须走内网、且和对外的小程序 API 分开,
* 便于后续在 Nginx 上按前缀做 IP 白名单。
*
* <p>/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 演示端(家属端 / 后台端)。
*
* <p>故意指到工程外的 {@code web} 目录,不塞进 {@code resources/static}:
* 改一行样式不用重新打包,刷新浏览器就生效——现场给老板演示时这一点很要紧。
* 目录不存在也不会报错,只是 404。
*
* <p><b>路径不再写死盘符</b>(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);
}
}
@@ -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 完全一致。
*
* <pre>
* I-18 GET /internal/iot/v1/commands/pending 长轮询领命令
* I-05 POST /internal/iot/v1/commands/result 回报执行结果
* </pre>
*
* <p>I-18 <b>不带幂等键</b>:它是 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 逗号分隔,<b>只传当前在线的接入端点</b>。
* 离线端点的命令因此一条都不会被拉走,天然实现"等上线再发"。
* @param wait 长轮询最长挂起秒数,0 = 立即返回
*/
@GetMapping("/pending")
public DeferredResult<ApiResponse<PendingCommandResponse>> pending(
@RequestParam String nodeId,
@RequestParam String gatewayIds,
@RequestParam(defaultValue = "30") int wait,
@RequestParam(defaultValue = "50") int limit) {
List<String> 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<IngestResponse> 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);
}
}
@@ -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;
/**
* 健康检查。
*
* <p>这个接口存在的意义不是"能给运维看",而是<b>一次请求验证四件事通</b>:
* Web 容器起来了、MySQL 连得上、Redis 连得上、Flyway 基线认上了。
* 四个环节任一断了,这里会直接显示断在哪一环,不用去翻日志猜。
*
* <p>不放 /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<Map<String, Object>> health() {
Map<String, Object> 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<String> 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;
}
}
@@ -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 完全一致。
*
* <pre>
* 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 原始报文
* </pre>
*
* <p>三个方法长得一样,因为差异全在 Service 里。这里只剩两件事:
* 取原始 body(给幂等算 hash)、调幂等执行器。
*
* <p><b>为什么幂等要拿原始 body</b>:用反序列化后再序列化的字符串算 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<IngestResponse> 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<IngestResponse> 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<IngestResponse> 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];
}
}
@@ -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 的**七态状态机**。
*
* <pre>
* PENDING ──I-18 领取──► CLAIMED ──I-05 SENT──► SENT ──ACKED code=0──► ACKED(终态)
* ▲ │ │
* ├─ 租约到期 60s ──────┤ └─ 24h 无回执 ──► FAILED(终态)
* ├─ 可延迟 + 失败重排 ──┘
* └─ 10 次重排 或 72h 硬顶 ──► TIMEOUT(终态)/ 撤单 ──► CANCELLED(终态)
* </pre>
*
* <p><b>为什么 {@code CLAIMED} 必须独立于 {@code SENT}</b>:前者是"IoT 把命令领走了",
* 后者是"已经发到设备了"。合成一个值以后,租约回收判不出该不该重派,
* 也分不清是中间件失联还是设备没响应——故障定位会直接断在这里。
*
* <p><b>租约代次({@code claim_generation})防的是什么</b>:
* 一条命令被领走 → 租约到期 → 回 PENDING → 又被领走。此时第一个 IoT 节点的回执才迟到,
* 如果不校验代次,这条旧回执会把第二次的流转状态覆盖掉,而且不报错。
* 每次 CLAIMED 让代次 +1,回执更新时带上前一次读到的代次,
* 代次对不上就只更新 0 行,由上层记为"过期回执"。
*
* <p><b>领取节点({@code claimed_node_id})补的是另一半</b>:代次只说明"第几轮",
* 不说明"谁"。没有节点列时,一个代次没变的命令,任何调用方都能用命令号改写它的流转。
* 现在两列由同一条 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<String> TERMINAL = List.of(ST_ACKED, ST_FAILED, ST_TIMEOUT, ST_CANCELLED);
/** 在途:只有这两个状态可以接收 I-05 回执 */
public static final List<String> 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";
/**
* 拉取待下发命令。
*
* <p>{@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,记下是谁领的。
*
* <p><b>{@code claimed_node_id} 必须和代次一起落</b>:代次只说明"第几次",
* 不说明"谁"。两列同一条 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<CommandRow> pullAndClaim(String nodeId, List<String> 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<Object> args = new ArrayList<>();
args.add(ST_PENDING);
args.addAll(gatewayIds);
args.add(limit);
List<CommandRow> 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<Object> 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<CommandRow> 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<CommandRow> 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<CommandRow> list = jdbc.query(
"SELECT " + COLS + " FROM device_command WHERE id = ?", ROW_MAPPER, id);
return list.isEmpty() ? null : list.get(0);
}
/**
* 按回报结果流转状态。<b>四个守卫缺一不可</b>:
*
* <ol>
* <li><b>节点守卫</b> {@code claimed_node_id = ?}:回执必须来自领走这一轮的那个节点;</li>
* <li><b>代次守卫</b> {@code claim_generation = ?}:旧回执(命令被回收重派过)改不动;</li>
* <li><b>前置状态守卫</b> {@code status IN ('CLAIMED','SENT')}:终态不回退、PENDING 不被莫名的回执改;</li>
* <li><b>终态守卫</b>:{@code ACKED/FAILED/TIMEOUT/CANCELLED} 一旦落定,重复回执只更新 0 行。</li>
* </ol>
*
* <p><b>为什么节点和代次都要</b>:代次回答"第几轮",节点回答"这一轮是谁"。
* 只有代次时,任何知道命令号的调用方都能在代次没变的情况下改写这条命令的流转;
* 只有节点时,租约回收重派后第一轮的迟到回执会被当成第二轮采纳。两个一起才闭合。
*
* <p>四条合起来的返回值就是判据:<b>1 = 这次回执生效,0 = 过期/重复/错节点回执</b>。
* 上层据此把"收到了但没采纳"和"处理失败"分开计数,不再混成一个 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<CommandRow> 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<CommandRow> 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 这条命令从哪个端点进去——<b>不是网关 SN</b>,独立设备填自身标识。
* 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) {
}
}
@@ -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} 表只读访问。
*
* <p><b>一条 SQL 查一批,不逐条查。</b>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<DeviceRow> 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<String, DeviceRow> selectBySensorIds(Collection<String> sensorIds) {
Map<String, DeviceRow> result = new HashMap<>(sensorIds.size() * 2);
if (sensorIds.isEmpty()) {
return result;
}
String placeholders = String.join(",", java.util.Collections.nCopies(sensorIds.size(), "?"));
List<DeviceRow> rows = jdbc.query(
SQL_SELECT_BY_SENSOR_IDS.formatted(placeholders),
MAPPER,
sensorIds.toArray());
for (DeviceRow r : rows) {
result.put(r.sensorId(), r);
}
return result;
}
}
@@ -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} 写入。
*
* <p>16 列里 11 列是纯抄 J-00 信封,见 M-00 §4.1:
* <pre>
* 抄 eventId / sensorId / sourceChannel / eventType / deviceTime /
* tsSource / receivedAt / seq / payload / rawRef / protocolVersion
* 查表补 devId / householdId (来自 device)
* 判 isBackfill (墓碑帧=1)
* 置 processed = 0 (等规则引擎扫)
* 派生 eventTime (EventTimeResolver)
* </pre>
*
* <p><b>不做预查重。</b>唯一键 {@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);
}
}
}
@@ -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。
*
* <p><b>三条铁律:</b>
* <ol>
* <li>{@code payload} 存 <b>Base64 文本本身,不解码</b>。列是 {@code MEDIUMTEXT} + utf8mb4,
* 装不了任意二进制——解码后存会报 {@code Incorrect string value},
* 严格模式关掉则被替换成 {@code ?},数据永久损坏且不报错;</li>
* <li>{@code payload_md5} 直接抄 {@code rawRef},<b>绝不重算</b>。
* 对 Base64 文本算出来的值跟"解码后字节的 MD5"完全不同,
* 重算会让 {@code iot_event.raw_ref} 再也 join 不上;</li>
* <li>{@code payload_size} 填解码后的字节数({@code frameBytes}),不是 Base64 文本长度。</li>
* </ol>
*
* <p><b>三列已由 Flyway V1.0.1 补上</b>({@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},<b>不拒收、不降级</b>。
*
* <p>{@code link_type} <b>不从 {@code sourceChannel} 推</b>——麦乐克两套 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 线走这张表。
*
* <p>{@code vendor} 由 {@code sourceChannel} 推出({@code HORN_*}→HORN、{@code MIR_CELL}→MIR),
* 不是从报文里猜——通道是部署时配死的,{@code HORN_CELL} 下不可能挂麦乐克的设备。
*
* <p>{@code device_sn} 是 {@code NOT NULL},而 I-12 的 {@code sensorId} 可空。
* 解析不出时填 {@code UNKNOWN} 并把 {@code parseResult} 标成 {@code FIELD_MISSING}——
* <b>不丢帧</b>,因为"设备连上来发了我方解析不了的东西"正是排障最需要的证据。
*/
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);
}
}
}
@@ -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} —— 孤儿摘要登记。
*
* <p><b>解决什么问题。</b>J-00 V1.7.1 把 {@code rawRef} 升为必填,那只挡住"字段为空"。
* 但 I-12(原文)与 I-01(事件)是<b>两次独立请求</b>:接入侧漏发原文、超时、
* 或者顺序反了(先 I-01 后 I-12),事件照样能入库成功,{@code raw_ref} 指向一个
* 不存在的记录。这类行如果不登记,就会出现"每家都号称能回查原文,实际查不到"。
*
* <p><b>为什么是软标记,不是拒收。</b>真机上 I-12 可能比 I-01 晚到几十毫秒,
* 硬拒会把正常事件挡在门外——V1.7 那次"64 位 SHA-256 被误判 SCHEMA_INVALID"
* 就是这个坑的翻版:契约允许,实现拒收,接真机第一天全量失败,
* 日志还只显示"字段非法",查不出原因。
*
* <p><b>能自愈。</b>原文后到时把 {@code resolved_at} 置位,行不删——
* 留着才能算孤儿率和自愈率;全删掉就只剩"此刻有多少条",没有历史。
*/
@Repository
public class RawRefGapDao {
private final JdbcTemplate jdbc;
public RawRefGapDao(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
/**
* 这个摘要对应的原文在不在。
*
* <p>只看一条链: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}。
*
* <p>墓碑帧一个 {@code rawRef} 会被多条事件引用(多对一),所以这里按
* {@code raw_ref} 唯一,不是按 {@code event_id}。
*
* <p>{@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;
}
}
@@ -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 的四条规则。
*
* <p>唯一键 {@code uk_dev_id(dev_id)},所以一设备一行,收到新快照就覆盖。
*
* <p><b>四条规则全在这条 SQL 里,改它之前先看 M-00 §5.3:</b>
* <ol>
* <li>{@code last_seen_at} 用 {@code GREATEST} 防回退——乱序到达时后一批可能时间更早,
* 直接覆盖会让"最后一次上报"倒退,离线判定跟着误判;</li>
* <li><b>不写 {@code battery}(百分比)</b>——换算式会变,换算在业务层;</li>
* <li><b>不写 {@code undelivered_count}</b>——那是业务层断档累计值,不是设备报的 {@code lostCnt},
* 覆盖了断档报表就再也算不对;</li>
* <li><b>不写 {@code last_heartbeat_at}</b>——I-02 没有"这是心跳"的标记,
* 拿告警当心跳等于伪造"设备在正常心跳"的证据。</li>
* </ol>
*/
@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 分支要传的在线状态。
*
* <p>{@code online=false} 时<b>传 UNKNOWN 而不是 OFFLINE</b>:
* 这条列是 {@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);
});
}
}
@@ -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}:未建档设备的落脚点。
*
* <p><b>为什么不能直接丢</b>:一台没登记过的设备在发数据,这件事本身就是运营要处理的信号。
* 丢了就永远不知道有野设备在跑(M-00 §4.5 / §5.5)。
*
* <p>两个方法对应两种场景,<b>别混用</b>:
* <ul>
* <li>{@link #upsertFromEvent} —— I-01 用,带 {@code last_payload}(最后一次上报的事件);</li>
* <li>{@link #touchFromStatus} —— I-02 用,<b>不写 last_payload</b>。
* 快照不是事件,写进去会把"最后一次上报的事件"覆盖成状态快照,
* 运营认领时看到的是错的东西。</li>
* </ul>
*/
@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);
});
}
}
@@ -0,0 +1,53 @@
package cn.aomin.gateway.domain;
/**
* 四个来源通道,取值与 J-00 §1.3 / OpenAPI {@code SourceChannel} 完全一致。
*
* <p><b>不要给它加 {@code KAIWAN}</b>:铠湾是协议名不是厂商,
* V1.0 定稿里 16 处提到铠湾全是"智能无线终端通信协议(4G)V3.0(铠湾)",
* 设备是麦乐克的 MIR-WA301 / SO300 / TE300。
* 铠湾设备的 {@code sourceChannel} 就该是 {@code MIR_CELL}。
*
* <p>这个枚举只决定三件事(OpenAPI 注释原文):
* {@code dev_id} 生成规则、{@code ts_precision} 默认值、原文落哪张表。
* <b>它从来不是"协议标识"</b>——麦乐克两套 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;
}
}
@@ -0,0 +1,25 @@
package cn.aomin.gateway.domain;
/**
* {@code device} 表一次批量查询的结果行(M-00 §2.3)。
*
* <p>只取入库真正要用的列,不 {@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) {
}
@@ -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";
}
@@ -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。
*
* <p>{@code result} 五个值,每个值的处置不一样——<b>IoT 只负责如实报,怎么处置是业务层的事</b>:
* <table>
* <tr><th>result</th><th>IoT 怎么得出</th><th>业务层处置</th></tr>
* <tr><td>ACKED</td><td>收到回执帧</td><td>看 resultCode 分流</td></tr>
* <tr><td>SENT</td><td>发出去了,本协议无回执通道</td><td>保持已下发,等 24h 兜底</td></tr>
* <tr><td>TIMEOUT</td><td>发出去了等不到回执</td><td>实时=失败;可延迟=重排</td></tr>
* <tr><td>DEVICE_OFFLINE</td><td>发前查连接,不在</td><td>实时=失败;可延迟=重排</td></tr>
* <tr><td>FAILED</td><td>IoT 自身发送失败</td><td>同上</td></tr>
* </table>
*
* <p>{@code resultCode} 是<b>标准字典值</b>(J-00 V1.7 §3.3),由 IoT 翻译后填:
* {@code 0} 成功/{@code 1} 不支持/{@code 2} 参数非法/{@code 3} 执行失败/{@code 4} 设备忙。
* <b>厂家原码不得透传到这里</b>,原样字符串填 {@code rawResultCode}。
*
* <p>为什么必须分开:厂家原码各不一样(铠湾 {@code 08} 是水浸、bignbiot {@code 08} 是白蚁,
* 命令侧同样有这种坑)。直透到标准列以后,业务层和前端就得按每个厂家各写一套判断,
* 等于把适配器的活摊到全平台。
*
* <p><b>顶层 {@code nodeId} 必填(V1.8 起)</b>:它必须和 I-18 领取时落库的
* {@code claimed_node_id} 一致,否则整条回执按 {@code NODE_MISMATCH} 拒收。
* 注意它的作用边界——<b>它不是身份认证</b>,值由 IoT 自报,同样可以被伪造;
* 证明"调用方是谁"靠的是内网来源白名单 + 签名。这里校验的是<b>领取事实的一致性</b>:
* 这条结果是不是领走这一轮的那个节点回的。
*/
public class CommandResultRequest {
private String nodeId;
private List<ResultItem> results = new ArrayList<>();
public String getNodeId() {
return nodeId;
}
public void setNodeId(String nodeId) {
this.nodeId = nodeId;
}
public List<ResultItem> getResults() {
return results;
}
public void setResults(List<ResultItem> 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;
/** 设备回执到达时刻。<b>SENT / DEVICE_OFFLINE / FAILED 时必须为 null</b> */
private OffsetDateTime ackAt;
/** 原始回执帧,Base64 或 hex,排障用 */
private String rawAck;
/**
* I-18 领取时拿到的 {@code claimGeneration},原样回带。<b>V1.8 起必填</b>。
*
* <p>服务端用它当 {@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;
}
}
}
@@ -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}。
*
* <p>批上限 200 条。外层 {@code sourceChannel} 是缺省值,内层优先——
* 同机多通道部署时有用(M-00 §6.2 同款规则)。
*
* <p><b>墓碑帧不走独立数组</b>: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<EventItem> events;
public String getSourceChannel() {
return sourceChannel;
}
public void setSourceChannel(String sourceChannel) {
this.sourceChannel = sourceChannel;
}
public List<EventItem> getEvents() {
return events;
}
public void setEvents(List<EventItem> events) {
this.events = events;
}
/**
* 单条事件。
*
* <p>字段名一律按 J-00 V1.6 的驼峰写法。
* <b>不要用早期信封的 {@code device_sn} / {@code event_ts}</b>,
* 那两个名字在 V1.4 起已废止。
*/
public static class EventItem {
/** 事件级幂等键,≤96 字符,不得含平台时间戳 */
private String eventId;
/** 设备物理标识,必须与 device.sensor_id 完全一致(含厂商前缀) */
private String sensorId;
private String sourceChannel;
/** 枚举值,出现枚举外的新值不拒收 */
private String eventType;
/** 设备侧时间,没有就 null。<b>禁止用 receivedAt 顶替</b> */
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;
}
}
}
@@ -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}。
*
* <p><b>这四个数不是互斥的四类,是重叠的</b>(见 M-00 §2.4):
* <pre>
* total = accepted + rejected
* accepted ⊇ duplicated
* accepted ⊇ unregistered
* </pre>
* 例:200 条里 2 条格式错、3 条重复、5 条未建档 →
* {@code accepted=198, rejected=2, duplicated=3, unregistered=5}。
*
* <p><b>{@code accepted++} 必须在"通过校验"时做,不能在 INSERT 成功后做</b>——
* 后者会让 {@code accepted + rejected != total},四个数全乱。
*/
public class IngestResponse {
private int accepted;
private int rejected;
private int duplicated;
private int unregistered;
private List<RejectedDetail> 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<RejectedDetail> getRejectedDetail() {
return rejectedDetail;
}
public void setRejectedDetail(List<RejectedDetail> rejectedDetail) {
this.rejectedDetail = rejectedDetail;
}
/**
* 拒收明细。
*
* <p>{@code reason} 只用 J-00 §1.7 原样那四个值,<b>不得自造</b>:
* {@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;
}
}
}
@@ -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。
*
* <p>字段名与 J-00 完全一致:{@code commandId} / {@code gatewayId} / {@code targetId}。
* <b>不要用 {@code devId} / {@code gatewayDevId} / {@code commandNo}</b>——
* 那是材料包 B6 写错的名字,会让 IoT 侧查不到通道,全部报 DEVICE_OFFLINE。
*/
public class PendingCommandResponse {
private String nodeId;
private List<PendingCommand> 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<PendingCommand> getCommands() {
return commands;
}
public void setCommands(List<PendingCommand> commands) {
this.commands = commands;
}
public boolean isHasMore() {
return hasMore;
}
public void setHasMore(boolean hasMore) {
this.hasMore = hasMore;
}
public static class PendingCommand {
/** 命令号,字符串。**不是数字**,数字会让前导零丢失 */
private String commandId;
/**
* 这条命令从哪个端点进去——<b>不是网关 SN</b>。
* 独立设备填设备自身标识,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 新增)。
*
* <p><b>IoT 必须原样保存,并在 I-05 回执里回带</b>。原因:一条命令可能被领走两次
* (第一次租约 60 秒到期没回话,被回收重派)。此时第一个节点的回执迟到,
* 如果只按命令号落库,这条旧回执会把第二次的流转覆盖掉,而且不报错。
*
* <p>代次只在服务端 { 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;
}
}
}
@@ -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}。
*
* <p><b>这是全集的口子,不是 I-01 的附件。</b>所有上行帧都从这里进一次,
* 其中"能变成业务事件"的那部分另由 I-01 再报一次。
* 同一帧走两条链路不是重复——一条存档,一条产生业务后果。
*
* <p>批上限 200 条。
*/
public class RawFrameBatchRequest {
private String sourceChannel;
private List<RawFrameItem> frames;
public String getSourceChannel() {
return sourceChannel;
}
public void setSourceChannel(String sourceChannel) {
this.sourceChannel = sourceChannel;
}
public List<RawFrameItem> getFrames() {
return frames;
}
public void setFrames(List<RawFrameItem> frames) {
this.frames = frames;
}
public static class RawFrameItem {
/** MD5(原始帧字节),32 位小写 hex。<b>它就是幂等键</b> */
private String rawRef;
/**
* 解析出设备标识时带上。<b>未建档设备也要带</b>——有值才追得回是哪个 SN。
* 实在解析不出可留空,原文照样落库。
*/
private String sensorId;
private String sourceChannel;
/** 平台收到这一帧的时刻。<b>不是设备时间</b> */
private OffsetDateTime receivedAt;
/**
* <b>Base64,定死这一种表示。</b>
* 十六进制会把体积翻倍,而 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;
}
}
}
@@ -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}。
*
* <p>批上限 500 条。这份数据<b>不产生任何业务后果</b>,只落 {@code device_tech_cache}
* 做界面展示与离线判定的原料(M-00 §5)。
*/
public class StatusBatchRequest {
private String sourceChannel;
private List<StatusItem> statuses;
public String getSourceChannel() {
return sourceChannel;
}
public void setSourceChannel(String sourceChannel) {
this.sourceChannel = sourceChannel;
}
public List<StatusItem> getStatuses() {
return statuses;
}
public void setStatuses(List<StatusItem> statuses) {
this.statuses = statuses;
}
public static class StatusItem {
private String sensorId;
private String sourceChannel;
/**
* 只表示"最近是否有报文",<b>不表示设备好坏</b>。
* M-03 只在 true 时写 ONLINE,false 不改状态(M-00 §5.4)。
*/
private Boolean online;
/** 最后一次收到有效报文的时刻 */
private OffsetDateTime lastSeenAt;
/** 毫伏整数。<b>不换算成百分比</b>——换算式会变,换算在业务层 */
private Integer batteryMv;
/** 0-31,99 = 未知。<b>99 不是弱信号</b> */
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;
}
}
}
@@ -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} 接口共用。
*
* <p>只做四件事,别的都不要往这里塞:
* <ol>
* <li><b>Token</b>:{@code X-Internal-Token},常量时间比对;</li>
* <li><b>时间戳</b>:{@code X-Timestamp} 与服务器时间差 ≤ 300 秒;</li>
* <li><b>防重放</b>:{@code X-Nonce} 在 Redis 里 600 秒内不可复用;</li>
* <li><b>签名</b>:{@code X-Sign = HMAC-SHA256(token, method\npath\ntimestamp\nnonce\nsha256(body))}。</li>
* </ol>
*
* <p><b>限流和幂等不在这里做</b>:
* <ul>
* <li>限流要知道"本批多少条"和"哪个通道",那是解析之后的事,放在 Service;
* 通道维度一次算一批,设备维度走 pipeline 批量判。</li>
* <li>幂等要包住整个业务处理并在成功后写回结果,拦截器拿不到响应体,放在 Service 更直接
* (见 {@link cn.aomin.gateway.security.IdempotentExecutor})。</li>
* </ul>
*
* <p><b>顺序是有讲究的</b>:Token(最便宜)→ 时间戳(一次减法)→ nonce(一次 Redis)
* → 签名(要算 HMAC,最贵)。前面挡掉的请求不用付后面的代价。
*
* <p>{@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;
}
}
@@ -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/**} 只接受内网调用。
*
* <p><b>为什么单独有这一层</b>:{@code gateway.auth.enabled} 默认 false(联调态不验签),
* 这时 {@link AuthInterceptor} 全部跳过,任何能连到 8080 的人都能调 I-01 伪造事件。
* 文档写的是"仅内网",那就得有代码兜住这句话,不能只靠部署时记得配防火墙。
* 这一层和签名校验是两件独立的事:签名关掉时它照样生效。
*
* <p><b>只认 {@code getRemoteAddr()},不认 {@code X-Forwarded-For}</b>。
* 后者是请求头,调用方随便写;按它判断等于把门开着还挂个牌子。
* 部署在 Nginx 后面时,{@code remoteAddr} 是 Nginx 所在机器(本机或内网),仍在白名单内——
* 真正的公网边界由 Nginx 那层做,这里兜的是"绕过 Nginx 直连 8080"。
*
* <p>默认允许环回 + 三段 RFC1918 私网,覆盖本机自测、局域网演示、同机 Nginx 反代三种场景。
* 需要更严(例如只放行某一台 IoT 服务器)就配 {@code gateway.auth.allowed-cidrs},
* 配了之后<b>以配置为准</b>,内置默认值不再参与。
*
* <p>要临时放开(现场演示机在公网 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<Cidr> 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<Cidr> parse(List<String> configured) {
List<String> 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<Cidr> 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 字面量转成定长字节。
*
* <p><b>先正则后解析</b>:{@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);
}
}
}
@@ -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 缓存下来,让验签能读到它。
*
* <p><b>为什么必须有这一层</b>:{@code HttpServletRequest} 的输入流只能读一次。
* 验签要算 {@code sha256(body)},读完之后 Spring 再去反序列化 JSON 就拿到空流,
* 表现为"验签通过但参数全是 null",非常难查。
*
* <p>放在拦截器之前({@code @Order} 最小),因为拦截器执行时 body 已经要被消费了。
*
* <p>只包 {@code /internal/} 路径:{@code /health} 不需要验签,缓存它纯属浪费内存。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class CachedBodyFilter extends OncePerRequestFilter {
/**
* 缓存 body 的 attribute 名。
*
* <p>拦截器通过 {@code request.getAttribute(BODY_ATTR)} 取,<b>不要去强转 Wrapper</b>——
* 中间只要再插一个 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));
}
}
}
@@ -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 小时内重复请求直接返回首次结果」。
*
* <p><b>四种结果,不是两种</b>(M-00 §3.5):
* <table>
* <tr><th>状态</th><th>什么情况</th><th>怎么回</th></tr>
* <tr><td>{@link State#NEW}</td><td>这个 key 第一次见</td><td>正常处理</td></tr>
* <tr><td>{@link State#REPLAY}</td><td>同 key 同内容,且已处理完</td><td>返回首次结果,不再落库</td></tr>
* <tr><td>{@link State#IN_PROGRESS}</td><td>同 key 同内容,上一条还在处理</td><td>409,让 IoT 稍后重试</td></tr>
* <tr><td>{@link State#CONFLICT}</td><td>同 key 但内容不同</td><td>409,这是 IoT 侧的 bug</td></tr>
* </table>
*
* <p>存的值格式:{@code bodyHash|responseJson};处理中只有 {@code PROC|bodyHash}。
* 带上 bodyHash 才能区分"重推同一批"(可以返回缓存)和"复用了 key 的另一个批次"(必须拒)。
*
* <p><b>Redis 挂了 fail-open</b>:放行让业务继续跑。代价是可能重复入库,
* 但 {@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());
}
}
}
@@ -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;
/**
* 把幂等判定包在业务处理外面,五个接口共用。
*
* <p>为什么不放在拦截器:幂等要"处理成功后写回结果",而拦截器拿不到响应体。
* 放在这里,调用方只要把整段处理塞进 {@link #execute} 就行。
*
* <p><b>为什么必须显式传 body 而不是让调用方自己算 hash</b>:
* 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> T execute(String idemKey, byte[] body, Class<T> type, Supplier<T> 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("未知幂等状态");
}
}
}
@@ -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 在有效期内只能用一次。
*
* <p><b>key 里必须带 token 前缀</b>(M-00 §3.4):
* 换 token 的时候如果只按 nonce 记,新旧 token 的 nonce 空间会互相污染——
* 新 token 发一个别人用过的 nonce 就被误判重放。
*
* <p><b>Redis 挂了 fail-closed</b>(拒绝),不是 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);
}
}
@@ -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)。
*
* <table>
* <tr><th>维度</th><th>阈值</th><th>在哪做</th><th>为什么</th></tr>
* <tr><td>通道</td><td>5000 / 分钟</td><td>拦截层,一次请求一次</td>
* <td>防止整条链路被打满,不需要解析 body 就能判</td></tr>
* <tr><td>设备</td><td>60 / 分钟</td><td>Service 层,解析后批量</td>
* <td>防止单台设备异常刷;批量场景用 pipeline,否则 200 次往返比入库还慢</td></tr>
* </table>
*
* <p><b>固定分钟窗</b>,不是滑动窗:实现简单、一次 INCR 就够。
* 代价是窗口边界可能放过去两倍流量,但这里是防异常刷不是计费,可以接受。
*
* <p><b>Redis 挂了 fail-open</b>(放行):限流是可用性能力不是安全能力,
* 挂了还拒绝等于自己把业务停了。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;
}
}
/**
* 设备维度:批量判,只返回<b>超限</b>的那些 sensorId。
*
* <p>用 pipeline 一次发完,不逐条往返——200 条设备逐个 INCR 就是 200 次 RTT,
* 比入库本身(96ms)还慢,限流反而成了瓶颈。
*
* @return 空的 Set 表示全部放行
*/
public Set<String> exceededDevices(Collection<String> sensorIds) {
if (sensorIds.isEmpty()) {
return Set.of();
}
String minute = LocalDateTime.now().format(MINUTE);
int limit = props.getRateLimit().getPerDevicePerMinute();
List<String> keys = new ArrayList<>(sensorIds.size());
for (String id : sensorIds) {
keys.add("iot:rl:dev:" + id + ":" + minute);
}
try {
List<Object> results = redis.executePipelined(new SessionCallback<Object>() {
@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<String> 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<String>) new ArrayList<>(sensorIds)).get(idx / 2));
}
idx++;
}
return exceeded;
} catch (DataAccessException e) {
log.warn("Redis 不可用,设备限流放行(fail-open):{}", e.getMessage());
return Set.of();
}
}
}
@@ -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 的公式:
*
* <pre>
* X-Sign = HMAC-SHA256( token,
* method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256hex(body) )
* </pre>
*
* <p><b>四个坑,每一个都能让对接方调到怀疑人生:</b>
* <ol>
* <li>key 是 token 本身,<b>不是另一个 secret</b>;</li>
* <li>分隔符是换行 {@code \n},不是冒号、不是竖线;</li>
* <li>body 要先算 sha256 的<b>十六进制小写</b>再拼进去,不是直接拼原文;</li>
* <li><b>GET 请求(I-18)没有 body</b>,取<b>空串的 sha256</b>
* ({@code e3b0c442...b855}),不要取字面量 {@code "null"}。</li>
* </ol>
*
* <p>{@code path} <b>含 query</b>,按发送时的原始路径算(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);
}
}
}
@@ -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 第七章。
*
* <p><b>长轮询为什么这么写</b>:IoT 侧希望"有新命令立刻拿到,没有就挂着",
* 而不是每秒问一次。做法是 {@link DeferredResult} 先把请求挂起,
* 定时任务每 500ms 扫一遍挂起的请求,查到命令就 {@code setResult} 立刻返回。
*
* <p>一期单实例,挂起的请求通常只有 1~2 个,所以每次扫描逐个查库可以接受。
* 如果 waiter 涨到几十个,要改成"一次查出所有 gatewayIds 的并集,再按网关分配",
* 否则 50 个 waiter × 每 500ms 一次 = 100 QPS 全打在命令表上。
*
* <p><b>租约与两个计数器</b>:
* <ul>
* <li>{@code retry_count}:设备侧失败了几次(IoT 报 TIMEOUT/DEVICE_OFFLINE/FAILED 时 +1);</li>
* <li>{@code reclaim_count}:IoT 拿走了却没回话几次(租约到期时 +1)。</li>
* </ul>
* 混成一个就分不清是设备坏了还是中间件坏了。
*/
@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<Waiter> 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<ApiResponse<PendingCommandResponse>> pull(String nodeId,
List<String> 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<ApiResponse<PendingCommandResponse>> immediate = new DeferredResult<>();
immediate.setResult(ApiResponse.ok(now));
return immediate;
}
DeferredResult<ApiResponse<PendingCommandResponse>> 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());
}
}
}
/**
* 拉一次。
*
* <p>{@code hasMore} 判据:本次恰好取满 {@code limit} 条,说明可能还有。
* 不额外 count 一次——多一次查询换一个近似值不值。
*/
public PendingCommandResponse pullNow(String nodeId, List<String> gatewayIds, int limit) {
List<CommandRow> rows = commandDao.pullAndClaim(nodeId, gatewayIds, limit, LEASE_SECONDS);
PendingCommandResponse resp = PendingCommandResponse.empty(nodeId);
List<PendingCommand> 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 结果回报
// ------------------------------------------------------------------
/**
* 处理一批回报。
*
* <p>状态流转(J-00 V1.7 §3.4):
* <pre>
* 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 才作废)
* </pre>
*
* <p><b>四计数在 I-05 上的含义</b>(与 I-01 不同,这里的 {@code duplicated} 是"收到了但没采纳"):
* <ul>
* <li>{@code accepted}:回执生效,命令状态真的变了;</li>
* <li>{@code rejected}:报文本身有问题(命令号不存在、字段缺失);</li>
* <li>{@code duplicated}:<b>过期或重复回执</b>——终态已定、代次对不上、状态不允许。
* 这类不算错,但也绝不能算生效;</li>
* <li>{@code unregistered}:恒 0(命令侧没有"未建档设备"这回事)。</li>
* </ul>
*/
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<IngestResponse.RejectedDetail> 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 被守卫挡下)。
*
* <p>注意 {@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 小时硬顶。
*
* <p><b>顺序不能反</b>:先判 {@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<String> gatewayIds, int limit,
DeferredResult<ApiResponse<PendingCommandResponse>> result) {
}
}
@@ -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 第四章。
*
* <p><b>整批一个事务,每条一个 SAVEPOINT</b>:
* 某条抛异常只回滚这一条,其余照常入库。
* 没有这层,一条坏数据能让整批 200 条全部失败——
* 而 IoT 侧重推又会撞唯一键,最后一条都进不来。
*
* <p><b>四个计数是重叠的</b>(M-00 §4.3):
* {@code accepted + rejected = total},{@code duplicated} 和 {@code unregistered} 都是
* {@code accepted} 的子集。所以 {@code accepted++} 必须在"通过校验"那一刻做,
* <b>不能等 INSERT 成功</b>——后者会让四个数加起来对不上总数。
*
* <p><b>不做预查重</b>:唯一键本身就是判据,撞了自然抛 {@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<EventItem> 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<String> sensorIds = new LinkedHashSet<>();
for (EventItem it : items) {
if (it != null && it.getSensorId() != null) {
sensorIds.add(it.getSensorId());
}
}
Set<String> exceeded = rateLimiter.exceededDevices(sensorIds);
// 一次 IN 批量查 device(最多 200 个),不逐条查
Map<String, DeviceRow> devices = deviceDao.selectBySensorIds(sensorIds);
int[] counters = new int[4]; // accepted, rejected, duplicated, unregistered
List<IngestResponse.RejectedDetail> details = new ArrayList<>();
// 同批合并:一个陌生 SN 出现 10 次只写一次 unregistered_device
Map<String, UnregAgg> unreg = new LinkedHashMap<>();
// 事务提交后再核验 rawRef 能否回查原文(见方法末尾)。
// 放事务外有两个理由:① 核验查的是原文表,与事件入库无因果关系,
// 不该因为它失败把事件回滚掉;② 少在长事务里多查一次库。
List<String[]> 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<String, UnregAgg> 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} 能否在原文表里找到对应记录。
*
* <p><b>找不到不拒收</b>,只登记进 {@code iot_raw_ref_gap}。真机上 I-12 可能比 I-01 晚到
* 几十毫秒,硬拒会把正常事件挡在门外——V1.7 那次"契约允许 64 位 SHA-256、实现只认 32 位"
* 就是这个坑:接真机第一天全量失败,日志还只显示"字段非法"。
*
* <p>登记后原文一到就自愈({@code RawFrameService} 负责置 {@code resolved_at});
* 一直没到就是真孤儿,能查出来才追得到接入侧。
*/
private void verifyRawRefs(List<String[]> 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。
*
* <p>键名 {@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);
}
}
}
@@ -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 第六章。
*
* <p><b>三条铁律(改代码前必看 M-00 §6.3–6.5):</b>
* <ol>
* <li>{@code rawFrame} 是 Base64 字符串,<b>原样存进 {@code payload} 列,不解码</b>。
* 那张列是 {@code MEDIUMTEXT} + utf8mb4,装不了任意二进制;</li>
* <li>{@code payload_md5} 直接抄 {@code rawRef},<b>绝不对 Base64 文本重算</b>;</li>
* <li>{@code payload_size} 填解码后的字节数({@code frameBytes}),不是 Base64 长度。</li>
* </ol>
*
* <p><b>幂等靠唯一键,不做预查</b>:G 线撞 {@code uk_md5(payload_md5)},
* S/H 线撞 {@code uk_dev_idem(device_sn, idem_key)},撞了就是 {@code duplicated}。
*
* <p>{@code unregistered} <b>恒 0</b>: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<RawFrameItem> 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<String> sensorIds = new LinkedHashSet<>();
for (RawFrameItem it : frames) {
if (it != null && it.getSensorId() != null) {
sensorIds.add(it.getSensorId());
}
}
Map<String, DeviceRow> devices = deviceDao.selectBySensorIds(sensorIds);
int[] counters = new int[3]; // accepted, rejected, duplicated
List<IngestResponse.RejectedDetail> details = new ArrayList<>();
// 事务提交后再解除孤儿标记,别让"清标记"失败把整批原文回滚掉
List<String> 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();
}
}
@@ -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 第五章。
*
* <p><b>这是覆盖写,不是插事件</b>:一设备一行,收到新快照就更新。
* 因此 {@code duplicated} 恒为 0——覆盖写不会撞唯一键,也没有"重复"这回事。
*
* <p>几条不能踩的线(都在 M-00 §5.3,改代码前先看):
* <ul>
* <li>{@code online=false} <b>不改</b> {@code online_status}:NB 设备休眠 23 小时本来就 false,
* 直接写成 OFFLINE 会让界面误报离线;</li>
* <li><b>不写</b> {@code battery} 百分比,只写 {@code battery_mv};</li>
* <li><b>不写</b> {@code undelivered_count}(那是业务层断档累计值);</li>
* <li><b>不写</b> {@code device_state_log}(那是业务状态机日志,写进去会淹没真正的状态变更)。</li>
* </ul>
*/
@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<StatusItem> 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<String> sensorIds = new LinkedHashSet<>();
for (StatusItem it : items) {
if (it != null && it.getSensorId() != null) {
sensorIds.add(it.getSensorId());
}
}
Set<String> exceeded = rateLimiter.exceededDevices(sensorIds);
Map<String, DeviceRow> devices = deviceDao.selectBySensorIds(sensorIds);
// 0=accepted 1=rejected 2=unregistered(重叠关系,见 M-00 §2.4)
int[] counters = new int[3];
List<IngestResponse.RejectedDetail> 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();
}
}
@@ -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。
*
* <p><b>纯函数,没有数据库访问,没有状态。</b>
* 五个接口里只有 I-01 用它,而且只在写库那一刻调用一次。
*
* <p>{@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;
}
}
@@ -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;
/**
* 字段级校验,只查格式,<b>不查业务</b>(M-00 §4.1 第 ③ 步)。
*
* <p>"这台设备该不该告警""这个类型对不对"都不在这里——
* 前者是 D 域规则引擎的事,后者是 IoT 侧翻译的事。
*
* <p>返回 {@code null} 表示通过,否则返回 J-00 §1.7 规定的那四个 reason 之一:
* {@code RATE_LIMITED} / {@code SCHEMA_INVALID} / {@code UNKNOWN_CHANNEL} / {@code SENSOR_ID_INVALID}。
* <b>不自造第五个词</b>,否则 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。
*
* <p>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:}。
*
* <p><b>中间件没补前缀就应当拒收</b>,不要让业务层用"长度像不像"去猜——
* 一旦猜错,{@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();
}
}
@@ -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 缺口③。
*
* <p><b>为什么需要它</b>:bignbiot 与铠湾 V3.0 两套 TCP 的 {@code sourceChannel} 都是
* {@code MIR_CELL},而两套帧格式不同、设备类型码还从 {@code 08} 分叉
* (铠湾 {@code 08}=水浸,bignbiot {@code 08}=白蚁)。不分出来,一行水浸原文有两种解释。
*
* <p><b>为什么不问 IoT 要字段</b>:J-00 信封已经定了,为一个排障字段去改契约不划算。
* 而"哪个监听口接哪套协议"本来就是部署事实,写配置比写协议更贴切——
* 换端口改配置,不用动代码、不用重签契约。
*
* <p><b>查不到就返回 null</b>:列可空,空着只是"不知道",编一个值才是错。
* 宁可在排障时看到 NULL 再回头补配置,也不要写进去一个看起来像对的假值。
*/
@Component
public class LinkTypeResolver {
private static final Logger log = LoggerFactory.getLogger(LinkTypeResolver.class);
/** 允许值。写错的值不落库,只报警——脏值比空值更难查 */
public static final Set<String> 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;
}
}
@@ -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
File diff suppressed because it is too large. Load diff
@@ -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;
@@ -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 '坐标最后一次修改时间';
@@ -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);
@@ -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);
@@ -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;
@@ -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 七种之一,无中文值残留
@@ -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='后台账号数据范围(按行政区划/小区/楼栋),一行一个覆盖范围';
@@ -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`;
@@ -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';
@@ -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='审计写入失败兜底;后台可补录';
@@ -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`);
+83
View File
@@ -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] 已停止
+111
View File
@@ -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`。*
+578
View File
@@ -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 = """<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备模拟器 · 鹤仆云</title>
<style>
:root{--ink:#1b2430;--sub:#6b7784;--line:#e4eaef;--brand:#146b7a;--red:#d94b4b}
*{box-sizing:border-box}
body{margin:0;background:#eef2f5;color:var(--ink);font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px}
.wrap{max-width:860px;margin:0 auto;padding:22px 18px 50px}
h1{font-size:20px;margin:0 0 4px}
.sub{color:var(--sub);font-size:12.5px;margin-bottom:18px}
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}
.card h2{font-size:14px;margin:0 0 12px;font-weight:600}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:9px}
button{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px 8px;font-size:13px;
cursor:pointer;font-weight:500;transition:.15s}
button:hover{border-color:var(--brand);color:var(--brand)}
button.hot{background:#fdeaea;border-color:#f5c6c6;color:#c0392b}
button.warn{background:#fdf3e3;border-color:#f3ddb8;color:#a9741c}
button.ok{background:#e9f5ef;border-color:#c6e5d5;color:#2f9e68}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--sub);font-weight:500;padding:6px 8px;border-bottom:1px solid var(--line);font-size:12px}
td{padding:7px 8px;border-bottom:1px solid #f0f4f6}
.mono{font-family:Consolas,monospace;font-size:12px}
.dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:5px}
.on{background:#2f9e68}.off{background:#b9c3cb}
#log{background:#16262f;color:#b8cdd6;border-radius:10px;padding:12px;font-family:Consolas,monospace;
font-size:12px;line-height:1.75;height:210px;overflow-y:auto;white-space:pre-wrap}
.tip{font-size:12px;color:var(--sub);line-height:1.8}
</style></head><body><div class="wrap">
<h1>设备模拟器</h1>
<div class="sub">扮演网关和 7 台设备,往 M 层发真实格式的报文 · 控制台 <b>127.0.0.1:__PORT__</b></div>
<div class="card"><h2>按设备发事件</h2>
<div style="margin:0 0 10px;font-size:13px;color:#5a6b76">
从哪台设备发:
<select id="snsel" style="padding:6px 10px;border-radius:8px;border:1px solid #dfe8ec;
font-size:13px;background:#fff;color:#1b2430;margin-left:6px"></select>
<span style="font-size:12px;color:#8b9aa1"> 验收新绑的设备时记得切到这里</span>
</div>
<div id="devhint" style="font-size:12.5px;color:#8b9aa1;line-height:1.7;margin-bottom:10px"></div>
<div class="grid" id="acts"></div>
<div id="tmsg" style="font-size:12.5px;line-height:1.7;margin-top:10px;min-height:18px"></div>
</div>
<div class="card"><h2>设备状态</h2>
<table id="devs"><thead><tr><th>设备</th><th>设备编号</th><th>类型</th><th>电量</th><th>信号</th><th>seq</th></tr></thead><tbody></tbody></table></div>
<div class="card"><h2>最近动作</h2><div id="log"></div></div>
<div class="card tip">
数据流:本页 → <span class="mono">POST /internal/iot/v1/events/batch</span> → M 层入库 iot_event
→ 告警引擎(入库即触发,30 秒兜底扫一次)→ alarm → 家属端。<br>
关掉这个窗口不影响已产生的数据;想看新数据得让它继续跑。
</div>
</div>
<script>
// 每种设备只显示它发得出的动作。门磁没有水浸探针,界面上就不该出现"漏水"按钮——
// 以前 8 个按钮常驻,选门磁也能点漏水,纯属误导(2026-11-09 改)。
var ACTIONS={
DOOR:[['door','🚪 门打开了','warn'],['door_close','✅ 门关上了','ok']],
WATER:[['leak','💧 漏水了','hot'],['leak_clear','✅ 漏水已止','ok']],
SOS:[['sos','🆘 按下 SOS','hot']],
SMOKE:[['smoke','🔥 烟感报警','hot'],['smoke_clear','✅ 烟雾已散','ok']],
VIB:[['vib','💧 用水活动(不告警)',''],['vib_stop','用水停止','']]
};
var TYPE_NAME={DOOR:'门磁',WATER:'水浸',SOS:'紧急按钮',SMOKE:'烟感',VIB:'水振'};
var COMMON=[['power_off','⚡ 设备断电','warn'],['power_on','🔌 设备来电','ok'],['status','📊 上报心跳','']];
var DEVS=[];
function curDev(){
var sel=document.getElementById('snsel');
var sn=sel?sel.value:'';
return DEVS.filter(function(x){return x.sn===sn;})[0]||DEVS[0]||null;
}
function renderBtns(){
var d=curDev(); if(!d) return;
var list=(ACTIONS[d.type]||[]).concat(COMMON);
document.getElementById('acts').innerHTML=list.map(function(a){
return '<button class="'+a[2]+'" onclick="t(\\''+a[0]+'\\')">'+a[1]+'</button>';
}).join('');
var hint='当前:<b>'+d.name+'</b> · '+d.sn+' · '+(TYPE_NAME[d.type]||d.type);
if(d.type==='VIB'){
hint+=' <span style="color:#a9741c">水振报的是"用水活动",不是告警——只进基线和组合研判,不会弹窗</span>';
}
hint+='<br><span style="color:#8b9aa1">恢复类事件(门关上 / 漏水已止 / 来电)只关闭"还没恢复"的那条告警,不会新建告警。</span>';
document.getElementById('devhint').innerHTML=hint;
}
function t(k){
var sel=document.getElementById('snsel'), sn=sel?sel.value:'';
var box=document.getElementById('tmsg');
box.style.color='#5a6b76'; box.textContent='发送中…';
fetch('/api/trigger?k='+k+(sn?'&sn='+encodeURIComponent(sn):''),{method:'POST'})
.then(function(r){return r.json()})
.then(function(d){
box.style.color = d.code===0 ? '#2f9e68' : '#c0392b';
box.textContent = (d.code===0?'✓ ':'✗ ') + (d.msg||'');
poll();
})
.catch(function(e){box.style.color='#c0392b';box.textContent='✗ 请求失败:'+e});
}
var SN_FILLED=false;
function fillSn(list){
if(SN_FILLED||!list) return;
DEVS=list;
document.getElementById('snsel').innerHTML=list.map(function(x){
return '<option value="'+x.sn+'">'+x.name+' · '+x.sn+'</option>';
}).join('');
document.getElementById('snsel').onchange=renderBtns;
SN_FILLED=true;
renderBtns();
}
function poll(){
fetch('/api/state').then(function(r){return r.json()}).then(function(d){
fillSn(d.devices);
document.getElementById('devs').querySelector('tbody').innerHTML = d.devices.map(function(x){
return '<tr><td><span class="dot '+(x.online?'on':'off')+'"></span>'+x.name+'</td>'+
'<td class="mono">'+x.sn+'</td><td>'+(TYPE_NAME[x.type]||x.type)+'</td>'+
'<td>'+x.battery+' mV</td><td>CSQ '+x.csq+'</td>'+
'<td class="mono">seq '+x.seq+'</td></tr>';
}).join('');
const box=document.getElementById('log');
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 10;
box.textContent = d.log.slice().reverse().join('\\n');
if(atBottom) box.scrollTop = 0;
})
}
poll(); setInterval(poll, 3000);
</script></body></html>"""
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):
"""控制台按钮 → 一条事件。全部走真实接口,不直接改库。
★ <b>必须按设备类型把关</b>(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()
File diff suppressed because it is too large. Load diff
+206
View File
@@ -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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<span style="color:' + meta[1] + '">' + esc(meta[0]) + '</span>';
if (row.result_msg) {
html += '<span style="color:#98a4b0"> · ' + esc(row.result_msg) + '</span>';
}
if (st === 'PENDING' && n > 1) {
html += '<span style="color:#98a4b0">(不用一直等,设备连上会自动下发)</span>';
}
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,
};
})();
File diff suppressed because it is too large. Load diff
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more