按周工交付的 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),不做归一化以免整树摘要失效。
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
或命令行:
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
正常返回:
{
"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 自测。
七、两个必须遵守的硬约束
- M-02 同步落库,不要异步入队。 返回值要带
accepted / rejected / duplicated / unregistered四个计数,其中unregistered必须现查device表才知道,入队就丢了。 - 查
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。