# 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`。