fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划
This commit is contained in:
1820
docs/AppController-API.md
Normal file
1820
docs/AppController-API.md
Normal file
File diff suppressed because it is too large
Load Diff
632
docs/code-review-standards.md
Normal file
632
docs/code-review-standards.md
Normal file
@@ -0,0 +1,632 @@
|
||||
# Water 项目代码审查标准与流程
|
||||
|
||||
> **版本**: 1.0 | **适用项目**: water (IoT 智能灌溉设备管理系统) | **技术栈**: Spring Boot 3.5 + Java 17 + MyBatis Plus + MQTT + Redis
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [代码审查标准](#1-代码审查标准)
|
||||
2. [代码审查流程](#2-代码审查流程)
|
||||
3. [代码审查检查清单](#3-代码审查检查清单)
|
||||
4. [静态分析工具集成方案](#4-静态分析工具集成方案)
|
||||
5. [附录:water 项目典型问题案例](#5-附录water-项目典型问题案例)
|
||||
|
||||
---
|
||||
|
||||
## 1. 代码审查标准
|
||||
|
||||
### 1.1 问题分级体系
|
||||
|
||||
所有审查意见必须标注严重等级,审查者不得给出无分级的意见:
|
||||
|
||||
| 等级 | 标记 | 含义 | 处理要求 |
|
||||
|------|------|------|----------|
|
||||
| **Blocker** | 🔴 | 存在安全漏洞、数据丢失风险、破坏 API 契约、竞态条件 | **必须修复后才能合并** |
|
||||
| **Suggestion** | 🟡 | 缺失输入校验、命名混乱、缺少测试、性能问题、重复代码 | **应当修复,特殊情况可延期** |
|
||||
| **Nit** | 💭 | 风格不一致、命名微调、文档缺失 | **建议改进,不阻塞合并** |
|
||||
|
||||
### 1.2 架构规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **禁止跨层调用**:Controller 不得直接操作 Mapper/数据库;Service 不得返回 `HttpEntity`/`ResponseEntity` 等 Web 层对象
|
||||
- **禁止在 Controller 中写业务逻辑**:Controller 只做参数接收、校验、调用 Service、组装返回值。逻辑超过 5 行的方法必须下沉到 Service
|
||||
- **循环依赖必须根治**:禁止用 `@Lazy @Autowired` 掩盖循环依赖。如果存在循环依赖,说明模块划分有问题,应重构拆分
|
||||
|
||||
> **water 项目现状**:`MqttCommandAckService` 中 `@Lazy @Autowired MqttClientManager` 是典型的循环依赖掩盖。审查中遇到此类代码应标记为 🟡 Suggestion,要求补充重构计划
|
||||
|
||||
- **单一职责**:单个 Controller/Service 类行数不得超过 **500 行**。超过的必须拆分
|
||||
|
||||
> **water 项目现状**:`AppController` 达 940 行,包含设备、排程、浇水记录、用户、统计、版本等全部业务接口,是典型的上帝类。新代码不得再向此类添加方法
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **接口与实现分离**:Service 必须定义接口(`IXxxService`)+ 实现(`XxxServiceImpl`),Controller 依赖接口
|
||||
- **依赖注入使用构造器注入**:使用 `@RequiredArgsConstructor` + `final` 字段,禁止字段注入 `@Autowired`(除 `@Lazy` 场景外)
|
||||
- **配置类使用 `@ConfigurationProperties`**:禁止用 `@Value` 散落配置读取
|
||||
|
||||
### 1.3 命名规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **禁止 Raw Type**:集合必须使用泛型。`Map map = new HashMap()` 必须写为 `Map<String, Object> map = new HashMap<>()`
|
||||
|
||||
> **water 项目现状**:`AppController` 中存在 `Map retMap = new HashMap()`、`List l = new ArrayList<>()`、`List deviceList = new ArrayList()` 等大量 Raw Type 用法。新代码**零容忍**
|
||||
|
||||
- **变量名不得使用单字母**:除循环变量 `i/j/k` 外,变量名必须有意义。`List l` 必须写为 `List<AppDeviceVo> deviceList`
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **接口与实现的命名一致**:注入字段名应与接口名对应。`IAppScheduleService` 注入为 `appScheduleService`,不得出现 `appScheduleServiceimpl`(小写 impl)
|
||||
- **布尔变量用 is/has/can 前缀**:`flag` → `isSwitchSuccess`、`hasPermission`
|
||||
- **常量全大写下划线**:`DEVICE_STATUS_LOCK_PREFIX`,禁止魔法值
|
||||
|
||||
#### 💭 Nit
|
||||
|
||||
- **方法名动词开头**:`getDeviceInfo`、`bindDevice`、`assertDeviceOwned`
|
||||
- **包名全小写**:不得使用大写字母或下划线
|
||||
|
||||
### 1.4 异常处理规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **禁止 `catch (Exception e)` 吞异常**:Controller 中不得使用 `try-catch(Exception)` 包裹所有逻辑。应依赖全局异常处理器(`GlobalExceptionHandler`)统一处理
|
||||
|
||||
> **water 项目现状**:`AppController` 几乎每个方法都被 `try { ... } catch (Exception e) { return fail(e); }` 包裹。这会:
|
||||
> 1. 吞掉异常栈,排查问题困难
|
||||
> 2. 与框架全局异常处理器功能重复
|
||||
> 3. 代码冗余严重
|
||||
>
|
||||
> **正确做法**:移除 try-catch,让异常自然抛出由全局处理器捕获。仅在需要业务降级或资源清理时才 catch
|
||||
|
||||
- **catch 块不得为空**:`catch (Exception e) {}` 是严重 bug。至少要记录日志
|
||||
- **不得 catch 后丢弃异常信息**:`catch (Exception e) { log.error("出错"); }` 丢失了堆栈,应使用 `log.error("出错", e)`
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **异常分类处理**:业务异常用 `ServiceException`/`UserException`,系统异常让其传播
|
||||
- **自定义异常携带上下文**:抛出异常时包含设备编号、用户 ID 等关键信息
|
||||
|
||||
### 1.5 安全规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **SQL 注入防护**:所有数据库查询必须使用 MyBatis Plus 的 `LambdaQueryWrapper` 或参数化查询,禁止字符串拼接 SQL
|
||||
- **所有接口必须有权限控制**:使用 `@SaCheckLogin`/`@SaCheckPermission`/`@SaCheckRole` 或在方法内校验用户所有权
|
||||
|
||||
> **water 项目正面案例**:`AppController` 中 `assertDeviceOwned(deviceNo)` 校验设备归属权,这个模式是对的
|
||||
|
||||
- **敏感数据不得出现在日志中**:密码、token、手机号完整信息等不得记录到日志
|
||||
- **TLS 证书校验不得全局关闭**:`TrustAllManager` 等信任所有证书的代码必须有明确的安全边界注释,且仅限内网测试环境
|
||||
|
||||
> **water 项目现状**:`MqttClientManager.createTrustAllSslContext()` 创建了信任所有证书的 TrustManager。如果用于生产环境是 🔴 Blocker
|
||||
|
||||
- **输入校验**:所有外部输入(HTTP 参数、MQTT 消息体)必须做格式校验和长度限制
|
||||
|
||||
### 1.6 性能规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **禁止 N+1 查询**:在循环中查询数据库/Redis 必须改为批量查询
|
||||
|
||||
> **water 项目现状**:`MqttCommandAckService.findPendingCommandsByDeviceNo()` 遍历所有 pending commandId 逐个查 Redis,当 pending 命令多时性能差。应改用 Redis 的 `MGET` 或 Hash 结构批量获取
|
||||
>
|
||||
> `AppController.scheduleDeviceList()` 在 for 循环中逐个设备调用 `appSchedulingDeviceService.findByDeviceNo()`,是典型的 N+1 查询
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **批量操作优先**:`deleteDevice` 中的 `for (deviceNo) { assertDeviceOwned(deviceNo); }` 应改为批量查询
|
||||
- **避免在热路径创建重对象**:`new SimpleDateFormat()` 应替换为 `DateTimeFormatter`(线程安全且无需重复创建)
|
||||
|
||||
> **water 项目现状**:`AppController.switchDevice()` 中 `new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())` 每次请求都创建新实例。应改为:
|
||||
> ```java
|
||||
> private static final DateTimeFormatter DATE_TIME_FORMATTER =
|
||||
> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
> // 使用
|
||||
> String startTime = LocalDateTime.now().format(DATE_TIME_FORMATTER);
|
||||
> ```
|
||||
|
||||
- **Redis Key 设置 TTL**:所有写入 Redis 的缓存必须设置过期时间,防止内存泄漏
|
||||
|
||||
### 1.7 代码整洁规范
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **删除注释掉的代码**:被注释的代码应删除,版本历史由 Git 管理。`// @SaCheckRole("appadmin")` 这类应清理
|
||||
- **魔法值提取为常量或枚举**:状态值 `"0"`/`"1"` 应定义为枚举
|
||||
|
||||
> **water 项目正面案例**:`MqttCommandAckService` 中的 `CommandLockResult` 枚举是好的实践。命令状态 `"0"`/`"1"` 也应类似处理
|
||||
|
||||
- **重复代码提取**:相同逻辑出现 3 次以上必须提取为公共方法
|
||||
|
||||
> **water 项目现状**:`DeviceCommandServiceImpl.parseStartTime()` 有 95 行,包含三段几乎相同的 try-catch 块解析不同时间格式,应提取为通用方法
|
||||
|
||||
#### 💭 Nit
|
||||
|
||||
- **方法行数不超过 50 行**:超过的考虑拆分
|
||||
- **方法参数不超过 5 个**:超过的考虑封装为参数对象
|
||||
- **import 不得使用通配符**:`import java.util.*` 应写明具体类
|
||||
|
||||
### 1.8 并发规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **共享可变状态必须加锁**:多线程访问的可变字段必须使用同步机制保护
|
||||
- **锁必须设置超时**:`tryLock()` 必须带超时参数,禁止无限等待导致死锁
|
||||
|
||||
> **water 项目正面案例**:`MqttCommandAckService.withCommandLock()` 使用 Redisson 分布式锁并设置了超时,这是正确的做法
|
||||
|
||||
- **SimpleDateFormat 非线程安全**:禁止在多线程环境共享 `SimpleDateFormat` 实例
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **优先使用不可变对象**:能声明 `final` 的就声明 `final`
|
||||
- **优先使用并发集合**:`ConcurrentHashMap` 代替 `HashMap` + synchronized
|
||||
|
||||
### 1.9 测试规范
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **核心业务逻辑必须有单元测试**:Service 层的公共方法和 MQTT Handler 必须有测试覆盖
|
||||
- **测试命名规范**:`methodName_scenario_expectedResult`,如 `handleAck_validPayload_ackConfirmed`
|
||||
- **测试必须独立**:不依赖执行顺序,不依赖外部状态
|
||||
|
||||
> **water 项目正面案例**:`MqttCommandAckService` 的静态方法 `ackMatchesPendingCommand` 和 `resolveMissingCommandId` 被设计为可测试的纯函数,这是好的实践
|
||||
|
||||
### 1.10 日志规范
|
||||
|
||||
#### 🔴 Blocker
|
||||
|
||||
- **日志必须包含上下文**:MQTT 相关日志必须包含设备编号、命令编号等关键信息
|
||||
|
||||
> **water 项目正面案例**:`log.warn("[MQTT] 收到空 ACK 设备编号={}", deviceNo)` 包含了设备编号,格式统一
|
||||
|
||||
#### 🟡 Suggestion
|
||||
|
||||
- **日志级别正确使用**:`ERROR` 用于系统异常、`WARN` 用于业务异常、`INFO` 用于关键业务流程、`DEBUG` 用于调试信息
|
||||
- **使用占位符而非字符串拼接**:`log.info("设备{}上线", deviceNo)` 而非 `log.info("设备" + deviceNo + "上线")`
|
||||
|
||||
---
|
||||
|
||||
## 2. 代码审查流程
|
||||
|
||||
### 2.1 角色定义
|
||||
|
||||
| 角色 | 职责 | 资质要求 |
|
||||
|------|------|----------|
|
||||
| **提交者** (Author) | 编写代码、自测、提交 PR、响应审查意见 | 熟悉项目编码规范 |
|
||||
| **审查者** (Reviewer) | 审查代码质量、提出改进意见、确认修复 | 熟悉相关模块业务逻辑 |
|
||||
| **合并者** (Merger) | 最终确认、合并代码 | 技术负责人或模块 Owner |
|
||||
|
||||
### 2.2 PR 提交前(自检阶段)
|
||||
|
||||
提交者在创建 PR 前必须完成:
|
||||
|
||||
1. **本地编译通过**:`mvn clean compile -DskipTests`
|
||||
2. **单元测试通过**:`mvn test`(不得使用 `-DskipTests`)
|
||||
3. **静态分析通过**:Checkstyle + SpotBugs 无 Error 级别问题
|
||||
4. **自检 Checklist**:逐项核对 [Section 3 检查清单](#3-代码审查检查清单)
|
||||
5. **PR 描述**:包含变更说明、测试方式、影响范围
|
||||
|
||||
#### PR 描述模板
|
||||
|
||||
```markdown
|
||||
## 变更说明
|
||||
<!-- 简述本次改了什么、为什么改 -->
|
||||
|
||||
## 测试方式
|
||||
<!-- 如何验证本次变更 -->
|
||||
|
||||
## 影响范围
|
||||
<!-- 影响哪些模块/功能 -->
|
||||
|
||||
## 关联 Issue
|
||||
<!-- Closes #xxx -->
|
||||
```
|
||||
|
||||
### 2.3 审查阶段
|
||||
|
||||
#### 审查时间要求
|
||||
|
||||
| PR 规模 | 审查时限 | 说明 |
|
||||
|---------|----------|------|
|
||||
| 小型 (< 100 行) | 4 小时内 | 单个 bugfix 或小功能 |
|
||||
| 中型 (100-500 行) | 1 个工作日内 | 常规功能开发 |
|
||||
| 大型 (> 500 行) | 2 个工作日内 | 大功能或重构,建议拆分 |
|
||||
|
||||
#### 审查步骤
|
||||
|
||||
```
|
||||
Step 1: 全局审视
|
||||
├── PR 描述是否清晰?
|
||||
├── 变更范围是否合理?
|
||||
└── 是否有对应的测试?
|
||||
|
||||
Step 2: 逐文件审查
|
||||
├── 按 [Section 3 检查清单] 逐项核对
|
||||
├── 标注问题等级 (🔴/🟡/💭)
|
||||
└── 给出具体的修改建议和原因
|
||||
|
||||
Step 3: 运行验证
|
||||
├── 代码能否编译通过?
|
||||
├── 测试是否通过?
|
||||
└── 静态分析是否有新问题?
|
||||
|
||||
Step 4: 总结
|
||||
├── Approve — 无 Blocker,可合并
|
||||
├── Request Changes — 有 Blocker 或多个 Suggestion
|
||||
└── Comment — 仅有 Nit 或讨论性问题
|
||||
```
|
||||
|
||||
### 2.4 合并标准
|
||||
|
||||
PR 必须满足以下**全部条件**才能合并:
|
||||
|
||||
- [ ] **零 🔴 Blocker**:所有 Blocker 已修复
|
||||
- [ ] **Suggestion 有明确处理**:已修复或标注为延期(需技术负责人确认)
|
||||
- [ ] **至少 1 个 Approve**:中型以上 PR 需要模块 Owner Approve
|
||||
- [ ] **CI 通过**:编译、测试、静态分析全部通过
|
||||
- [ ] **无未解决的讨论**:所有 review comment 已 resolved
|
||||
|
||||
### 2.5 冲突升级机制
|
||||
|
||||
| 冲突类型 | 升级路径 |
|
||||
|----------|----------|
|
||||
| 审查意见分歧 | 提交者与审查者协商 → 协商不成由模块 Owner 裁决 |
|
||||
| 架构方案分歧 | 模块 Owner → 技术负责人(你)最终裁决 |
|
||||
| 紧急修复绕过审查 | 需技术负责人批准,事后 24 小时内补审查 |
|
||||
|
||||
### 2.6 紧急修复流程
|
||||
|
||||
生产环境紧急 bug 修复可走快速通道:
|
||||
|
||||
1. 技术负责人批准走紧急流程
|
||||
2. 最少 1 人审查(可简化为只查 Blocker 级别问题)
|
||||
3. 合并后 24 小时内补完整审查
|
||||
4. 记录在 `docs/hotfix-log.md` 中
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码审查检查清单
|
||||
|
||||
> 审查者逐项核对,每项标记 ✅ 通过 / ❌ 未通过 / ➖ 不适用
|
||||
|
||||
### 3.1 安全 (Security)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| S1 | 所有数据库查询使用参数化查询,无 SQL 注入风险 | 🔴 |
|
||||
| S2 | 所有接口有权限控制(`@SaCheckLogin` / 所有权校验) | 🔴 |
|
||||
| S3 | 敏感数据(密码、token)不出现在日志中 | 🔴 |
|
||||
| S4 | 外部输入(HTTP 参数、MQTT 消息)做了格式校验和长度限制 | 🔴 |
|
||||
| S5 | TLS 证书校验未在生产环境关闭 | 🔴 |
|
||||
| S6 | 文件上传做了类型和大小限制 | 🟡 |
|
||||
|
||||
### 3.2 正确性 (Correctness)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| C1 | 代码逻辑是否实现了预期功能 | 🔴 |
|
||||
| C2 | 边界条件是否处理(空值、空集合、零值、最大值) | 🔴 |
|
||||
| C3 | 异常路径是否正确处理(不吞异常、不丢失异常栈) | 🔴 |
|
||||
| C4 | 并发场景下数据一致性是否保证 | 🔴 |
|
||||
| C5 | Redis Key 都设置了 TTL | 🟡 |
|
||||
| C6 | 分布式锁设置了超时时间 | 🟡 |
|
||||
|
||||
### 3.3 架构 (Architecture)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| A1 | Controller 不含业务逻辑(仅参数接收+调用 Service) | 🔴 |
|
||||
| A2 | 无跨层调用(Controller 不直接调 Mapper) | 🔴 |
|
||||
| A3 | 无循环依赖(未使用 `@Lazy` 掩盖) | 🔴 |
|
||||
| A4 | 单个类行数不超过 500 行 | 🟡 |
|
||||
| A5 | Service 有接口定义 | 🟡 |
|
||||
| A6 | 使用构造器注入(`@RequiredArgsConstructor`) | 🟡 |
|
||||
|
||||
### 3.4 代码质量 (Code Quality)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| Q1 | 集合使用泛型,无 Raw Type | 🔴 |
|
||||
| Q2 | 无魔法值(字符串/数字硬编码),常量已提取 | 🟡 |
|
||||
| Q3 | 无注释掉的代码 | 🟡 |
|
||||
| Q4 | 重复代码已提取为公共方法 | 🟡 |
|
||||
| Q5 | 变量命名有意义,无单字母变量 | 🟡 |
|
||||
| Q6 | 方法行数不超过 50 行 | 💭 |
|
||||
| Q7 | import 无通配符 | 💭 |
|
||||
|
||||
### 3.5 性能 (Performance)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| P1 | 无 N+1 查询(循环内不查数据库/Redis) | 🔴 |
|
||||
| P2 | 批量操作使用批量接口 | 🟡 |
|
||||
| P3 | 热路径无重对象创建(如 `SimpleDateFormat`) | 🟡 |
|
||||
| P4 | 大集合操作考虑分页或流式处理 | 🟡 |
|
||||
|
||||
### 3.6 异常处理 (Error Handling)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| E1 | 无 `catch (Exception e)` 包裹全部逻辑 | 🔴 |
|
||||
| E2 | catch 块不为空,至少记录日志 | 🔴 |
|
||||
| E3 | 日志使用 `log.error("msg", e)` 保留异常栈 | 🔴 |
|
||||
| E4 | 业务异常用 `ServiceException`,不混用 | 🟡 |
|
||||
|
||||
### 3.7 测试 (Testing)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| T1 | 核心业务逻辑有单元测试 | 🟡 |
|
||||
| T2 | 测试覆盖正常路径和异常路径 | 🟡 |
|
||||
| T3 | 测试命名规范,能表达意图 | 💭 |
|
||||
| T4 | 测试独立运行,不依赖顺序 | 🟡 |
|
||||
|
||||
### 3.8 日志 (Logging)
|
||||
|
||||
| # | 检查项 | 等级 |
|
||||
|---|--------|------|
|
||||
| L1 | 关键操作有日志记录 | 🟡 |
|
||||
| L2 | 日志包含上下文信息(设备号、用户ID等) | 🟡 |
|
||||
| L3 | 日志级别使用正确 | 💭 |
|
||||
| L4 | 使用占位符而非字符串拼接 | 💭 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 静态分析工具集成方案
|
||||
|
||||
### 4.1 工具选型
|
||||
|
||||
| 工具 | 作用 | 集成方式 | 优先级 |
|
||||
|------|------|----------|--------|
|
||||
| **Checkstyle** | 代码风格检查(命名、import、行数) | Maven 插件 | P0 立即集成 |
|
||||
| **SpotBugs** | 潜在 bug 检测(空指针、资源泄漏) | Maven 插件 | P0 立即集成 |
|
||||
| **SonarQube** | 综合质量平台(重复率、覆盖率、复杂度) | 独立服务 + Scanner | P1 二期集成 |
|
||||
|
||||
### 4.2 Maven 集成配置
|
||||
|
||||
在根 `pom.xml` 的 `<build><plugins>` 中添加以下配置:
|
||||
|
||||
```xml
|
||||
<!-- Checkstyle: 代码风格检查 -->
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-checkstyle-plugin</artifactId>
|
||||
<version>3.5.0</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.puppycrawl.tools</groupId>
|
||||
<artifactId>checkstyle</artifactId>
|
||||
<version>10.18.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<configLocation>checkstyle.xml</configLocation>
|
||||
<consoleOutput>true</consoleOutput>
|
||||
<failsOnError>true</failsOnError>
|
||||
<includeTestSourceDirectory>true</includeTestSourceDirectory>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>validate</id>
|
||||
<phase>validate</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
|
||||
<!-- SpotBugs: 静态 bug 分析 -->
|
||||
<plugin>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs-maven-plugin</artifactId>
|
||||
<version>4.8.6.4</version>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.github.spotbugs</groupId>
|
||||
<artifactId>spotbugs</artifactId>
|
||||
<version>4.8.6</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<configuration>
|
||||
<effort>Max</effort>
|
||||
<threshold>Low</threshold>
|
||||
<failOnError>true</failOnError>
|
||||
<excludeFilterFile>spotbugs-exclude.xml</excludeFilterFile>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>spotbugs-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
### 4.3 实施路线
|
||||
|
||||
| 阶段 | 周期 | 目标 |
|
||||
|------|------|------|
|
||||
| **Phase 1** | 第 1 周 | 集成 Checkstyle,配置规则文件,CI 中运行 |
|
||||
| **Phase 2** | 第 2 周 | 集成 SpotBugs,修复现有 High 级别问题 |
|
||||
| **Phase 3** | 第 3-4 周 | 搭建 SonarQube 服务,建立质量基线 |
|
||||
| **Phase 4** | 持续 | 将静态分析纳入 PR 检查,设置质量门禁 |
|
||||
|
||||
### 4.4 过渡策略
|
||||
|
||||
> **重要**:集成静态分析工具时,现有代码会有大量违规。采取以下策略:
|
||||
|
||||
1. **新代码零容忍**:PR 中新增/修改的代码必须通过检查
|
||||
2. **存量代码分期修复**:用 SonarQube 的 "New Code" 模式,只关注新增问题
|
||||
3. **基线快照**:记录当前问题数量作为基线,要求只减不增
|
||||
|
||||
---
|
||||
|
||||
## 5. 附录:water 项目典型问题案例
|
||||
|
||||
### 案例一:上帝控制器 + 异常吞噬
|
||||
|
||||
**文件**: `AppController.java`
|
||||
**问题等级**: 🔴 Blocker (架构) + 🔴 Blocker (异常处理)
|
||||
|
||||
```java
|
||||
// ❌ 当前代码 — 940 行上帝类,每个方法 try-catch 包裹
|
||||
@PostMapping("/bindDeviceStatus")
|
||||
public R<Map> bindDeviceStatus(@RequestBody String body) {
|
||||
try {
|
||||
AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class);
|
||||
appDevice.setUserId(LoginHelper.getUserId());
|
||||
Map<String, Object> map = appDeviceService.bindDeviceStatus(appDevice);
|
||||
return R.ok(map);
|
||||
} catch (Exception e) {
|
||||
return fail(e); // 吞异常栈,与全局处理器重复
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// ✅ 改进后 — 移到独立 Controller,移除 try-catch
|
||||
@RestController
|
||||
@RequestMapping("/app/v1/device")
|
||||
@RequiredArgsConstructor
|
||||
public class AppDeviceCommandController extends BaseController {
|
||||
|
||||
private final IAppDeviceService appDeviceService;
|
||||
|
||||
@ApiEncrypt
|
||||
@PostMapping("/bindDeviceStatus")
|
||||
public R<Map<String, Object>> bindDeviceStatus(@RequestBody String body) {
|
||||
AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class);
|
||||
appDevice.setUserId(LoginHelper.getUserId());
|
||||
return R.ok(appDeviceService.bindDeviceStatus(appDevice));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 案例二:Raw Type + 魔法值
|
||||
|
||||
**文件**: `AppController.java`
|
||||
**问题等级**: 🔴 Blocker (Raw Type) + 🟡 Suggestion (魔法值)
|
||||
|
||||
```java
|
||||
// ❌ 当前代码
|
||||
Map retMap = new HashMap(); // Raw Type
|
||||
List l = new ArrayList<>(); // 单字母变量 + 推断丢失泛型
|
||||
String status = map.get("workStatus"); // 魔法字符串
|
||||
ack.setStatus("1"); // 魔法值
|
||||
```
|
||||
|
||||
```java
|
||||
// ✅ 改进后
|
||||
Map<String, Object> resultMap = new HashMap<>();
|
||||
List<AppDeviceVo> availableDevices = new ArrayList<>();
|
||||
String status = params.get(DeviceCommand.FIELD_WORK_STATUS);
|
||||
ack.setStatus(CommandStatus.SUCCESS.getCode()); // 枚举
|
||||
```
|
||||
|
||||
### 案例三:N+1 查询
|
||||
|
||||
**文件**: `AppController.java` — `scheduleDeviceList()` 方法
|
||||
**问题等级**: 🔴 Blocker (性能)
|
||||
|
||||
```java
|
||||
// ❌ 当前代码 — 循环内逐个查询
|
||||
for (AppDeviceVo appDeviceVo : appDeviceVos) {
|
||||
List<AppSchedulingDeviceVo> scheduleDevices =
|
||||
appSchedulingDeviceService.findByDeviceNo(appDeviceVo.getDeviceNo()); // N+1!
|
||||
if (scheduleDevices.size() == 0) {
|
||||
appDeviceVoList.add(appDeviceVo);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// ✅ 改进后 — 批量查询
|
||||
List<String> deviceNos = appDeviceVos.stream()
|
||||
.map(AppDeviceVo::getDeviceNo)
|
||||
.collect(Collectors.toList());
|
||||
Set<String> scheduledDeviceNos = appSchedulingDeviceService
|
||||
.findScheduledDeviceNos(deviceNos); // 一次批量查询
|
||||
List<AppDeviceVo> availableDevices = appDeviceVos.stream()
|
||||
.filter(d -> !scheduledDeviceNos.contains(d.getDeviceNo()))
|
||||
.collect(Collectors.toList());
|
||||
```
|
||||
|
||||
### 案例四:线程安全问题
|
||||
|
||||
**文件**: `AppController.java` — `switchDevice()` 方法
|
||||
**问题等级**: 🔴 Blocker (线程安全)
|
||||
|
||||
```java
|
||||
// ❌ 当前代码 — SimpleDateFormat 非线程安全
|
||||
String startTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
|
||||
```
|
||||
|
||||
```java
|
||||
// ✅ 改进后 — 使用 DateTimeFormatter(线程安全)
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
String startTime = LocalDateTime.now().format(DATE_TIME_FORMATTER);
|
||||
```
|
||||
|
||||
### 案例五:正面案例 — MQTT 消息分发策略模式
|
||||
|
||||
**文件**: `MqttMessageDispatcher.java` + `MqttTopicHandler.java`
|
||||
**评价**: 🟢 优秀设计
|
||||
|
||||
```java
|
||||
// 策略接口
|
||||
public interface MqttTopicHandler {
|
||||
String getTopicPattern();
|
||||
void handle(String topic, String payload);
|
||||
}
|
||||
|
||||
// 分发器 — 新增 Topic 处理器零改动
|
||||
// 这是开闭原则的标准实践,值得团队学习
|
||||
```
|
||||
|
||||
**值得推广的模式**:
|
||||
1. 策略模式解耦消息分发
|
||||
2. 有界队列 + 多消费者的高并发架构
|
||||
3. 优雅停机设计(`DisposableBean` + `awaitTermination`)
|
||||
4. 分布式锁保护并发操作
|
||||
|
||||
---
|
||||
|
||||
## 6. 推广与落地建议
|
||||
|
||||
### 6.1 分阶段实施
|
||||
|
||||
| 阶段 | 时间 | 目标 | 负责人 |
|
||||
|------|------|------|--------|
|
||||
| **宣贯** | 第 1 周 | 团队学习本文档,理解审查标准 | 技术负责人 |
|
||||
| **工具** | 第 2 周 | 集成 Checkstyle + SpotBugs,修复 P0 问题 | 全员 |
|
||||
| **试运行** | 第 3-4 周 | 所有 PR 执行审查流程,以学习为主 | 全员 |
|
||||
| **正式执行** | 第 5 周起 | 严格执行 Blocker 零容忍 | 全员 |
|
||||
|
||||
### 6.2 度量指标
|
||||
|
||||
| 指标 | 目标 | 度量方式 |
|
||||
|------|------|----------|
|
||||
| PR 审查覆盖率 | 100% | 所有合并的 PR 都经过审查 |
|
||||
| Blocker 修复率 | 100% | 所有 Blocker 在合并前修复 |
|
||||
| 平均审查响应时间 | < 1 工作日 | PR 提交到首次审查 |
|
||||
| 静态分析问题数 | 逐月递减 | SonarQube 趋势图 |
|
||||
| 单元测试覆盖率 | 核心模块 > 60% | JaCoCo 报告 |
|
||||
|
||||
### 6.3 持续改进
|
||||
|
||||
- **每月复盘**:审查中发现的共性问题汇总,更新本标准
|
||||
- **季度培训**:针对高频问题组织技术分享
|
||||
- **标准迭代**:本文档每季度评审一次,根据团队反馈调整
|
||||
|
||||
---
|
||||
|
||||
*文档维护:技术负责人 | 最后更新:2026-07-16*
|
||||
@@ -16,8 +16,8 @@
|
||||
| --- | --- |
|
||||
| Broker | `mqtt.broker-url` |
|
||||
| QoS | `mqtt.qos`,当前默认 `1` |
|
||||
| 设备标识 | 上行 Topic 第一段可为设备 **MAC** 或 `deviceNo`;下行命令 Topic 第一段统一使用 `deviceNo`,仅 `registerDeviceNo` 例外仍使用 MAC |
|
||||
| Topic 变量 | 上行文档中的 `{deviceNo}` 表示设备标识占位符;下行文档中的 `{deviceNo}` 即设备编号 |
|
||||
| 设备标识 | 首次注册 Topic 使用设备 **MAC**;注册完成后的上、下行业务 Topic 统一使用 `deviceNo`;LWT 离线 Topic 兼容 MAC |
|
||||
| Topic 变量 | 文档中的 `{deviceNo}` 指服务端分配的设备编号,`{mac}` 指设备 MAC 地址 |
|
||||
| JSON 编码 | UTF-8 |
|
||||
| 时间格式 | `yyyy-MM-dd HH:mm:ss` |
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
```yaml
|
||||
- /+/publish/finish/schedule
|
||||
- /+/publish/register
|
||||
- /+/publish/status
|
||||
- /+/publish/power
|
||||
- /+/publish/ack
|
||||
- /+/publish/finish/key
|
||||
@@ -38,8 +39,9 @@
|
||||
| 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 排程任务完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 |
|
||||
| 设备注册 | 设备发布,后端订阅 | `/{deviceNo}/publish/register` | JSON | 注册或更新设备 |
|
||||
| 电量上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量 |
|
||||
| 设备注册 | 设备发布,后端订阅 | `/{mac}/publish/register` | JSON | 注册、标记上线并下发 deviceNo |
|
||||
| 设备离线遗嘱 | 设备发布,后端订阅 | `/{deviceNo}/publish/status` | JSON | 收到 offline 后立即标记离线 |
|
||||
| 电量及在线心跳 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量并刷新 10 分钟在线心跳 |
|
||||
| 命令应答 ACK | 设备发布,后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 |
|
||||
| 按键浇水完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 |
|
||||
| 硬件故障上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 |
|
||||
@@ -51,7 +53,7 @@
|
||||
| --- | --- |
|
||||
| 功能名称 | 设备注册 |
|
||||
| 通信方向 | 设备发布,后端订阅 |
|
||||
| Topic | `/{deviceNo}/publish/register` |
|
||||
| Topic | `/{mac}/publish/register` |
|
||||
| Payload 类型 | JSON |
|
||||
|
||||
后端以 Topic 中的设备标识作为解析入口,payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。
|
||||
@@ -106,6 +108,29 @@
|
||||
| --- | --- | --- | --- |
|
||||
| powerLevel | string | 是 | 电量值,后端当前按字符串保存 |
|
||||
|
||||
注册成功后后端会立即将设备标记为在线。之后收到包含 `powerLevel` 的有效消息,会把在线心跳有效期刷新为 600 秒。设备必须周期上报,即使电量没有变化也要发送,建议每 5 分钟一次;连续 10 分钟未收到电量或 ACK 刷新时将被判定为离线。ACK 仍保留现有的在线缓存刷新逻辑。
|
||||
|
||||
## 设备离线遗嘱
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 功能名称 | 设备离线遗嘱 |
|
||||
| 通信方向 | 设备发布,后端订阅 |
|
||||
| Topic | `/{deviceNo}/publish/status`,注册前可使用 `/{mac}/publish/status` |
|
||||
| Payload 类型 | JSON 或纯文本 |
|
||||
|
||||
推荐将以下消息配置为 MQTT LWT,并设置 `retain=false`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "offline"
|
||||
}
|
||||
```
|
||||
|
||||
后端收到 `offline` 或 `0` 后立即标记设备离线。`online` 或 `1` 不会标记在线,设备上线通过注册成功或电量心跳确认。
|
||||
|
||||
兼容设备可发送 `{"deviceMac":"AA:BB:CC:DD:EE:FF","offline":"true"}`,服务端会使用 `deviceMac` 解析设备并标记离线。
|
||||
|
||||
## 命令应答 ACK
|
||||
|
||||
| 项目 | 内容 |
|
||||
@@ -306,9 +331,10 @@ JSON 示例:
|
||||
|
||||
## 嵌入式侧实现要点
|
||||
|
||||
1. 设备启动后发布注册消息到 `/{deviceNo}/publish/register`。
|
||||
2. 设备定时或电量变化时发布电量到 `/{deviceNo}/publish/power`。
|
||||
3. 设备订阅自己的命令 Topic:`/{deviceNo}/subscriber/cmd` 和 `/{deviceNo}/subscriber/schedule`。
|
||||
4. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`。
|
||||
5. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule` 或 `/{deviceNo}/publish/finish/key`。
|
||||
6. 硬件异常时发布到 `/{deviceNo}/publish/error`。
|
||||
1. 设备启动后使用 MAC 发布注册消息到 `/{mac}/publish/register`,取得服务端分配的 deviceNo。
|
||||
2. MQTT 连接时配置离线遗嘱 `/{deviceNo}/publish/status`,payload 为 `{"status":"offline"}`,`retain=false`。
|
||||
3. 设备至少每 5 分钟发布一次电量到 `/{deviceNo}/publish/power`,电量未变化也要上报。
|
||||
4. 设备订阅自己的命令 Topic:`/{deviceNo}/subscriber/cmd` 和 `/{deviceNo}/subscriber/schedule`。
|
||||
5. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`。
|
||||
6. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule` 或 `/{deviceNo}/publish/finish/key`。
|
||||
7. 硬件异常时发布到 `/{deviceNo}/publish/error`。
|
||||
|
||||
@@ -38,14 +38,15 @@
|
||||
|
||||
| Topic 模式 | 正则 | 处理器 | 说明 |
|
||||
|------------|------|--------|------|
|
||||
| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册/上线 |
|
||||
| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报 |
|
||||
| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册 |
|
||||
| `/{identity}/publish/status` | `^/([^/]+)/publish/status$` | `DeviceStatusHandler` | 设备 LWT 离线通知(仅处理 offline) |
|
||||
| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报及在线心跳 |
|
||||
| `/{identity}/publish/finish/key` | `^/([^/]+)/publish/finish/key$` | `KeyFinishHandler` | 按键/手动浇水完成 |
|
||||
| `/{identity}/publish/finish/schedule` | `^/([^/]+)/publish/finish/schedule$` | `ScheduleFinishHandler` | 排程浇水完成 |
|
||||
| `/{identity}/publish/error` | `^/([^/]+)/publish/error$` | `ErromesHandler` | 设备异常告警 |
|
||||
| `/{identity}/publish/ack` | `^/([^/]+)/publish/ack$` | `DeviceCommandAckHandler` | 命令执行确认 |
|
||||
|
||||
> `{identity}` 可以是设备编号(deviceNo)或 MAC 地址,由 `DeviceIdentityResolver` 统一解析为 deviceNo。
|
||||
> 首次注册可使用 MAC 地址;注册完成后的业务 Topic 使用 deviceNo。LWT 离线 Topic 兼容 MAC 地址,服务端通过 `DeviceIdentityResolver` 解析为 deviceNo。
|
||||
|
||||
### 2.2 下行 Topic(服务端 → 设备)
|
||||
|
||||
@@ -304,8 +305,9 @@ long nextRetryAt; // 下次重试时间戳
|
||||
**服务端处理**:
|
||||
1. 通过 MAC 或设备编号解析入库设备
|
||||
2. 更新设备注册信息(名称、电量、固件版本等)
|
||||
3. 刷新设备在线状态到 Redis
|
||||
4. 自动回复设备编号(`registerDeviceNo` 命令)
|
||||
3. 自动回复设备编号(`registerDeviceNo` 命令)
|
||||
|
||||
> 注册成功即将设备标记为在线;设备取得 deviceNo 后仍必须定时上报电量,以持续刷新在线心跳。
|
||||
|
||||
---
|
||||
|
||||
@@ -317,11 +319,36 @@ long nextRetryAt; // 下次重试时间戳
|
||||
}
|
||||
```
|
||||
|
||||
**服务端处理**: 更新设备电量 + 刷新在线状态。
|
||||
**服务端处理**: 更新设备电量,将数据库设备状态改为在线,并将 Redis 在线心跳刷新为 600 秒。
|
||||
|
||||
设备必须周期上报,即使电量没有变化也要发送。建议每 5 分钟上报一次,连续 10 分钟未收到有效电量消息且期间无 ACK 刷新时,服务端将设备判定为离线。
|
||||
|
||||
---
|
||||
|
||||
### 5.3 按键浇水完成 — `/{identity}/publish/finish/key`
|
||||
### 5.3 设备离线遗嘱 — `/{identity}/publish/status`
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "offline"
|
||||
}
|
||||
```
|
||||
|
||||
**服务端处理**: 收到非 retained 的 `offline` 或 `0` 后立即将设备改为离线。`online` 或 `1` 不再用于上线,服务端会忽略,上线状态只由电量心跳维护。
|
||||
|
||||
设备应将该消息配置为 MQTT LWT,建议 `retain=false`。Topic 第一段优先使用 deviceNo;设备注册前无法取得 deviceNo 时可使用 MAC 地址。
|
||||
|
||||
兼容设备也可以发送以下格式,服务端会使用 `deviceMac` 解析设备,并将 `offline=true`(字符串或布尔值)按离线处理:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceMac": "AA:BB:CC:DD:EE:FF",
|
||||
"offline": "true"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 按键浇水完成 — `/{identity}/publish/finish/key`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -340,7 +367,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.4 排程浇水完成 — `/{identity}/publish/finish/schedule`
|
||||
### 5.5 排程浇水完成 — `/{identity}/publish/finish/schedule`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -358,7 +385,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.5 设备异常告警 — `/{identity}/publish/error`
|
||||
### 5.6 设备异常告警 — `/{identity}/publish/error`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -371,7 +398,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.6 命令 ACK — `/{identity}/publish/ack`
|
||||
### 5.7 命令 ACK — `/{identity}/publish/ack`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -411,6 +438,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
| `maxRetryCount` | 3 | 最大重试次数 |
|
||||
| `retryIntervalMs` | 5000 | 重试间隔(ms) |
|
||||
| `scanIntervalMs` | 5000 | 定时扫描间隔(ms) |
|
||||
| `ackLockWaitMs` | 3000 | ACK 等待同一命令重试锁的最长时间(ms) |
|
||||
| `pendingTtlSeconds` | 86400 | pending 命令 TTL(秒) |
|
||||
| `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL(秒) |
|
||||
|
||||
@@ -495,8 +523,14 @@ mqtt:
|
||||
pending-set-key: "mqtt:command:pending:ids"
|
||||
retry-lock-key-prefix: "lock:mqtt:command:retry:"
|
||||
retry-lock-ttl-ms: 30000
|
||||
ack-lock-wait-ms: 3000
|
||||
device-status-cache-prefix: "mqtt:device:status:"
|
||||
device-status-cache-ttl-seconds: 300
|
||||
device-status-cache-ttl-seconds: 600
|
||||
|
||||
offline-check:
|
||||
enabled: true
|
||||
ttl-compat-enabled: true
|
||||
interval-ms: 30000
|
||||
```
|
||||
|
||||
---
|
||||
@@ -538,10 +572,13 @@ public class MyNewHandler implements MqttTopicHandler {
|
||||
|
||||
## 10. 设备在线状态检测
|
||||
|
||||
设备在线状态通过 Redis 缓存管理:
|
||||
设备在线状态由电量上报和离线遗嘱共同管理:
|
||||
|
||||
- **写入时机**: 设备注册、电量上报、ACK 确认时刷新
|
||||
- **上线时机**: 注册成功或收到包含 `powerLevel` 的有效电量上报时刷新
|
||||
- **ACK 兼容逻辑**: 保留现有 ACK 在线刷新逻辑,收到有效 ACK 也会延长在线缓存
|
||||
- **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }`
|
||||
- **TTL**: 默认 300 秒(5 分钟)
|
||||
- **离线判定**: 缓存过期 = 设备离线
|
||||
- **TTL**: 默认 600 秒(10 分钟)
|
||||
- **超时离线**: 每 30 秒扫描一次非离线设备,缓存过期后更新数据库状态为离线
|
||||
- **遗嘱离线**: 收到非 retained 的 LWT `status=offline/0` 时立即离线
|
||||
- **忽略在线状态**: `status=online/1` 不会刷新在线状态
|
||||
- **并发保护**: 使用分布式锁(`lock:mqtt:device:status:{deviceNo}`)防止并发写入
|
||||
|
||||
@@ -133,7 +133,7 @@ sequenceDiagram
|
||||
```mermaid
|
||||
flowchart TB
|
||||
REDIS[("Redis")]
|
||||
STATUS["mqtt:device:status:{deviceNo}<br/>设备最新在线状态<br/>TTL: 300s"]
|
||||
STATUS["mqtt:device:status:{deviceNo}<br/>设备电量在线心跳<br/>TTL: 600s"]
|
||||
PENDING["mqtt:command:pending:{commandId}<br/>待ACK命令详情<br/>TTL: 86400s"]
|
||||
PENDING_IDS["mqtt:command:pending:ids<br/>待ACK commandId 集合"]
|
||||
ACK["mqtt:command:ack:{commandId}<br/>设备ACK结果<br/>TTL: 86400s"]
|
||||
@@ -176,7 +176,7 @@ flowchart TB
|
||||
B --> C["后端单/少量订阅客户端消费通配 topic"]
|
||||
C --> D["有界队列吸收突发流量"]
|
||||
D --> E["批量消费降低线程调度成本"]
|
||||
E --> F["状态写 Redis,避免心跳打数据库"]
|
||||
E --> F["电量心跳写 Redis 并同步设备状态"]
|
||||
F --> G["数据/告警后续建议批量落库"]
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# AppController Concurrency Test Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add repeatable 32-thread unit concurrency tests for the singleton `AppController` without live infrastructure.
|
||||
|
||||
**Architecture:** Create one standalone JUnit 5 test class with a shared controller instance and mocked collaborators. A fixed thread pool, ready latch, start latch, and bounded `Future#get` synchronize each test; concurrent collections retain observations for deterministic assertions after workers finish.
|
||||
|
||||
**Tech Stack:** Java 17, JUnit 5, Mockito, AssertJ, Spring `MockMultipartFile`, Maven Surefire.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not modify production code unless a concurrency failure is reproduced.
|
||||
- Use exactly 32 worker threads per test.
|
||||
- Do not connect to MySQL, Redis, MQTT, or OSS.
|
||||
- Every future must have a bounded timeout.
|
||||
- Use one shared `AppController` instance within each test.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add AppController Concurrency Coverage
|
||||
|
||||
**Files:**
|
||||
- Create: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `AppController.switchDevice(String)`, `uploadImage(MultipartFile)`, and `editScheduleStatus(AppScheduleBo)`.
|
||||
- Produces: three repeatable JUnit concurrency tests and shared executor helpers.
|
||||
|
||||
- [ ] **Step 1: Create the concurrency test fixture**
|
||||
|
||||
Add Mockito mocks for all constructor dependencies, construct one controller per test, register an `ObjectMapper` in a `GenericApplicationContext`, and add a helper that submits 32 workers behind ready/start latches.
|
||||
|
||||
- [ ] **Step 2: Add concurrent timestamp formatting test**
|
||||
|
||||
Run 32 workers with 100 calls each. Capture every `startTime`, require 3,200 successful responses and invocations, and parse every captured value with strict `yyyy-MM-dd HH:mm:ss` formatting.
|
||||
|
||||
- [ ] **Step 3: Add concurrent image validation test**
|
||||
|
||||
Run 32 workers with 50 uploads each, cycling through JPEG, PNG, GIF, WebP, and BMP signatures. Require 1,600 successful responses and OSS calls.
|
||||
|
||||
- [ ] **Step 4: Add concurrent schedule payload isolation test**
|
||||
|
||||
Run 32 workers with 20 unique schedules each. Scope `LoginHelper.getUserId()` to each worker, generate a unique device and detail marker per schedule, capture MQTT payloads in a `ConcurrentHashMap`, and verify all 640 payloads contain their matching values.
|
||||
|
||||
- [ ] **Step 5: Run the focused concurrency test**
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppControllerConcurrencyTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
|
||||
```
|
||||
|
||||
Expected: 3 tests pass with zero failures, errors, and timeouts.
|
||||
|
||||
- [ ] **Step 6: Run the full water-app suite and diff checks**
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: reactor `BUILD SUCCESS`; all tests pass; `git diff --check` exits 0.
|
||||
@@ -0,0 +1,689 @@
|
||||
# AppController Review Remediation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix the approved exception-safety, type-safety, N+1-query, and image-upload findings while preserving every existing `/app/v1/**` route.
|
||||
|
||||
**Architecture:** Keep `AppController` in place for this phase, but move database batching behind existing service interfaces. Route all non-degraded exceptions through `GlobalExceptionHandler`, validate image bytes before OSS, and cover behavior through focused unit and standalone MVC tests.
|
||||
|
||||
**Tech Stack:** Java 17, Spring Boot 3.5, Spring MVC, MyBatis Plus, Hutool, JUnit 5, Mockito, AssertJ, Maven.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not split `AppController` in this phase.
|
||||
- Preserve existing route paths, request field names, successful response payloads, MQTT topics, and database schema.
|
||||
- Unexpected system exceptions must not expose their original messages to clients.
|
||||
- Image uploads are limited to 20 MB and to JPEG, PNG, GIF, WebP, and BMP detected by file signature.
|
||||
- Keep the four intentional MQTT notification degradation catches and include the exception object in each log call.
|
||||
- Do not revert unrelated dirty-worktree changes.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Make Global Exception Responses Safe And Observable
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-common/water-common-web/pom.xml`
|
||||
- Modify: `water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java:60-167`
|
||||
- Create: `water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ServiceException`, `BaseException`, `R<Void>`, `HttpServletRequest`.
|
||||
- Produces: unchanged handler method signatures; generic system failures return `R.fail()` instead of `e.getMessage()`.
|
||||
|
||||
- [ ] **Step 1: Add the test dependency and failing exception-handler tests**
|
||||
|
||||
Add this dependency to `water-common-web/pom.xml`:
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Create `GlobalExceptionHandlerTest` with these behaviors:
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new GlobalExceptionHandler();
|
||||
when(request.getRequestURI()).thenReturn("/app/v1/device/D01");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleRuntimeException_hidesInternalMessage() {
|
||||
R<Void> result = handler.handleRuntimeException(
|
||||
new IllegalStateException("jdbc:mysql://internal-host/water"), request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.FAIL);
|
||||
assertThat(result.getMsg()).doesNotContain("internal-host");
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleBaseException_logsThrowableAndRequestUri() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
try {
|
||||
handler.handleBaseException(new BaseException("user", "业务校验失败"), request);
|
||||
|
||||
ILoggingEvent event = appender.list.get(appender.list.size() - 1);
|
||||
assertThat(event.getLevel()).isEqualTo(Level.WARN);
|
||||
assertThat(event.getFormattedMessage()).contains("/app/v1/device/D01");
|
||||
assertThat(event.getThrowableProxy()).isNotNull();
|
||||
} finally {
|
||||
logger.detachAppender(appender);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl water-common/water-common-web -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=GlobalExceptionHandlerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
|
||||
```
|
||||
|
||||
Expected: failures because runtime messages are currently returned unchanged and `BaseException` logs have no throwable at `ERROR` level.
|
||||
|
||||
- [ ] **Step 3: Implement the minimal handler changes**
|
||||
|
||||
Apply these handler rules:
|
||||
|
||||
```java
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String message = resolveErrorMessage(e.getMessage());
|
||||
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
|
||||
Integer code = e.getCode();
|
||||
return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(BaseException.class)
|
||||
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
String message = e.getMessage();
|
||||
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
|
||||
return R.fail(message);
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
||||
return R.fail();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the focused tests and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: `GlobalExceptionHandlerTest` passes with zero failures.
|
||||
|
||||
- [ ] **Step 5: Record the Task 1 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-common/water-common-web/pom.xml water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Validate Image Upload Content And Size
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java:131-169`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:593-612`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `MultipartFile` and existing `ISysOssService.upload(MultipartFile)`.
|
||||
- Produces: unchanged `POST /app/v1/uploadImage`; invalid files throw `ServiceException` before OSS is called.
|
||||
|
||||
- [ ] **Step 1: Write failing upload validation tests**
|
||||
|
||||
Replace the fake valid PNG bytes with a real signature helper and add forged, oversized, and SVG cases:
|
||||
|
||||
```java
|
||||
private byte[] pngBytes() {
|
||||
return new byte[]{
|
||||
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
|
||||
};
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadImage_rejectsForgedImageContent() {
|
||||
AppController controller = newController();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "plant.png", "image/png", "not-an-image".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("图片文件内容不合法");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadImage_rejectsFileLargerThanTwentyMegabytes() {
|
||||
AppController controller = newController();
|
||||
MultipartFile file = mock(MultipartFile.class);
|
||||
when(file.isEmpty()).thenReturn(false);
|
||||
when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1);
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("图片大小不能超过20MB");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadImage_rejectsSvg() {
|
||||
AppController controller = newController();
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file", "plant.svg", "image/svg+xml", "<svg/>".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片");
|
||||
verify(ossService, never()).upload(any(MultipartFile.class));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run upload tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
|
||||
```
|
||||
|
||||
Expected: the forged and oversized tests fail because current code trusts `Content-Type` and has no endpoint-specific limit.
|
||||
|
||||
- [ ] **Step 3: Implement image validation**
|
||||
|
||||
Add constants and mappings:
|
||||
|
||||
```java
|
||||
private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024 * 1024;
|
||||
private static final Set<String> ALLOWED_IMAGE_EXTENSIONS =
|
||||
Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp");
|
||||
private static final Map<String, String> IMAGE_CONTENT_TYPES = Map.of(
|
||||
"jpg", "image/jpeg",
|
||||
"png", "image/png",
|
||||
"gif", "image/gif",
|
||||
"webp", "image/webp",
|
||||
"bmp", "image/bmp"
|
||||
);
|
||||
```
|
||||
|
||||
Call `validateImageFile(file)` before OSS. Implement it with `FileUtil.extName` and `FileTypeUtil.getType(file.getInputStream())`, normalize `jpeg` to `jpg`, and enforce extension/MIME/signature compatibility:
|
||||
|
||||
```java
|
||||
private void validateImageFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("上传图片不能为空");
|
||||
}
|
||||
if (file.getSize() > MAX_IMAGE_SIZE_BYTES) {
|
||||
throw new ServiceException("图片大小不能超过20MB");
|
||||
}
|
||||
String extension = StringUtils.lowerCase(FileUtil.extName(file.getOriginalFilename()));
|
||||
if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) {
|
||||
throw new ServiceException("仅支持jpg、jpeg、png、gif、webp、bmp图片");
|
||||
}
|
||||
String contentType = StringUtils.lowerCase(file.getContentType());
|
||||
String normalizedExtension = "jpeg".equals(extension) ? "jpg" : extension;
|
||||
if (!IMAGE_CONTENT_TYPES.get(normalizedExtension).equals(contentType)) {
|
||||
throw new ServiceException("图片文件类型不匹配");
|
||||
}
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
String detectedType = StringUtils.lowerCase(FileTypeUtil.getType(inputStream));
|
||||
String normalizedDetectedType = "jpeg".equals(detectedType) ? "jpg" : detectedType;
|
||||
if (!normalizedExtension.equals(normalizedDetectedType)) {
|
||||
throw new ServiceException("图片文件内容不合法");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new ServiceException("读取图片文件失败");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run AppController tests and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: all existing and new upload tests pass.
|
||||
|
||||
- [ ] **Step 5: Record the Task 2 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add Bounded Batch Query Service APIs
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java`
|
||||
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java`
|
||||
- Create: `water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos)`.
|
||||
- Produces: `Set<String> findBoundDeviceNos(Collection<String> deviceNos)`.
|
||||
|
||||
- [ ] **Step 1: Write failing service tests against the desired APIs**
|
||||
|
||||
Add to `AppDeviceServiceImplTest`:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void queryByDeviceNos_delegatesToSingleBatchQuery() {
|
||||
AppDeviceServiceImpl service = new AppDeviceServiceImpl(
|
||||
appDeviceMapper, schedulingDeviceMapper, wateringLogMapper, wateringLogService);
|
||||
List<String> deviceNos = List.of("D01", "D02");
|
||||
List<AppDeviceVo> expected = List.of(new AppDeviceVo(), new AppDeviceVo());
|
||||
when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected);
|
||||
|
||||
assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected);
|
||||
verify(appDeviceMapper).selectVoByIds(deviceNos);
|
||||
}
|
||||
```
|
||||
|
||||
Create `AppSchedulingDeviceServiceImplTest`:
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AppSchedulingDeviceServiceImplTest {
|
||||
|
||||
@Mock
|
||||
private AppSchedulingDeviceMapper mapper;
|
||||
|
||||
@Test
|
||||
void findBoundDeviceNos_usesOneConstrainedQuery() {
|
||||
AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper);
|
||||
AppSchedulingDevice first = new AppSchedulingDevice();
|
||||
first.setDeviceNo("D01");
|
||||
AppSchedulingDevice duplicate = new AppSchedulingDevice();
|
||||
duplicate.setDeviceNo("D01");
|
||||
when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, duplicate));
|
||||
|
||||
Set<String> result = service.findBoundDeviceNos(List.of("D01", "D02"));
|
||||
|
||||
assertThat(result).containsExactly("D01");
|
||||
verify(mapper, times(1)).selectList(any(Wrapper.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run service tests and verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppDeviceServiceImplTest,AppSchedulingDeviceServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
|
||||
```
|
||||
|
||||
Expected: test compilation fails because the two batch APIs do not exist yet.
|
||||
|
||||
- [ ] **Step 3: Add the interfaces and minimal implementations**
|
||||
|
||||
Add to `IAppDeviceService` and `AppDeviceServiceImpl`:
|
||||
|
||||
```java
|
||||
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
|
||||
```
|
||||
|
||||
```java
|
||||
@Override
|
||||
public List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return baseMapper.selectVoByIds(deviceNos);
|
||||
}
|
||||
```
|
||||
|
||||
Add to `IAppSchedulingDeviceService` and `AppSchedulingDeviceServiceImpl`:
|
||||
|
||||
```java
|
||||
Set<String> findBoundDeviceNos(Collection<String> deviceNos);
|
||||
```
|
||||
|
||||
```java
|
||||
@Override
|
||||
public Set<String> findBoundDeviceNos(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
return Set.of();
|
||||
}
|
||||
return baseMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
.select(AppSchedulingDevice::getDeviceNo)
|
||||
.in(AppSchedulingDevice::getDeviceNo, deviceNos))
|
||||
.stream()
|
||||
.map(AppSchedulingDevice::getDeviceNo)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run service tests and verify GREEN**
|
||||
|
||||
Run the command from Step 2. Expected: both service test classes pass.
|
||||
|
||||
- [ ] **Step 5: Record the Task 3 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-modules/water-app/src/main/java/org/dromara/app/service water-modules/water-app/src/test/java/org/dromara/app/service/impl
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Replace Controller N+1 Queries
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:175-195`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:357-400`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `IAppDeviceService.queryByDeviceNos` from Task 3.
|
||||
- Consumes: `IAppSchedulingDeviceService.findBoundDeviceNos` from Task 3.
|
||||
- Preserves: `GET /app/v1/scheduleDeviceList/{scheduleId}` and `GET /app/v1/schedule/{id}` payloads.
|
||||
|
||||
- [ ] **Step 1: Write failing Controller interaction tests**
|
||||
|
||||
Add these tests:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void scheduleDeviceList_usesSingleBatchBindingLookup() {
|
||||
AppController controller = newController();
|
||||
AppDeviceVo first = ownedDevice("D01", 99L);
|
||||
AppDeviceVo second = ownedDevice("D02", 99L);
|
||||
when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second));
|
||||
when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02")))
|
||||
.thenReturn(Set.of("D01"));
|
||||
|
||||
R<Map<String, Object>> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L));
|
||||
|
||||
assertThat((List<AppDeviceVo>) result.getData().get("deviceList"))
|
||||
.extracting(AppDeviceVo::getDeviceNo)
|
||||
.containsExactly("D02");
|
||||
verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getScheduleInfo_usesSingleBatchDeviceLookup() {
|
||||
AppController controller = newController();
|
||||
AppScheduleVo schedule = ownedSchedule(10L, 99L);
|
||||
AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo();
|
||||
firstBinding.setDeviceNo("D01");
|
||||
AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo();
|
||||
secondBinding.setDeviceNo("D02");
|
||||
List<AppDeviceVo> devices = List.of(ownedDevice("D01", 99L), ownedDevice("D02", 99L));
|
||||
when(appScheduleService.queryById(10L)).thenReturn(schedule);
|
||||
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
|
||||
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
|
||||
.thenReturn(List.of(firstBinding, secondBinding));
|
||||
when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(devices);
|
||||
|
||||
R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));
|
||||
|
||||
assertThat(result.getData().getDeviceNos()).isEqualTo(devices);
|
||||
verify(appDeviceService, never()).queryById("D01");
|
||||
verify(appDeviceService, never()).queryById("D02");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run AppController tests and verify RED**
|
||||
|
||||
Run the Task 2 test command. Expected: interaction assertions fail because current methods query once per device/binding.
|
||||
|
||||
- [ ] **Step 3: Implement batch-backed Controller methods**
|
||||
|
||||
Use one binding lookup in `scheduleDeviceList`:
|
||||
|
||||
```java
|
||||
List<String> deviceNos = appDeviceVos.stream().map(AppDeviceVo::getDeviceNo).toList();
|
||||
Set<String> boundDeviceNos = appSchedulingDeviceService.findBoundDeviceNos(deviceNos);
|
||||
List<AppDeviceVo> availableDevices = appDeviceVos.stream()
|
||||
.filter(device -> !boundDeviceNos.contains(device.getDeviceNo()))
|
||||
.toList();
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("deviceList", availableDevices);
|
||||
return R.ok(result);
|
||||
```
|
||||
|
||||
Use one batch device lookup in `getScheduleInfo`:
|
||||
|
||||
```java
|
||||
List<String> deviceNos = bindings.stream()
|
||||
.map(AppSchedulingDeviceVo::getDeviceNo)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<AppDeviceVo> devices = appDeviceService.queryByDeviceNos(deviceNos);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run AppController tests and verify GREEN**
|
||||
|
||||
Run the Task 2 command. Expected: all `AppControllerTest` tests pass.
|
||||
|
||||
- [ ] **Step 5: Record the Task 4 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Reuse Schedule Details Across MQTT Notifications
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:200-287`
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:763-794`
|
||||
|
||||
**Interfaces:**
|
||||
- Preserves all schedule MQTT command payload fields.
|
||||
- Changes internal helper to consume prebuilt `List<Map<String, Object>> details`.
|
||||
|
||||
- [ ] **Step 1: Write a failing query-count test**
|
||||
|
||||
Add an open-schedule test with two bindings:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() {
|
||||
AppController controller = newController();
|
||||
AppScheduleBo bo = new AppScheduleBo();
|
||||
bo.setId(10L);
|
||||
bo.setStatus("1");
|
||||
AppSchedulingDeviceVo first = new AppSchedulingDeviceVo();
|
||||
first.setDeviceNo("D01");
|
||||
AppSchedulingDeviceVo second = new AppSchedulingDeviceVo();
|
||||
second.setDeviceNo("D02");
|
||||
when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L));
|
||||
when(appScheduleService.updateStatusByBo(bo)).thenReturn(true);
|
||||
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class)))
|
||||
.thenReturn(List.of(first, second));
|
||||
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of());
|
||||
|
||||
callWithLogin(99L, () -> controller.editScheduleStatus(bo));
|
||||
|
||||
verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L);
|
||||
verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any());
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify RED**
|
||||
|
||||
Run the Task 2 command. Expected: detail service is called twice.
|
||||
|
||||
- [ ] **Step 3: Build details once and reuse them**
|
||||
|
||||
Change payload construction to:
|
||||
|
||||
```java
|
||||
private Map<String, Object> buildScheduleBindPayload(
|
||||
String deviceNo, List<Map<String, Object>> details) {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("deviceNo", deviceNo);
|
||||
payload.put("details", details);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> loadScheduleDetailPayload(Long scheduleId) {
|
||||
return toScheduleDetailPayload(appScheduleDetailService.queryByScheduleIdByStatus(scheduleId));
|
||||
}
|
||||
```
|
||||
|
||||
Call `loadScheduleDetailPayload(scheduleId)` once before each device loop in add/update/open-status notification paths, then pass the result into `buildScheduleBindPayload(deviceNo, details)`.
|
||||
|
||||
- [ ] **Step 4: Run AppController tests and verify GREEN**
|
||||
|
||||
Run the Task 2 command. Expected: all tests pass and the detail service is called once for two devices.
|
||||
|
||||
- [ ] **Step 5: Record the Task 5 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Complete Type-Safety And Local Cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java`
|
||||
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Removes the unused `AppScheduleServiceImpl` constructor dependency.
|
||||
- Preserves all endpoint return JSON shapes.
|
||||
|
||||
- [ ] **Step 1: Capture the current static-analysis failure**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
rg -n '^import .*\*;|\b(Map|List|Set)\s+[a-zA-Z][a-zA-Z0-9_]*\s*=|R<Map>' water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java
|
||||
```
|
||||
|
||||
Expected: matches for wildcard imports, raw collections, and raw response maps.
|
||||
|
||||
- [ ] **Step 2: Replace raw types and wildcard imports**
|
||||
|
||||
Use these concrete types:
|
||||
|
||||
```java
|
||||
R<Map<String, Object>>
|
||||
List<AppDeviceVo>
|
||||
List<AppWateringLogVo>
|
||||
List<Map<String, Object>>
|
||||
List<Object>
|
||||
Map<String, Object>
|
||||
Map<String, Long>
|
||||
```
|
||||
|
||||
Replace `org.dromara.app.domain.vo.*`, `org.dromara.app.service.*`, `org.springframework.web.bind.annotation.*`, and `java.util.*` with explicit imports. Remove the unused `AppScheduleServiceImpl appScheduleServiceimpl` field and its test mock/constructor argument.
|
||||
|
||||
Replace Controller time formatting with:
|
||||
|
||||
```java
|
||||
private static final DateTimeFormatter SECOND_TIME_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
String startTime = LocalDateTime.now().format(SECOND_TIME_FORMATTER);
|
||||
|
||||
private String formatSecondTime(Date time) {
|
||||
return time == null ? null : time.toInstant()
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDateTime()
|
||||
.format(SECOND_TIME_FORMATTER);
|
||||
}
|
||||
```
|
||||
|
||||
Remove commented-out annotations and dead commented code only in sections touched by this plan.
|
||||
|
||||
- [ ] **Step 3: Re-run the static query and verify GREEN**
|
||||
|
||||
Run the Step 1 command. Expected: no output and exit code 1 from `rg` because there are no matches.
|
||||
|
||||
- [ ] **Step 4: Run all water-app tests**
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test
|
||||
```
|
||||
|
||||
Expected: reactor `BUILD SUCCESS`, zero test failures.
|
||||
|
||||
- [ ] **Step 5: Record the Task 6 diff checkpoint**
|
||||
|
||||
```powershell
|
||||
git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Final Verification Against The Review Standard
|
||||
|
||||
**Files:**
|
||||
- Verify only; no planned production edits.
|
||||
|
||||
**Interfaces:**
|
||||
- Verifies the deliverables from Tasks 1-6 together.
|
||||
|
||||
- [ ] **Step 1: Run common-web tests**
|
||||
|
||||
```powershell
|
||||
mvn -pl water-common/water-common-web -am "-DskipTests=false" "-Dmaven.test.skip=false" test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESS` and `GlobalExceptionHandlerTest` passes.
|
||||
|
||||
- [ ] **Step 2: Run water-app tests**
|
||||
|
||||
```powershell
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test
|
||||
```
|
||||
|
||||
Expected: `BUILD SUCCESS` with zero failures and errors.
|
||||
|
||||
- [ ] **Step 3: Run structural and diff checks**
|
||||
|
||||
```powershell
|
||||
rg -n 'return fail\(|private <T> R<T> fail|toI18nErrorMessage|^import .*\*;|R<Map>|\b(Map|List|Set)\s+[a-zA-Z][a-zA-Z0-9_]*\s*=' water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: no forbidden Controller matches, `git diff --check` exits 0, and status contains only intentional task files plus pre-existing unrelated changes.
|
||||
|
||||
- [ ] **Step 4: Review the final diff by task scope**
|
||||
|
||||
```powershell
|
||||
git diff -- water-common/water-common-web water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/main/java/org/dromara/app/service water-modules/water-app/src/test
|
||||
```
|
||||
|
||||
Expected: no route changes, no MQTT topic changes, no schema changes, and no unrelated file modifications.
|
||||
Reference in New Issue
Block a user