Compare commits
18 Commits
86e511f418
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509e760f3d | ||
|
|
b789c1c07e | ||
|
|
2f7597ed53 | ||
|
|
215d6ba167 | ||
|
|
b6c1edaeba | ||
|
|
e3f4f0e98f | ||
|
|
b448eb83a4 | ||
|
|
a1b12f4241 | ||
|
|
3728f9a009 | ||
|
|
96941abc0d | ||
|
|
8b0612f744 | ||
|
|
1c122dfc68 | ||
|
|
38f6cc06d4 | ||
|
|
97862b8d52 | ||
|
|
70e2356f22 | ||
|
|
4a4aabf19e | ||
|
|
85bc979445 | ||
|
|
51512615fb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -46,6 +46,7 @@
|
||||
/.trae/
|
||||
/.windsurf/
|
||||
/.workbuddy/
|
||||
/.worktrees/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -63,3 +64,6 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/docs/*
|
||||
!/docs/adr/
|
||||
!/docs/adr/*.md
|
||||
|
||||
48
.idea/compiler.xml
generated
48
.idea/compiler.xml
generated
@@ -13,30 +13,30 @@
|
||||
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||
<outputRelativeToContentRoot value="true" />
|
||||
<processorPath useClasspath="false">
|
||||
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||
<entry name="$PROJECT_DIR$/../../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||
</processorPath>
|
||||
<module name="water-app" />
|
||||
<module name="water-common-excel" />
|
||||
|
||||
65
CONTEXT.md
Normal file
65
CONTEXT.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Watex Device Management
|
||||
|
||||
This context defines how Watex manages device identity, ownership, and user-generated device data throughout a device's lifecycle.
|
||||
|
||||
## Language
|
||||
|
||||
**Active Device Binding**:
|
||||
The exclusive relationship that grants one user access to a device's live state, details, data, and controls and permits binding-scoped MQTT reports to create or update data. A device has at most one active binding at a time.
|
||||
_Avoid_: Device ownership, shared binding
|
||||
|
||||
**User Device Relationship**:
|
||||
The single lifecycle relationship between one user and one device that drives the user's list state. It is reactivated rather than duplicated when the same user binds the device again, while the device may have relationships with different users over time.
|
||||
_Avoid_: Binding history, device ownership
|
||||
|
||||
**Binding in Progress**:
|
||||
The exclusive reservation created while a binding identifier is being delivered to the device. It appears as "binding" in the user's list and grants no device access until the device acknowledges that identifier.
|
||||
_Avoid_: Active binding, bound device
|
||||
|
||||
**Binding Failed**:
|
||||
A non-active list state reached when binding delivery exhausts its retries without device acknowledgment. It grants no device access, does not reserve the device, and allows the user to retry or remove the list entry.
|
||||
_Avoid_: Active binding, binding in progress
|
||||
|
||||
**Device Unbinding**:
|
||||
The source-independent workflow that terminates a device's active binding, revokes the former user's device access, and clears user-specific or runtime data while retaining the device's inventory identity and an unbound device entry for that user.
|
||||
_Avoid_: Device deletion, device removal
|
||||
|
||||
**Unbinding in Progress**:
|
||||
A binding state entered as soon as a valid unbinding command is accepted. It appears as a non-interactive "unbinding" item in the former user's device list, grants no device access, prevents any new binding while cleanup is incomplete, and never returns to active after cleanup failure.
|
||||
_Avoid_: Active binding, completed unbinding
|
||||
|
||||
**Unbinding Recovery**:
|
||||
The durable continuation of cleanup for a binding in progress. It survives server restarts, retries without depending on another device message, and keeps the device unavailable until cleanup succeeds.
|
||||
_Avoid_: Binding rollback, device-triggered retry
|
||||
|
||||
**Device-Initiated Unbinding**:
|
||||
Device unbinding initiated when the server receives an MQTT unbinding message from the device. It follows the same state and cleanup rules as user-initiated and administrator-initiated unbinding; offline button behavior and messages not received by the server are outside this workflow.
|
||||
_Avoid_: Offline unbinding, local reset
|
||||
|
||||
**Unbound Device Entry**:
|
||||
A display-only item retained in a former user's device list with the device name and number captured at unbinding, the unbound status, and the unbinding time. If the same user binds the device again, this item becomes the active list item instead of creating a binding-history entry; it grants no access while unbound.
|
||||
_Avoid_: Binding history, archived device, inactive binding
|
||||
|
||||
**List Entry Removal**:
|
||||
The physical removal of the user's `UNBOUND` or `BIND_FAILED` relationship from their own device list. It does not remove command or audit records, delete the device's inventory identity, or affect another user's active binding; a later binding creates a new relationship.
|
||||
_Avoid_: Device deletion, device unbinding
|
||||
|
||||
**Binding-Scoped Device Data**:
|
||||
User configuration and runtime data belonging to an active binding, including the user's device name, Wi-Fi credentials, watering records, telemetry state, pending commands, and runtime caches. It is permanently cleared when that binding is terminated and is never part of an unbound device entry.
|
||||
_Avoid_: Device history, archived device data
|
||||
|
||||
**Device Inventory Identity**:
|
||||
The stable device information retained across bindings: device number, MAC address, factory name, model, serial number, firmware version, QR code, image, and expiration time. It allows an unbound device to remain managed and bindable without retaining a former user's data.
|
||||
_Avoid_: User device data, device binding
|
||||
|
||||
**User Schedule**:
|
||||
A user-owned watering plan whose timing and configuration exist independently of any one device. Unbinding a device removes that device's schedule associations but retains the schedule, its details, and associations with other devices.
|
||||
_Avoid_: Device schedule, device-owned schedule
|
||||
|
||||
**Device Deletion**:
|
||||
The permanent removal of an unbound device's inventory identity together with its related data. A bound device must be unbound before it can be deleted.
|
||||
_Avoid_: Device unbinding
|
||||
|
||||
**Device Initialization**:
|
||||
The physical reset that removes user configuration from a device after unbinding. Device-initiated unbinding completes it after an `applied` result, while user-initiated or administrator-initiated unbinding requests it asynchronously from the server.
|
||||
_Avoid_: Device unbinding
|
||||
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(服务端 → 设备)
|
||||
|
||||
@@ -88,17 +89,26 @@ long nextRetryAt; // 下次重试时间戳
|
||||
}
|
||||
```
|
||||
|
||||
与用户绑定关系有关的命令(`switchDevice`、排程命令、`queryPower`、`otaUpgrade` 和自定义命令)还会携带当前关系的 `bindingId`。设备应在对应上报或 ACK 中原样返回 `bindingId`;服务端只接受与当前 ACTIVE 绑定一致的报文,旧绑定报文会被丢弃。`bindDevice` 使用命令自身携带的目标 `bindingId`,`initDevice` 不携带绑定标识。
|
||||
|
||||
### 3.2 上行 ACK 结构(DeviceCommandAck)
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceNo": "设备编号",
|
||||
"commandId": "对应下发命令的commandId",
|
||||
"status": "1",
|
||||
"message": "可选的文本消息"
|
||||
"bindingId": "对应下发命令的绑定标识(有则原样返回)",
|
||||
"ack": "receive",
|
||||
"status": "兼容旧协议的确认标志",
|
||||
"message": "可选的文本消息",
|
||||
"powerLevel": 4,
|
||||
"charging": 0
|
||||
}
|
||||
```
|
||||
|
||||
> 也支持非 JSON 格式的纯文本 ACK(当设备只有一条待确认命令时可自动匹配)。
|
||||
> - 设备确认标志优先使用 `ack` 字段(以 `"receive"` 开头即视为接受/成功,如 `"receive"`、`"receive deviceNo"`);`status` 为兼容旧协议的字段。
|
||||
> - `powerLevel` / `charging` 仅在 `queryPower` 命令的 ACK 中返回,为**数字类型**,服务端会自动转为字符串后分别更新到设备表的 `power_level` 和 `power_status` 字段。
|
||||
> - 也支持非 JSON 格式的纯文本 ACK(当设备只有一条待确认命令时可自动匹配)。
|
||||
|
||||
---
|
||||
|
||||
@@ -115,6 +125,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
"commandId": "xxx",
|
||||
"commandType": "switchDevice",
|
||||
"deviceNo": "01",
|
||||
"bindingId": "当前绑定标识",
|
||||
"cmd": "1",
|
||||
"startTime": "2026-06-25 14:35:00",
|
||||
"durationMin": 20
|
||||
@@ -213,6 +224,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
"commandType": "bindSchedule",
|
||||
"cmd": -1,
|
||||
"deviceNo": "01",
|
||||
"bindingId": "当前绑定标识",
|
||||
"schedule": {
|
||||
"id": 1,
|
||||
"name": "每日浇水",
|
||||
@@ -257,6 +269,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
"commandType": "unbindSchedule",
|
||||
"cmd": -1,
|
||||
"deviceNo": "01",
|
||||
"bindingId": "当前绑定标识",
|
||||
"scheduleId": 1,
|
||||
"unbind": true
|
||||
}
|
||||
@@ -284,6 +297,83 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 4.8 电量查询命令 — `queryPower`
|
||||
|
||||
**触发**: 用户在 APP 主动查询设备电量
|
||||
**接口**: `POST /app/v1/queryPower/{deviceNo}`
|
||||
**Topic**: `/{deviceNo}/subscriber/cmd`
|
||||
|
||||
```json
|
||||
{
|
||||
"commandId": "xxx",
|
||||
"commandType": "queryPower",
|
||||
"deviceNo": "01",
|
||||
"bindingId": "当前绑定标识"
|
||||
}
|
||||
```
|
||||
|
||||
**设备 ACK 回复**(`/{deviceNo}/publish/ack`):
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceNo": "2074069050702561282",
|
||||
"commandId": "xxx",
|
||||
"bindingId": "当前绑定标识",
|
||||
"powerLevel": 4,
|
||||
"charging": 0,
|
||||
"ack": "receive"
|
||||
}
|
||||
```
|
||||
|
||||
**附加行为**: 服务端收到 ACK 后,将 `powerLevel` 更新到设备表的 `power_level` 字段、`charging` 更新到 `power_status` 字段,并刷新 `power_level_updatatime`。`powerLevel`/`charging` 为数字类型(服务端自动转字符串入库),电量查询 ACK 不依赖 `status` 字段。
|
||||
|
||||
---
|
||||
|
||||
### 4.9 设备 OTA 升级命令 — `otaUpgrade`
|
||||
|
||||
**触发**: 管理员在设备列表选择设备(或全部在线设备)下发固件升级
|
||||
**接口**: `POST /app/firmware/upgrade` · `POST /app/firmware/upgradeAll`
|
||||
**Topic**: `/{deviceNo}/subscriber/cmd`
|
||||
|
||||
```json
|
||||
{
|
||||
"commandId": "xxx",
|
||||
"commandType": "otaUpgrade",
|
||||
"deviceNo": "01",
|
||||
"firmwareUrl": "https://api.example.com/app/firmware/download/0123456789abcdef0123456789abcdef.bin",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"md5": "可选,固件 MD5 校验值"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `firmwareUrl` | String | 服务器固件下载地址 |
|
||||
| `firmwareVersion` | String | 目标固件版本号 |
|
||||
| `md5` | String | 固件 MD5 校验值(可选) |
|
||||
|
||||
**升级结果回报**: 设备收到命令后回 ACK 确认,下载固件并升级,重启后重新走 `/publish/register` 上报新 `version`,服务端据此更新设备表的 `fw_ver` 字段。
|
||||
|
||||
---
|
||||
|
||||
### 4.10 设备恢复出厂命令 — `factoryReset`
|
||||
|
||||
**触发**: 管理员在设备列表控制设备恢复出厂
|
||||
**接口**: `POST /app/device/factory-reset/{deviceNo}`
|
||||
**Topic**: `/{deviceNo}/subscriber/cmd`
|
||||
|
||||
```json
|
||||
{
|
||||
"commandId": "xxx",
|
||||
"commandType": "factoryReset",
|
||||
"deviceNo": "01"
|
||||
}
|
||||
```
|
||||
|
||||
服务端只下发 MQTT 命令并等待设备 ACK,不清理服务端的用户绑定、排程或浇水日志。
|
||||
|
||||
---
|
||||
|
||||
## 5. 上行消息类型详解
|
||||
|
||||
### 5.1 设备注册 — `/{identity}/publish/register`
|
||||
@@ -304,8 +394,9 @@ long nextRetryAt; // 下次重试时间戳
|
||||
**服务端处理**:
|
||||
1. 通过 MAC 或设备编号解析入库设备
|
||||
2. 更新设备注册信息(名称、电量、固件版本等)
|
||||
3. 刷新设备在线状态到 Redis
|
||||
4. 自动回复设备编号(`registerDeviceNo` 命令)
|
||||
3. 自动回复设备编号(`registerDeviceNo` 命令)
|
||||
|
||||
> 注册成功即将设备标记为在线;设备取得 deviceNo 后仍必须定时上报电量,以持续刷新在线心跳。
|
||||
|
||||
---
|
||||
|
||||
@@ -313,20 +404,47 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
```json
|
||||
{
|
||||
"powerLevel": "78"
|
||||
"powerLevel": "78",
|
||||
"bindingId": "当前绑定标识"
|
||||
}
|
||||
```
|
||||
|
||||
**服务端处理**: 更新设备电量 + 刷新在线状态。
|
||||
**服务端处理**: 更新设备电量,将数据库设备状态改为在线,并将 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
|
||||
{
|
||||
"deviceNo": "01",
|
||||
"commandId": "对应的命令ID",
|
||||
"bindingId": "当前绑定标识",
|
||||
"startTime": "2026-06-25 08:00:00",
|
||||
"endTime": "2026-06-25 08:15:00",
|
||||
"durationMin": 15,
|
||||
@@ -340,12 +458,13 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.4 排程浇水完成 — `/{identity}/publish/finish/schedule`
|
||||
### 5.5 排程浇水完成 — `/{identity}/publish/finish/schedule`
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceNo": "01",
|
||||
"commandId": "对应的命令ID",
|
||||
"bindingId": "当前绑定标识",
|
||||
"startTime": "2026-06-25 08:00:00",
|
||||
"endTime": "2026-06-25 08:15:00",
|
||||
"durationMin": 15,
|
||||
@@ -358,12 +477,13 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.5 设备异常告警 — `/{identity}/publish/error`
|
||||
### 5.6 设备异常告警 — `/{identity}/publish/error`
|
||||
|
||||
```json
|
||||
{
|
||||
"errorCode": "E001",
|
||||
"message": "水泵故障"
|
||||
"message": "水泵故障",
|
||||
"bindingId": "当前绑定标识"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -371,7 +491,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
|
||||
---
|
||||
|
||||
### 5.6 命令 ACK — `/{identity}/publish/ack`
|
||||
### 5.7 命令 ACK — `/{identity}/publish/ack`
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -411,6 +531,7 @@ long nextRetryAt; // 下次重试时间戳
|
||||
| `maxRetryCount` | 3 | 最大重试次数 |
|
||||
| `retryIntervalMs` | 5000 | 重试间隔(ms) |
|
||||
| `scanIntervalMs` | 5000 | 定时扫描间隔(ms) |
|
||||
| `ackLockWaitMs` | 3000 | ACK 等待同一命令重试锁的最长时间(ms) |
|
||||
| `pendingTtlSeconds` | 86400 | pending 命令 TTL(秒) |
|
||||
| `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL(秒) |
|
||||
|
||||
@@ -440,6 +561,9 @@ long nextRetryAt; // 下次重试时间戳
|
||||
| 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 |
|
||||
| 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) |
|
||||
| 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 |
|
||||
| 查询电量 | `POST /queryPower/{deviceNo}` | `queryPower` | Service 层下发,ACK 回复电量 |
|
||||
| OTA 升级 | `POST /firmware/upgrade` · `POST /firmware/upgradeAll` | `otaUpgrade` | Service 层下发,重启注册后更新 fw_ver |
|
||||
| 恢复出厂 | `POST /device/factory-reset/{deviceNo}` | `factoryReset` | 仅下发 MQTT 命令,不修改服务端关系数据 |
|
||||
| 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 |
|
||||
| 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 |
|
||||
| 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 |
|
||||
@@ -495,8 +619,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 +668,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.
|
||||
538
docs/superpowers/plans/2026-07-16-omen-cat-pet.md
Normal file
538
docs/superpowers/plans/2026-07-16-omen-cat-pet.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# Omen Cat Codex Pet 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:** Build, validate, and locally install a complete Codex v2 animated pet named `幽喵`, grounded in the approved Omen-cat reference and dark character design.
|
||||
|
||||
**Architecture:** The installed `hatch-pet` scripts own deterministic layout, extraction, atlas assembly, chroma cleanup, and validation. The installed `imagegen` skill is the only visual generation layer; each visual job runs in an isolated worker and returns one selected source path. The parent copies approved outputs into a durable workspace run, updates the structured job manifest, runs every deterministic gate, and installs only the validated v2 WebP and manifest.
|
||||
|
||||
**Tech Stack:** Codex `hatch-pet` skill, Codex `imagegen` skill, bundled Python 3 with Pillow, PowerShell, JSON manifests, PNG/WebP/GIF assets.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Design spec: `D:/code/water/docs/superpowers/specs/2026-07-16-omen-cat-pet-design.md`.
|
||||
- Canonical source: `C:/Users/admin/AppData/Local/Temp/codex-clipboard-ecce7d4b-e0a8-495f-98ba-9bf2f2d030b4.png`.
|
||||
- Durable work root: `D:/code/water/tmp/hatch-pet/omen-cat`.
|
||||
- Run directory: `D:/code/water/tmp/hatch-pet/omen-cat/run`.
|
||||
- Installed skill directory: `C:/Users/admin/.codex/skills/hatch-pet`.
|
||||
- Bundled Python: `C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe`.
|
||||
- Pet id: `youmiao`; display name: `幽喵`; sprite contract: `spriteVersionNumber: 2`.
|
||||
- Style: dark flat illustration with restrained cel shading; preserve purple cat-eared hood, black void face, three cyan face marks, compact paws, long attached tail, broad armor, and bandage wraps.
|
||||
- Never add text, logos, weapons, scenery, floor shadows, smoke, particles, detached effects, thin fragments, blur, or bloom.
|
||||
- Use `imagegen` for every normal visual job. Do not replace missing generated rows with local drawing, tiling, or procedural synthesis.
|
||||
- The final atlas must be exactly `1536x2288`, based on `192x208` cells, and pass all v2 deterministic and visual gates.
|
||||
- The dirty repository worktree is user-owned. Stage or commit only the design and plan documents after Git write approval succeeds.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Preserve The Reference And Prepare The Run
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/pet_request.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/imagegen-jobs.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/prompts/**`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/references/layout-guides/**`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the approved design spec and the clipboard PNG.
|
||||
- Produces: a durable source image, prepared pet request, prompt set, layout guides, and dependency-aware visual job manifest.
|
||||
|
||||
- [ ] **Step 1: Verify the temporary source still exists**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
Test-Path -LiteralPath 'C:/Users/admin/AppData/Local/Temp/codex-clipboard-ecce7d4b-e0a8-495f-98ba-9bf2f2d030b4.png'
|
||||
```
|
||||
|
||||
Expected: `True`. If it is `False`, stop and ask the user to attach the image again; do not substitute the web-search image.
|
||||
|
||||
- [ ] **Step 2: Copy the source into the durable work root**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path 'D:/code/water/tmp/hatch-pet/omen-cat/reference'
|
||||
Copy-Item -LiteralPath 'C:/Users/admin/AppData/Local/Temp/codex-clipboard-ecce7d4b-e0a8-495f-98ba-9bf2f2d030b4.png' -Destination 'D:/code/water/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png'
|
||||
Get-FileHash -Algorithm SHA256 -LiteralPath 'D:/code/water/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png'
|
||||
```
|
||||
|
||||
Expected: the destination exists and `Get-FileHash` prints one SHA-256 value.
|
||||
|
||||
- [ ] **Step 3: Prepare the hatch-pet run**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$PYTHON = 'C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe'
|
||||
$SKILL_DIR = 'C:/Users/admin/.codex/skills/hatch-pet'
|
||||
& $PYTHON "$SKILL_DIR/scripts/prepare_pet_run.py" --pet-name '幽喵' --pet-id 'youmiao' --display-name '幽喵' --description '一只披着暗紫幽影兜帽、以冷青面纹观察任务的沉静猫形伙伴。' --reference 'D:/code/water/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png' --output-dir 'D:/code/water/tmp/hatch-pet/omen-cat/run' --pet-notes 'Dark Omen-inspired cartoon cat; oversized angular purple cat-eared hood; black void face with three cyan energy marks; compact feline body; broad charcoal armor and bandage wraps; long attached tail; no weapons, logos, text, scenery, shadows, smoke, particles, detached effects, blur, or bloom.' --style-preset 'flat-vector' --style-notes 'Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.' --force
|
||||
```
|
||||
|
||||
Expected: exit code `0`; the run contains `pet_request.json`, `imagegen-jobs.json`, prompts, and layout guides.
|
||||
|
||||
- [ ] **Step 4: Verify prepared identity and v2 job graph**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$request = Get-Content -Raw -LiteralPath 'D:/code/water/tmp/hatch-pet/omen-cat/run/pet_request.json' | ConvertFrom-Json
|
||||
$jobs = Get-Content -Raw -LiteralPath 'D:/code/water/tmp/hatch-pet/omen-cat/run/imagegen-jobs.json' | ConvertFrom-Json
|
||||
$request | Select-Object pet_id, display_name, description, chroma_key
|
||||
$jobs.jobs | Select-Object id, kind, status, depends_on, output_path
|
||||
```
|
||||
|
||||
Expected: `pet_id` is `youmiao`; display name is `幽喵`; jobs include `base`, all nine standard states, `look-cardinals`, `look-row-9`, and `look-row-10`.
|
||||
|
||||
- [ ] **Step 5: Checkpoint the design documents when Git approval is available**
|
||||
|
||||
Run only after Git write approval succeeds:
|
||||
|
||||
```powershell
|
||||
git add -- docs/superpowers/specs/2026-07-16-omen-cat-pet-design.md docs/superpowers/plans/2026-07-16-omen-cat-pet.md
|
||||
git commit -m "docs: plan Omen cat Codex pet"
|
||||
```
|
||||
|
||||
Expected: one commit containing only the two documentation files. If the approval service still returns `404`, record the environment blocker and continue the asset run without staging unrelated files.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Generate And Approve The Canonical Base
|
||||
|
||||
**Files:**
|
||||
- Consume: `D:/code/water/tmp/hatch-pet/omen-cat/run/prompts/base-pet.md`
|
||||
- Consume: `D:/code/water/tmp/hatch-pet/omen-cat/run/imagegen-jobs.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/base.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/references/canonical-base.png`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the durable Omen-cat source with the role recorded in the base job.
|
||||
- Produces: one approved full-body chroma-key base that every row worker uses as the identity source of truth.
|
||||
|
||||
- [ ] **Step 1: Dispatch one isolated base worker**
|
||||
|
||||
Give the worker exactly this task, with every `input_images` entry from the `base` manifest job attached and role-labeled:
|
||||
|
||||
```text
|
||||
Generate the hatch-pet base image.
|
||||
|
||||
Run dir: D:/code/water/tmp/hatch-pet/omen-cat/run
|
||||
Job id: base
|
||||
Prompt file: D:/code/water/tmp/hatch-pet/omen-cat/run/prompts/base-pet.md
|
||||
|
||||
Use imagegen only. Preserve the canonical reference's purple cat-eared hood, black void face, three cyan marks, compact feline paws, and attached tail. Apply the approved darker layered mantle and broad bandage armor. Return one centered full-body pet on the exact flat chroma background with no text, scenery, shadows, smoke, particles, detached effects, blur, or bloom.
|
||||
|
||||
Return exactly two lines. The first line starts with `selected_source=` followed by the absolute PNG path produced by imagegen. The second starts with `qa_note=` followed by one sentence confirming the visible identity and background checks.
|
||||
```
|
||||
|
||||
Expected: exactly two output lines and a visually coherent single pet.
|
||||
|
||||
- [ ] **Step 2: Copy the selected file and establish canonical identity**
|
||||
|
||||
Copy the exact `selected_source` returned by the worker to both paths below; do not transform it:
|
||||
|
||||
```text
|
||||
D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/base.png
|
||||
D:/code/water/tmp/hatch-pet/omen-cat/run/references/canonical-base.png
|
||||
```
|
||||
|
||||
Expected: both files exist and have identical SHA-256 hashes.
|
||||
|
||||
- [ ] **Step 3: Validate the base visually before marking it complete**
|
||||
|
||||
Require all of the following: one centered whole body, both cat ears readable, three cyan face marks in the correct arrangement, complete paws and tail, broad attached armor, flat uniform key background, and no forbidden effects. Reject identity drift or a cropped tail.
|
||||
|
||||
Expected: a pass note grounded in visible identity landmarks.
|
||||
|
||||
- [ ] **Step 4: Mark only `base` complete in the JSON manifest**
|
||||
|
||||
Update the `base` object in `imagegen-jobs.json` with `status: "complete"`, the exact `source_path`, and an ISO-8601 UTC `completed_at`. Preserve every other job and field.
|
||||
|
||||
Expected: `idle` and `running-right` become dependency-ready; no other visual job is marked complete.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Generate And Incrementally Validate Standard Rows
|
||||
|
||||
**Files:**
|
||||
- Consume: `D:/code/water/tmp/hatch-pet/omen-cat/run/prompts/rows/*.md`
|
||||
- Consume: `D:/code/water/tmp/hatch-pet/omen-cat/run/references/layout-guides/*.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/{idle,running-right,running-left,waving,jumping,failed,waiting,running,review}.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/rows/*/review.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the canonical base and each job's exact manifest-listed inputs.
|
||||
- Produces: nine separately approved row strips with deterministic frame-extraction evidence.
|
||||
|
||||
- [ ] **Step 1: Generate `idle` and `running-right` in parallel isolated workers**
|
||||
|
||||
Use one worker per job. Each worker reads its exact prompt and retry prompt, attaches every manifest input image, and returns only `selected_source` plus `qa_note`.
|
||||
|
||||
Expected: `idle` has visible micro-variation; `running-right` faces screen-right and visibly alternates its gait without dust, speed lines, or shadow.
|
||||
|
||||
- [ ] **Step 2: Copy and validate each selected row before manifest completion**
|
||||
|
||||
For each copied row, run:
|
||||
|
||||
```powershell
|
||||
$PYTHON = 'C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe'
|
||||
$SKILL_DIR = 'C:/Users/admin/.codex/skills/hatch-pet'
|
||||
$RUN_DIR = 'D:/code/water/tmp/hatch-pet/omen-cat/run'
|
||||
$JOB_ID = 'idle'
|
||||
& $PYTHON "$SKILL_DIR/scripts/extract_strip_frames.py" --decoded-dir "$RUN_DIR/decoded" --output-dir "$RUN_DIR/qa/rows/$JOB_ID/frames" --states $JOB_ID --method auto
|
||||
& $PYTHON "$SKILL_DIR/scripts/inspect_frames.py" --frames-root "$RUN_DIR/qa/rows/$JOB_ID/frames" --json-out "$RUN_DIR/qa/rows/$JOB_ID/review.json" --states $JOB_ID --require-components
|
||||
```
|
||||
|
||||
Repeat with `$JOB_ID = 'running-right'`, then later with each remaining standard row id.
|
||||
|
||||
Expected: both commands exit `0`; review JSON has no errors. Inspect warnings before acceptance.
|
||||
|
||||
- [ ] **Step 3: Decide whether `running-left` can be mirrored**
|
||||
|
||||
Mirror only when hood, cyan marks, armor, bandage wraps, tail construction, and cel lighting are symmetrical and retain their meaning after flipping. When safe, run:
|
||||
|
||||
```powershell
|
||||
& 'C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe' 'C:/Users/admin/.codex/skills/hatch-pet/scripts/derive_running_left_from_running_right.py' --run-dir 'D:/code/water/tmp/hatch-pet/omen-cat/run' --confirm-appropriate-mirror --decision-note 'The approved base and running-right row use symmetric face marks, armor, wraps, tail construction, and cel lighting; per-frame mirroring preserves identity and temporal order.'
|
||||
```
|
||||
|
||||
If any listed feature is asymmetric, dispatch a normal `running-left` imagegen worker instead.
|
||||
|
||||
Expected: `running-left` faces screen-left, preserves frame timing, and passes its incremental frame inspection.
|
||||
|
||||
- [ ] **Step 4: Generate the remaining six rows with up to three workers active**
|
||||
|
||||
Jobs: `waving`, `jumping`, `failed`, `waiting`, `running`, and `review`. Backfill a worker slot as soon as another job becomes dependency-ready.
|
||||
|
||||
Expected state gates:
|
||||
|
||||
- `waving`: forepaw gesture only; no wave marks.
|
||||
- `jumping`: body-height motion only; no floor cue.
|
||||
- `failed`: lowered ears, dimmed cyan marks, inward tail; no floating symbols.
|
||||
- `waiting`: expectant, alert pose distinct from idle.
|
||||
- `running`: focused task activity without literal locomotion.
|
||||
- `review`: narrowed marks and deliberate head tilt without new props.
|
||||
|
||||
- [ ] **Step 5: Copy, inspect, and complete each remaining row individually**
|
||||
|
||||
After each source is copied, run the same `extract_strip_frames.py` and `inspect_frames.py` pair with that row's exact id. Mark a job complete only after deterministic inspection and visual state review pass.
|
||||
|
||||
Expected: all nine standard jobs are `complete`; every per-row review JSON has no errors.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Assemble And Review The Standard Atlas
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/frames/**`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/review.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/final/spritesheet.webp`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/contact-sheet.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/previews/*.gif`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nine approved standard strips.
|
||||
- Produces: the reviewed intermediate 8x9 atlas and motion evidence required before look-direction generation.
|
||||
|
||||
- [ ] **Step 1: Extract all standard frames and inspect them together**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$PYTHON = 'C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe'
|
||||
$SKILL_DIR = 'C:/Users/admin/.codex/skills/hatch-pet'
|
||||
$RUN_DIR = 'D:/code/water/tmp/hatch-pet/omen-cat/run'
|
||||
New-Item -ItemType Directory -Force -Path "$RUN_DIR/final", "$RUN_DIR/qa"
|
||||
& $PYTHON "$SKILL_DIR/scripts/extract_strip_frames.py" --decoded-dir "$RUN_DIR/decoded" --output-dir "$RUN_DIR/frames" --states all --method auto
|
||||
& $PYTHON "$SKILL_DIR/scripts/inspect_frames.py" --frames-root "$RUN_DIR/frames" --json-out "$RUN_DIR/qa/review.json" --require-components
|
||||
```
|
||||
|
||||
Expected: exit code `0` and no errors in `qa/review.json`.
|
||||
|
||||
- [ ] **Step 2: Compose the intermediate atlas, contact sheet, and previews**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/compose_atlas.py" --frames-root "$RUN_DIR/frames" --output "$RUN_DIR/final/spritesheet.png" --webp-output "$RUN_DIR/final/spritesheet.webp"
|
||||
& $PYTHON "$SKILL_DIR/scripts/make_contact_sheet.py" "$RUN_DIR/final/spritesheet.webp" --output "$RUN_DIR/qa/contact-sheet.png"
|
||||
& $PYTHON "$SKILL_DIR/scripts/render_animation_previews.py" --frames-root "$RUN_DIR/frames" --output-dir "$RUN_DIR/qa/previews"
|
||||
```
|
||||
|
||||
Expected: an intermediate `1536x1872` atlas, contact sheet, and one preview per standard state.
|
||||
|
||||
- [ ] **Step 3: Run standard visual review**
|
||||
|
||||
Inspect the contact sheet and all GIFs for identity drift, cropped parts, reversed or static gait, wrong facing, size popping, and incorrect state semantics. Key-color fringe is not a failure at this stage.
|
||||
|
||||
Expected: all nine rows pass. If extraction alone causes popping and source strips are stable, rerun extraction with `--method stable-slots`, then rerun inspection with `--allow-stable-slots`, atlas composition, contact sheet, and previews.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Define Look Mechanics And Approve Cardinal Anchors
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/look-mechanics.md`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/look-cardinals.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/look-anchors/{000,090,180,270}.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/look-anchors-approved.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/cardinal-anchors.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: approved standard contact sheet, canonical base, and design look mechanics.
|
||||
- Produces: four semantically approved direction anchors in fixed order: up, screen-right, down, screen-left.
|
||||
|
||||
- [ ] **Step 1: Write the pet-specific look mechanics note**
|
||||
|
||||
Use this exact decision:
|
||||
|
||||
```markdown
|
||||
# Look Mechanics
|
||||
|
||||
The paws and lower torso remain anchored. The three cyan face marks lead the gaze inside the black face plane; the hood opening and head turn follow. Cat ears tilt with restrained follow-through. The layered mantle and attached tail lag by one small visual step without changing attachment points. Screen-left and screen-right require the hood opening and face plane to turn visibly, not only the cyan marks. Up and down combine face-mark placement, hood pitch, ear angle, and upper-body compression. The complete sprite never rotates, skews, or tilts to fake direction.
|
||||
```
|
||||
|
||||
Expected: `qa/look-mechanics.md` exists before cardinal generation.
|
||||
|
||||
- [ ] **Step 2: Generate the four-cardinal strip in one isolated worker**
|
||||
|
||||
The worker reads the cardinal prompt and `qa/look-mechanics.md`, attaches all manifest inputs, and returns only `selected_source` plus a landmark-based `qa_note` covering `000`, `090`, `180`, and `270`.
|
||||
|
||||
Expected: four separated poses in that order; `090` unmistakably points to the viewer's right and `270` to the viewer's left.
|
||||
|
||||
- [ ] **Step 3: Extract and compose approved anchors**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$request = Get-Content -Raw -LiteralPath "$RUN_DIR/pet_request.json" | ConvertFrom-Json
|
||||
$CHROMA_KEY = $request.chroma_key.hex
|
||||
& $PYTHON "$SKILL_DIR/scripts/extract_cardinal_anchors.py" --strip "$RUN_DIR/decoded/look-cardinals.png" --output-dir "$RUN_DIR/decoded/look-anchors" --chroma-key $CHROMA_KEY --json-out "$RUN_DIR/qa/cardinal-anchors.json"
|
||||
& $PYTHON "$SKILL_DIR/scripts/compose_cardinal_anchor_strip.py" --anchors-dir "$RUN_DIR/decoded/look-anchors" --output "$RUN_DIR/decoded/look-anchors-approved.png"
|
||||
```
|
||||
|
||||
Expected: extraction report passes and all four anchor files are complete and unclipped.
|
||||
|
||||
- [ ] **Step 4: Approve cardinal semantics before row 9**
|
||||
|
||||
Inspect all four at normal pet size. For left/right, record the cyan marks and hood opening relative to head center; for up/down, record vertical mark placement, hood pitch, and ear angle.
|
||||
|
||||
Expected: all four pass. If one fails, regenerate only that cardinal anchor with its prepared repair prompt, recompose the approved strip, and repeat semantic review.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Generate, Register, And Approve Look Row 9
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/look-row-9.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/look-row-9-registered.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/look-row-9-registration.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: approved cardinal strip, canonical base, standard contact sheet, layout guide, and look mechanics.
|
||||
- Produces: eight registered poses for `000` through `157.5` with one shared scale and baseline.
|
||||
|
||||
- [ ] **Step 1: Generate row 9 as one coherent eight-pose family**
|
||||
|
||||
The isolated worker uses the manifest prompt and retry prompt plus every listed input. It must synthesize exactly `000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5` in order.
|
||||
|
||||
Expected: eight separated pose groups, no overlap or outer clipping, stable identity, and evenly progressing head, marks, ears, mantle, and tail.
|
||||
|
||||
- [ ] **Step 2: Register row 9 using the final assembly transform**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/assemble_extended_atlas.py" --base-atlas "$RUN_DIR/final/spritesheet.webp" --look-row-9 "$RUN_DIR/decoded/look-row-9.png" --neutral-cell "$RUN_DIR/frames/idle/00.png" --chroma-key $CHROMA_KEY --chroma-threshold 96 --registered-row-output "$RUN_DIR/qa/look-row-9-registered.png" --registration-manifest-output "$RUN_DIR/qa/look-row-9-registration.json"
|
||||
```
|
||||
|
||||
Expected: deterministic pose recovery, registration, and post-registration edge checks pass.
|
||||
|
||||
- [ ] **Step 3: Review row-9 semantics and adjacent continuity**
|
||||
|
||||
Inspect all eight registered cells at normal pet size. Require correct quadrants, stable paws and lower torso, continuous hood and face-mark motion, and attached mantle and tail.
|
||||
|
||||
Expected: no hard semantic or continuity failure. Regenerate the complete row for a wrong quadrant, reversal, conspicuous snap, identity drift, or broken attachment.
|
||||
|
||||
- [ ] **Step 4: Mark `look-row-9` complete**
|
||||
|
||||
Update only the row-9 manifest object with its exact source path and completion timestamp.
|
||||
|
||||
Expected: `look-row-10` becomes ready only after this completion.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Generate Row 10 And Assemble The V2 Atlas
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/decoded/look-row-10.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/final/spritesheet-extended.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/final/spritesheet-extended.webp`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/final/spritesheet-extended.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/chroma-despill-extended.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/final/validation-extended.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: approved cardinals, completed row 9, row-9 registration, and standard atlas.
|
||||
- Produces: the cleaned, validated `1536x2288` v2 atlas.
|
||||
|
||||
- [ ] **Step 1: Generate row 10 as one coherent eight-pose family**
|
||||
|
||||
The isolated worker must attach the approved cardinal strip and completed row 9 and generate exactly `180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5` in order.
|
||||
|
||||
Expected: `180` continues one even step after `157.5`, and `337.5` lands one even step before the row-9 `000` pose.
|
||||
|
||||
- [ ] **Step 2: Assemble the complete v2 atlas**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/assemble_extended_atlas.py" --base-atlas "$RUN_DIR/final/spritesheet.webp" --registered-row-9 "$RUN_DIR/qa/look-row-9-registered.png" --row-9-registration "$RUN_DIR/qa/look-row-9-registration.json" --look-row-10 "$RUN_DIR/decoded/look-row-10.png" --neutral-cell "$RUN_DIR/frames/idle/00.png" --chroma-key $CHROMA_KEY --chroma-threshold 96 --output "$RUN_DIR/final/spritesheet-extended.png" --webp-output "$RUN_DIR/final/spritesheet-extended.webp" --manifest-output "$RUN_DIR/final/spritesheet-extended.json"
|
||||
```
|
||||
|
||||
Expected: eight row-10 pose groups are recovered under the persisted row-9 scale and all normalized-cell edge checks pass.
|
||||
|
||||
- [ ] **Step 3: Run the single authoritative chroma cleanup pass**
|
||||
|
||||
Run exactly once:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/despill_chroma_edges.py" "$RUN_DIR/final/spritesheet-extended.png" --output "$RUN_DIR/final/spritesheet-extended.png" --webp-output "$RUN_DIR/final/spritesheet-extended.webp" --chroma-key $CHROMA_KEY --json-out "$RUN_DIR/qa/chroma-despill-extended.json"
|
||||
```
|
||||
|
||||
Expected: `qa/chroma-despill-extended.json` has `ok: true`. Do not run a second cleanup pass.
|
||||
|
||||
- [ ] **Step 4: Validate the v2 atlas contract**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/validate_atlas.py" "$RUN_DIR/final/spritesheet-extended.webp" --json-out "$RUN_DIR/final/validation-extended.json" --chroma-key $CHROMA_KEY --require-v2
|
||||
```
|
||||
|
||||
Expected: exit code `0`, dimensions `1536x2288`, used cells non-empty, unused cells transparent, and no opaque chroma-key errors.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Run Direction QA And Final Visual Review
|
||||
|
||||
**Files:**
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/contact-sheet-extended.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/look-directions.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-blind-pairs.png`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-blind-answer-key.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-blind-verdicts-{1,2,3}.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-blind-verdicts.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-blind-validation.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/look-continuity.json`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/direction-semantics.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the finalized v2 atlas plus standard previews and validation reports.
|
||||
- Produces: independent blind evidence, labeled per-direction semantics, continuity evidence, and final visual acceptance.
|
||||
|
||||
- [ ] **Step 1: Create final review media**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/make_contact_sheet.py" "$RUN_DIR/final/spritesheet-extended.webp" --output "$RUN_DIR/qa/contact-sheet-extended.png"
|
||||
& $PYTHON "$SKILL_DIR/scripts/make_direction_qa_sheet.py" "$RUN_DIR/final/spritesheet-extended.webp" --output "$RUN_DIR/qa/look-directions.png"
|
||||
& $PYTHON "$SKILL_DIR/scripts/make_direction_blind_qa_sheet.py" "$RUN_DIR/final/spritesheet-extended.webp" --output "$RUN_DIR/qa/direction-blind-pairs.png" --answer-key "$RUN_DIR/qa/direction-blind-answer-key.json"
|
||||
& $PYTHON "$SKILL_DIR/scripts/measure_direction_continuity.py" "$RUN_DIR/final/spritesheet-extended.webp" --json-out "$RUN_DIR/qa/look-continuity.json"
|
||||
```
|
||||
|
||||
Expected: all four commands exit `0` and create their declared outputs.
|
||||
|
||||
- [ ] **Step 2: Dispatch three fresh isolated blind reviewers**
|
||||
|
||||
Each reviewer receives only `qa/direction-blind-pairs.png`, never the atlas, prompt, degree order, labeled sheet, hidden key, or another verdict. Each returns exactly one JSON object classifying every shown A/B pair on the named horizontal or vertical axis.
|
||||
|
||||
Expected: three separate valid verdict files with all pairs present.
|
||||
|
||||
- [ ] **Step 3: Combine and validate blind verdicts**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& $PYTHON "$SKILL_DIR/scripts/combine_direction_blind_verdicts.py" --verdicts "$RUN_DIR/qa/direction-blind-verdicts-1.json" --verdicts "$RUN_DIR/qa/direction-blind-verdicts-2.json" --verdicts "$RUN_DIR/qa/direction-blind-verdicts-3.json" --json-out "$RUN_DIR/qa/direction-blind-verdicts.json"
|
||||
& $PYTHON "$SKILL_DIR/scripts/validate_direction_blind_verdicts.py" --answer-key "$RUN_DIR/qa/direction-blind-answer-key.json" --verdicts "$RUN_DIR/qa/direction-blind-verdicts.json" --json-out "$RUN_DIR/qa/direction-blind-validation.json"
|
||||
```
|
||||
|
||||
Expected: `direction-blind-validation.json` has `ok: true`; both cardinal pairs pass. Intermediate warnings proceed to labeled review.
|
||||
|
||||
- [ ] **Step 4: Run one independent final visual QA worker**
|
||||
|
||||
The worker inspects the standard and extended contact sheets, all standard GIFs, focused direction sheet, blind validation, continuity JSON, frame review JSON, and v2 validation. It must report all 16 direction verdicts and concrete horizontal/vertical evidence.
|
||||
|
||||
Expected: `visual_qa=pass`, no repair rows, and no wrong or ambiguous cardinal.
|
||||
|
||||
- [ ] **Step 5: Persist all 16 labeled semantic verdicts**
|
||||
|
||||
Write `qa/direction-semantics.json` as structured JSON with one entry for each exact label: `000`, `022.5`, `045`, `067.5`, `090`, `112.5`, `135`, `157.5`, `180`, `202.5`, `225`, `247.5`, `270`, `292.5`, `315`, and `337.5`. Every entry contains `verdict`, `expected`, `observed`, and `reason` from the independent worker.
|
||||
|
||||
Expected: no `fail` verdict. A `warning` is allowed only for an intermediate direction whose labeled loop remains coherent.
|
||||
|
||||
- [ ] **Step 6: Resolve failures at row scope**
|
||||
|
||||
For a major failure, regenerate the smallest complete failing row, then repeat that row's deterministic checks and all downstream assembly and QA. Never patch an individual final look cell. For a minor intermediate blind disagreement, record an accepted evidence-based resolution in `qa/blind-review-resolution.json` only when labeled review confirms the correct quadrant and continuity.
|
||||
|
||||
Expected: all hard gates pass before packaging.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Install The Pet And Retain QA Evidence
|
||||
|
||||
**Files:**
|
||||
- Create outside workspace: `C:/Users/admin/.codex/pets/youmiao/pet.json`
|
||||
- Create outside workspace: `C:/Users/admin/.codex/pets/youmiao/spritesheet.webp`
|
||||
- Create: `D:/code/water/tmp/hatch-pet/omen-cat/run/qa/run-summary.json`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: the fully validated extended WebP and all required QA reports.
|
||||
- Produces: one locally installed Codex v2 pet and a durable run summary.
|
||||
|
||||
- [ ] **Step 1: Request the required out-of-workspace write approval**
|
||||
|
||||
Request approval specifically for creating `C:/Users/admin/.codex/pets/youmiao` and writing only `pet.json` plus `spritesheet.webp` there.
|
||||
|
||||
Expected: explicit approval before any installation write.
|
||||
|
||||
- [ ] **Step 2: Install the validated WebP and manifest**
|
||||
|
||||
After approval, run:
|
||||
|
||||
```powershell
|
||||
$PET_DIR = 'C:/Users/admin/.codex/pets/youmiao'
|
||||
New-Item -ItemType Directory -Force -Path $PET_DIR
|
||||
Copy-Item -LiteralPath 'D:/code/water/tmp/hatch-pet/omen-cat/run/final/spritesheet-extended.webp' -Destination "$PET_DIR/spritesheet.webp"
|
||||
$petManifest = [ordered]@{ id = 'youmiao'; displayName = '幽喵'; description = '一只披着暗紫幽影兜帽、以冷青面纹观察任务的沉静猫形伙伴。'; spriteVersionNumber = 2; spritesheetPath = 'spritesheet.webp' }
|
||||
$petManifest | ConvertTo-Json | Set-Content -Encoding utf8 -LiteralPath "$PET_DIR/pet.json"
|
||||
```
|
||||
|
||||
Expected: both files exist together and `pet.json` declares `spriteVersionNumber: 2`.
|
||||
|
||||
- [ ] **Step 3: Validate the installed copy**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
& 'C:/Users/admin/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe' 'C:/Users/admin/.codex/skills/hatch-pet/scripts/validate_atlas.py' 'C:/Users/admin/.codex/pets/youmiao/spritesheet.webp' --json-out 'D:/code/water/tmp/hatch-pet/omen-cat/run/final/validation-installed.json' --chroma-key $CHROMA_KEY --require-v2
|
||||
Get-Content -Raw -LiteralPath 'C:/Users/admin/.codex/pets/youmiao/pet.json' | ConvertFrom-Json | Select-Object id, displayName, spriteVersionNumber, spritesheetPath
|
||||
```
|
||||
|
||||
Expected: validation passes; printed values are `youmiao`, `幽喵`, `2`, and `spritesheet.webp`.
|
||||
|
||||
- [ ] **Step 4: Write the run summary and clean disposable intermediates**
|
||||
|
||||
Write `qa/run-summary.json` with `ok: true`, `spriteVersionNumber: 2`, and absolute paths for the run, spritesheet, validation, despill report, contact sheet, direction sheet, semantics, blind validation, continuity, review, and installed package.
|
||||
|
||||
Keep the source copy, `pet_request.json`, final WebP, final and installed validation JSON, chroma report, extended contact sheet, focused direction sheet, direction semantics, blind sheets/verdicts/validation, continuity report, standard GIF previews, frame review, and run summary. Remove prompts, layout guides, decoded row strips, extracted frames, PNG intermediates, 8x9 atlas, and job manifest only after installation and summary validation pass.
|
||||
|
||||
Expected: the installed pet remains intact and the retained QA set is sufficient to audit every acceptance gate.
|
||||
|
||||
- [ ] **Step 5: Final completion report**
|
||||
|
||||
Report the installed pet directory, retained run directory, final atlas dimensions, manifest version, deterministic validation result, blind cardinal result, final visual QA result, and any accepted intermediate warnings.
|
||||
|
||||
Expected: no completion claim without fresh passing evidence from the installed copy.
|
||||
@@ -0,0 +1,59 @@
|
||||
# MQTT Pending Cleanup And Retry Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 单实例应用启动完成后清理旧 MQTT pending 命令,并让新命令等待 10 秒后才进行 ACK 重试。
|
||||
|
||||
**Architecture:** `MqttCommandAckService` 提供幂等清理方法,独立的 `ApplicationReadyEvent` 监听器在 Redis 初始化完成后调用。命令发送继续由 `DeviceMqttCommandPublisher` 设置 `nextRetryAt`,只调整默认值和配置为 10000 毫秒。
|
||||
|
||||
**Tech Stack:** Spring Boot、ApplicationReadyEvent、Spring `@EventListener`、Redisson `RSet`、RedisUtils、JUnit 5、Mockito、Maven。
|
||||
|
||||
## Global Constraints
|
||||
- 当前按单实例部署,启动时清理全部 pending 命令。
|
||||
- 只删除 pending ID 集合和对应命令缓存,不删除 ACK 历史缓存。
|
||||
- 清理失败记录完整异常,不阻止应用启动。
|
||||
- `retry-interval-ms` 使用 `10000`,`scan-interval-ms` 保持 `5000`。
|
||||
- 最大重试次数保持 3 次,不包含首次发送。
|
||||
- 不修改 MQTT Topic、ACK 消息格式和 Payload。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Pending Cleanup Behavior
|
||||
|
||||
**Files:** Modify `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java`; test `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java`.
|
||||
|
||||
**Interface:** add `public int clearPendingCommands()` using existing `pendingIds()`, `pendingKey(String)`, and Redis key conventions.
|
||||
|
||||
- [ ] Write a failing test: stub `RSet.readAll()` with `cmd-1` and `cmd-2`; call the method; assert return `2`, verify deletion of both `mqtt:command:pending:{id}` objects, and verify `pendingIds.clear()`. Add an empty-set case returning `0`.
|
||||
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=MqttCommandAckServiceTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect the missing-method failure.
|
||||
- [ ] Implement by reading `Set<String> commandIds = pendingIds().readAll()`, deleting every `pendingKey(commandId)`, clearing the set after iteration, and returning `commandIds.size()`. Do not call `deletePending` while iterating.
|
||||
- [ ] Rerun the focused command; expect all `MqttCommandAckServiceTest` tests to pass.
|
||||
- [ ] Commit only these two files with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java && git commit -m "fix: clear mqtt pending commands on request"`.
|
||||
|
||||
### Task 2: Clear Pending Commands After Application Startup
|
||||
|
||||
**Files:** create `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttPendingCommandCleanupListener.java` and `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttPendingCommandCleanupListenerTest.java`.
|
||||
|
||||
**Interface:** constructor consumes `MqttCommandAckService`; `onApplicationReady(ApplicationReadyEvent)` returns `void`.
|
||||
|
||||
- [ ] Write failing tests: verify a ready event calls `clearPendingCommands()`; stub it to throw `IllegalStateException("redis unavailable")` and assert the listener does not throw.
|
||||
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=MqttPendingCommandCleanupListenerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect the class-missing failure.
|
||||
- [ ] Implement `@Slf4j @Component @RequiredArgsConstructor` with `@EventListener(ApplicationReadyEvent.class)`. Call the service, log the count, catch `RuntimeException`, and log `log.error("[MQTT] 应用启动清理待确认命令失败", e)` without rethrowing.
|
||||
- [ ] Rerun the listener test; expect both tests to pass.
|
||||
- [ ] Commit only the listener files with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttPendingCommandCleanupListener.java water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttPendingCommandCleanupListenerTest.java && git commit -m "fix: clear mqtt pending commands after startup"`.
|
||||
|
||||
### Task 3: Set New Retry Interval To Ten Seconds
|
||||
|
||||
**Files:** modify `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java`, `water-admin/src/main/resources/application.yml`; test `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/DeviceMqttCommandPublisherTest.java`.
|
||||
|
||||
- [ ] Add a publisher timing assertion: after `send(command)`, assert `command.getNextRetryAt() - command.getLastSentAt()` equals `10000`.
|
||||
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=DeviceMqttCommandPublisherTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect failure against the current 5000 millisecond default.
|
||||
- [ ] Change `CommandAck.retryIntervalMs` default from `5000` to `10000`; change application YAML `retry-interval-ms` from `30000` to `10000`; leave scan interval `5000` and max retry count `3` unchanged.
|
||||
- [ ] Run `mvn -pl water-admin -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceMqttCommandPublisherTest,MqttCommandAckServiceTest,MqttPendingCommandCleanupListenerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect `BUILD SUCCESS`.
|
||||
- [ ] Commit configuration and publisher test with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java water-admin/src/main/resources/application.yml water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/DeviceMqttCommandPublisherTest.java && git commit -m "fix: wait ten seconds before mqtt command retry"`.
|
||||
|
||||
### Task 4: Final Verification
|
||||
|
||||
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" test`; expect `BUILD SUCCESS` and zero failures.
|
||||
- [ ] Run `git diff --check` and `git status --short`; expect no whitespace errors and no reversion of unrelated worktree changes.
|
||||
- [ ] Rebuild and restart the single application instance; confirm one startup cleanup log and that a newly sent command is retried no earlier than roughly 10 seconds later.
|
||||
203
docs/superpowers/plans/2026-07-20-repository-agents-init.md
Normal file
203
docs/superpowers/plans/2026-07-20-repository-agents-init.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Repository AGENTS.md 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:** Create a comprehensive root `AGENTS.md` with repository-specific development, safety, workflow, and verification instructions for the water backend.
|
||||
|
||||
**Architecture:** Use one root instruction file as the default policy for all Maven modules. Keep operational rules and commands in that file, while linking to the existing detailed review standard and deferring workflow phase details to the selected Comet skill.
|
||||
|
||||
**Tech Stack:** Markdown, Java 17, Spring Boot 3.5.12, Maven, JUnit 5, Mockito, AssertJ, OpenSpec/Comet
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Create only the root `AGENTS.md`; do not modify application code, POM files, configuration, or existing workflow assets.
|
||||
- Preserve all unrelated tracked and untracked working-tree changes.
|
||||
- Keep Java 17 and Spring Boot 3.5.12 as the documented baseline.
|
||||
- State explicitly that the root POM defaults `skipTests` to `true`.
|
||||
- Default human-facing communication to Simplified Chinese while preserving code, commands, paths, API names, and errors verbatim.
|
||||
- Link to `docs/code-review-standards.md` rather than duplicating its full checklist.
|
||||
- Do not add credentials, tokens, personal data, production endpoints, or other secrets.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create And Validate Root Repository Instructions
|
||||
|
||||
**Files:**
|
||||
- Create: `AGENTS.md`
|
||||
- Reference: `pom.xml`
|
||||
- Reference: `docs/code-review-standards.md`
|
||||
- Reference: `docs/superpowers/specs/2026-07-20-repository-agents-init-design.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Maven module names, root test defaults, existing CodeGraph rule, and installed OpenSpec/Comet workflows.
|
||||
- Produces: Root-scoped natural-language instructions consumed by Codex for every file in the repository unless a future nested `AGENTS.md` overrides them.
|
||||
|
||||
- [ ] **Step 1: Confirm the expected precondition and referenced paths**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
if (Test-Path 'AGENTS.md') { throw 'AGENTS.md already exists; inspect it before continuing.' }
|
||||
$requiredPaths = @(
|
||||
'pom.xml',
|
||||
'water-admin',
|
||||
'water-common',
|
||||
'water-common/water-common-mqtt',
|
||||
'water-modules/water-app',
|
||||
'water-modules/water-system',
|
||||
'water-extend',
|
||||
'docs/code-review-standards.md',
|
||||
'openspec',
|
||||
'.codex/skills/comet/SKILL.md'
|
||||
)
|
||||
$missingPaths = $requiredPaths | Where-Object { -not (Test-Path $_) }
|
||||
if ($missingPaths) { throw "Missing required paths: $($missingPaths -join ', ')" }
|
||||
'Preconditions: PASS'
|
||||
```
|
||||
|
||||
Expected: `Preconditions: PASS`.
|
||||
|
||||
- [ ] **Step 2: Create the complete root instruction file**
|
||||
|
||||
Create `AGENTS.md` with exactly this content:
|
||||
|
||||
```markdown
|
||||
# AGENTS.md
|
||||
|
||||
## 交流与适用范围
|
||||
|
||||
- 本文件适用于整个仓库;更深目录中的 `AGENTS.md` 可为其子树补充或覆盖规则。
|
||||
- 默认使用简体中文交流。代码、命令、文件路径、API 名称和错误原文保持原样。
|
||||
- 只修改当前任务需要的内容。保留并避开工作区中与任务无关的已跟踪或未跟踪改动。
|
||||
|
||||
## CodeGraph
|
||||
|
||||
- 如果仓库根目录存在 `.codegraph/`,在定位或理解代码时,先使用 CodeGraph,再使用文本搜索或直接读取大量文件。
|
||||
- MCP 可用时优先使用 `codegraph_explore` 回答代码问题,使用 `codegraph_node` 查看符号、调用者或带行号的文件。
|
||||
- MCP 不可用时使用 Shell 命令,例如 `codegraph explore "MqttCommandAckService callers and retry flow"` 和 `codegraph node MqttCommandAckService`。
|
||||
- 如果不存在 `.codegraph/`,不要自行建立索引;使用 `rg` 和 `rg --files`。
|
||||
|
||||
## 项目概览
|
||||
|
||||
- 本项目是智能灌溉系统后端,基于 Java 17、Spring Boot 3.5.12 和 Maven 多模块构建。
|
||||
- `water-admin`:可执行 Web 应用、入口控制器及应用配置。
|
||||
- `water-common`:跨业务模块共享的基础设施;MQTT 位于 `water-common/water-common-mqtt`,安全基础设施位于 `water-common/water-common-security`。
|
||||
- `water-modules/water-app`:App、设备、灌溉及设备消息业务。
|
||||
- `water-modules/water-system`:用户、权限及系统管理能力。
|
||||
- `water-extend`:监控、任务调度等独立辅助服务。
|
||||
|
||||
## 修改前准备
|
||||
|
||||
- 先检查 `git status --short`,识别用户已有改动;不得回退、覆盖或顺手格式化无关文件。
|
||||
- 修改共享行为前,追踪调用方、配置绑定、数据库或 Redis 状态、MQTT 消息路径及相关测试。
|
||||
- 优先复用现有分层、包结构、工具类和领域类型。没有明确收益时不引入新抽象或跨模块重构。
|
||||
- 变更范围涉及多个模块时,先确认真正的所有者模块和依赖方向,避免复制共享逻辑。
|
||||
|
||||
## 架构与编码约束
|
||||
|
||||
- 遵循现有 controller、service、mapper、domain 分层和相邻代码风格。
|
||||
- 共享基础能力放入其所属 `water-common-*` 模块;业务规则保留在对应业务模块。
|
||||
- 除非任务明确允许破坏性变更,否则保持跨模块接口、配置键、消息字段和持久化格式向后兼容。
|
||||
- 新代码不得吞掉异常;日志应包含排障上下文,但不得记录密码、验证码、令牌、密钥或完整个人信息。
|
||||
- 新代码使用明确泛型,避免循环查询造成 N+1,避免共享非线程安全格式化器,并用枚举、常量或领域类型表达状态值。
|
||||
- 不为通过测试而削弱生产行为、删除断言或扩大公开访问范围。
|
||||
|
||||
## 安全敏感区域
|
||||
|
||||
- 修改登录、验证码、找回密码、注销账号或公开路由时,必须检查限流、输入校验、租户隔离、数据权限和敏感信息处理。
|
||||
- 修改安全排除路径时,同时检查配置绑定和拦截器的最终生效路径,并添加聚焦测试证明只开放目标端点。
|
||||
- 配置文件不得写入真实凭据、令牌、个人数据或生产地址;沿用环境变量、占位符或既有外部配置方式。
|
||||
- 涉及密码更新时,保持密码散列、验证码一次性消费和失败分支行为一致,避免泄露账号是否存在。
|
||||
|
||||
## MQTT 与设备命令
|
||||
|
||||
- MQTT 或设备命令变更必须同时考虑协议字段兼容、ACK 匹配、重试间隔与次数、启动清理、Redis 键与 TTL、重复投递和并发状态转换。
|
||||
- 不得在未验证设备兼容性的情况下重命名消息字段、改变状态含义或调整 topic 结构。
|
||||
- 对时序门控、ACK 缺失、清理失败、重复 ACK、达到重试上限和并发完成等边界添加或更新测试。
|
||||
- 涉及 Redis 临时状态时,明确创建、读取、续期和删除时机,避免永久残留或提前删除。
|
||||
|
||||
## 构建与测试
|
||||
|
||||
- 所有 Maven 命令从仓库根目录运行。
|
||||
- 根 POM 默认配置 `skipTests=true`;`mvn clean package` 会跳过测试,不能据此声称测试通过。
|
||||
- 全量测试:`mvn -DskipTests=false test`
|
||||
- 带测试完整验证:`mvn clean verify -DskipTests=false`
|
||||
- App 模块测试:`mvn -pl water-modules/water-app -am -DskipTests=false test`
|
||||
- MQTT 模块测试:`mvn -pl water-common/water-common-mqtt -am -DskipTests=false test`
|
||||
- 单个测试示例:`mvn -pl water-admin -am -DskipTests=false -Dtest=CaptchaControllerUnitTest -Dsurefire.failIfNoSpecifiedTests=false test`
|
||||
- 其他模块或测试类沿用上述命令结构,替换为 Maven reactor 中的真实模块路径和测试类名。
|
||||
- 行为变更先添加或更新能够失败的聚焦测试,再修改实现。共享模块或跨模块变更应扩大到受影响 reactor 范围。
|
||||
- 安全、并发、Redis 和 MQTT 变更必须覆盖失败路径及兼容性;不能运行依赖外部基础设施的验证时,执行最窄的隔离验证并报告缺口。
|
||||
|
||||
## OpenSpec 与 Comet
|
||||
|
||||
- 新能力或较大行为变更使用 `/comet`。
|
||||
- 不引入新能力的缺陷修复使用 `/comet-hotfix`。
|
||||
- 文档、文案、提示词或局部配置调整使用 `/comet-tweak`。
|
||||
- 选定工作流后,以对应 skill 的阶段、确认点和归档规则为准,不在任务中绕过其门禁。
|
||||
|
||||
## 完成标准
|
||||
|
||||
- 完成前检查 `git diff` 和 `git status`,确认只包含任务范围内的改动。
|
||||
- 运行与风险范围匹配的测试,并在相关时运行编译、打包或配置验证。
|
||||
- 只有看到当前会话中的命令成功输出后,才能声称测试通过、构建成功或问题已修复。
|
||||
- 最终说明实际运行的命令、结果以及未能执行的验证;保留关键错误原文。
|
||||
- 代码审查遵循 `docs/code-review-standards.md`,重点检查正确性、安全、并发、性能、异常处理、测试和日志。
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify structure, required guidance, and referenced paths**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
$content = Get-Content -Raw 'AGENTS.md'
|
||||
$requiredText = @(
|
||||
'## CodeGraph',
|
||||
'Java 17',
|
||||
'Spring Boot 3.5.12',
|
||||
'water-common/water-common-mqtt',
|
||||
'skipTests=true',
|
||||
'mvn -DskipTests=false test',
|
||||
'/comet-hotfix',
|
||||
'docs/code-review-standards.md'
|
||||
)
|
||||
$missingText = $requiredText | Where-Object { -not $content.Contains($_) }
|
||||
if ($missingText) { throw "Missing required guidance: $($missingText -join ', ')" }
|
||||
$references = @(
|
||||
'docs/code-review-standards.md',
|
||||
'water-common/water-common-mqtt',
|
||||
'water-common/water-common-security',
|
||||
'water-modules/water-app',
|
||||
'water-modules/water-system'
|
||||
)
|
||||
$missingReferences = $references | Where-Object { -not (Test-Path $_) }
|
||||
if ($missingReferences) { throw "Broken references: $($missingReferences -join ', ')" }
|
||||
'Content validation: PASS'
|
||||
```
|
||||
|
||||
Expected: `Content validation: PASS`.
|
||||
|
||||
- [ ] **Step 4: Review the isolated diff and Markdown formatting**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check -- AGENTS.md
|
||||
git diff --stat -- AGENTS.md
|
||||
git status --short -- AGENTS.md
|
||||
```
|
||||
|
||||
Expected: `git diff --check` prints no errors; the stat reports one new file; status reports only `?? AGENTS.md` for this task.
|
||||
|
||||
- [ ] **Step 5: Commit the initialized repository instructions**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git add -- AGENTS.md
|
||||
git diff --cached --check
|
||||
git diff --cached --name-only
|
||||
git commit -m "docs: initialize repository agent guidance" -- AGENTS.md
|
||||
```
|
||||
|
||||
Expected: the cached file list contains only `AGENTS.md`, and the commit succeeds.
|
||||
@@ -0,0 +1,33 @@
|
||||
# AppController Concurrency Test Design
|
||||
|
||||
## Goal
|
||||
|
||||
Verify that one singleton `AppController` instance remains correct under concurrent calls without requiring live MySQL, Redis, MQTT, or OSS services.
|
||||
|
||||
## Scope
|
||||
|
||||
Add a standalone unit concurrency test class covering three shared execution paths:
|
||||
|
||||
1. `switchDevice`: 32 threads, 3,200 total calls. Validate every generated `startTime` as `yyyy-MM-dd HH:mm:ss`.
|
||||
2. `uploadImage`: 32 threads, 1,600 total calls across JPEG, PNG, GIF, WebP, and BMP. Validate each result and ensure no file type state leaks between calls.
|
||||
3. `editScheduleStatus`: 32 threads, 640 total calls. Give every call a unique schedule/device pair and validate that its MQTT payload contains only the matching device and schedule detail.
|
||||
|
||||
## Concurrency Model
|
||||
|
||||
Each test uses one fixed 32-thread executor and one shared controller instance. A ready latch ensures all workers are resident before a start latch releases them together. Every future has a bounded timeout so deadlocks and stalls fail the test instead of hanging the build.
|
||||
|
||||
All service collaborators are preconfigured Mockito mocks. Login state required by schedule ownership checks is scoped independently in each worker thread.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Every submitted operation completes within the timeout.
|
||||
- No malformed timestamps, image validation errors, payload contamination, deadlocks, or unexpected exceptions occur.
|
||||
- Invocation counts equal the requested operation totals.
|
||||
- Existing `water-app` tests remain green.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- HTTP server throughput or latency benchmarking.
|
||||
- Database transaction contention testing.
|
||||
- Redis, MQTT broker, or OSS load testing.
|
||||
- Production code changes unless a concurrency defect is reproduced.
|
||||
@@ -0,0 +1,98 @@
|
||||
# AppController Code Review Remediation Design
|
||||
|
||||
## Goal
|
||||
|
||||
Resolve the actionable findings from `docs/code-review-standards.md` without splitting `AppController` in this phase. Preserve all existing `/app/v1/**` routes and successful response payloads.
|
||||
|
||||
## Scope
|
||||
|
||||
This phase includes:
|
||||
|
||||
- complete and safe global exception handling;
|
||||
- removal of raw collection types and wildcard imports in `AppController`;
|
||||
- removal of the identified device/schedule N+1 queries;
|
||||
- safe image upload validation with a 20 MB endpoint limit;
|
||||
- HTTP-level and unit regression tests for the changed behavior.
|
||||
|
||||
This phase does not include:
|
||||
|
||||
- splitting `AppController` into domain-specific controllers;
|
||||
- changing MQTT topic contracts;
|
||||
- changing database tables;
|
||||
- renaming or removing existing HTTP routes.
|
||||
|
||||
## Exception Handling
|
||||
|
||||
`AppController` continues to let business and system exceptions propagate to `GlobalExceptionHandler`. The four local MQTT notification catches remain because notification failure is a deliberate degradation path after the primary database operation. Each retained catch must include the exception object in the log call.
|
||||
|
||||
`GlobalExceptionHandler` applies these rules:
|
||||
|
||||
- `ServiceException`: resolve the existing i18n key, log the request URI and full exception at `WARN`, and return the business message and existing business code.
|
||||
- `BaseException`: use its already localized message, log the request URI and full exception at `WARN`, and return the business message.
|
||||
- `RuntimeException` and `Exception`: log the request URI and full exception at `ERROR`, but return the standard `R.fail()` response so internal SQL, Redis, OSS, or network details are not exposed.
|
||||
|
||||
The response body contract remains `R<T>`. No new HTTP status mapping is introduced in this phase.
|
||||
|
||||
## Type Safety And Controller Cleanup
|
||||
|
||||
All raw `Map`, `List`, and response generic declarations in `AppController` are replaced with concrete generic types. Wildcard imports are replaced with explicit imports. The unused `AppScheduleServiceImpl` dependency is removed so the Controller depends only on service interfaces.
|
||||
|
||||
Repeated `SimpleDateFormat` creation in `AppController` is replaced with a shared immutable `DateTimeFormatter`. Commented-out annotations and dead commented code in touched sections are removed.
|
||||
|
||||
## Batch Query Design
|
||||
|
||||
Two batch query capabilities are added behind service interfaces:
|
||||
|
||||
1. `IAppDeviceService.queryByDeviceNos(Collection<String> deviceNos)` returns device VOs for the requested device numbers in one database query.
|
||||
2. `IAppSchedulingDeviceService.findBoundDeviceNos(Collection<String> deviceNos)` returns the subset of device numbers that have schedule bindings in one database query.
|
||||
|
||||
`scheduleDeviceList` loads the current user's devices once, submits their device numbers to `findBoundDeviceNos`, and filters in memory. This replaces one binding query per device.
|
||||
|
||||
`getScheduleInfo` loads schedule bindings once, extracts device numbers, and calls `queryByDeviceNos` once. This replaces one device query per binding.
|
||||
|
||||
Schedule notification helpers load schedule details once per HTTP operation and reuse the resulting payload details for every bound device. Device-specific payload fields remain unchanged.
|
||||
|
||||
No unbounded all-table query is introduced: every new batch query is constrained by the device numbers already associated with the current request or current user.
|
||||
|
||||
## Image Upload Validation
|
||||
|
||||
`/app/v1/uploadImage` enforces all of the following before calling OSS:
|
||||
|
||||
- file is present and non-empty;
|
||||
- file size is at most 20 MB;
|
||||
- original filename has an allowed extension;
|
||||
- declared `Content-Type` is an allowed image MIME type;
|
||||
- file signature resolves to an allowed image type;
|
||||
- extension, MIME type, and detected type are mutually compatible.
|
||||
|
||||
Allowed formats are JPEG (`jpg`, `jpeg`), PNG, GIF, WebP, and BMP. SVG is rejected because it is active content and cannot be safely accepted using only binary image validation.
|
||||
|
||||
The general Spring multipart limit remains 100 MB because other upload endpoints may need it. The 20 MB limit is local to the image endpoint.
|
||||
|
||||
## Testing
|
||||
|
||||
Implementation follows red-green-refactor.
|
||||
|
||||
Tests cover:
|
||||
|
||||
- unexpected Controller exceptions reach the global handler and return a generic message rather than the original exception message;
|
||||
- `ServiceException` and `BaseException` retain their client-facing business messages;
|
||||
- oversized, forged, unsupported, and valid image uploads;
|
||||
- `scheduleDeviceList` performs one batch binding lookup and no per-device lookup;
|
||||
- `getScheduleInfo` performs one batch device lookup and no per-binding lookup;
|
||||
- schedule notification payload construction queries details once for multiple devices;
|
||||
- existing AppController behavior remains green.
|
||||
|
||||
Verification commands:
|
||||
|
||||
```powershell
|
||||
mvn -pl water-common/water-common-web -am "-DskipTests=false" "-Dmaven.test.skip=false" test
|
||||
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Compatibility And Rollback
|
||||
|
||||
The change preserves route paths, request field names, successful response payloads, MQTT topics, and database schema. The intentional observable change is that unexpected system exceptions no longer expose their original messages to clients.
|
||||
|
||||
The work is separable into exception handling, batch queries, image validation, and type cleanup. Each part can be reverted independently if a regression is found.
|
||||
96
docs/superpowers/specs/2026-07-16-omen-cat-pet-design.md
Normal file
96
docs/superpowers/specs/2026-07-16-omen-cat-pet-design.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Omen Cat Codex Pet Design
|
||||
|
||||
## Goal
|
||||
|
||||
Create a Codex-compatible v2 animated pet inspired by the user-provided cartoon Omen cat reference. The pet should preserve the reference's feline silhouette and recognizable Omen cues while moving toward a darker, more detailed game-character interpretation that remains readable in a `192x208` sprite cell.
|
||||
|
||||
The working display name is `幽喵`. The package id will use an ASCII-safe derivative chosen by the hatch pipeline.
|
||||
|
||||
## Source Reference
|
||||
|
||||
Canonical input:
|
||||
|
||||
`C:/Users/admin/AppData/Local/Temp/codex-clipboard-ecce7d4b-e0a8-495f-98ba-9bf2f2d030b4.png`
|
||||
|
||||
The source is an identity reference, not an atlas. It must be copied into the pet run before generation so the temporary clipboard path is not a long-term dependency.
|
||||
|
||||
## Visual Direction
|
||||
|
||||
Use a dark flat-illustration style with restrained cel shading. Preserve these identity invariants across every animation row:
|
||||
|
||||
- oversized purple cat-eared hood with a layered, angular Omen silhouette
|
||||
- black void face framed by the hood
|
||||
- three cyan facial energy marks, with the center mark slightly longer
|
||||
- compact feline body, short paws, and a long attached tail
|
||||
- dark charcoal armor and bandage-like limb wraps
|
||||
- deep violet, charcoal, cool gray, and luminous cyan palette
|
||||
- heavy upper silhouette balanced by a compact grounded lower body
|
||||
|
||||
Compared with the source, add a segmented shoulder mantle, layered scarf or short cape panels, and readable foreleg wraps. Keep all added armor broad and simple enough to survive reduction to pet size.
|
||||
|
||||
Do not include readable logos, text, weaponry, scenery, floor shadows, smoke, floating particles, detached energy effects, or thin decorative fragments. Cyan glow should be represented by opaque hard-edged color shapes rather than blur or bloom.
|
||||
|
||||
## Animation Language
|
||||
|
||||
The standard animation rows follow the Codex v2 contract:
|
||||
|
||||
- `idle`: subtle breathing, ear twitch, and slow tail sway
|
||||
- `running-right` and `running-left`: low feline dash with alternating paws and cape follow-through
|
||||
- `waving`: one forepaw wave without motion marks
|
||||
- `jumping`: compact crouch, lift, apex, and landing pose without a floor cue
|
||||
- `failed`: ears lower, cyan face marks dim, body slumps, and tail curls inward
|
||||
- `waiting`: alert seated pose with raised ears and an expectant head angle
|
||||
- `running`: focused task pose using quick paw and eye activity, not literal locomotion
|
||||
- `review`: narrowed cyan marks, deliberate head tilt, and restrained paw movement
|
||||
|
||||
`running-left` may be derived from `running-right` only when the generated armor, face marks, and lighting are fully symmetrical. Otherwise it must be generated independently.
|
||||
|
||||
## Look Mechanics
|
||||
|
||||
The body base and paws remain anchored. Direction is carried by a coordinated motion family:
|
||||
|
||||
1. The cyan face marks lead and shift within the black face plane.
|
||||
2. The hood opening and head turn follow the target direction.
|
||||
3. The cat ears tilt with restrained follow-through.
|
||||
4. The upper mantle and tail lag slightly while remaining attached.
|
||||
|
||||
Cardinal directions must be unmistakable at normal pet size. Left and right require visible head and hood-opening turns, not only moving the cyan marks. Up and down use face-mark position, hood pitch, ear angle, and upper-body compression. Intermediate directions interpolate continuously without rotating or tilting the complete sprite.
|
||||
|
||||
## Production Flow
|
||||
|
||||
The `hatch-pet` workflow owns production:
|
||||
|
||||
1. Copy the canonical source into a durable run directory and prepare prompts plus layout guides.
|
||||
2. Generate a grounded base image through `imagegen`.
|
||||
3. Generate and incrementally validate all nine standard animation rows.
|
||||
4. Define the final look-mechanics note, generate four cardinal anchors, and approve their semantics.
|
||||
5. Generate coherent look rows 9 and 10 with deterministic registration and edge checks.
|
||||
6. Assemble the `1536x2288` v2 atlas, remove the chroma key once, and validate the final WebP.
|
||||
7. Run contact-sheet review, motion previews, three isolated blind direction reviews, labeled direction semantics, and continuity review.
|
||||
8. Install `pet.json` and `spritesheet.webp` together with `spriteVersionNumber: 2`.
|
||||
|
||||
Generated row strips are always grounded by the canonical reference and the relevant layout guide. Repairs replace the smallest failed complete row rather than patching individual cells.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
- Identity drift, missing cat features, replacement face marks, or materially changed armor require row regeneration.
|
||||
- Clipping, extraction, registration, and chroma failures use deterministic corrections first.
|
||||
- A wrong or ambiguous cardinal direction blocks packaging.
|
||||
- Minor intermediate-direction uncertainty may be accepted only when labeled normal-size review confirms the intended quadrant and the ordered loop remains coherent.
|
||||
- Detached effects, accidental transparent body holes, reversed gait, visible size popping, or broken tail attachment are hard failures.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- The final pet visibly matches the source cat and the selected dark Omen interpretation.
|
||||
- All nine standard animation rows communicate their intended state without text or detached effects.
|
||||
- All 16 look directions form a continuous clockwise family with unmistakable cardinals.
|
||||
- The final atlas is exactly `1536x2288`; used cells are non-empty and unused cells are transparent.
|
||||
- Deterministic atlas validation, chroma despill validation, frame inspection, direction blind review, semantic review, continuity review, and final visual QA pass.
|
||||
- The installed manifest declares `spriteVersionNumber: 2` and points to the packaged `spritesheet.webp`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Reproducing an exact in-game model or official Riot asset
|
||||
- Adding weapons, readable VALORANT branding, UI, or game environments
|
||||
- Creating alternate skins or multiple pet variants in this run
|
||||
- Publishing or distributing the resulting pet outside the local Codex installation
|
||||
@@ -0,0 +1,56 @@
|
||||
# App Forgot Password Design
|
||||
|
||||
## Goal
|
||||
|
||||
Make `POST /app/v1/retrievePassword` a secure unauthenticated password recovery endpoint based on an account identifier, verification code, and new password.
|
||||
|
||||
## Request Contract
|
||||
|
||||
The encrypted JSON request contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "phone, email, or account name",
|
||||
"code": "123456",
|
||||
"password": "new password"
|
||||
}
|
||||
```
|
||||
|
||||
For backward compatibility, `userName` is accepted as an alias of `username`, and `smsCode` is accepted as an alias of `code`.
|
||||
|
||||
## Endpoint Behavior
|
||||
|
||||
- Preserve `POST /app/v1/retrievePassword` and `@ApiEncrypt`.
|
||||
- Add method-level `@SaIgnore` so a logged-out user can recover their password.
|
||||
- Validate required fields and the existing password length rule before invoking the service.
|
||||
- Do not read `LoginHelper` or rely on a token.
|
||||
|
||||
## Service Behavior
|
||||
|
||||
Add a shared `ISysUserService.resetPasswordByVerificationCode(username, code, password)` operation:
|
||||
|
||||
1. Detect email, mobile number, or account name and query the matching active user record.
|
||||
2. Read `GlobalConstants.CAPTCHA_CODE_KEY + username` from Redis.
|
||||
3. Reject missing/expired and mismatched codes without updating the database.
|
||||
4. BCrypt-hash the new password and update only the matched user ID.
|
||||
5. Delete the verification code only after a successful database update so it cannot be replayed.
|
||||
|
||||
The existing `/auth/forgot` service delegates to the same operation to avoid two conflicting reset implementations.
|
||||
|
||||
## Verification Code Delivery
|
||||
|
||||
Keep `GET /resource/code?username=...`. For a mobile number or email, send directly as before. For an account name, load the account and send to its bound mobile number, otherwise its bound email, while storing the code under the original account-name cache key. Do not return the generated code to the client.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Missing account: user-facing account-not-registered error.
|
||||
- Missing Redis code: existing `CaptchaExpireException`.
|
||||
- Wrong code: user-facing invalid-code error.
|
||||
- Failed database update: `ServiceException` handled by the global exception handler.
|
||||
|
||||
## Testing
|
||||
|
||||
- Controller test proves recovery works without login state and delegates all three values.
|
||||
- System service tests cover success, wrong code, expired code, account lookup, BCrypt hashing, and one-time code deletion.
|
||||
- Captcha controller test covers account-name delivery to a bound contact with the original username as cache key.
|
||||
- Run focused tests, then `water-app`, `water-admin`, and relevant reactor tests.
|
||||
@@ -0,0 +1,53 @@
|
||||
# MQTT 待确认命令启动清理与重试间隔设计
|
||||
|
||||
## 背景
|
||||
|
||||
MQTT 待 ACK 命令保存在 Redis 中,默认保留 24 小时。应用重启后,旧命令的
|
||||
`nextRetryAt` 通常已经早于当前时间,因此重试扫描任务启动后会立即重新下发旧命令。
|
||||
|
||||
当前服务按单实例部署处理。目标是启动时清除旧待确认命令,并将新命令等待 ACK 的
|
||||
时间从 30 秒调整为 10 秒。
|
||||
|
||||
## 行为约定
|
||||
|
||||
- 应用每次启动完成后清理全部 pending 命令。
|
||||
- 清理范围包括 pending ID 集合及集合中每个命令对应的缓存对象。
|
||||
- 不清理已经收到的 ACK 历史缓存。
|
||||
- 启动清理失败只记录完整异常,不阻止应用启动。
|
||||
- 新命令首次下发后等待 10 秒才具备重试条件。
|
||||
- 重试扫描周期保持 5 秒,因此实际重发时间约为下发后的 10 至 15 秒。
|
||||
- 最大重试次数保持 3 次,不包含首次发送。
|
||||
|
||||
## 设计
|
||||
|
||||
### 清理服务
|
||||
|
||||
在 `MqttCommandAckService` 增加公开的启动清理方法。方法读取 pending ID 集合,逐一
|
||||
删除 `mqtt:command:pending:{commandId}`,然后清空 pending ID 集合,并返回清理数量。
|
||||
清理操作应可重复执行,空集合返回 0。
|
||||
|
||||
### 启动时机
|
||||
|
||||
新增 MQTT 启动清理监听器,在 `ApplicationReadyEvent` 到达后执行一次。此时 Spring、
|
||||
Redis 和 MQTT 相关 Bean 已完成初始化。监听器捕获运行时异常并记录完整异常栈,避免
|
||||
Redis 短暂不可用导致整个应用启动失败。
|
||||
|
||||
### 重试配置
|
||||
|
||||
将 `mqtt.command-ack.retry-interval-ms` 设置为 `10000`。首次发送和每次重试后均继续使用
|
||||
该配置计算 `nextRetryAt`。`scan-interval-ms` 保持 `5000`。
|
||||
|
||||
## 测试
|
||||
|
||||
- 清理方法删除集合中每个 pending 缓存并清空集合。
|
||||
- 空 pending 集合清理成功且返回 0。
|
||||
- 启动监听器在应用就绪事件后调用一次清理方法。
|
||||
- 清理异常不会从监听器继续抛出。
|
||||
- 配置绑定后的重试间隔为 10000 毫秒。
|
||||
- 保留现有命令发送和三次重试测试。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不修改 ACK 消息格式、MQTT Topic 或命令 Payload。
|
||||
- 不清理 ACK 历史缓存。
|
||||
- 不设计多实例命令归属;若后续改为多实例部署,需要重新设计启动清理策略。
|
||||
@@ -0,0 +1,101 @@
|
||||
# Repository AGENTS.md Initialization Design
|
||||
|
||||
## Goal
|
||||
|
||||
Create one root-level `AGENTS.md` that gives Codex durable, repository-specific instructions for safely changing and verifying the water backend. The file must be useful on its own while linking to detailed review guidance instead of duplicating it.
|
||||
|
||||
## Scope
|
||||
|
||||
The change adds only the root `AGENTS.md`. It does not change application code, Maven configuration, module-specific instructions, or existing OpenSpec/Comet assets.
|
||||
|
||||
The instructions apply to the entire repository. A future nested `AGENTS.md` may add narrower module rules, but none are needed for this initialization.
|
||||
|
||||
## Repository Facts
|
||||
|
||||
- The project is an intelligent irrigation backend based on Java 17, Spring Boot 3.5, and Maven.
|
||||
- The root reactor contains `water-admin`, `water-common`, `water-modules`, and `water-extend`.
|
||||
- `water-admin` is the executable web application.
|
||||
- `water-common` contains shared infrastructure, including MQTT support in `water-common-mqtt`.
|
||||
- `water-modules` contains business modules; `water-app` handles app/device workflows and `water-system` handles system and user capabilities.
|
||||
- `water-extend` contains auxiliary services such as monitoring and job scheduling.
|
||||
- The root POM sets `skipTests=true`, so verification commands must explicitly use `-DskipTests=false`.
|
||||
- The repository already contains OpenSpec/Comet workflows and detailed review guidance in `docs/code-review-standards.md`.
|
||||
|
||||
## AGENTS.md Structure
|
||||
|
||||
The root instructions will contain these sections in order:
|
||||
|
||||
1. Project overview and technology baseline.
|
||||
2. Module map and ownership boundaries.
|
||||
3. Code discovery and change preparation.
|
||||
4. Architecture and implementation constraints.
|
||||
5. Security-sensitive areas.
|
||||
6. MQTT and device-command constraints.
|
||||
7. Testing and verification commands.
|
||||
8. OpenSpec/Comet workflow selection.
|
||||
9. Definition of done and review reference.
|
||||
|
||||
## Instruction Design
|
||||
|
||||
### Code Discovery
|
||||
|
||||
When `.codegraph/` exists, agents must use CodeGraph before text search or broad file reads. Otherwise they should use `rg` or `rg --files`. Before changing shared behavior, agents should identify callers, configuration bindings, persistence or Redis state, and relevant tests.
|
||||
|
||||
Agents must preserve unrelated working-tree changes and avoid broad cleanup or refactoring unless required by the task.
|
||||
|
||||
### Architecture and Code Quality
|
||||
|
||||
Changes should follow the existing controller/service/mapper/domain layering and established package patterns. Shared behavior belongs in the owning common module rather than being copied into business modules. Cross-module APIs should remain backward compatible unless the task explicitly authorizes a breaking change.
|
||||
|
||||
The instructions will prohibit swallowed exceptions, raw collection types in new code, hidden N+1 queries, unsafe shared formatters, unexplained magic status values, and sensitive values in source or logs. Detailed review criteria remain in `docs/code-review-standards.md`.
|
||||
|
||||
### Security Boundaries
|
||||
|
||||
Changes involving authentication, captcha, password recovery, account cancellation, or public route exclusions must preserve rate limiting, input validation, tenant and data-permission behavior, and sensitive-data handling. Public-route changes require focused tests and inspection of the effective security exclusion configuration.
|
||||
|
||||
Configuration files must not receive real credentials, tokens, personal data, or production endpoints. Existing placeholders and environment-driven configuration patterns should be retained.
|
||||
|
||||
### MQTT Boundaries
|
||||
|
||||
MQTT and device-command changes must consider protocol compatibility, ACK matching, retry timing and limits, startup cleanup, Redis key and TTL behavior, duplicate delivery, and concurrent state transitions. Relevant unit tests must cover changed behavior, especially timing gates and cleanup failure paths.
|
||||
|
||||
### Build and Test Commands
|
||||
|
||||
The file will document these commands:
|
||||
|
||||
```powershell
|
||||
mvn clean package
|
||||
mvn -DskipTests=false test
|
||||
mvn -pl <module> -am -DskipTests=false test
|
||||
mvn -pl <module> -am -DskipTests=false -Dtest=<TestClass> -Dsurefire.failIfNoSpecifiedTests=false test
|
||||
```
|
||||
|
||||
`mvn clean package` reflects the repository's default package behavior and skips tests because of the root property. Any claim that tests pass must come from a command containing `-DskipTests=false`.
|
||||
|
||||
Behavior changes should add or update focused tests before implementation. Shared or cross-module changes require broader reactor testing. High-risk security, concurrency, Redis, or MQTT changes require focused failure-path and compatibility coverage.
|
||||
|
||||
### Workflow Selection
|
||||
|
||||
- Use `/comet` for new capabilities or substantial behavior changes.
|
||||
- Use `/comet-hotfix` for defect corrections that do not introduce a new capability.
|
||||
- Use `/comet-tweak` for documentation, copy, prompt, or narrowly scoped configuration changes.
|
||||
|
||||
The root instructions will not duplicate each workflow's phase rules; the selected skill remains authoritative.
|
||||
|
||||
### Definition of Done
|
||||
|
||||
Before reporting completion, agents must inspect `git diff`, run tests proportional to the change, confirm compilation or packaging when relevant, and check configuration compatibility. They must report commands actually run and any verification that could not be performed.
|
||||
|
||||
## Error Handling
|
||||
|
||||
If a required build or test depends on unavailable infrastructure, the agent should run the narrowest isolated verification available, preserve the original error output, and clearly report the remaining gap. It must not weaken production behavior or tests merely to obtain a passing command.
|
||||
|
||||
## Validation
|
||||
|
||||
The implementation will be validated by:
|
||||
|
||||
1. Confirming the root `AGENTS.md` exists and is readable Markdown.
|
||||
2. Checking every documented path against the repository.
|
||||
3. Checking Maven commands against the root reactor and explicit test override.
|
||||
4. Scanning for placeholders, contradictions, duplicated rules, and accidental secrets.
|
||||
5. Reviewing the final diff to ensure no unrelated files were changed.
|
||||
19
openspec/changes/fix-register-body-validation/.comet.yaml
Normal file
19
openspec/changes/fix-register-body-validation/.comet.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
workflow: hotfix
|
||||
phase: verify
|
||||
context_compression: off
|
||||
build_mode: direct
|
||||
build_pause: null
|
||||
subagent_dispatch: null
|
||||
tdd_mode: direct
|
||||
isolation: branch
|
||||
verify_mode: full
|
||||
auto_transition: true
|
||||
base_ref: 2f7597ed53736b965191d4e48ee2c73d09ff71c0
|
||||
design_doc: null
|
||||
plan: null
|
||||
verify_result: pending
|
||||
verification_report: docs/superpowers/reports/2026-08-14-fix-register-body-validation-verify.md
|
||||
branch_status: pending
|
||||
created_at: 2026-08-14
|
||||
verified_at: null
|
||||
archived: false
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-14
|
||||
27
openspec/changes/fix-register-body-validation/design.md
Normal file
27
openspec/changes/fix-register-body-validation/design.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Context
|
||||
|
||||
`AuthController.register` 当前依赖 Spring MVC 的 `@Validated` 自动校验。该校验发生在控制器方法调用之前,所以方法体无法为缺失的 `clientId` 补默认值。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 空白 `clientId` 使用固定注册客户端 ID `32324a04c0175c8d61ac8a282553aea1`。
|
||||
- 默认值补充后仍执行 `RegisterBody` 的全部 Bean Validation 约束。
|
||||
- 保持注册开关检查和注册服务调用顺序不变。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不调整 `RegisterBody` 的继承结构或其他登录字段。
|
||||
- 不改变登录接口和认证客户端配置。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 移除注册方法参数上的 `@Validated`,在方法体开始处补默认 `clientId` 后调用现有 `ValidatorUtils.validate(user)`。该工具已被认证控制器和各登录策略使用,异常处理行为与现有认证流程一致。
|
||||
- 使用 `StringUtils.isBlank`,同时覆盖 `null`、空字符串和纯空白字符串。
|
||||
- 通过 `AuthController` 单元测试观察注册服务收到的请求对象,验证公共接口行为。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 固定客户端 ID 变更时需要同步修改代码和测试;本次按明确业务值实现。
|
||||
- 手动校验依赖控制器方法内调用顺序;测试覆盖默认值补充发生在校验和业务调用之前。
|
||||
25
openspec/changes/fix-register-body-validation/proposal.md
Normal file
25
openspec/changes/fix-register-body-validation/proposal.md
Normal file
@@ -0,0 +1,25 @@
|
||||
## Why
|
||||
|
||||
`/auth/register` 使用 `@Validated` 在进入控制器方法前校验 `RegisterBody`,缺少 `clientId` 的请求会直接返回“认证客户端id不能为空”,因此控制器无法为注册请求补充系统默认客户端 ID。注册入口需要先补默认值,再执行原有参数校验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 注册接口收到空白 `clientId` 时,将其设置为 `32324a04c0175c8d61ac8a282553aea1`。
|
||||
- 默认值补充完成后,通过现有 `ValidatorUtils` 执行 `RegisterBody` 的完整 Bean Validation。
|
||||
- 增加控制器回归测试,覆盖默认客户端 ID 和注册服务调用。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `auth-registration`: 为已有注册入口补充默认认证客户端 ID 的行为规范。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。仓库当前没有注册流程的既有 OpenSpec。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响 `water-admin` 中的 `/auth/register` 控制器及其单元测试。
|
||||
- `/auth/register` 允许调用方省略或传入空白 `clientId`,其他注册参数约束保持不变。
|
||||
- 不新增依赖,不修改数据库结构,也不新增公共端点。
|
||||
@@ -0,0 +1,11 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 注册请求使用默认认证客户端
|
||||
|
||||
系统 SHALL 在校验注册请求前,为缺失或空白的 `clientId` 设置默认值 `32324a04c0175c8d61ac8a282553aea1`,并在补值后执行 `RegisterBody` 的全部参数约束。
|
||||
|
||||
#### Scenario: 注册请求未提供客户端 ID
|
||||
|
||||
- **WHEN** 调用方提交的注册请求中 `clientId` 缺失或为空白
|
||||
- **THEN** 系统将 `clientId` 设置为 `32324a04c0175c8d61ac8a282553aea1`
|
||||
- **AND** 系统在调用注册服务前校验补值后的注册请求
|
||||
4
openspec/changes/fix-register-body-validation/tasks.md
Normal file
4
openspec/changes/fix-register-body-validation/tasks.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## 1. 注册默认客户端 ID
|
||||
|
||||
- [x] 1.1 在控制器单元测试中覆盖空白 `clientId` 自动补默认值的注册场景,并确认修改前失败。
|
||||
- [x] 1.2 调整 `/auth/register` 的校验顺序,补默认 `clientId` 后执行完整参数校验,并运行相关测试。
|
||||
41
overview.md
Normal file
41
overview.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 代码审查标准与流程 - 工作概述
|
||||
|
||||
## 完成事项
|
||||
|
||||
为 water 项目制定了系统化的代码审查机制,包含完整的审查标准、流程规范和落地工具方案。
|
||||
|
||||
## 交付物
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `docs/code-review-standards.md` | 代码审查标准与流程主文档(6 大章节) |
|
||||
|
||||
## 关键发现
|
||||
|
||||
通过对项目代码的全面审查,识别出以下系统性问题:
|
||||
|
||||
1. **零静态分析工具** — 项目未配置 Checkstyle/SpotBugs/SonarQube
|
||||
2. **上帝类** — `AppController` 940 行,职责过多
|
||||
3. **异常吞噬** — 几乎每个方法用 `try-catch(Exception)` 包裹
|
||||
4. **Raw Type 泛滥** — `Map`、`List`、`HashMap` 大量无泛型
|
||||
5. **N+1 查询** — 循环内逐个查询数据库/Redis
|
||||
6. **线程安全** — `SimpleDateFormat` 在多线程环境使用
|
||||
7. **魔法值** — 状态字符串 `"0"`/`"1"` 硬编码
|
||||
|
||||
同时发现优秀实践:MQTT 架构设计(策略模式+有界队列+ACK重试)、分布式锁使用规范。
|
||||
|
||||
## 标准文档结构
|
||||
|
||||
1. **代码审查标准** — 10 个维度(架构/命名/安全/性能/异常/并发/测试/日志等),三级问题分级(Blocker/Suggestion/Nit)
|
||||
2. **代码审查流程** — 角色定义、PR 提交前自检、审查步骤、合并标准、冲突升级、紧急修复通道
|
||||
3. **检查清单** — 8 个维度 40+ 检查项,可直接作为 PR 审查模板
|
||||
4. **静态分析工具集成方案** — Checkstyle + SpotBugs + SonarQube 的 Maven 配置和 4 周实施路线
|
||||
5. **典型问题案例** — 5 个 water 项目真实代码案例(含正反对比)
|
||||
6. **推广落地建议** — 分阶段实施计划 + 度量指标
|
||||
|
||||
## 后续建议
|
||||
|
||||
- 第 1 周:团队宣贯,学习审查标准
|
||||
- 第 2 周:集成 Checkstyle + SpotBugs,修复 P0 问题
|
||||
- 第 3-4 周:PR 审查试运行
|
||||
- 第 5 周起:正式执行 Blocker 零容忍
|
||||
19
script/sql/update/add_app_firmware.sql
Normal file
19
script/sql/update/add_app_firmware.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- 设备固件版本表(OTA 升级),MySQL 8+。
|
||||
CREATE TABLE app_firmware (
|
||||
id bigint NOT NULL COMMENT '主键',
|
||||
firmware_version varchar(50) NOT NULL COMMENT '固件版本号',
|
||||
file_name varchar(255) NOT NULL COMMENT '原始文件名',
|
||||
file_url varchar(500) NOT NULL COMMENT '服务器固件下载地址',
|
||||
file_size bigint NULL COMMENT '文件大小(字节)',
|
||||
md5 varchar(64) NULL COMMENT 'MD5校验值',
|
||||
release_notes varchar(1000) NULL COMMENT '更新说明',
|
||||
status char(1) NOT NULL DEFAULT '1' COMMENT '状态 0停用 1启用',
|
||||
create_dept bigint NULL COMMENT '创建部门',
|
||||
create_by bigint NULL COMMENT '创建者',
|
||||
create_time datetime NULL COMMENT '创建时间',
|
||||
update_by bigint NULL COMMENT '更新者',
|
||||
update_time datetime NULL COMMENT '更新时间',
|
||||
remark varchar(500) NULL COMMENT '备注',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_app_firmware_version_status (firmware_version, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='设备固件版本表';
|
||||
63
script/sql/update/add_device_binding_lifecycle.sql
Normal file
63
script/sql/update/add_device_binding_lifecycle.sql
Normal file
@@ -0,0 +1,63 @@
|
||||
-- User-device lifecycle and device-initiated unbinding support (MySQL 8+).
|
||||
CREATE TABLE app_device_binding (
|
||||
id bigint NOT NULL,
|
||||
device_no varchar(64) NOT NULL COMMENT '设备编号',
|
||||
user_id bigint NOT NULL COMMENT '用户ID',
|
||||
binding_id varchar(64) NOT NULL COMMENT '本次绑定标识',
|
||||
binding_status varchar(16) NOT NULL COMMENT 'BINDING/ACTIVE/BIND_FAILED/UNBINDING/UNBOUND',
|
||||
binding_started_time datetime NULL COMMENT '本次绑定开始时间',
|
||||
last_bind_command_time datetime NULL COMMENT '最近一次绑定命令下发时间',
|
||||
device_name_snapshot varchar(255) NULL COMMENT '列表显示名称快照',
|
||||
unbound_time datetime NULL COMMENT '解绑完成时间',
|
||||
unbind_source varchar(16) NULL COMMENT 'DEVICE/APP/ADMIN',
|
||||
unbind_command_id varchar(64) NULL COMMENT '触发解绑的命令编号',
|
||||
cleanup_attempts int NOT NULL DEFAULT 0 COMMENT '清理尝试次数',
|
||||
cleanup_error varchar(500) NULL COMMENT '最近清理错误',
|
||||
create_dept bigint NULL,
|
||||
create_by bigint NULL,
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by bigint NULL,
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
exclusive_device_no varchar(64) GENERATED ALWAYS AS (
|
||||
CASE WHEN binding_status IN ('BINDING', 'ACTIVE', 'UNBINDING') THEN device_no ELSE NULL END
|
||||
) STORED,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_app_device_binding_user_device (user_id, device_no),
|
||||
UNIQUE KEY uk_app_device_binding_binding_id (binding_id),
|
||||
UNIQUE KEY uk_app_device_binding_exclusive (exclusive_device_no),
|
||||
KEY idx_app_device_binding_device_status (device_no, binding_status),
|
||||
KEY idx_app_device_binding_recovery (binding_status, binding_started_time, last_bind_command_time)
|
||||
) COMMENT='用户设备绑定生命周期';
|
||||
|
||||
CREATE TABLE app_device_unbind_command (
|
||||
id bigint NOT NULL,
|
||||
device_no varchar(64) NOT NULL COMMENT '设备编号',
|
||||
command_id varchar(64) NOT NULL COMMENT '设备解绑命令编号',
|
||||
binding_id varchar(64) NOT NULL COMMENT '命令目标绑定标识',
|
||||
command_status varchar(16) NOT NULL COMMENT 'PROCESSING/APPLIED/STALE/FAILED',
|
||||
message varchar(500) NULL COMMENT '处理结果',
|
||||
attempt_count int NOT NULL DEFAULT 0 COMMENT '处理次数',
|
||||
completed_time datetime NULL COMMENT '完成时间',
|
||||
create_dept bigint NULL,
|
||||
create_by bigint NULL,
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
update_by bigint NULL,
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_app_device_unbind_command (device_no, command_id),
|
||||
KEY idx_app_device_unbind_binding (device_no, binding_id)
|
||||
) COMMENT='设备解绑命令处理记录';
|
||||
|
||||
-- Existing bindings must re-establish the device-side bindingId before becoming ACTIVE.
|
||||
INSERT INTO app_device_binding (
|
||||
id, device_no, user_id, binding_id, binding_status, device_name_snapshot,
|
||||
binding_started_time, cleanup_attempts, create_time, update_time
|
||||
)
|
||||
SELECT
|
||||
UUID_SHORT(), device_no, user_id, REPLACE(UUID(), '-', ''), 'BINDING', device_name,
|
||||
NOW(), 0, NOW(), NOW()
|
||||
FROM app_device
|
||||
WHERE user_id IS NOT NULL;
|
||||
|
||||
-- Keep the legacy column during the rollout, but remove it as a relationship source.
|
||||
UPDATE app_device SET user_id = NULL WHERE user_id IS NOT NULL;
|
||||
66
water-admin/logs/sys-console.log
Normal file
66
water-admin/logs/sys-console.log
Normal file
@@ -0,0 +1,66 @@
|
||||
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
|
||||
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
|
||||
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
|
||||
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
|
||||
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
|
||||
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
|
||||
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
|
||||
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
|
||||
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
|
||||
2026-08-18 17:55:47 [main] WARN c.b.m.c.injector.DefaultSqlInjector - class org.dromara.app.domain.AppSchedulingDevice ,Not found @TableId annotation, Cannot use Mybatis-Plus 'xxById' Method.
|
||||
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceDataHandler -> ^/([^/]+)/publish/power$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceRegisterHandler -> ^/([^/]+)/publish/register$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceStatusHandler -> ^/([^/]+)/publish/status$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:ErromesHandler -> ^/([^/]+)/publish/error$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:StartWaterHandler -> ^/([^/]+)/publish/start$
|
||||
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
|
||||
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
|
||||
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
|
||||
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
|
||||
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接:ssl://www.mmipco.cn:8883
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
|
||||
2026-08-18 17:55:55 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
|
||||
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
|
||||
2026-08-18 17:55:56 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
|
||||
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
|
||||
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接:ssl://www.mmipco.cn:8883
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
|
||||
2026-08-18 17:55:57 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:57 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:58 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:58 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
|
||||
2026-08-18 17:55:59 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of close method failed on bean with name 'mqttConnect': 已在进行连接 (32110)
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of destroy method failed on bean with name 'org.dromara.mqtt.MqttClientManager': 已在进行连接 (32110)
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye
|
||||
0
water-admin/logs/sys-error.log
Normal file
0
water-admin/logs/sys-error.log
Normal file
55
water-admin/logs/sys-info.log
Normal file
55
water-admin/logs/sys-info.log
Normal file
@@ -0,0 +1,55 @@
|
||||
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
|
||||
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
|
||||
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
|
||||
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
|
||||
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
|
||||
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
|
||||
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
|
||||
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
|
||||
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
|
||||
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceDataHandler -> ^/([^/]+)/publish/power$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceRegisterHandler -> ^/([^/]+)/publish/register$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceStatusHandler -> ^/([^/]+)/publish/status$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:ErromesHandler -> ^/([^/]+)/publish/error$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
|
||||
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器:StartWaterHandler -> ^/([^/]+)/publish/start$
|
||||
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
|
||||
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
|
||||
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
|
||||
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
|
||||
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接:ssl://www.mmipco.cn:8883
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
|
||||
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
|
||||
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
|
||||
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
|
||||
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
|
||||
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接:ssl://www.mmipco.cn:8883
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
|
||||
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
|
||||
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye
|
||||
@@ -201,7 +201,11 @@ public class AuthController {
|
||||
*/
|
||||
@ApiEncrypt
|
||||
@PostMapping("/register")
|
||||
public R<Void> register(@Validated @RequestBody RegisterBody user) {
|
||||
public R<Void> register(@RequestBody RegisterBody user) {
|
||||
if (StringUtils.isBlank(user.getClientId())) {
|
||||
user.setClientId("32324a04c0175c8d61ac8a282553aea1");
|
||||
}
|
||||
ValidatorUtils.validate(user);
|
||||
if (!configService.selectRegisterEnabled(user.getTenantId())) {
|
||||
return R.fail("当前系统没有开启注册功能!");
|
||||
}
|
||||
|
||||
@@ -67,26 +67,32 @@ public class CaptchaController {
|
||||
@RateLimiter(key = "#username", time = 60, count = 1)
|
||||
@GetMapping("/resource/code")
|
||||
public R<Void> code(@NotBlank(message = "{user.username.not.blank}") String username) {
|
||||
String key = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||
String code = RandomUtil.randomNumbers(6);
|
||||
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
||||
if (Validator.isMobile(username)) {
|
||||
return sendSmsCode(username, username);
|
||||
}
|
||||
if (Validator.isEmail(username)) {
|
||||
return sendEmailCode(username, username);
|
||||
}
|
||||
|
||||
if (Validator.isMobile(username)){
|
||||
// 验证码模板id 自行处理 (查数据库或写死均可)
|
||||
String templateId = "SMS_333877107";
|
||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
||||
map.put("code", code);
|
||||
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
|
||||
SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map);
|
||||
if (!smsResponse.isSuccess()) {
|
||||
log.error("验证码短信发送异常 => {}", smsResponse);
|
||||
return R.fail(smsResponse.getData().toString());
|
||||
}
|
||||
}else {
|
||||
emailCodeImpl(username);
|
||||
}
|
||||
SysUserVo user = userService.selectUserByUserName(username);
|
||||
if (user == null) {
|
||||
return R.fail("账号未注册");
|
||||
}
|
||||
if (Validator.isMobile(user.getPhonenumber())) {
|
||||
return sendSmsCode(user.getPhonenumber(), username);
|
||||
}
|
||||
if (Validator.isEmail(user.getEmail())) {
|
||||
return sendEmailCode(user.getEmail(), username);
|
||||
}
|
||||
return R.fail("当前账号未绑定手机号或邮箱");
|
||||
}
|
||||
|
||||
return R.ok(code);
|
||||
private R<Void> sendEmailCode(String email, String cacheKey) {
|
||||
if (!mailProperties.getEnabled()) {
|
||||
return R.fail("当前系统没有开启邮箱功能!");
|
||||
}
|
||||
emailCodeImpl(email, cacheKey);
|
||||
return R.ok("操作成功");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,9 +2,16 @@ package org.dromara.web.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.app.domain.*;
|
||||
import org.dromara.app.mapper.*;
|
||||
import org.dromara.app.service.IDeviceCommandService;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
import org.dromara.app.domain.AppScheduleDetail;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppScheduleDetailMapper;
|
||||
import org.dromara.app.mapper.AppScheduleMapper;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mapper.AppWateringLogMapper;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
@@ -29,13 +36,12 @@ public class AccountCancellationService {
|
||||
private final SysUserRoleMapper sysUserRoleMapper;
|
||||
private final SysUserPostMapper sysUserPostMapper;
|
||||
private final SysSocialMapper sysSocialMapper;
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private final AppScheduleMapper appScheduleMapper;
|
||||
private final AppScheduleDetailMapper appScheduleDetailMapper;
|
||||
private final AppSchedulingDeviceMapper appSchedulingDeviceMapper;
|
||||
private final AppWateringLogMapper appWateringLogMapper;
|
||||
private final AccountCancelCodeService accountCancelCodeService;
|
||||
private final IDeviceCommandService deviceCommandService;
|
||||
private final IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cancelCurrentAccount(String code) {
|
||||
@@ -52,9 +58,12 @@ public class AccountCancellationService {
|
||||
List<String> deviceNos = queryUserDeviceNos(userId);
|
||||
List<Long> scheduleIds = queryUserScheduleIds(userId);
|
||||
|
||||
sendInitDeviceCommands(deviceNos);
|
||||
if (!deviceNos.isEmpty()) {
|
||||
deviceBindingService.unbindByUser(deviceNos, userId);
|
||||
}
|
||||
deviceBindingService.removeAllListEntries(userId);
|
||||
deleteAppScheduleData(userId, scheduleIds);
|
||||
deleteAppDeviceData(userId, deviceNos);
|
||||
deleteAppUserData(userId);
|
||||
deleteSystemUserData(userId);
|
||||
accountCancelCodeService.delete(username);
|
||||
}
|
||||
@@ -68,12 +77,8 @@ public class AccountCancellationService {
|
||||
}
|
||||
|
||||
private List<String> queryUserDeviceNos(Long userId) {
|
||||
return appDeviceMapper.selectList(
|
||||
Wrappers.<AppDevice>lambdaQuery()
|
||||
.select(AppDevice::getDeviceNo)
|
||||
.eq(AppDevice::getUserId, userId)
|
||||
).stream()
|
||||
.map(AppDevice::getDeviceNo)
|
||||
return deviceBindingService.queryActiveDevices(userId).stream()
|
||||
.map(AppDeviceVo::getDeviceNo)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
@@ -91,12 +96,6 @@ public class AccountCancellationService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
private void sendInitDeviceCommands(List<String> deviceNos) {
|
||||
for (String deviceNo : deviceNos) {
|
||||
deviceCommandService.sendInitDeviceCommand(deviceNo);
|
||||
}
|
||||
}
|
||||
|
||||
private void deleteAppScheduleData(Long userId, List<Long> scheduleIds) {
|
||||
if (!scheduleIds.isEmpty()) {
|
||||
appSchedulingDeviceMapper.delete(
|
||||
@@ -114,25 +113,11 @@ public class AccountCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
private void deleteAppDeviceData(Long userId, List<String> deviceNos) {
|
||||
if (!deviceNos.isEmpty()) {
|
||||
appSchedulingDeviceMapper.delete(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
.in(AppSchedulingDevice::getDeviceNo, deviceNos)
|
||||
);
|
||||
appWateringLogMapper.delete(
|
||||
Wrappers.<AppWateringLog>lambdaQuery()
|
||||
.in(AppWateringLog::getDeviceNo, deviceNos)
|
||||
);
|
||||
}
|
||||
private void deleteAppUserData(Long userId) {
|
||||
appWateringLogMapper.delete(
|
||||
Wrappers.<AppWateringLog>lambdaQuery()
|
||||
.eq(AppWateringLog::getUserId, userId)
|
||||
);
|
||||
appDeviceMapper.delete(
|
||||
Wrappers.<AppDevice>lambdaQuery()
|
||||
.eq(AppDevice::getUserId, userId)
|
||||
);
|
||||
}
|
||||
|
||||
private void deleteSystemUserData(Long userId) {
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
package org.dromara.web.service;
|
||||
|
||||
import cn.hutool.core.lang.Validator;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.common.core.constant.GlobalConstants;
|
||||
import org.dromara.common.core.domain.model.ForgotLoginBody;
|
||||
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
||||
import org.dromara.common.core.exception.user.UserException;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.system.domain.SysUser;
|
||||
import org.dromara.system.domain.vo.SysUserVo;
|
||||
import org.dromara.system.mapper.SysUserMapper;
|
||||
import org.dromara.system.service.ISysUserService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
@@ -22,41 +11,14 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class ForgotPasswordService {
|
||||
|
||||
private final SysUserMapper userMapper;
|
||||
private final ISysUserService userService;
|
||||
|
||||
|
||||
public boolean forgotPasswordService(ForgotLoginBody loginBody) {
|
||||
SysUserVo appUserVo =new SysUserVo();
|
||||
if(Validator.isEmail(loginBody.getUsername())){
|
||||
userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, loginBody.getUsername()));
|
||||
}else if (Validator.isMobile(loginBody.getUsername())){
|
||||
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, loginBody.getUsername()));
|
||||
}else {
|
||||
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, loginBody.getUsername()));
|
||||
}
|
||||
if (ObjectUtil.isNull(appUserVo)){
|
||||
throw new UserException("账号未注册");
|
||||
}
|
||||
|
||||
boolean validateFlag = validateSmsCode(loginBody.getUsername(), loginBody.getSmsCode());
|
||||
if (!validateFlag){
|
||||
throw new UserException("验证码无效");
|
||||
}
|
||||
|
||||
appUserVo.setPassword(BCrypt.hashpw(loginBody.getPassword()));
|
||||
SysUser update = MapstructUtils.convert(appUserVo, SysUser.class);
|
||||
userMapper.updateById(update);
|
||||
return userMapper.updateById(update) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验短信验证码
|
||||
*/
|
||||
private boolean validateSmsCode(String username, String smsCode) {
|
||||
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + username);
|
||||
if (StringUtils.isBlank(code)) {
|
||||
throw new CaptchaExpireException();
|
||||
}
|
||||
return code.equals(smsCode);
|
||||
String verificationCode = StringUtils.isNotBlank(loginBody.getSmsCode())
|
||||
? loginBody.getSmsCode()
|
||||
: loginBody.getCode();
|
||||
return userService.resetPasswordByVerificationCode(
|
||||
loginBody.getUsername(), verificationCode, loginBody.getPassword());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class SysRegisterService {
|
||||
sysUser.setUserName(username);
|
||||
|
||||
|
||||
sysUser.setNickName("用户"+new Date().getTime());
|
||||
sysUser.setNickName("Watex_"+new Date().getTime());
|
||||
sysUser.setPassword(BCrypt.hashpw(password));
|
||||
sysUser.setUserType(userType);
|
||||
boolean exist;
|
||||
|
||||
@@ -19,20 +19,28 @@ server:
|
||||
io: 8
|
||||
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
|
||||
worker: 256
|
||||
|
||||
app:
|
||||
firmware-storage:
|
||||
# 固件持久化目录;生产环境应挂载到服务器持久化磁盘
|
||||
directory: ${FIRMWARE_STORAGE_DIRECTORY:./data/firmware}
|
||||
# 设备可访问的服务端公网地址;为空时根据上传请求生成
|
||||
download-base-url: ${FIRMWARE_DOWNLOAD_BASE_URL:}
|
||||
--- # mqtt配置信息
|
||||
mqtt:
|
||||
enabled: ${MQTT_ENABLED:true}
|
||||
#broker-url: ssl://www.mmipco.cn:8883
|
||||
broker-url: tcp://47.97.217.123
|
||||
broker-url: ssl://www.mmipco.cn:8883
|
||||
#broker-url: tcp://47.97.217.123
|
||||
username: admin
|
||||
password: 61e6062129e9
|
||||
client-id: water-server-1${server.port}
|
||||
client-id: water-server-2${server.port}
|
||||
qos: 1
|
||||
keep-alive: 300
|
||||
connection-timeout: 30
|
||||
max-inflight: 5000
|
||||
clean-session: false
|
||||
automatic-reconnect: true
|
||||
register-device-no-dedup-seconds: 30
|
||||
async:
|
||||
core-pool-size: 16
|
||||
max-pool-size: 64
|
||||
@@ -47,31 +55,36 @@ mqtt:
|
||||
command-ack:
|
||||
enabled: true
|
||||
max-retry-count: 3
|
||||
retry-interval-ms: 30000
|
||||
retry-interval-ms: 10000
|
||||
scan-interval-ms: 5000
|
||||
pending-ttl-seconds: 86400
|
||||
ack-ttl-seconds: 86400
|
||||
startup-cleanup-wait-ms: 30000
|
||||
pending-key-prefix: "mqtt:command:pending:"
|
||||
ack-key-prefix: "mqtt:command:ack:"
|
||||
pending-set-key: "mqtt:command:pending:ids"
|
||||
ack-lock-wait-ms: 3000
|
||||
device-status-cache-prefix: "mqtt:device:status:"
|
||||
device-status-cache-ttl-seconds: 900
|
||||
device-status-cache-ttl-seconds: 600
|
||||
offline-check:
|
||||
enabled: true
|
||||
ttl-compat-enabled: true
|
||||
interval-ms: 90000
|
||||
interval-ms: 30000
|
||||
topics:
|
||||
# 订阅/下发主题第一段为设备 MAC(小写、去分隔符),上行业务侧由 DeviceIdentityResolver 解析为设备编号
|
||||
# 首次注册使用 MAC;注册后的业务主题使用 deviceNo;LWT 离线主题兼容 MAC
|
||||
# 同一共享组内仅一个后端实例消费每条上行消息,防止重复下发和重复处理 ACK
|
||||
shared-group: ${MQTT_SHARED_GROUP:water-backend}
|
||||
subscribe:
|
||||
|
||||
- /+/publish/finish/schedule #排程任务完成上报
|
||||
- /+/publish/register #设备注册
|
||||
- /+/publish/status #设备在线/离线状态(online/offline,离线配合设备 LWT)
|
||||
- /+/publish/power #电量
|
||||
- /+/publish/status #设备 LWT 离线状态(仅处理 offline)
|
||||
- /+/publish/power #电量及在线心跳
|
||||
- /+/publish/ack #应答
|
||||
- /+/publish/start #开始浇水上报
|
||||
- /+/publish/finish/key #按键浇水完成
|
||||
- /+/publish/error #硬件故障
|
||||
- /+/publish/unbind #设备长按解绑
|
||||
# - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水
|
||||
|
||||
publish-prefix: ""
|
||||
@@ -171,6 +184,8 @@ security:
|
||||
- /*/api-docs
|
||||
- /*/api-docs/**
|
||||
- /warm-flow-ui/config
|
||||
- /app/v1/retrievePassword
|
||||
- /app/firmware/download/**
|
||||
|
||||
# 多租户配置
|
||||
tenant:
|
||||
|
||||
@@ -14,8 +14,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
class MqttCommandAckConfigUnitTest {
|
||||
|
||||
@Test
|
||||
void commandAckMaxRetryCountIsThree() throws Exception {
|
||||
Integer maxRetryCount = null;
|
||||
void commandAckRetrySettingsMatchRuntimeRequirements() throws Exception {
|
||||
Map<?, ?> commandAckConfig = null;
|
||||
Yaml yaml = new Yaml();
|
||||
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
|
||||
for (Object document : yaml.loadAll(inputStream)) {
|
||||
@@ -27,17 +27,16 @@ class MqttCommandAckConfigUnitTest {
|
||||
continue;
|
||||
}
|
||||
Object commandAck = mqttConfig.get("command-ack");
|
||||
if (!(commandAck instanceof Map<?, ?> commandAckConfig)) {
|
||||
continue;
|
||||
}
|
||||
Object value = commandAckConfig.get("max-retry-count");
|
||||
if (value instanceof Number number) {
|
||||
maxRetryCount = number.intValue();
|
||||
if (commandAck instanceof Map<?, ?> config) {
|
||||
commandAckConfig = config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertThat(maxRetryCount).isEqualTo(3);
|
||||
assertThat(commandAckConfig).isNotNull();
|
||||
assertThat(commandAckConfig.get("retry-interval-ms")).isEqualTo(10000);
|
||||
assertThat(commandAckConfig.get("scan-interval-ms")).isEqualTo(5000);
|
||||
assertThat(commandAckConfig.get("max-retry-count")).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.dromara.web.config;
|
||||
|
||||
import org.dromara.common.security.config.SecurityConfig;
|
||||
import org.dromara.common.security.config.properties.SecurityProperties;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.context.properties.bind.Bindable;
|
||||
import org.springframework.boot.context.properties.bind.Binder;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.MutablePropertySources;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.handler.MappedInterceptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@Tag("dev")
|
||||
class SecurityExcludesConfigUnitTest {
|
||||
|
||||
@Test
|
||||
void retrievePassword_isExcludedFromLoginAuthentication() throws Exception {
|
||||
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
|
||||
.load("application", new ClassPathResource("application.yml"));
|
||||
MutablePropertySources propertySources = new MutablePropertySources();
|
||||
for (PropertySource<?> source : sources) {
|
||||
propertySources.addLast(source);
|
||||
}
|
||||
SecurityProperties properties = new Binder(ConfigurationPropertySources.from(propertySources))
|
||||
.bind("security", Bindable.of(SecurityProperties.class))
|
||||
.orElseThrow(() -> new AssertionError("security 配置未绑定"));
|
||||
|
||||
assertThat(properties.getExcludes()).contains("/app/v1/retrievePassword");
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityInterceptor_alwaysExcludesRetrievePasswordWithoutExternalConfiguration() {
|
||||
SecurityProperties properties = new SecurityProperties();
|
||||
properties.setExcludes(new String[0]);
|
||||
SecurityConfig config = new SecurityConfig(properties);
|
||||
ReflectionTestUtils.setField(config, "ssePath", "/resource/sse");
|
||||
ExposedInterceptorRegistry registry = new ExposedInterceptorRegistry();
|
||||
|
||||
config.addInterceptors(registry);
|
||||
|
||||
MappedInterceptor interceptor = (MappedInterceptor) registry.getRegisteredInterceptors().get(0);
|
||||
assertThat(interceptor.getExcludePathPatterns()).contains("/app/v1/retrievePassword");
|
||||
}
|
||||
|
||||
private static final class ExposedInterceptorRegistry extends InterceptorRegistry {
|
||||
|
||||
private List<Object> getRegisteredInterceptors() {
|
||||
return super.getInterceptors();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
package org.dromara.web.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.ValidatorFactory;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.domain.model.AccountCancelBody;
|
||||
import org.dromara.common.core.domain.model.RegisterBody;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.service.ISysConfigService;
|
||||
import org.dromara.web.service.AccountCancellationService;
|
||||
import org.dromara.web.service.SysRegisterService;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
@@ -23,6 +31,12 @@ class AuthControllerUnitTest {
|
||||
@Mock
|
||||
private AccountCancellationService accountCancellationService;
|
||||
|
||||
@Mock
|
||||
private SysRegisterService registerService;
|
||||
|
||||
@Mock
|
||||
private ISysConfigService configService;
|
||||
|
||||
@Test
|
||||
void cancelAccount_checksLoginCancelsAccountAndLogsOut() {
|
||||
AuthController controller = new AuthController(
|
||||
@@ -52,4 +66,38 @@ class AuthControllerUnitTest {
|
||||
stpUtil.verify(() -> StpUtil.logout(100L));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void register_defaultsClientIdWhenMissing() {
|
||||
AuthController controller = new AuthController(
|
||||
null,
|
||||
null,
|
||||
registerService,
|
||||
configService,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
accountCancellationService
|
||||
);
|
||||
RegisterBody user = new RegisterBody();
|
||||
user.setUsername("register-user");
|
||||
user.setPassword("12345");
|
||||
user.setGrantType("password");
|
||||
when(configService.selectRegisterEnabled(user.getTenantId())).thenReturn(true);
|
||||
|
||||
try (ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
|
||||
GenericApplicationContext context = new GenericApplicationContext()) {
|
||||
context.getBeanFactory().registerSingleton("validator", validatorFactory.getValidator());
|
||||
context.refresh();
|
||||
new SpringUtil().setApplicationContext(context);
|
||||
|
||||
R<Void> result = controller.register(user);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(200);
|
||||
assertThat(user.getClientId()).isEqualTo("32324a04c0175c8d61ac8a282553aea1");
|
||||
verify(registerService).register(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,52 @@ class CaptchaControllerUnitTest {
|
||||
@Mock
|
||||
private ISysUserService userService;
|
||||
|
||||
@Test
|
||||
void code_resolvesAccountPhoneAndUsesAccountAsCacheKey() {
|
||||
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||
SysUserVo user = new SysUserVo();
|
||||
user.setPhonenumber("13305376054");
|
||||
when(userService.selectUserByUserName("alice")).thenReturn(user);
|
||||
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "alice");
|
||||
|
||||
R<Void> result = controller.code("alice");
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(200);
|
||||
assertThat(result.getData()).isNull();
|
||||
verify(userService).selectUserByUserName("alice");
|
||||
verify(controller).sendSmsCode("13305376054", "alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void code_resolvesAccountEmailAndUsesAccountAsCacheKey() {
|
||||
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||
SysUserVo user = new SysUserVo();
|
||||
user.setEmail("alice@example.com");
|
||||
when(userService.selectUserByUserName("alice")).thenReturn(user);
|
||||
when(mailProperties.getEnabled()).thenReturn(true);
|
||||
doNothing().when(controller).emailCodeImpl("alice@example.com", "alice");
|
||||
|
||||
R<Void> result = controller.code("alice");
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(200);
|
||||
assertThat(result.getData()).isNull();
|
||||
verify(userService).selectUserByUserName("alice");
|
||||
verify(controller).emailCodeImpl("alice@example.com", "alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void code_keepsDirectMobileBehaviorWithoutReturningPlainCode() {
|
||||
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "13305376054");
|
||||
|
||||
R<Void> result = controller.code("13305376054");
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(200);
|
||||
assertThat(result.getData()).isNull();
|
||||
verify(controller).sendSmsCode("13305376054", "13305376054");
|
||||
verifyNoInteractions(userService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
|
||||
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||
|
||||
@@ -4,9 +4,16 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.dromara.app.domain.*;
|
||||
import org.dromara.app.mapper.*;
|
||||
import org.dromara.app.service.IDeviceCommandService;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
import org.dromara.app.domain.AppScheduleDetail;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.AppWateringLog;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppScheduleDetailMapper;
|
||||
import org.dromara.app.mapper.AppScheduleMapper;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mapper.AppWateringLogMapper;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
||||
import org.dromara.common.core.exception.user.UserException;
|
||||
@@ -45,8 +52,6 @@ class AccountCancellationServiceUnitTest {
|
||||
@Mock
|
||||
private SysSocialMapper sysSocialMapper;
|
||||
@Mock
|
||||
private AppDeviceMapper appDeviceMapper;
|
||||
@Mock
|
||||
private AppScheduleMapper appScheduleMapper;
|
||||
@Mock
|
||||
private AppScheduleDetailMapper appScheduleDetailMapper;
|
||||
@@ -57,12 +62,11 @@ class AccountCancellationServiceUnitTest {
|
||||
@Mock
|
||||
private AccountCancelCodeService accountCancelCodeService;
|
||||
@Mock
|
||||
private IDeviceCommandService deviceCommandService;
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
initTableInfo(assistant, AppDevice.class);
|
||||
initTableInfo(assistant, AppSchedule.class);
|
||||
initTableInfo(assistant, AppScheduleDetail.class);
|
||||
initTableInfo(assistant, AppSchedulingDevice.class);
|
||||
@@ -80,11 +84,11 @@ class AccountCancellationServiceUnitTest {
|
||||
@Test
|
||||
void cancelCurrentAccount_deletesCurrentUserAndRelatedData() {
|
||||
AccountCancellationService service = newService();
|
||||
AppDevice device = new AppDevice();
|
||||
AppDeviceVo device = new AppDeviceVo();
|
||||
device.setDeviceNo("D01");
|
||||
AppSchedule schedule = new AppSchedule();
|
||||
schedule.setId(10L);
|
||||
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
|
||||
when(deviceBindingService.queryActiveDevices(100L)).thenReturn(List.of(device));
|
||||
when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule));
|
||||
when(sysUserMapper.deleteById(100L)).thenReturn(1);
|
||||
|
||||
@@ -96,12 +100,12 @@ class AccountCancellationServiceUnitTest {
|
||||
service.cancelCurrentAccount("123456");
|
||||
|
||||
verify(accountCancelCodeService).validate("zhangsan", "123456");
|
||||
verify(deviceCommandService).sendInitDeviceCommand("D01");
|
||||
verify(deviceBindingService).unbindByUser(List.of("D01"), 100L);
|
||||
verify(deviceBindingService).removeAllListEntries(100L);
|
||||
verify(appSchedulingDeviceMapper, atLeastOnce()).delete(any(Wrapper.class));
|
||||
verify(appScheduleDetailMapper).delete(any(Wrapper.class));
|
||||
verify(appScheduleMapper).delete(any(Wrapper.class));
|
||||
verify(appWateringLogMapper, atLeastOnce()).delete(any(Wrapper.class));
|
||||
verify(appDeviceMapper).delete(any(Wrapper.class));
|
||||
verify(sysSocialMapper).delete(any(Wrapper.class));
|
||||
verify(sysUserRoleMapper).delete(any(Wrapper.class));
|
||||
verify(sysUserPostMapper).delete(any(Wrapper.class));
|
||||
@@ -127,7 +131,7 @@ class AccountCancellationServiceUnitTest {
|
||||
sysUserRoleMapper,
|
||||
sysUserPostMapper,
|
||||
sysSocialMapper,
|
||||
appDeviceMapper,
|
||||
deviceBindingService,
|
||||
appScheduleMapper,
|
||||
appScheduleDetailMapper,
|
||||
appSchedulingDeviceMapper,
|
||||
@@ -156,7 +160,7 @@ class AccountCancellationServiceUnitTest {
|
||||
sysUserRoleMapper,
|
||||
sysUserPostMapper,
|
||||
sysSocialMapper,
|
||||
appDeviceMapper,
|
||||
deviceBindingService,
|
||||
appScheduleMapper,
|
||||
appScheduleDetailMapper,
|
||||
appSchedulingDeviceMapper,
|
||||
@@ -186,7 +190,7 @@ class AccountCancellationServiceUnitTest {
|
||||
sysUserRoleMapper,
|
||||
sysUserPostMapper,
|
||||
sysSocialMapper,
|
||||
appDeviceMapper,
|
||||
deviceBindingService,
|
||||
appScheduleMapper,
|
||||
appScheduleDetailMapper,
|
||||
appSchedulingDeviceMapper,
|
||||
@@ -203,13 +207,12 @@ class AccountCancellationServiceUnitTest {
|
||||
sysUserRoleMapper,
|
||||
sysUserPostMapper,
|
||||
sysSocialMapper,
|
||||
appDeviceMapper,
|
||||
appScheduleMapper,
|
||||
appScheduleDetailMapper,
|
||||
appSchedulingDeviceMapper,
|
||||
appWateringLogMapper,
|
||||
accountCancelCodeService,
|
||||
deviceCommandService
|
||||
deviceBindingService
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.dromara.web.service;
|
||||
|
||||
import org.dromara.common.core.domain.model.ForgotLoginBody;
|
||||
import org.dromara.system.service.ISysUserService;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class ForgotPasswordServiceTest {
|
||||
|
||||
@Mock
|
||||
private ISysUserService userService;
|
||||
@InjectMocks
|
||||
private ForgotPasswordService forgotPasswordService;
|
||||
|
||||
@Test
|
||||
void forgotPasswordService_delegatesVerificationAndResetToUserService() {
|
||||
ForgotLoginBody body = new ForgotLoginBody();
|
||||
body.setUsername("alice");
|
||||
body.setSmsCode("123456");
|
||||
body.setPassword("newPassword");
|
||||
when(userService.resetPasswordByVerificationCode("alice", "123456", "newPassword"))
|
||||
.thenReturn(true);
|
||||
|
||||
boolean result = forgotPasswordService.forgotPasswordService(body);
|
||||
|
||||
assertThat(result).isTrue();
|
||||
verify(userService).resetPasswordByVerificationCode("alice", "123456", "newPassword");
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public class DeviceMqttCommandPublisher implements IDeviceCommandPublisher {
|
||||
|
||||
@Override
|
||||
public String send(DeviceCommand command) {
|
||||
ackService.awaitStartupCleanup();
|
||||
long now = System.currentTimeMillis();
|
||||
if (StringUtils.isBlank(command.getCommandId())) {
|
||||
command.setCommandId(UUID.randomUUID().toString().replace("-", ""));
|
||||
|
||||
@@ -51,6 +51,18 @@ public class MqttClientManager implements DisposableBean {
|
||||
return identity.hashCode();
|
||||
}
|
||||
|
||||
static String subscriptionFilter(String topic, String sharedGroup) {
|
||||
if (StringUtils.isBlank(topic) || StringUtils.isBlank(sharedGroup)
|
||||
|| topic.startsWith("$share/") || topic.startsWith("$queue/")) {
|
||||
return topic;
|
||||
}
|
||||
String group = sharedGroup.trim();
|
||||
if (group.contains("/") || group.contains("+") || group.contains("#")) {
|
||||
throw new ServiceException("MQTT 共享订阅组名称不能包含 /、+ 或 #");
|
||||
}
|
||||
return "$share/" + group + "/" + topic;
|
||||
}
|
||||
|
||||
private void configureTls(MqttConnectOptions options) throws MqttException {
|
||||
if (!isTlsEnabled()) {
|
||||
return;
|
||||
@@ -254,14 +266,18 @@ public class MqttClientManager implements DisposableBean {
|
||||
if (client == null || !client.isConnected()) {
|
||||
return;
|
||||
}
|
||||
List<String> topics = props.getTopics() == null ? List.of() : props.getTopics().getSubscribe();
|
||||
MqttProperties.Topics topicProperties = props.getTopics();
|
||||
List<String> topics = topicProperties == null ? List.of() : topicProperties.getSubscribe();
|
||||
if (topics == null || topics.isEmpty()) {
|
||||
log.warn("[MQTT] 未配置订阅主题");
|
||||
return;
|
||||
}
|
||||
int[] qos = topics.stream().mapToInt(topic -> props.getQos()).toArray();
|
||||
client.subscribe(topics.toArray(new String[0]), qos).waitForCompletion();
|
||||
topics.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos()));
|
||||
List<String> subscriptionFilters = topics.stream()
|
||||
.map(topic -> subscriptionFilter(topic, topicProperties.getSharedGroup()))
|
||||
.toList();
|
||||
int[] qos = subscriptionFilters.stream().mapToInt(topic -> props.getQos()).toArray();
|
||||
client.subscribe(subscriptionFilters.toArray(new String[0]), qos).waitForCompletion();
|
||||
subscriptionFilters.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos()));
|
||||
} catch (MqttException e) {
|
||||
log.error("[MQTT] 订阅失败", e);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.mqtt.DeviceCommand;
|
||||
import org.dromara.app.domain.mqtt.DeviceCommandAck;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IDeviceCommandAckHandler;
|
||||
import org.dromara.app.service.IDeviceCommandLifecycleListener;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
@@ -21,6 +24,7 @@ import org.springframework.stereotype.Service;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@@ -37,13 +41,15 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
private MqttClientManager mqttClientManager;
|
||||
private final MqttProperties mqttProperties;
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private final CountDownLatch startupCleanupLatch = new CountDownLatch(1);
|
||||
@Autowired(required = false)
|
||||
private List<IDeviceCommandLifecycleListener> lifecycleListeners = Collections.emptyList();
|
||||
private volatile boolean startupCleanupReady;
|
||||
@Lazy
|
||||
@Autowired(required = false)
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
|
||||
|
||||
public void savePending(DeviceCommand command) {
|
||||
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
|
||||
pendingIds().add(command.getCommandId());
|
||||
}
|
||||
|
||||
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
|
||||
return StringUtils.isNotBlank(topicDeviceNo)
|
||||
&& pendingCommand != null
|
||||
@@ -68,16 +74,6 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
public void removePending(String commandId) {
|
||||
if (StringUtils.isBlank(commandId)) {
|
||||
return;
|
||||
}
|
||||
withCommandLock(commandId, () -> {
|
||||
deletePending(commandId);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleAck(String deviceNo, String payload) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
@@ -111,7 +107,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
boolean ackSaved = withCommandLock(ack.getCommandId(), () -> {
|
||||
long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
|
||||
CommandLockResult ackResult = withCommandLock(ack.getCommandId(), lockWaitMs, () -> {
|
||||
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId()));
|
||||
if (pending == null) {
|
||||
log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
|
||||
@@ -122,18 +119,110 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
deviceNo, ack.getCommandId(), pending.getDeviceNo());
|
||||
return false;
|
||||
}
|
||||
notifyCommandAcknowledged(pending, ack);
|
||||
deletePending(ack.getCommandId());
|
||||
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
|
||||
return true;
|
||||
});
|
||||
if (!ackSaved) {
|
||||
log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
|
||||
if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
|
||||
log.warn("[MQTT] ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
|
||||
deviceNo, ack.getCommandId(), lockWaitMs);
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.INTERRUPTED) {
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.ACTION_FAILED) {
|
||||
return;
|
||||
}
|
||||
refreshDeviceOnline(deviceNo);
|
||||
log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} message={}", deviceNo, ack.getCommandId(), ack.getMessage());
|
||||
}
|
||||
|
||||
public void savePending(DeviceCommand command) {
|
||||
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
|
||||
pendingIds().add(command.getCommandId());
|
||||
}
|
||||
|
||||
public int clearPendingCommands() {
|
||||
RSet<String> pendingIds = pendingIds();
|
||||
Set<String> commandIds = pendingIds.readAll();
|
||||
pendingIds.removeAll(commandIds);
|
||||
|
||||
RuntimeException cleanupFailure = null;
|
||||
try {
|
||||
for (String commandId : commandIds) {
|
||||
try {
|
||||
RedisUtils.deleteObject(pendingKey(commandId));
|
||||
} catch (RuntimeException e) {
|
||||
if (cleanupFailure == null) {
|
||||
cleanupFailure = new RuntimeException("Failed to delete MQTT pending command object: " + commandId, e);
|
||||
} else {
|
||||
cleanupFailure.addSuppressed(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
startupCleanupReady = true;
|
||||
startupCleanupLatch.countDown();
|
||||
}
|
||||
if (cleanupFailure != null) {
|
||||
throw cleanupFailure;
|
||||
}
|
||||
return commandIds.size();
|
||||
}
|
||||
|
||||
boolean isStartupCleanupReady() {
|
||||
return startupCleanupReady;
|
||||
}
|
||||
|
||||
void awaitStartupCleanup() {
|
||||
if (startupCleanupReady) {
|
||||
return;
|
||||
}
|
||||
long waitMs = Math.max(1000L, mqttProperties.getCommandAck().getStartupCleanupWaitMs());
|
||||
try {
|
||||
if (!startupCleanupLatch.await(waitMs, TimeUnit.MILLISECONDS)) {
|
||||
throw new ServiceException("MQTT 启动清理未完成,暂不下发命令");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ServiceException("等待 MQTT 启动清理被中断");
|
||||
}
|
||||
}
|
||||
|
||||
public void removePending(String commandId) {
|
||||
if (StringUtils.isBlank(commandId)) {
|
||||
return;
|
||||
}
|
||||
CommandLockResult result = withCommandLock(commandId, () -> {
|
||||
deletePending(commandId);
|
||||
return true;
|
||||
});
|
||||
if (result != CommandLockResult.SUCCESS) {
|
||||
throw new IllegalStateException("清理 MQTT 待确认命令失败,命令编号=" + commandId + ",结果=" + result);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearPendingCommands(Collection<String> deviceNos) {
|
||||
if (deviceNos == null || deviceNos.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Set<String> targetDeviceNos = deviceNos.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
if (targetDeviceNos.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (String commandId : pendingIds().readAll()) {
|
||||
DeviceCommand command = RedisUtils.getCacheObject(pendingKey(commandId));
|
||||
if (command != null && targetDeviceNos.contains(command.getDeviceNo())) {
|
||||
removePending(commandId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePlainAck(String deviceNo, String payload) {
|
||||
List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo);
|
||||
refreshDeviceOnline(deviceNo);
|
||||
@@ -150,7 +239,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
ack.setStatus("1");
|
||||
ack.setMessage(payload);
|
||||
|
||||
boolean ackSaved = withCommandLock(commandId, () -> {
|
||||
long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
|
||||
CommandLockResult ackResult = withCommandLock(commandId, lockWaitMs, () -> {
|
||||
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId));
|
||||
if (pending == null) {
|
||||
log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId);
|
||||
@@ -161,17 +251,29 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
deviceNo, commandId, pending.getDeviceNo());
|
||||
return false;
|
||||
}
|
||||
notifyCommandAcknowledged(pending, ack);
|
||||
deletePending(commandId);
|
||||
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
|
||||
return true;
|
||||
});
|
||||
if (!ackSaved) {
|
||||
log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId);
|
||||
if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
|
||||
log.warn("[MQTT] 非 JSON ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
|
||||
deviceNo, commandId, lockWaitMs);
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.INTERRUPTED) {
|
||||
return;
|
||||
}
|
||||
if (ackResult == CommandLockResult.ACTION_FAILED) {
|
||||
return;
|
||||
}
|
||||
log.info("[MQTT] 收到非 JSON 命令确认 设备编号={} 命令编号={} 消息体={}", deviceNo, commandId, payload);
|
||||
}
|
||||
|
||||
private CommandLockResult withCommandLock(String commandId, BooleanSupplier action) {
|
||||
return withCommandLock(commandId, 0L, action);
|
||||
}
|
||||
|
||||
private List<DeviceCommand> findPendingCommandsByDeviceNo(String deviceNo) {
|
||||
List<DeviceCommand> commands = new ArrayList<>();
|
||||
for (String commandId : pendingIds().readAll()) {
|
||||
@@ -208,10 +310,19 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
return;
|
||||
}
|
||||
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
|
||||
notifyCommandExpired(command);
|
||||
deletePending(commandId);
|
||||
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
|
||||
return;
|
||||
}
|
||||
if (Boolean.FALSE.equals(command.getRetryEnabled())) {
|
||||
command.setRetryCount(command.getRetryCount() + 1);
|
||||
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
|
||||
savePending(command);
|
||||
log.debug("[MQTT] 命令已关闭自动重发,等待 ACK 或到期清理 设备编号={} 命令编号={} 等待次数={}",
|
||||
command.getDeviceNo(), commandId, command.getRetryCount());
|
||||
return;
|
||||
}
|
||||
if (!isDeviceOnline(command.getDeviceNo())) {
|
||||
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
|
||||
savePending(command);
|
||||
@@ -243,6 +354,9 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
}
|
||||
|
||||
private void refreshDeviceOnline(String deviceNo) {
|
||||
if (deviceBindingService != null && !deviceBindingService.isActive(deviceNo)) {
|
||||
return;
|
||||
}
|
||||
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
|
||||
boolean locked = false;
|
||||
try {
|
||||
@@ -284,22 +398,39 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
pendingIds().remove(commandId);
|
||||
}
|
||||
|
||||
private boolean withCommandLock(String commandId, BooleanSupplier action) {
|
||||
private void notifyCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
|
||||
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
|
||||
listener.onCommandAcknowledged(command, ack);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyCommandExpired(DeviceCommand command) {
|
||||
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
|
||||
try {
|
||||
listener.onCommandExpired(command);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[MQTT] 命令过期生命周期回调失败 设备编号={} 命令编号={}",
|
||||
command.getDeviceNo(), command.getCommandId(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) {
|
||||
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
|
||||
boolean locked = false;
|
||||
try {
|
||||
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
|
||||
locked = lock.tryLock(waitTimeMs, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
|
||||
if (!locked) {
|
||||
return false;
|
||||
return CommandLockResult.LOCK_UNAVAILABLE;
|
||||
}
|
||||
return action.getAsBoolean();
|
||||
return action.getAsBoolean() ? CommandLockResult.SUCCESS : CommandLockResult.ACTION_FAILED;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId);
|
||||
return false;
|
||||
return CommandLockResult.INTERRUPTED;
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e);
|
||||
return false;
|
||||
return CommandLockResult.ACTION_FAILED;
|
||||
} finally {
|
||||
if (locked && lock.isHeldByCurrentThread()) {
|
||||
lock.unlock();
|
||||
@@ -307,6 +438,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private enum CommandLockResult {
|
||||
SUCCESS,
|
||||
LOCK_UNAVAILABLE,
|
||||
INTERRUPTED,
|
||||
ACTION_FAILED
|
||||
}
|
||||
|
||||
private String pendingKey(String commandId) {
|
||||
return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class MqttCommandRetryTask {
|
||||
|
||||
@Scheduled(fixedDelayString = "${mqtt.command-ack.scan-interval-ms:5000}")
|
||||
public void retryExpiredCommands() {
|
||||
if (mqttProperties.getCommandAck().isEnabled()) {
|
||||
if (ackService.isStartupCleanupReady() && mqttProperties.getCommandAck().isEnabled()) {
|
||||
// log.info("[设备命令重新下发] 定时器调用成功 ");
|
||||
ackService.retryExpiredCommands();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
|
||||
import org.dromara.app.service.IDeviceUnbindResultPublisher;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.mqtt.config.properties.MqttProperties;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MqttDeviceUnbindResultPublisher implements IDeviceUnbindResultPublisher {
|
||||
|
||||
private final MqttProperties mqttProperties;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private MqttClientManager mqttClientManager;
|
||||
|
||||
@Override
|
||||
public void publish(DeviceUnbindResult result) {
|
||||
if (result == null || StringUtils.isBlank(result.deviceNo())) {
|
||||
throw new ServiceException("解绑回执缺少设备编号");
|
||||
}
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("commandId", result.commandId());
|
||||
payload.put("bindingId", result.bindingId());
|
||||
payload.put("status", result.status());
|
||||
payload.put("message", result.message());
|
||||
mqttClientManager.publish(buildTopic(result.deviceNo()), JsonUtils.toJsonString(payload));
|
||||
}
|
||||
|
||||
private String buildTopic(String deviceNo) {
|
||||
String device = deviceNo.trim().toLowerCase(Locale.ROOT);
|
||||
String prefix = mqttProperties.getTopics().getPublishPrefix();
|
||||
if (StringUtils.isBlank(prefix)) {
|
||||
return "/" + device + "/subscriber/unbind/ack";
|
||||
}
|
||||
String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix;
|
||||
if (normalizedPrefix.endsWith("/")) {
|
||||
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
|
||||
}
|
||||
return normalizedPrefix + "/" + device + "/subscriber/unbind/ack";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MqttPendingCommandCleanupListener {
|
||||
|
||||
private final MqttCommandAckService mqttCommandAckService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onApplicationReady(ApplicationReadyEvent event) {
|
||||
try {
|
||||
int clearedCount = mqttCommandAckService.clearPendingCommands();
|
||||
log.info("[MQTT] 应用启动清理待确认命令完成,清理数量: {}", clearedCount);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[MQTT] 应用启动清理待确认命令失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public class MqttProperties {
|
||||
@Data
|
||||
public static class Topics {
|
||||
private List<String> subscribe = new ArrayList<>();
|
||||
private String sharedGroup;
|
||||
private String publishPrefix;
|
||||
}
|
||||
|
||||
@@ -55,15 +56,17 @@ public class MqttProperties {
|
||||
public static class CommandAck {
|
||||
private boolean enabled = true;
|
||||
private int maxRetryCount = 3;
|
||||
private long retryIntervalMs = 5000;
|
||||
private long retryIntervalMs = 10000;
|
||||
private long scanIntervalMs = 5000;
|
||||
private long pendingTtlSeconds = 86400;
|
||||
private long ackTtlSeconds = 86400;
|
||||
private long startupCleanupWaitMs = 30000;
|
||||
private String pendingKeyPrefix = "mqtt:command:pending:";
|
||||
private String ackKeyPrefix = "mqtt:command:ack:";
|
||||
private String pendingSetKey = "mqtt:command:pending:ids";
|
||||
private String retryLockKeyPrefix = "lock:mqtt:command:retry:";
|
||||
private long retryLockTtlMs = 30000;
|
||||
private long ackLockWaitMs = 3000;
|
||||
private String deviceStatusCachePrefix = "mqtt:device:status:";
|
||||
private long deviceStatusCacheTtlSeconds = 900;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.dromara.app.domain.mqtt.DeviceCommand;
|
||||
import org.dromara.mqtt.config.properties.MqttProperties;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
|
||||
|
||||
@Tag("dev")
|
||||
class DeviceMqttCommandPublisherTest {
|
||||
|
||||
private static GenericApplicationContext applicationContext;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeJsonUtils() {
|
||||
applicationContext = new GenericApplicationContext();
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeApplicationContext() {
|
||||
applicationContext.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildCommandTopicDefaultsToDeviceNo() {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
@@ -23,4 +48,40 @@ class DeviceMqttCommandPublisherTest {
|
||||
|
||||
assertThat(topic).isEqualTo("/d01/subscriber/cmd");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendSchedulesFirstRetryTenSecondsAfterInitialPublish() {
|
||||
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
|
||||
mock(MqttClientManager.class),
|
||||
mock(MqttCommandAckService.class),
|
||||
new MqttProperties()
|
||||
);
|
||||
DeviceCommand command = new DeviceCommand();
|
||||
command.setDeviceNo("D01");
|
||||
command.setCommandType("switch");
|
||||
|
||||
publisher.send(command);
|
||||
|
||||
assertThat(command.getNextRetryAt() - command.getLastSentAt()).isEqualTo(10000);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendWaitsForStartupCleanupBeforeSavingPendingCommand() {
|
||||
MqttClientManager clientManager = mock(MqttClientManager.class);
|
||||
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
|
||||
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
|
||||
clientManager,
|
||||
ackService,
|
||||
new MqttProperties()
|
||||
);
|
||||
DeviceCommand command = new DeviceCommand();
|
||||
command.setDeviceNo("D01");
|
||||
command.setCommandType("switch");
|
||||
|
||||
publisher.send(command);
|
||||
|
||||
var order = inOrder(ackService, clientManager);
|
||||
order.verify(ackService).awaitStartupCleanup();
|
||||
order.verify(ackService).savePending(command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@Tag("dev")
|
||||
class MqttClientManagerTest {
|
||||
|
||||
@Test
|
||||
void subscriptionFilterWrapsTopicInSharedGroup() {
|
||||
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", "water-backend"))
|
||||
.isEqualTo("$share/water-backend//+/publish/register");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionFilterKeepsExistingSharedSubscription() {
|
||||
String topic = "$share/existing//+/publish/register";
|
||||
|
||||
assertThat(MqttClientManager.subscriptionFilter(topic, "water-backend")).isEqualTo(topic);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionFilterKeepsTopicWhenSharedGroupIsNotConfigured() {
|
||||
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", " "))
|
||||
.isEqualTo("/+/publish/register");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionFilterRejectsInvalidSharedGroup() {
|
||||
assertThatThrownBy(() -> MqttClientManager.subscriptionFilter("/+/publish/register", "water/backend"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessageContaining("共享订阅组名称");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.dromara.mqtt;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.apache.ibatis.session.Configuration;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
@@ -16,16 +17,22 @@ import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.redisson.api.RLock;
|
||||
import org.redisson.api.RSet;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
@@ -42,6 +49,7 @@ class MqttCommandAckServiceTest {
|
||||
}
|
||||
applicationContext = new GenericApplicationContext();
|
||||
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
|
||||
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
}
|
||||
@@ -93,6 +101,313 @@ class MqttCommandAckServiceTest {
|
||||
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingCommandsRemovesSnapshotBeforeDeletingPendingObjects() {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
|
||||
AtomicBoolean snapshotRemoved = new AtomicBoolean();
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(snapshot);
|
||||
doAnswer(invocation -> {
|
||||
snapshotRemoved.set(true);
|
||||
return true;
|
||||
}).when(pendingIds).removeAll(snapshot);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.deleteObject(any(String.class))).thenAnswer(invocation -> {
|
||||
assertThat(snapshotRemoved).isTrue();
|
||||
return true;
|
||||
});
|
||||
|
||||
int cleared = service.clearPendingCommands();
|
||||
|
||||
assertThat(cleared).isEqualTo(2);
|
||||
assertThat(service.isStartupCleanupReady()).isTrue();
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
|
||||
verify(pendingIds).removeAll(snapshot);
|
||||
verify(pendingIds, never()).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingCommandsOpensRetryGateForEmptySet() {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
Set<String> snapshot = Set.of();
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(snapshot);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
|
||||
int cleared = service.clearPendingCommands();
|
||||
|
||||
assertThat(cleared).isZero();
|
||||
assertThat(service.isStartupCleanupReady()).isTrue();
|
||||
verify(pendingIds).removeAll(snapshot);
|
||||
verify(pendingIds, never()).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingCommandsKeepsRetryGateClosedWhenSnapshotRemovalFails() {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
Set<String> snapshot = Set.of("cmd-1");
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(snapshot);
|
||||
doThrow(new IllegalStateException("redis unavailable")).when(pendingIds).removeAll(snapshot);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
|
||||
assertThat(service.isStartupCleanupReady()).isFalse();
|
||||
assertThatThrownBy(service::clearPendingCommands)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("redis unavailable");
|
||||
|
||||
assertThat(service.isStartupCleanupReady()).isFalse();
|
||||
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
|
||||
verify(pendingIds, never()).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingCommandsContinuesDeletingObjectsAndReportsFailures() {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(snapshot);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"))
|
||||
.thenThrow(new IllegalStateException("delete failed"));
|
||||
|
||||
assertThatThrownBy(service::clearPendingCommands)
|
||||
.isInstanceOf(RuntimeException.class)
|
||||
.hasMessageContaining("cmd-1")
|
||||
.hasCauseInstanceOf(IllegalStateException.class);
|
||||
|
||||
verify(pendingIds).removeAll(snapshot);
|
||||
verify(pendingIds, never()).clear();
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
|
||||
assertThat(service.isStartupCleanupReady()).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void clearPendingCommandsByDeviceKeepsOtherDeviceCommands() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1", "cmd-2"));
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
|
||||
DeviceCommand first = new DeviceCommand();
|
||||
first.setCommandId("cmd-1");
|
||||
first.setDeviceNo("D01");
|
||||
DeviceCommand second = new DeviceCommand();
|
||||
second.setCommandId("cmd-2");
|
||||
second.setDeviceNo("D02");
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(first);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-2")).thenReturn(second);
|
||||
|
||||
service.clearPendingCommands(List.of("D01"));
|
||||
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"), never());
|
||||
verify(pendingIds).remove("cmd-1");
|
||||
verify(pendingIds, never()).remove("cmd-2");
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleAckWaitsBrieflyForCommandLock() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
RLock statusLock = mock(RLock.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
|
||||
DeviceCommand pending = new DeviceCommand();
|
||||
pending.setCommandId("cmd-1");
|
||||
pending.setDeviceNo("D01");
|
||||
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
|
||||
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||
|
||||
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
|
||||
|
||||
verify(commandLock).tryLock(3000, 30000, TimeUnit.MILLISECONDS);
|
||||
redis.verify(() -> RedisUtils.setCacheObject(
|
||||
eq("mqtt:command:ack:cmd-1"),
|
||||
any(DeviceCommandAck.class),
|
||||
eq(Duration.ofSeconds(86400))
|
||||
));
|
||||
verify(pendingIds).remove("cmd-1");
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryExpiredCommandsAdvancesRetryDisabledCommandsWithoutPublishing() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
|
||||
|
||||
DeviceCommand pending = new DeviceCommand();
|
||||
pending.setCommandId("cmd-1");
|
||||
pending.setDeviceNo("D01");
|
||||
pending.setTopic("/aa:bb:cc/subscriber/cmd");
|
||||
pending.setRetryEnabled(false);
|
||||
pending.setNextRetryAt(0);
|
||||
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||
|
||||
service.retryExpiredCommands();
|
||||
|
||||
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
|
||||
assertThat(pending.getRetryCount()).isEqualTo(1);
|
||||
assertThat(pending.getNextRetryAt()).isGreaterThan(0);
|
||||
redis.verify(() -> RedisUtils.setCacheObject(
|
||||
eq("mqtt:command:pending:cmd-1"),
|
||||
eq(pending),
|
||||
eq(Duration.ofSeconds(86400))
|
||||
));
|
||||
verify(pendingIds).add("cmd-1");
|
||||
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryExpiredCommandsDeletesRetryDisabledCommandsAtRetryLimit() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
|
||||
|
||||
DeviceCommand pending = new DeviceCommand();
|
||||
pending.setCommandId("cmd-1");
|
||||
pending.setDeviceNo("D01");
|
||||
pending.setTopic("/aa:bb:cc/subscriber/cmd");
|
||||
pending.setRetryEnabled(false);
|
||||
pending.setRetryCount(properties.getCommandAck().getMaxRetryCount());
|
||||
pending.setNextRetryAt(0);
|
||||
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||
|
||||
service.retryExpiredCommands();
|
||||
|
||||
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||
verify(pendingIds).remove("cmd-1");
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void handleAckDeletesRetryDisabledPendingCommand() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||
RLock commandLock = mock(RLock.class);
|
||||
RLock statusLock = mock(RLock.class);
|
||||
RSet<String> pendingIds = mock(RSet.class);
|
||||
|
||||
DeviceCommand pending = new DeviceCommand();
|
||||
pending.setCommandId("cmd-1");
|
||||
pending.setDeviceNo("D01");
|
||||
pending.setRetryEnabled(false);
|
||||
|
||||
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
|
||||
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
|
||||
|
||||
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||
|
||||
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
|
||||
|
||||
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||
verify(pendingIds).remove("cmd-1");
|
||||
redis.verify(() -> RedisUtils.setCacheObject(
|
||||
eq("mqtt:command:ack:cmd-1"),
|
||||
any(DeviceCommandAck.class),
|
||||
eq(Duration.ofSeconds(86400))
|
||||
));
|
||||
verify(commandLock).unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
|
||||
MqttProperties properties = new MqttProperties();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import org.dromara.mqtt.config.properties.MqttProperties;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Tag("dev")
|
||||
class MqttCommandRetryTaskTest {
|
||||
|
||||
@Test
|
||||
void retryExpiredCommandsSkipsRetryBeforeStartupCleanupIsReady() {
|
||||
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
|
||||
when(ackService.isStartupCleanupReady()).thenReturn(false);
|
||||
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
|
||||
|
||||
task.retryExpiredCommands();
|
||||
|
||||
verify(ackService, never()).retryExpiredCommands();
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryExpiredCommandsRetriesAfterStartupCleanupIsReady() {
|
||||
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
|
||||
when(ackService.isStartupCleanupReady()).thenReturn(true);
|
||||
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
|
||||
|
||||
task.retryExpiredCommands();
|
||||
|
||||
verify(ackService).retryExpiredCommands();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.dromara.mqtt;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@Tag("dev")
|
||||
class MqttPendingCommandCleanupListenerTest {
|
||||
|
||||
@Test
|
||||
void onApplicationReadyClearsPendingCommands() {
|
||||
MqttCommandAckService service = mock(MqttCommandAckService.class);
|
||||
when(service.clearPendingCommands()).thenReturn(3);
|
||||
MqttPendingCommandCleanupListener listener = new MqttPendingCommandCleanupListener(service);
|
||||
|
||||
listener.onApplicationReady(mock(ApplicationReadyEvent.class));
|
||||
|
||||
verify(service).clearPendingCommands();
|
||||
}
|
||||
|
||||
@Test
|
||||
void onApplicationReadyDoesNotThrowWhenCleanupFails() {
|
||||
MqttCommandAckService service = mock(MqttCommandAckService.class);
|
||||
when(service.clearPendingCommands()).thenThrow(new IllegalStateException("redis unavailable"));
|
||||
MqttPendingCommandCleanupListener listener = new MqttPendingCommandCleanupListener(service);
|
||||
|
||||
assertThatCode(() -> listener.onApplicationReady(mock(ApplicationReadyEvent.class)))
|
||||
.doesNotThrowAnyException();
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig implements WebMvcConfigurer {
|
||||
|
||||
private static final String APP_RETRIEVE_PASSWORD_PATH = "/app/v1/retrievePassword";
|
||||
|
||||
private final SecurityProperties securityProperties;
|
||||
@Value("${sse.path}")
|
||||
private String ssePath;
|
||||
@@ -83,6 +85,7 @@ public class SecurityConfig implements WebMvcConfigurer {
|
||||
})).addPathPatterns("/**")
|
||||
// 排除不需要拦截的路径
|
||||
.excludePathPatterns(securityProperties.getExcludes())
|
||||
.excludePathPatterns(APP_RETRIEVE_PASSWORD_PATH)
|
||||
.excludePathPatterns(ssePath);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,12 @@
|
||||
<groupId>cn.hutool</groupId>
|
||||
<artifactId>hutool-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -12,7 +12,9 @@ import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.exception.SseException;
|
||||
import org.dromara.common.core.exception.base.BaseException;
|
||||
import org.dromara.common.core.utils.MessageUtils;
|
||||
import org.dromara.common.core.utils.StreamUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.context.MessageSourceResolvable;
|
||||
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||
@@ -57,9 +59,11 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(ServiceException.class)
|
||||
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
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, e.getMessage()) : R.fail(e.getMessage());
|
||||
return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,8 +92,10 @@ public class GlobalExceptionHandler {
|
||||
*/
|
||||
@ExceptionHandler(BaseException.class)
|
||||
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
|
||||
log.error(e.getMessage());
|
||||
return R.fail(e.getMessage());
|
||||
String requestURI = request.getRequestURI();
|
||||
String message = e.getMessage();
|
||||
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
|
||||
return R.fail(message);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,7 +156,7 @@ public class GlobalExceptionHandler {
|
||||
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生未知异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,7 +166,7 @@ public class GlobalExceptionHandler {
|
||||
public R<Void> handleException(Exception e, HttpServletRequest request) {
|
||||
String requestURI = request.getRequestURI();
|
||||
log.error("请求地址'{}',发生系统异常.", requestURI, e);
|
||||
return R.fail(e.getMessage());
|
||||
return R.fail();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,4 +238,12 @@ public class GlobalExceptionHandler {
|
||||
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败:" + e.getMessage());
|
||||
}
|
||||
|
||||
private String resolveErrorMessage(String message) {
|
||||
if (StringUtils.isBlank(message)) {
|
||||
return MessageUtils.message("operation.fail");
|
||||
}
|
||||
String i18nMessage = MessageUtils.message(message);
|
||||
return message.equals(i18nMessage) ? message : i18nMessage;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.dromara.common.web.handler;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.exception.base.BaseException;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.support.GenericApplicationContext;
|
||||
import org.springframework.context.support.StaticMessageSource;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@Tag("dev")
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
private GenericApplicationContext applicationContext;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
StaticMessageSource messageSource = new StaticMessageSource();
|
||||
messageSource.addMessage("operation.fail", Locale.getDefault(), "操作失败");
|
||||
applicationContext = new GenericApplicationContext();
|
||||
applicationContext.registerBean("messageSource", StaticMessageSource.class, () -> messageSource);
|
||||
applicationContext.refresh();
|
||||
new SpringUtil().setApplicationContext(applicationContext);
|
||||
handler = new GlobalExceptionHandler();
|
||||
when(request.getRequestURI()).thenReturn("/app/v1/device/D01");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
applicationContext.close();
|
||||
}
|
||||
|
||||
@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 handleException_hidesInternalMessage() {
|
||||
R<Void> result = handler.handleException(
|
||||
new Exception("oss accessKeySecret=internal-secret"), request);
|
||||
|
||||
assertThat(result.getCode()).isEqualTo(R.FAIL);
|
||||
assertThat(result.getMsg()).doesNotContain("internal-secret");
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.dromara.app.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* OTA 固件本地存储配置。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "app.firmware-storage")
|
||||
public class FirmwareStorageProperties {
|
||||
|
||||
/** 固件文件保存目录。 */
|
||||
private Path directory = Path.of("./data/firmware");
|
||||
|
||||
/** 对设备公开的服务端基础地址;为空时根据上传请求生成。 */
|
||||
private String downloadBaseUrl;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,16 +2,23 @@ package org.dromara.app.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceImportVo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.app.listener.AppDeviceImportListener;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IDeviceCommandService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.core.utils.file.FileUtils;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.excel.core.ExcelResult;
|
||||
import org.dromara.common.excel.utils.ExcelUtil;
|
||||
import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
@@ -19,9 +26,14 @@ import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -38,6 +50,7 @@ import java.util.List;
|
||||
public class AppDeviceController extends BaseController {
|
||||
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final IDeviceCommandService deviceCommandService;
|
||||
|
||||
/**
|
||||
* 查询设备信息
|
||||
@@ -70,6 +83,27 @@ public class AppDeviceController extends BaseController {
|
||||
ExcelUtil.exportExcel(list, "设备信息 ", AppDeviceVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入设备数据
|
||||
*
|
||||
* @param file 导入文件
|
||||
*/
|
||||
@SaCheckPermission("app:device:import")
|
||||
@Log(title = "设备信息", businessType = BusinessType.IMPORT)
|
||||
@PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<Void> importData(@RequestPart("file") MultipartFile file) throws Exception {
|
||||
ExcelResult<AppDeviceImportVo> excelResult = ExcelUtil.importExcel(file.getInputStream(), AppDeviceImportVo.class, new AppDeviceImportListener());
|
||||
return R.ok(excelResult.getAnalysis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备导入模板
|
||||
*/
|
||||
@PostMapping("/importTemplate")
|
||||
public void importTemplate(HttpServletResponse response) {
|
||||
ExcelUtil.exportExcel(new ArrayList<>(), "设备数据", AppDeviceImportVo.class, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备信息
|
||||
详细信息
|
||||
@@ -122,6 +156,50 @@ public class AppDeviceController extends BaseController {
|
||||
return toAjax(appDeviceService.generateQrCode(List.of(ids)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载设备二维码 ZIP。
|
||||
*
|
||||
* @param deviceNos 逗号分隔的设备编号
|
||||
*/
|
||||
@SaCheckPermission("app:device:edit")
|
||||
@Log(title = "设备二维码", businessType = BusinessType.EXPORT)
|
||||
@PostMapping("/downloadQrCodes")
|
||||
public void downloadQrCodes(HttpServletResponse response,
|
||||
@NotEmpty(message = "设备编号不能为空") @RequestParam("deviceNos") String deviceNos) throws IOException {
|
||||
List<String> ids = Arrays.stream(deviceNos.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.toList();
|
||||
response.reset();
|
||||
FileUtils.setAttachmentResponseHeader(response, "device_qrcodes.zip");
|
||||
response.setContentType("application/zip");
|
||||
appDeviceService.downloadQrCodes(ids, response.getOutputStream());
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台解绑设备:清理用户数据并保留设备主档,支持跨用户批量操作。
|
||||
*/
|
||||
@SaCheckPermission("app:device:unbind")
|
||||
@Log(title = "设备解绑", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PostMapping("/unbind/{deviceNos}")
|
||||
public R<Void> unbind(@NotEmpty(message = "设备编号不能为空")
|
||||
@PathVariable String[] deviceNos) {
|
||||
return toAjax(appDeviceService.unbindDevicesByAdmin(List.of(deviceNos)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台向设备下发恢复出厂命令,不修改服务端设备关系数据。
|
||||
*/
|
||||
@SaCheckPermission("app:device:edit")
|
||||
@Log(title = "设备恢复出厂", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PostMapping("/factory-reset/{deviceNo}")
|
||||
public R<String> factoryReset(@NotBlank(message = "设备编号不能为空")
|
||||
@PathVariable String deviceNo) {
|
||||
return R.ok(deviceCommandService.sendFactoryResetCommand(deviceNo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除设备信息
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.dromara.app.controller;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.annotation.SaIgnore;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.dromara.app.domain.bo.AppFirmwareBo;
|
||||
import org.dromara.app.domain.vo.AppFirmwareVo;
|
||||
import org.dromara.app.service.IAppFirmwareService;
|
||||
import org.dromara.common.core.domain.R;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.idempotent.annotation.RepeatSubmit;
|
||||
import org.dromara.common.log.annotation.Log;
|
||||
import org.dromara.common.log.enums.BusinessType;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
import org.dromara.common.web.core.BaseController;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备固件版本
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/app/firmware")
|
||||
public class AppFirmwareController extends BaseController {
|
||||
|
||||
private final IAppFirmwareService appFirmwareService;
|
||||
|
||||
/**
|
||||
* 查询固件版本列表
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:list")
|
||||
@GetMapping("/list")
|
||||
public TableDataInfo<AppFirmwareVo> list(AppFirmwareBo bo, PageQuery pageQuery) {
|
||||
return appFirmwareService.queryPageList(bo, pageQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传固件 bin 文件并保存固件版本
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:add")
|
||||
@Log(title = "固件版本", businessType = BusinessType.INSERT)
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public R<AppFirmwareVo> upload(@RequestPart("file") MultipartFile file, AppFirmwareBo bo) {
|
||||
return R.ok(appFirmwareService.uploadFirmware(file, bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 供设备下载服务器本地保存的固件。
|
||||
*/
|
||||
@SaIgnore
|
||||
@GetMapping(value = "/download/{storedName:.+}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
|
||||
public ResponseEntity<Resource> download(@PathVariable String storedName) throws Exception {
|
||||
Resource resource = appFirmwareService.loadFirmware(storedName);
|
||||
return ResponseEntity.ok()
|
||||
.contentLength(resource.contentLength())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(storedName).build().toString())
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取固件版本详情
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:query")
|
||||
@GetMapping("/{id}")
|
||||
public R<AppFirmwareVo> getInfo(@NotNull(message = "主键不能为空")
|
||||
@PathVariable Long id) {
|
||||
return R.ok(appFirmwareService.queryById(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改固件版本
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:edit")
|
||||
@Log(title = "固件版本", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PutMapping()
|
||||
public R<Void> edit(@Validated(EditGroup.class) @RequestBody AppFirmwareBo bo) {
|
||||
return toAjax(appFirmwareService.updateByBo(bo));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除固件版本
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:remove")
|
||||
@Log(title = "固件版本", businessType = BusinessType.DELETE)
|
||||
@DeleteMapping("/{ids}")
|
||||
public R<Void> remove(@NotEmpty(message = "主键不能为空")
|
||||
@PathVariable Long[] ids) {
|
||||
return toAjax(appFirmwareService.deleteWithValidByIds(List.of(ids), true));
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择设备升级
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:upgrade")
|
||||
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PostMapping("/upgrade")
|
||||
public R<Integer> upgrade(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId,
|
||||
@NotEmpty(message = "设备编号不能为空") @RequestParam String deviceNos) {
|
||||
List<String> nos = parseDeviceNos(deviceNos);
|
||||
return R.ok(appFirmwareService.upgrade(firmwareId, nos));
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部在线设备升级
|
||||
*/
|
||||
@SaCheckPermission("app:firmware:upgrade")
|
||||
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
|
||||
@RepeatSubmit()
|
||||
@PostMapping("/upgradeAll")
|
||||
public R<Integer> upgradeAll(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId) {
|
||||
return R.ok(appFirmwareService.upgradeAll(firmwareId));
|
||||
}
|
||||
|
||||
private List<String> parseDeviceNos(String deviceNos) {
|
||||
return Arrays.stream(deviceNos.split(","))
|
||||
.map(String::trim)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class AppDevice extends BaseEntity {
|
||||
private Long userId;
|
||||
|
||||
private String deviceName;
|
||||
|
||||
private String deviceInitName;
|
||||
|
||||
private String deviceImg;
|
||||
@@ -35,6 +36,8 @@ public class AppDevice extends BaseEntity {
|
||||
|
||||
private String powerLevel;
|
||||
|
||||
private String powerStatus;
|
||||
|
||||
private Date powerLevelUpdatatime;
|
||||
|
||||
private String wifiName;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("app_device_binding")
|
||||
public class AppDeviceBinding extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId("id")
|
||||
private Long id;
|
||||
private String deviceNo;
|
||||
private Long userId;
|
||||
private String bindingId;
|
||||
private String bindingStatus;
|
||||
private Date bindingStartedTime;
|
||||
private Date lastBindCommandTime;
|
||||
private String deviceNameSnapshot;
|
||||
private Date unboundTime;
|
||||
private String unbindSource;
|
||||
private String unbindCommandId;
|
||||
private Integer cleanupAttempts;
|
||||
private String cleanupError;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("app_device_unbind_command")
|
||||
public class AppDeviceUnbindCommand extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId("id")
|
||||
private Long id;
|
||||
private String deviceNo;
|
||||
private String commandId;
|
||||
private String bindingId;
|
||||
private String commandStatus;
|
||||
private String message;
|
||||
private Integer attemptCount;
|
||||
private Date completedTime;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* 设备固件版本对象 app_firmware
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("app_firmware")
|
||||
public class AppFirmware extends BaseEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@TableId(value = "id")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 固件版本号
|
||||
*/
|
||||
private String firmwareVersion;
|
||||
|
||||
/**
|
||||
* 原始文件名
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 固件下载地址
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* MD5校验值
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
private String releaseNotes;
|
||||
|
||||
/**
|
||||
* 状态 0停用 1启用
|
||||
*/
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
public final class DeviceBindingStatus {
|
||||
|
||||
public static final String BINDING = "BINDING";
|
||||
public static final String ACTIVE = "ACTIVE";
|
||||
public static final String BIND_FAILED = "BIND_FAILED";
|
||||
public static final String UNBINDING = "UNBINDING";
|
||||
public static final String UNBOUND = "UNBOUND";
|
||||
|
||||
public static final Set<String> EXCLUSIVE = Set.of(BINDING, ACTIVE, UNBINDING);
|
||||
|
||||
private DeviceBindingStatus() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
public final class DeviceUnbindCommandStatus {
|
||||
|
||||
public static final String PROCESSING = "PROCESSING";
|
||||
public static final String APPLIED = "APPLIED";
|
||||
public static final String STALE = "STALE";
|
||||
public static final String FAILED = "FAILED";
|
||||
|
||||
private DeviceUnbindCommandStatus() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.dromara.app.domain;
|
||||
|
||||
public final class DeviceUnbindSource {
|
||||
|
||||
public static final String DEVICE = "DEVICE";
|
||||
public static final String APP = "APP";
|
||||
public static final String ADMIN = "ADMIN";
|
||||
|
||||
private DeviceUnbindSource() {
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ public class AppDeviceBo extends BaseEntity {
|
||||
|
||||
private Long userId;
|
||||
|
||||
/** 用户设备关系状态。 */
|
||||
private String bindingStatus;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
@@ -52,6 +55,10 @@ public class AppDeviceBo extends BaseEntity {
|
||||
* 电量
|
||||
*/
|
||||
private String powerLevel;
|
||||
/**
|
||||
* 电量状态 0-正常 1-充电中
|
||||
*/
|
||||
private String powerStatus;
|
||||
|
||||
/**
|
||||
* 电量更新时间
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.dromara.app.domain.bo;
|
||||
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.dromara.app.domain.AppFirmware;
|
||||
import org.dromara.common.core.validate.AddGroup;
|
||||
import org.dromara.common.core.validate.EditGroup;
|
||||
import org.dromara.common.mybatis.core.domain.BaseEntity;
|
||||
|
||||
/**
|
||||
* 设备固件版本业务对象 app_firmware
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@AutoMapper(target = AppFirmware.class, reverseConvertGenerate = false)
|
||||
public class AppFirmwareBo extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@NotNull(message = "主键不能为空", groups = { EditGroup.class })
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 固件版本号
|
||||
*/
|
||||
@NotBlank(message = "固件版本号不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private String firmwareVersion;
|
||||
|
||||
/**
|
||||
* 原始文件名
|
||||
*/
|
||||
private String fileName;
|
||||
|
||||
/**
|
||||
* 固件下载地址
|
||||
*/
|
||||
private String fileUrl;
|
||||
|
||||
/**
|
||||
* 文件大小(字节)
|
||||
*/
|
||||
private Long fileSize;
|
||||
|
||||
/**
|
||||
* MD5校验值
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* 更新说明
|
||||
*/
|
||||
private String releaseNotes;
|
||||
|
||||
/**
|
||||
* 状态 0停用 1启用
|
||||
*/
|
||||
@NotBlank(message = "状态不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.dromara.app.domain.bo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.Length;
|
||||
|
||||
/**
|
||||
* APP 忘记密码请求。
|
||||
*/
|
||||
@Data
|
||||
public class AppForgotPasswordBo {
|
||||
|
||||
@JsonAlias("userName")
|
||||
@NotBlank(message = "账号不能为空")
|
||||
@Length(min = 2, max = 64, message = "账号长度必须在2到64个字符之间")
|
||||
private String username;
|
||||
|
||||
@JsonAlias("smsCode")
|
||||
@NotBlank(message = "验证码不能为空")
|
||||
private String code;
|
||||
|
||||
@NotBlank(message = "新密码不能为空")
|
||||
@Length(min = 5, max = 30, message = "密码长度必须在5到30个字符之间")
|
||||
private String password;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class DeviceCommand implements Serializable {
|
||||
private String commandType;
|
||||
private String topic;
|
||||
private Map<String, Object> payload = new HashMap<>();
|
||||
private Boolean retryEnabled = Boolean.TRUE;
|
||||
private int retryCount;
|
||||
private long createdAt;
|
||||
private long lastSentAt;
|
||||
|
||||
@@ -13,6 +13,10 @@ public class DeviceCommandAck implements Serializable {
|
||||
|
||||
private String commandId;
|
||||
private String deviceNo;
|
||||
private String bindingId;
|
||||
private String status;
|
||||
private String message;
|
||||
private String powerLevel;
|
||||
private String charging;
|
||||
private String ack;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.dromara.app.domain.mqtt;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class DeviceUnbindRequest {
|
||||
private String commandId;
|
||||
private String bindingId;
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.dromara.app.domain.mqtt;
|
||||
|
||||
public record DeviceUnbindResult(
|
||||
String deviceNo,
|
||||
String commandId,
|
||||
String bindingId,
|
||||
String status,
|
||||
String message
|
||||
) {
|
||||
public static final String APPLIED = "applied";
|
||||
public static final String ALREADY_APPLIED = "already_applied";
|
||||
public static final String STALE_BINDING = "stale_binding";
|
||||
public static final String FAILED = "failed";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 设备导入对象
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Data
|
||||
public class AppDeviceImportVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* MAC地址
|
||||
*/
|
||||
@NotBlank(message = "MAC地址不能为空")
|
||||
@ExcelProperty(value = "MAC地址")
|
||||
private String macAddress;
|
||||
|
||||
/**
|
||||
* 设备名称
|
||||
*/
|
||||
@ExcelProperty(value = "设备名称")
|
||||
private String deviceName;
|
||||
|
||||
/**
|
||||
* 设备型号
|
||||
*/
|
||||
@ExcelProperty(value = "设备型号")
|
||||
private String deviceEm;
|
||||
|
||||
/**
|
||||
* 设备序列号
|
||||
*/
|
||||
@ExcelProperty(value = "设备序列号")
|
||||
private String deviceSn;
|
||||
}
|
||||
@@ -24,6 +24,12 @@ public class AppDeviceVo implements Serializable {
|
||||
@ExcelProperty(value = "用户id")
|
||||
private Long userId;
|
||||
|
||||
@ExcelProperty(value = "绑定状态")
|
||||
private String bindingStatus;
|
||||
|
||||
@ExcelProperty(value = "解绑时间")
|
||||
private Date unboundTime;
|
||||
|
||||
@ExcelProperty(value = "设备名称")
|
||||
private String deviceName;
|
||||
private String deviceInitName;
|
||||
@@ -37,11 +43,15 @@ public class AppDeviceVo implements Serializable {
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
@ExcelProperty(value = "工作状态")
|
||||
private String workStatus;
|
||||
|
||||
@ExcelProperty(value = "电量")
|
||||
private String powerLevel;
|
||||
|
||||
@ExcelProperty(value = "电量状态")
|
||||
private String powerStatus;
|
||||
|
||||
@ExcelProperty(value = "电量更新时间")
|
||||
private Date powerLevelUpdatatime;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import lombok.Data;
|
||||
import org.dromara.app.domain.AppFirmware;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 设备固件版本视图对象 app_firmware
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Data
|
||||
@ExcelIgnoreUnannotated
|
||||
@AutoMapper(target = AppFirmware.class)
|
||||
public class AppFirmwareVo implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long id;
|
||||
|
||||
@ExcelProperty(value = "固件版本号")
|
||||
private String firmwareVersion;
|
||||
|
||||
@ExcelProperty(value = "文件名")
|
||||
private String fileName;
|
||||
|
||||
@ExcelProperty(value = "下载地址")
|
||||
private String fileUrl;
|
||||
|
||||
@ExcelProperty(value = "文件大小")
|
||||
private Long fileSize;
|
||||
|
||||
@ExcelProperty(value = "MD5")
|
||||
private String md5;
|
||||
|
||||
@ExcelProperty(value = "更新说明")
|
||||
private String releaseNotes;
|
||||
|
||||
@ExcelProperty(value = "状态")
|
||||
private String status;
|
||||
|
||||
@ExcelProperty(value = "创建时间")
|
||||
private Date createTime;
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
package org.dromara.app.domain.vo;
|
||||
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
|
||||
import cn.idev.excel.annotation.ExcelProperty;
|
||||
import io.github.linpeilie.annotations.AutoMapper;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
import org.dromara.app.domain.AppSchedule;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/**
|
||||
@@ -47,7 +49,7 @@ public class AppScheduleVo implements Serializable {
|
||||
/**
|
||||
* 设备ids
|
||||
*/
|
||||
private List deviceNos;
|
||||
private List<AppDeviceVo> deviceNos;
|
||||
/**
|
||||
* 状态 0-关闭 1 开启
|
||||
*/
|
||||
@@ -55,7 +57,7 @@ public class AppScheduleVo implements Serializable {
|
||||
private String status;
|
||||
|
||||
@NotEmpty(message = "至少需要配置一天")
|
||||
private List<AppScheduleDetailVo> details;
|
||||
private List<Map<String, Object>> details;
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
@@ -13,7 +17,7 @@ import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power
|
||||
* 设备电量及在线心跳处理器,匹配 /{deviceNo}/publish/power。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -23,6 +27,10 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
private final MqttDeviceStatusService deviceStatusService;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -30,6 +38,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
@@ -40,20 +49,28 @@ public class DeviceDataHandler implements MqttTopicHandler {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
|
||||
log.debug("[MQTT] 忽略非当前绑定设备电量上报 设备编号={}", deviceNo);
|
||||
return;
|
||||
}
|
||||
if (dto == null || dto.get("powerLevel") == null) {
|
||||
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
return;
|
||||
}
|
||||
// 更新设备电量 + 同步在线状态到数据库
|
||||
// 有效电量上报同时作为设备在线心跳。
|
||||
AppDeviceBo appDeviceBo = new AppDeviceBo();
|
||||
appDeviceBo.setDeviceNo(deviceNo);
|
||||
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
|
||||
appDeviceBo.setPowerStatus(dto.get("charging") == null ? null : dto.get("charging").toString());
|
||||
appDeviceBo.setPowerLevelUpdatatime(new Date());
|
||||
appDeviceService.updateByBo(appDeviceBo);
|
||||
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
deviceStatusService.markOnline(deviceNo);
|
||||
log.info("[MQTT] 设备电量更新并刷新在线心跳 时间={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
private final MqttDeviceStatusService deviceStatusService;
|
||||
private final DeviceRegisterReplyGuard registerReplyGuard;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -60,8 +62,9 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
}
|
||||
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
|
||||
appDeviceService.registerByMqtt(device);
|
||||
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
|
||||
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}",
|
||||
deviceStatusService.markRegisteredOnline(deviceNo);
|
||||
sendDeviceNoOnce(deviceNo, normalizedDeviceMac);
|
||||
log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e);
|
||||
@@ -102,17 +105,28 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
return text.isBlank() ? null : text.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void sendDeviceNoToDevice(String deviceNo, String deviceMac) {
|
||||
private void sendDeviceNoOnce(String deviceNo, String deviceMac) {
|
||||
if (!registerReplyGuard.tryAcquire(deviceNo)) {
|
||||
log.info("[MQTT] 忽略重复设备编号下发 时间={} MAC={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo);
|
||||
return;
|
||||
}
|
||||
if (!sendDeviceNoToDevice(deviceNo, deviceMac)) {
|
||||
registerReplyGuard.release(deviceNo);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean sendDeviceNoToDevice(String deviceNo, String deviceMac) {
|
||||
IDeviceCommandPublisher commandPublisher = commandPublisherProvider.getIfAvailable();
|
||||
if (commandPublisher == null) {
|
||||
log.warn("[MQTT] 设备编号下发失败,未找到命令发布器 时间={} MAC={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (deviceMac == null) {
|
||||
log.warn("[MQTT] 设备编号下发失败,缺少设备 MAC 时间={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceNo);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -121,15 +135,18 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
||||
command.setDeviceMac(deviceMac);
|
||||
command.setCommandType("registerDeviceNo");
|
||||
command.setTopic("/" + deviceMac.trim().toLowerCase(Locale.ROOT) + "/subscriber/cmd");
|
||||
command.setRetryEnabled(false);
|
||||
command.getPayload().put("deviceNo", deviceNo);
|
||||
command.getPayload().put("deviceMac", deviceMac);
|
||||
|
||||
String commandId = commandPublisher.send(command);
|
||||
log.info("[MQTT] 设备编号已下发 时间={} MAC={} 设备编号={} 命令编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo, commandId);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备编号下发失败 时间={} MAC={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceMac, deviceNo, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import org.redisson.api.RBucket;
|
||||
import org.redisson.api.RedissonClient;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Prevents repeated register messages from producing duplicate device-number replies.
|
||||
*/
|
||||
@Component
|
||||
class DeviceRegisterReplyGuard {
|
||||
|
||||
private static final String KEY_PREFIX = "mqtt:register-device-no:sent:";
|
||||
|
||||
private final RedissonClient redissonClient;
|
||||
private final Duration dedupWindow;
|
||||
|
||||
DeviceRegisterReplyGuard(
|
||||
RedissonClient redissonClient,
|
||||
@Value("${mqtt.register-device-no-dedup-seconds:30}") long dedupSeconds
|
||||
) {
|
||||
this.redissonClient = redissonClient;
|
||||
this.dedupWindow = Duration.ofSeconds(Math.max(1L, dedupSeconds));
|
||||
}
|
||||
|
||||
boolean tryAcquire(String deviceNo) {
|
||||
RBucket<String> bucket = redissonClient.getBucket(key(deviceNo));
|
||||
return bucket.setIfAbsent("sent", dedupWindow);
|
||||
}
|
||||
|
||||
void release(String deviceNo) {
|
||||
redissonClient.getBucket(key(deviceNo)).delete();
|
||||
}
|
||||
|
||||
private String key(String deviceNo) {
|
||||
return KEY_PREFIX + deviceNo;
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,10 @@ import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
|
||||
* 设备遗嘱离线状态处理器,匹配 /{deviceIdentity}/publish/status。
|
||||
* <p>
|
||||
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
|
||||
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
|
||||
* 在线状态由电量上报刷新;本处理器仅通过 LWT status=offline 标记异常离线。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -42,17 +42,18 @@ public class DeviceStatusHandler implements MqttTopicHandler {
|
||||
|
||||
@Override
|
||||
public void handle(String deviceIdentity, String payload, boolean retained) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
Map<String, Object> body = parsePayload(payload);
|
||||
String deviceNo = resolveDeviceNo(deviceIdentity, body);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
String status = parseStatus(payload);
|
||||
String status = parseStatus(payload, body);
|
||||
if ("online".equals(status) || "1".equals(status)) {
|
||||
deviceStatusService.markOnline(deviceNo);
|
||||
log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
|
||||
log.debug("[MQTT] 忽略设备主动在线状态,在线状态由电量心跳维护 时间={} 设备编号={} 消息体={}",
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
return;
|
||||
}
|
||||
if ("offline".equals(status) || "0".equals(status)) {
|
||||
@@ -69,22 +70,52 @@ public class DeviceStatusHandler implements MqttTopicHandler {
|
||||
HandlerLogTime.now(), deviceNo, payload);
|
||||
}
|
||||
|
||||
private String parseStatus(String payload) {
|
||||
private String resolveDeviceNo(String deviceIdentity, Map<String, Object> body) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
if (deviceNo != null || body == null) {
|
||||
return deviceNo;
|
||||
}
|
||||
Object deviceMac = body.get("deviceMac");
|
||||
if (deviceMac == null || StringUtils.isBlank(String.valueOf(deviceMac))) {
|
||||
return null;
|
||||
}
|
||||
return deviceIdentityResolver.resolveDeviceNo(String.valueOf(deviceMac));
|
||||
}
|
||||
|
||||
private Map<String, Object> parsePayload(String payload) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = payload.trim();
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return trimmed.toLowerCase(Locale.ROOT);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> body = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
|
||||
return objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Object status = body.get("status");
|
||||
return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT);
|
||||
} catch (Exception e) {
|
||||
log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String parseStatus(String payload, Map<String, Object> body) {
|
||||
if (body == null) {
|
||||
if (StringUtils.isBlank(payload)) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = payload.trim();
|
||||
return trimmed.startsWith("{") ? null : trimmed.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
Object status = body.get("status");
|
||||
if (status != null) {
|
||||
return String.valueOf(status).trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
Object offline = body.get("offline");
|
||||
if (offline != null && "true".equalsIgnoreCase(String.valueOf(offline).trim())) {
|
||||
return "offline";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IDeviceUnbindResultPublisher;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DeviceUnbindHandler implements MqttTopicHandler {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/unbind$");
|
||||
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
private final IAppDeviceBindingService bindingService;
|
||||
private final IDeviceUnbindResultPublisher resultPublisher;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
return PATTERN;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
DeviceUnbindRequest request = null;
|
||||
try {
|
||||
request = JsonUtils.parseObject(payload, DeviceUnbindRequest.class);
|
||||
if (deviceNo == null) {
|
||||
log.warn("[MQTT] 设备解绑未找到设备 设备标识={} 消息体={}", deviceIdentity, payload);
|
||||
return;
|
||||
}
|
||||
DeviceUnbindResult result = bindingService.unbindByDevice(deviceNo, request);
|
||||
resultPublisher.publish(result);
|
||||
log.info("[MQTT] 设备解绑已处理 设备编号={} 命令编号={} 结果={}",
|
||||
deviceNo, result.commandId(), result.status());
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[MQTT] 设备解绑处理失败 设备标识={} 设备编号={} 消息体={}",
|
||||
deviceIdentity, deviceNo, payload, e);
|
||||
if (deviceNo != null && request != null && request.getCommandId() != null) {
|
||||
try {
|
||||
resultPublisher.publish(new DeviceUnbindResult(
|
||||
deviceNo, request.getCommandId(), request.getBindingId(),
|
||||
DeviceUnbindResult.FAILED, e.getMessage()));
|
||||
} catch (RuntimeException publishFailure) {
|
||||
log.warn("[MQTT] 设备解绑失败回执发送失败 设备编号={} 命令编号={}",
|
||||
deviceNo, request.getCommandId(), publishFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -19,6 +23,9 @@ public class ErromesHandler implements MqttTopicHandler {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/error$");
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -26,6 +33,7 @@ public class ErromesHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
|
||||
try {
|
||||
@@ -35,10 +43,15 @@ public class ErromesHandler implements MqttTopicHandler {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
|
||||
log.debug("[MQTT] 忽略非当前绑定设备异常上报 设备编号={}", deviceNo);
|
||||
return;
|
||||
}
|
||||
log.info("[MQTT] 设备异常告警 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, dto);
|
||||
} catch (Exception e) {
|
||||
log.error("[MQTT] 设备异常处理失败 时间={} 设备标识={} 设备编号={}",
|
||||
HandlerLogTime.now(), deviceIdentity, deviceNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
@@ -36,6 +39,9 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -43,6 +49,7 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
@@ -53,6 +60,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
|
||||
log.debug("[MQTT] 忽略非当前绑定设备按键完成上报 设备编号={}", deviceNo);
|
||||
return;
|
||||
}
|
||||
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
|
||||
appWateringLogService.confirmScheduleLog(logBo);
|
||||
updateDeviceWorkStatusIdle(deviceNo);
|
||||
@@ -106,10 +117,10 @@ public class KeyFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
private Long queryUserId(String deviceNo) {
|
||||
AppDeviceVo device = appDeviceService.queryById(deviceNo);
|
||||
return device == null ? null : device.getUserId();
|
||||
return deviceBindingService.queryActiveUserId(deviceNo);
|
||||
}
|
||||
|
||||
|
||||
private Long queryScheduleId(String deviceNo) {
|
||||
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 校验 MQTT 上报是否仍属于设备当前的 ACTIVE 绑定关系。
|
||||
*/
|
||||
final class MqttBindingReportGuard {
|
||||
|
||||
private MqttBindingReportGuard() {
|
||||
}
|
||||
|
||||
static boolean accepts(IAppDeviceBindingService bindingService,
|
||||
String deviceNo,
|
||||
Map<String, Object> payload) {
|
||||
if (!bindingService.isActive(deviceNo)) {
|
||||
return false;
|
||||
}
|
||||
String bindingId = payload == null || payload.get("bindingId") == null
|
||||
? null : String.valueOf(payload.get("bindingId"));
|
||||
return StringUtils.isNotBlank(bindingId)
|
||||
&& bindingService.isCurrentBinding(deviceNo, bindingId);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.mapper.AppDeviceMapper;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.redisson.api.RLock;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -28,17 +32,33 @@ public class MqttDeviceStatusService {
|
||||
|
||||
private final AppDeviceMapper appDeviceMapper;
|
||||
private final IAppWateringLogService wateringLogService;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-prefix}")
|
||||
private String deviceStatusCachePrefix;
|
||||
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}")
|
||||
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
|
||||
private long deviceStatusCacheTtlSeconds;
|
||||
|
||||
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void markOnline(String deviceNo) {
|
||||
if (StringUtils.isBlank(deviceNo) || !isActive(deviceNo)) {
|
||||
return;
|
||||
}
|
||||
markOnlineInternal(deviceNo);
|
||||
}
|
||||
|
||||
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void markRegisteredOnline(String deviceNo) {
|
||||
if (StringUtils.isBlank(deviceNo)) {
|
||||
return;
|
||||
}
|
||||
markOnlineInternal(deviceNo);
|
||||
}
|
||||
|
||||
private void markOnlineInternal(String deviceNo) {
|
||||
withDeviceStatusLock(deviceNo, () -> {
|
||||
Map<String, Object> statusCache = new HashMap<>();
|
||||
statusCache.put("deviceNo", deviceNo);
|
||||
@@ -58,6 +78,7 @@ public class MqttDeviceStatusService {
|
||||
});
|
||||
}
|
||||
|
||||
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void markOffline(String deviceNo, String reason) {
|
||||
if (StringUtils.isBlank(deviceNo)) {
|
||||
return;
|
||||
@@ -65,6 +86,7 @@ public class MqttDeviceStatusService {
|
||||
markOfflineWithLock(deviceNo, reason, false);
|
||||
}
|
||||
|
||||
@Lock4j(keys = {"'device:command:' + #deviceNo"}, acquireTimeout = 5000, expire = 30000)
|
||||
public boolean markOfflineIfCacheMissing(String deviceNo, String reason) {
|
||||
if (StringUtils.isBlank(deviceNo)) {
|
||||
return false;
|
||||
@@ -73,6 +95,9 @@ public class MqttDeviceStatusService {
|
||||
}
|
||||
|
||||
private boolean markOfflineWithLock(String deviceNo, String reason, boolean skipWhenCacheExists) {
|
||||
if (!isActive(deviceNo)) {
|
||||
return false;
|
||||
}
|
||||
return withDeviceStatusLock(deviceNo, () -> {
|
||||
if (skipWhenCacheExists && RedisUtils.hasKey(deviceStatusCachePrefix + deviceNo)) {
|
||||
return false;
|
||||
@@ -95,6 +120,10 @@ public class MqttDeviceStatusService {
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isActive(String deviceNo) {
|
||||
return deviceBindingService.isActive(deviceNo);
|
||||
}
|
||||
|
||||
private boolean withDeviceStatusLock(String deviceNo, java.util.function.BooleanSupplier action) {
|
||||
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
|
||||
boolean locked = false;
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
@@ -36,6 +39,9 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -43,6 +49,7 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
@@ -53,6 +60,10 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
|
||||
log.debug("[MQTT] 忽略非当前绑定设备排程完成上报 设备编号={}", deviceNo);
|
||||
return;
|
||||
}
|
||||
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
|
||||
appWateringLogService.confirmScheduleLog(logBo);
|
||||
updateDeviceWorkStatusIdle(deviceNo);
|
||||
@@ -106,10 +117,10 @@ public class ScheduleFinishHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
private Long queryUserId(String deviceNo) {
|
||||
AppDeviceVo device = appDeviceService.queryById(deviceNo);
|
||||
return device == null ? null : device.getUserId();
|
||||
return deviceBindingService.queryActiveUserId(deviceNo);
|
||||
}
|
||||
|
||||
|
||||
private Long queryScheduleId(String deviceNo) {
|
||||
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
package org.dromara.app.handler;
|
||||
|
||||
import com.baomidou.lock.annotation.Lock4j;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.AppSchedulingDevice;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.AppWateringLogVo;
|
||||
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
|
||||
import org.dromara.app.mqtt.MqttTopicHandler;
|
||||
import org.dromara.app.service.IAppDeviceBindingService;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.app.service.IAppWateringLogService;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.text.ParseException;
|
||||
@@ -35,6 +38,9 @@ public class StartWaterHandler implements MqttTopicHandler {
|
||||
private final IAppDeviceService appDeviceService;
|
||||
private final AppSchedulingDeviceMapper schedulingDeviceMapper;
|
||||
private final DeviceIdentityResolver deviceIdentityResolver;
|
||||
@Lazy
|
||||
@Autowired
|
||||
private IAppDeviceBindingService deviceBindingService;
|
||||
|
||||
@Override
|
||||
public Pattern topicPattern() {
|
||||
@@ -42,6 +48,7 @@ public class StartWaterHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
@Override
|
||||
@Lock4j(keys = {"'device:command:' + #deviceIdentity"}, acquireTimeout = 5000, expire = 30000)
|
||||
public void handle(String deviceIdentity, String payload) {
|
||||
String deviceNo = null;
|
||||
try {
|
||||
@@ -52,6 +59,10 @@ public class StartWaterHandler implements MqttTopicHandler {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> dto = JsonUtils.parseObject(payload, Map.class);
|
||||
if (!MqttBindingReportGuard.accepts(deviceBindingService, deviceNo, dto)) {
|
||||
log.debug("[MQTT] 忽略非当前绑定设备开始浇水上报 设备编号={}", deviceNo);
|
||||
return;
|
||||
}
|
||||
AppWateringLogBo logBo = buildWateringLog(deviceNo, dto);
|
||||
if (hasDuplicateRunningLog(logBo)) {
|
||||
updateDeviceWorkStatusWorking(deviceNo);
|
||||
@@ -177,10 +188,10 @@ public class StartWaterHandler implements MqttTopicHandler {
|
||||
}
|
||||
|
||||
private Long queryUserId(String deviceNo) {
|
||||
AppDeviceVo device = appDeviceService.queryById(deviceNo);
|
||||
return device == null ? null : device.getUserId();
|
||||
return deviceBindingService.queryActiveUserId(deviceNo);
|
||||
}
|
||||
|
||||
|
||||
private Long queryScheduleId(String deviceNo) {
|
||||
List<AppSchedulingDevice> schedulingDevices = schedulingDeviceMapper.selectList(
|
||||
Wrappers.<AppSchedulingDevice>lambdaQuery()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.dromara.app.listener;
|
||||
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.idev.excel.context.AnalysisContext;
|
||||
import cn.idev.excel.event.AnalysisEventListener;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceImportVo;
|
||||
import org.dromara.app.service.IAppDeviceService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.utils.SpringUtils;
|
||||
import org.dromara.common.excel.core.ExcelListener;
|
||||
import org.dromara.common.excel.core.ExcelResult;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 设备导入监听,逐条新增设备并收集成功/失败统计。
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
@Slf4j
|
||||
public class AppDeviceImportListener extends AnalysisEventListener<AppDeviceImportVo> implements ExcelListener<AppDeviceImportVo> {
|
||||
|
||||
private final IAppDeviceService deviceService;
|
||||
private final StringBuilder successMsg = new StringBuilder();
|
||||
private final StringBuilder failureMsg = new StringBuilder();
|
||||
private int successNum = 0;
|
||||
private int failureNum = 0;
|
||||
|
||||
public AppDeviceImportListener() {
|
||||
this.deviceService = SpringUtils.getBean(IAppDeviceService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void invoke(AppDeviceImportVo importVo, AnalysisContext context) {
|
||||
try {
|
||||
AppDeviceBo bo = BeanUtil.toBean(importVo, AppDeviceBo.class);
|
||||
bo.setStatus("0");
|
||||
bo.setWorkStatus("2");
|
||||
deviceService.insertByBo(bo);
|
||||
successNum++;
|
||||
successMsg.append("<br/>").append(successNum).append("、设备 ").append(importVo.getMacAddress()).append(" 导入成功");
|
||||
} catch (Exception e) {
|
||||
failureNum++;
|
||||
String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage();
|
||||
failureMsg.append("<br/>").append(failureNum).append("、设备 ").append(importVo.getMacAddress()).append(" 导入失败:").append(msg);
|
||||
log.warn("[设备导入] 设备导入失败 MAC={} 原因={}", importVo.getMacAddress(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||
// 无需额外处理
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExcelResult<AppDeviceImportVo> getExcelResult() {
|
||||
return new ExcelResult<>() {
|
||||
@Override
|
||||
public List<AppDeviceImportVo> getList() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getErrorList() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAnalysis() {
|
||||
if (failureNum > 0) {
|
||||
failureMsg.insert(0, "导入失败!共 " + failureNum + " 条数据失败,错误如下:");
|
||||
throw new ServiceException(failureMsg.toString());
|
||||
}
|
||||
successMsg.insert(0, "恭喜您,数据已全部导入成功!共 " + successNum + " 条,数据如下:");
|
||||
return successMsg.toString();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package org.dromara.app.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.AppDeviceBinding;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface AppDeviceBindingMapper extends BaseMapperPlus<AppDeviceBinding, AppDeviceBinding> {
|
||||
|
||||
@Select("""
|
||||
select * from app_device_binding
|
||||
where device_no = #{deviceNo}
|
||||
and binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
|
||||
limit 1
|
||||
""")
|
||||
AppDeviceBinding selectExclusiveByDeviceNo(@Param("deviceNo") String deviceNo);
|
||||
|
||||
@Select("""
|
||||
select * from app_device_binding
|
||||
where device_no = #{deviceNo} and user_id = #{userId}
|
||||
limit 1
|
||||
""")
|
||||
AppDeviceBinding selectByUserAndDevice(@Param("userId") Long userId,
|
||||
@Param("deviceNo") String deviceNo);
|
||||
|
||||
@Select("""
|
||||
select * from app_device_binding
|
||||
where device_no = #{deviceNo} and binding_id = #{bindingId}
|
||||
limit 1
|
||||
""")
|
||||
AppDeviceBinding selectByDeviceAndBindingId(@Param("deviceNo") String deviceNo,
|
||||
@Param("bindingId") String bindingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
select
|
||||
d.device_no,
|
||||
b.user_id,
|
||||
case when b.binding_status = 'ACTIVE' then d.device_name else b.device_name_snapshot end as device_name,
|
||||
case when b.binding_status = 'ACTIVE' then d.device_init_name else null end as device_init_name,
|
||||
case when b.binding_status = 'ACTIVE' then d.device_img else null end as device_img,
|
||||
case when b.binding_status = 'ACTIVE' then d.qrcode else null end as qrcode,
|
||||
case when b.binding_status = 'ACTIVE' then d.status else null end as status,
|
||||
case when b.binding_status = 'ACTIVE' then d.work_status else null end as work_status,
|
||||
case when b.binding_status = 'ACTIVE' then d.power_level else null end as power_level,
|
||||
case when b.binding_status = 'ACTIVE' then d.power_status else null end as power_status,
|
||||
case when b.binding_status = 'ACTIVE' then d.power_level_updatatime else null end as power_level_updatatime,
|
||||
case when b.binding_status = 'ACTIVE' then d.wifi_name else null end as wifi_name,
|
||||
case when b.binding_status = 'ACTIVE' then d.wifi_password else null end as wifi_password,
|
||||
case when b.binding_status = 'ACTIVE' then d.device_em else null end as device_em,
|
||||
case when b.binding_status = 'ACTIVE' then d.device_sn else null end as device_sn,
|
||||
case when b.binding_status = 'ACTIVE' then d.fw_ver else null end as fw_ver,
|
||||
case when b.binding_status = 'ACTIVE' then d.mac_address else null end as mac_address,
|
||||
case when b.binding_status = 'ACTIVE' then d.expiration_time else null end as expiration_time,
|
||||
b.binding_status,
|
||||
b.unbound_time
|
||||
from app_device_binding b
|
||||
inner join app_device d on d.device_no = b.device_no
|
||||
where b.user_id = #{userId}
|
||||
<if test="bo.deviceNo != null and bo.deviceNo != ''">
|
||||
and b.device_no = #{bo.deviceNo}
|
||||
</if>
|
||||
<if test="bo.deviceName != null and bo.deviceName != ''">
|
||||
and (case when b.binding_status = 'ACTIVE' then d.device_name else b.device_name_snapshot end)
|
||||
like concat('%', #{bo.deviceName}, '%')
|
||||
</if>
|
||||
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
|
||||
and b.binding_status = #{bo.bindingStatus}
|
||||
</if>
|
||||
order by b.update_time desc, b.id desc
|
||||
</script>
|
||||
""")
|
||||
Page<AppDeviceVo> selectUserDevicePage(Page<AppDeviceVo> page,
|
||||
@Param("userId") Long userId,
|
||||
@Param("bo") AppDeviceBo bo);
|
||||
|
||||
@Select("""
|
||||
select
|
||||
d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
|
||||
d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
|
||||
d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
|
||||
d.expiration_time, b.binding_status, b.unbound_time
|
||||
from app_device_binding b
|
||||
inner join app_device d on d.device_no = b.device_no
|
||||
where b.user_id = #{userId} and b.binding_status = 'ACTIVE'
|
||||
order by d.device_no asc
|
||||
""")
|
||||
List<AppDeviceVo> selectActiveDevicesByUser(@Param("userId") Long userId);
|
||||
|
||||
@Select("""
|
||||
select
|
||||
d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
|
||||
d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
|
||||
d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
|
||||
d.expiration_time, b.binding_status, b.unbound_time
|
||||
from app_device_binding b
|
||||
inner join app_device d on d.device_no = b.device_no
|
||||
where b.user_id = #{userId}
|
||||
and b.device_no = #{deviceNo}
|
||||
and b.binding_status = 'ACTIVE'
|
||||
limit 1
|
||||
""")
|
||||
AppDeviceVo selectActiveDevice(@Param("userId") Long userId,
|
||||
@Param("deviceNo") String deviceNo);
|
||||
|
||||
@Select("""
|
||||
select d.*, b.user_id as user_id
|
||||
from app_device_binding b
|
||||
inner join app_device d on d.device_no = b.device_no
|
||||
where b.binding_status = 'ACTIVE' and d.status = '0'
|
||||
order by d.device_no asc
|
||||
""")
|
||||
List<AppDevice> selectOfflineActiveDevices();
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -25,7 +26,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
<script>
|
||||
select
|
||||
d.device_no,
|
||||
d.user_id,
|
||||
b.user_id,
|
||||
d.device_name,
|
||||
d.device_init_name,
|
||||
d.device_img,
|
||||
@@ -33,6 +34,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
d.status,
|
||||
d.work_status,
|
||||
d.power_level,
|
||||
d.power_status,
|
||||
d.power_level_updatatime,
|
||||
d.wifi_name,
|
||||
d.wifi_password,
|
||||
@@ -41,15 +43,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
d.fw_ver,
|
||||
d.mac_address,
|
||||
d.expiration_time,
|
||||
u.nick_name
|
||||
u.nick_name,
|
||||
b.binding_status,
|
||||
b.unbound_time
|
||||
from app_device d
|
||||
left join sys_user u on u.user_id = d.user_id
|
||||
left join app_device_binding b
|
||||
on b.device_no = d.device_no
|
||||
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
|
||||
left join sys_user u on u.user_id = b.user_id
|
||||
<where>
|
||||
<if test="bo.deviceNo != null and bo.deviceNo != ''">
|
||||
and d.device_no = #{bo.deviceNo}
|
||||
</if>
|
||||
<if test="bo.userId != null">
|
||||
and d.user_id = #{bo.userId}
|
||||
and b.user_id = #{bo.userId}
|
||||
</if>
|
||||
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
|
||||
and b.binding_status = #{bo.bindingStatus}
|
||||
</if>
|
||||
<if test="bo.deviceName != null and bo.deviceName != ''">
|
||||
and d.device_name like concat('%', #{bo.deviceName}, '%')
|
||||
@@ -103,7 +113,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
<script>
|
||||
select
|
||||
d.device_no,
|
||||
d.user_id,
|
||||
b.user_id,
|
||||
d.device_name,
|
||||
d.device_init_name,
|
||||
d.device_img,
|
||||
@@ -111,6 +121,7 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
d.status,
|
||||
d.work_status,
|
||||
d.power_level,
|
||||
d.power_status,
|
||||
d.power_level_updatatime,
|
||||
d.wifi_name,
|
||||
d.wifi_password,
|
||||
@@ -119,15 +130,23 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
d.fw_ver,
|
||||
d.mac_address,
|
||||
d.expiration_time,
|
||||
u.nick_name
|
||||
u.nick_name,
|
||||
b.binding_status,
|
||||
b.unbound_time
|
||||
from app_device d
|
||||
left join sys_user u on u.user_id = d.user_id
|
||||
left join app_device_binding b
|
||||
on b.device_no = d.device_no
|
||||
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
|
||||
left join sys_user u on u.user_id = b.user_id
|
||||
<where>
|
||||
<if test="bo.deviceNo != null and bo.deviceNo != ''">
|
||||
and d.device_no = #{bo.deviceNo}
|
||||
</if>
|
||||
<if test="bo.userId != null">
|
||||
and d.user_id = #{bo.userId}
|
||||
and b.user_id = #{bo.userId}
|
||||
</if>
|
||||
<if test="bo.bindingStatus != null and bo.bindingStatus != ''">
|
||||
and b.binding_status = #{bo.bindingStatus}
|
||||
</if>
|
||||
<if test="bo.deviceName != null and bo.deviceName != ''">
|
||||
and d.device_name like concat('%', #{bo.deviceName}, '%')
|
||||
@@ -177,44 +196,36 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
""")
|
||||
List<AppDeviceVo> selectDeviceVoList(@Param("bo") AppDeviceBo bo);
|
||||
|
||||
@Update("""
|
||||
<script>
|
||||
update app_device
|
||||
<set>
|
||||
user_id = #{userId},
|
||||
<if test="deviceName != null and deviceName != ''">
|
||||
device_name = #{deviceName},
|
||||
</if>
|
||||
<if test="status != null and status != ''">
|
||||
status = #{status},
|
||||
</if>
|
||||
<if test="workStatus != null and workStatus != ''">
|
||||
work_status = #{workStatus},
|
||||
</if>
|
||||
<if test="wifiName != null and wifiName != ''">
|
||||
wifi_name = #{wifiName},
|
||||
</if>
|
||||
<if test="wifiPassword != null and wifiPassword != ''">
|
||||
wifi_password = #{wifiPassword},
|
||||
</if>
|
||||
</set>
|
||||
where device_no = #{deviceNo}
|
||||
and (user_id is null or user_id = #{userId})
|
||||
</script>
|
||||
""")
|
||||
int bindIfAvailable(@Param("deviceNo") String deviceNo,
|
||||
@Param("userId") Long userId,
|
||||
@Param("deviceName") String deviceName,
|
||||
@Param("status") String status,
|
||||
@Param("workStatus") String workStatus,
|
||||
@Param("wifiName") String wifiName,
|
||||
@Param("wifiPassword") String wifiPassword);
|
||||
@Select("""
|
||||
<script>
|
||||
select
|
||||
d.device_no, b.user_id, d.device_name, d.device_init_name, d.device_img, d.qrcode,
|
||||
d.status, d.work_status, d.power_level, d.power_status, d.power_level_updatatime,
|
||||
d.wifi_name, d.wifi_password, d.device_em, d.device_sn, d.fw_ver, d.mac_address,
|
||||
d.expiration_time, u.nick_name, b.binding_status, b.unbound_time
|
||||
from app_device d
|
||||
left join app_device_binding b
|
||||
on b.device_no = d.device_no
|
||||
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
|
||||
left join sys_user u on u.user_id = b.user_id
|
||||
where d.device_no in
|
||||
<foreach collection="deviceNos" item="deviceNo" open="(" separator="," close=")">
|
||||
#{deviceNo}
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
List<AppDeviceVo> selectDeviceVosByDeviceNos(@Param("deviceNos") Collection<String> deviceNos);
|
||||
|
||||
@Update("""
|
||||
update app_device
|
||||
set work_status = #{workStatus}
|
||||
where device_no = #{deviceNo}
|
||||
and user_id = #{userId}
|
||||
and exists (
|
||||
select 1 from app_device_binding b
|
||||
where b.device_no = app_device.device_no
|
||||
and b.user_id = #{userId}
|
||||
and b.binding_status = 'ACTIVE'
|
||||
)
|
||||
""")
|
||||
int updateWorkStatusByUser(@Param("deviceNo") String deviceNo,
|
||||
@Param("userId") Long userId,
|
||||
@@ -246,8 +257,11 @@ public interface AppDeviceMapper extends BaseMapperPlus<AppDevice, AppDeviceVo>
|
||||
sum(case when status = '3' then 1 else 0 end) as faultDevices,
|
||||
sum(case when work_status = '1' then 1 else 0 end) as workingDevices,
|
||||
sum(case when work_status = '0' then 1 else 0 end) as idleDevices,
|
||||
sum(case when user_id is null then 1 else 0 end) as unboundDevices
|
||||
from app_device
|
||||
sum(case when b.device_no is null then 1 else 0 end) as unboundDevices
|
||||
from app_device d
|
||||
left join app_device_binding b
|
||||
on b.device_no = d.device_no
|
||||
and b.binding_status in ('BINDING', 'ACTIVE', 'UNBINDING')
|
||||
""")
|
||||
DashboardStatsVo selectDashboardStats();
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.dromara.app.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.dromara.app.domain.AppDeviceUnbindCommand;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
|
||||
public interface AppDeviceUnbindCommandMapper
|
||||
extends BaseMapperPlus<AppDeviceUnbindCommand, AppDeviceUnbindCommand> {
|
||||
|
||||
@Select("""
|
||||
select * from app_device_unbind_command
|
||||
where device_no = #{deviceNo} and command_id = #{commandId}
|
||||
limit 1
|
||||
""")
|
||||
AppDeviceUnbindCommand selectByDeviceAndCommandId(@Param("deviceNo") String deviceNo,
|
||||
@Param("commandId") String commandId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.dromara.app.mapper;
|
||||
|
||||
import org.dromara.app.domain.AppFirmware;
|
||||
import org.dromara.app.domain.vo.AppFirmwareVo;
|
||||
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
|
||||
/**
|
||||
* 设备固件版本 Mapper 接口
|
||||
*
|
||||
* @author water team
|
||||
*/
|
||||
public interface AppFirmwareMapper extends BaseMapperPlus<AppFirmware, AppFirmwareVo> {
|
||||
}
|
||||
@@ -14,14 +14,16 @@ import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
|
||||
*/
|
||||
public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, AppWateringLogVo> {
|
||||
|
||||
@Select("SELECT count(id) FROM app_watering_log\n" +
|
||||
"WHERE user_id=#{userId} AND YEAR(create_time) = YEAR(CURDATE()) AND MONTH(create_time) = MONTH(CURDATE());")
|
||||
@Select("SELECT count(id) FROM app_watering_log l\n" +
|
||||
"WHERE user_id=#{userId} AND YEAR(create_time) = YEAR(CURDATE()) AND MONTH(create_time) = MONTH(CURDATE()) " +
|
||||
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=l.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
|
||||
Long selectByMonCout(@Param("userId") Long userId);
|
||||
|
||||
|
||||
@Select("SELECT COUNT(id) \n" +
|
||||
"FROM app_watering_log \n" +
|
||||
"WHERE user_id=#{userId} AND WEEK(create_time) = WEEK(CURDATE());")
|
||||
"WHERE user_id=#{userId} AND WEEK(create_time) = WEEK(CURDATE()) " +
|
||||
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
|
||||
Long selectByWeekCount(@Param("userId") Long userId);
|
||||
|
||||
|
||||
@@ -29,11 +31,25 @@ public interface AppWateringLogMapper extends BaseMapperPlus<AppWateringLog, App
|
||||
|
||||
@Select("SELECT COUNT(id) \n" +
|
||||
"FROM app_watering_log \n" +
|
||||
"WHERE user_id=#{userId} AND trigger_type =#{triggerType}")
|
||||
"WHERE user_id=#{userId} AND trigger_type =#{triggerType} " +
|
||||
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
|
||||
Long selectByTriggerTypeCount(@Param("userId") Long userId, @Param("triggerType") String triggerType);
|
||||
|
||||
@Select("SELECT SUM(duration_min)\n" +
|
||||
"FROM app_watering_log \n" +
|
||||
"WHERE user_id=#{userId}")
|
||||
"WHERE user_id=#{userId} " +
|
||||
"AND EXISTS (SELECT 1 FROM app_device_binding b WHERE b.device_no=app_watering_log.device_no AND b.user_id=#{userId} AND b.binding_status='ACTIVE')")
|
||||
Long selectByTotalWateringTime(@Param("userId") Long userId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(id) FROM app_watering_log l
|
||||
WHERE l.user_id = #{userId}
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM app_device_binding b
|
||||
WHERE b.device_no = l.device_no
|
||||
AND b.user_id = #{userId}
|
||||
AND b.binding_status = 'ACTIVE'
|
||||
)
|
||||
""")
|
||||
Long selectActiveCount(@Param("userId") Long userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.dromara.app.service;
|
||||
|
||||
import org.dromara.app.domain.AppDevice;
|
||||
import org.dromara.app.domain.AppDeviceBinding;
|
||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindRequest;
|
||||
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
|
||||
import org.dromara.app.domain.vo.AppDeviceVo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface IAppDeviceBindingService {
|
||||
|
||||
TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery);
|
||||
|
||||
List<AppDeviceVo> queryActiveDevices(Long userId);
|
||||
|
||||
AppDeviceVo queryActiveDevice(Long userId, String deviceNo);
|
||||
|
||||
boolean isActive(String deviceNo);
|
||||
|
||||
boolean isCurrentBinding(String deviceNo, String bindingId);
|
||||
|
||||
Long queryActiveUserId(String deviceNo);
|
||||
|
||||
AppDeviceBinding queryExclusiveBinding(String deviceNo);
|
||||
|
||||
AppDeviceBinding queryUserRelationship(Long userId, String deviceNo);
|
||||
|
||||
AppDeviceVo startBinding(AppDevice device, AppDeviceBo request, Long userId);
|
||||
|
||||
AppDeviceVo retryBinding(AppDevice device, Long userId);
|
||||
|
||||
boolean removeListEntry(Long userId, String deviceNo);
|
||||
|
||||
void removeAllListEntries(Long userId);
|
||||
|
||||
boolean unbindByUser(Collection<String> deviceNos, Long userId);
|
||||
|
||||
boolean unbindOneByUser(String deviceNo, Long userId);
|
||||
|
||||
boolean unbindByAdmin(Collection<String> deviceNos);
|
||||
|
||||
boolean unbindOneByAdmin(String deviceNo);
|
||||
|
||||
DeviceUnbindResult unbindByDevice(String deviceNo, DeviceUnbindRequest request);
|
||||
|
||||
boolean hasExclusiveBinding(String deviceNo);
|
||||
|
||||
void deleteRelationships(Collection<String> deviceNos);
|
||||
|
||||
void recoverUnbinding();
|
||||
|
||||
void recoverOneUnbinding(String deviceNo);
|
||||
|
||||
void recoverBindingCommands();
|
||||
|
||||
void failExpiredBindings();
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import org.dromara.app.domain.vo.DashboardStatsVo;
|
||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -29,6 +31,20 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
AppDeviceVo queryById(String deviceNo);
|
||||
|
||||
/**
|
||||
* 按设备编号批量查询设备。
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @return 设备列表
|
||||
*/
|
||||
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
|
||||
|
||||
TableDataInfo<AppDeviceVo> queryUserDevicePage(Long userId, AppDeviceBo bo, PageQuery pageQuery);
|
||||
|
||||
List<AppDeviceVo> queryActiveDevices(Long userId);
|
||||
|
||||
AppDeviceVo queryActiveDevice(Long userId, String deviceNo);
|
||||
|
||||
/**
|
||||
* 分页查询设备信息
|
||||
列表
|
||||
@@ -70,6 +86,9 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
Boolean updateByBo(AppDeviceBo bo);
|
||||
|
||||
/** 用户侧修改设备资料,并在设备锁内复核当前绑定用户。 */
|
||||
Boolean updateByBo(AppDeviceBo bo, Long userId);
|
||||
|
||||
/**
|
||||
* MQTT设备注册或刷新设备基础信息
|
||||
*
|
||||
@@ -86,6 +105,10 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
AppDeviceVo bindRegisteredDevice(AppDeviceBo bo);
|
||||
|
||||
AppDeviceVo retryBinding(String deviceNo, Long userId);
|
||||
|
||||
Boolean removeUserDeviceEntry(String deviceNo, Long userId);
|
||||
|
||||
Boolean switchDevice(String deviceNo, String workStatus, String startTime, Integer durationMin);
|
||||
|
||||
/**
|
||||
@@ -96,6 +119,15 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
Boolean generateQrCode(Collection<String> deviceNos);
|
||||
|
||||
/**
|
||||
* 从 OSS 批量下载设备二维码并打包为 ZIP。
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @param outputStream ZIP 输出流
|
||||
* @throws IOException 写入 ZIP 失败
|
||||
*/
|
||||
void downloadQrCodes(Collection<String> deviceNos, OutputStream outputStream) throws IOException;
|
||||
|
||||
/**
|
||||
* 校验并批量删除设备信息
|
||||
信息
|
||||
@@ -115,6 +147,14 @@ public interface IAppDeviceService {
|
||||
*/
|
||||
Boolean unbindDevices(Collection<String> deviceNos, Long userId);
|
||||
|
||||
/**
|
||||
* 后台解绑设备:允许按权限跨用户操作,但仍要求设备当前处于绑定状态。
|
||||
*
|
||||
* @param deviceNos 设备编号集合
|
||||
* @return 是否解绑成功
|
||||
*/
|
||||
Boolean unbindDevicesByAdmin(Collection<String> deviceNos);
|
||||
|
||||
Map<String, Object> bindDeviceStatus(AppDeviceBo appDevice);
|
||||
|
||||
/**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user