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

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