Compare commits

..

12 Commits

Author SHA1 Message Date
yuhaiming
70e2356f22 fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
2026-07-17 08:20:44 +08:00
yuhaiming
4a4aabf19e docs: plan Omen cat Codex pet 2026-07-16 14:23:35 +08:00
yuhaiming
85bc979445 docs: define AppController concurrency test 2026-07-16 11:42:41 +08:00
yuhaiming
51512615fb docs: define AppController remediation design 2026-07-16 10:20:21 +08:00
yuhaiming
86e511f418 浇水bug修改 2026-07-13 14:22:11 +08:00
yuhaiming
1e1f1042fe docs: 记录遗嘱消息MAC解析方案 2026-07-13 14:00:50 +08:00
yuhaiming
7deda9bdc0 浇水bug修改 2026-07-09 08:26:37 +08:00
yuhaiming
319ebd19ad MQTT通信修改 使用服务器数据库 2026-06-30 08:12:35 +08:00
yuhaiming
0098220a9a chore(sql): 5.5.3→5.6.0 升级脚本补充设备二维码与浇水日志备注字段
- app_device 增加 qrcode 字段(设备二维码 URL,1000)
- app_watering_log 增加 remark 字段(备注,500)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:33 +08:00
yuhaiming
0f165b50ff fix: MQTT 排程完成回调移除无效的 LoginHelper.getUserId
MQTT 消息处理线程没有登录上下文,LoginHelper.getUserId() 取不到值
还会覆盖 buildWateringLog 里基于设备查询设置的 userId

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:19 +08:00
yuhaiming
228a01843f chore: 补充忽略 AI 工具本地目录与构建产物
新增 .flattened-pom.xml、node_modules、各类 AI 工具目录
(.claude/.codex/.workbuddy/... 共 30+)的忽略规则,避免
本地工具痕迹进入版本控制

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:09 +08:00
yuhaiming
694e32abbf fix: 设备删除增加用户校验、ACK 在线状态刷新改非阻塞、停止跟踪日志
- AppDeviceServiceImpl#deleteWithValidByIds 校验设备归属当前用户,
  避免任意已登录用户越权删除其他用户的设备及关联数据
- MqttCommandAckService#refreshDeviceOnline 由 lock.lock 改为 tryLock,
  防止 MQTT 处理线程在状态刷新锁上排队堆积;状态写入幂等,跳过本次刷新
  由后续 ACK 兜底
- 从版本控制中移除 logs/ 历史日志,gitignore 已忽略该目录

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-23 15:36:38 +08:00
158 changed files with 12806 additions and 1450 deletions

44
.gitignore vendored
View File

@@ -3,6 +3,49 @@
/water-modules/*/target/ /water-modules/*/target/
/logs/ /logs/
/water-extend/*/target/ /water-extend/*/target/
/.flattened-pom.xml
/**/.flattened-pom.xml
# Node / local AI tooling
/node_modules/
/package-lock.json
/package.json
/skills-lock.json
/.codegraph/
/.agent/
/.agents/
/.amazonq/
/.augment/
/.bob/
/.claude/
/.cline/
/.clinerules/
/.codebuddy/
/.codex/
/.comet/
/.continue/
/.cospec/
/.crush/
/.cursor/
/.factory/
/.forge/
/.gemini/
/.github/
/.iflow/
/.junie/
/.kilocode/
/.kimi/
/.kimi-code/
/.kiro/
/.lingma/
/.opencode/
/.pi/
/.qoder/
/.qwen/
/.roo/
/.trae/
/.windsurf/
/.workbuddy/
# IDE # IDE
.idea/ .idea/
@@ -20,3 +63,4 @@
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/docs/

1820
docs/AppController-API.md Normal file

File diff suppressed because it is too large Load Diff

View 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*

View File

@@ -2,6 +2,8 @@
本文档说明后端服务与嵌入式设备之间的 MQTT Topic、通信方向、Payload 类型和 JSON 数据格式。 本文档说明后端服务与嵌入式设备之间的 MQTT Topic、通信方向、Payload 类型和 JSON 数据格式。
> **Topic 标识变更v5.6 起)**:除首次注册回复 `registerDeviceNo` 仍按 **MAC 地址** 下发外,其余 MQTT 下行命令 Topic 第一段统一使用**设备编号 deviceNo**。后端上行处理通过 `DeviceIdentityResolver` 兼容 MAC / 设备编号两种入参;下行命令请按 `deviceNo` 订阅 `subscriber/cmd` 或 `subscriber/schedule`。业务字段payload 中的 `deviceNo`、数据库主键、API 入参)仍是设备编号,未变更。
当前配置位置: 当前配置位置:
- 后端订阅配置:`water-admin/src/main/resources/application.yml` - 后端订阅配置:`water-admin/src/main/resources/application.yml`
@@ -14,8 +16,8 @@
| --- | --- | | --- | --- |
| Broker | `mqtt.broker-url` | | Broker | `mqtt.broker-url` |
| QoS | `mqtt.qos`,当前默认 `1` | | QoS | `mqtt.qos`,当前默认 `1` |
| 设备编号 | Topic 第一段 `{deviceNo}` | | 设备标识 | 首次注册 Topic 使用设备 **MAC**;注册完成后的上、下行业务 Topic 统一使用 `deviceNo`LWT 离线 Topic 兼容 MAC |
| Topic 变量 | 文档中的 `{deviceNo}` 替换为真实设备编号,例如 `/01/publish/register` | | Topic 变量 | 文档中的 `{deviceNo}` 指服务端分配的设备编号,`{mac}` 指设备 MAC 地址 |
| JSON 编码 | UTF-8 | | JSON 编码 | UTF-8 |
| 时间格式 | `yyyy-MM-dd HH:mm:ss` | | 时间格式 | `yyyy-MM-dd HH:mm:ss` |
@@ -24,6 +26,7 @@
```yaml ```yaml
- /+/publish/finish/schedule - /+/publish/finish/schedule
- /+/publish/register - /+/publish/register
- /+/publish/status
- /+/publish/power - /+/publish/power
- /+/publish/ack - /+/publish/ack
- /+/publish/finish/key - /+/publish/finish/key
@@ -36,8 +39,9 @@
| 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 | | 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 排程任务完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 | | 排程任务完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 |
| 设备注册 | 设备发布,后端订阅 | `/{deviceNo}/publish/register` | JSON | 注册或更新设备 | | 设备注册 | 设备发布,后端订阅 | `/{mac}/publish/register` | JSON | 注册、标记上线并下发 deviceNo |
| 电量上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量 | | 设备离线遗嘱 | 设备发布,后端订阅 | `/{deviceNo}/publish/status` | JSON | 收到 offline 后立即标记离线 |
| 电量及在线心跳 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量并刷新 10 分钟在线心跳 |
| 命令应答 ACK | 设备发布,后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 | | 命令应答 ACK | 设备发布,后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 |
| 按键浇水完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 | | 按键浇水完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 |
| 硬件故障上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 | | 硬件故障上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 |
@@ -49,25 +53,22 @@
| --- | --- | | --- | --- |
| 功能名称 | 设备注册 | | 功能名称 | 设备注册 |
| 通信方向 | 设备发布,后端订阅 | | 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/register` | | Topic | `/{mac}/publish/register` |
| Payload 类型 | JSON | | Payload 类型 | JSON |
后端以 Topic 中的 `{deviceNo}` 作为设备编号payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。 后端以 Topic 中的设备标识作为解析入口payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。
示例: 示例:
```json ```json
{ {
"deviceName": "一号浇水设备", "deviceName": "一号浇水设备",
"status": "1",
"workStatus": "0",
"powerLevel": "86", "powerLevel": "86",
"wifiName": "office-wifi",
"deviceEm": "WATER-EM-01", "deviceEm": "WATER-EM-01",
"deviceSn": "SN202606100001", "deviceSn": "SN202606100001",
"fwVer": "1.0.0", "fwVer": "1.0.0",
"macAddress": "AA:BB:CC:DD:EE:FF", "macAddress": "AA:BB:CC:DD:EE:FF"
"bindToken": "123456"
} }
``` ```
@@ -76,15 +77,11 @@
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| deviceName | string | 否 | 设备名称 | | deviceName | string | 否 | 设备名称 |
| status | string | 否 | 设备状态:`1` 在线,`0` 离线,`2` 到期,`3` 故障;为空时后端默认 `2` |
| workStatus | string | 否 | 工作状态:`0` 休息,`1` 工作 |
| powerLevel | string | 否 | 电量 | | powerLevel | string | 否 | 电量 |
| wifiName | string | 否 | WiFi 名称 |
| deviceEm | string | 否 | 设备型号 | | deviceEm | string | 否 | 设备型号 |
| deviceSn | string | 否 | 设备序列号 | | deviceSn | string | 否 | 设备序列号 |
| fwVer | string | 否 | 固件版本 | | fwVer | string | 否 | 固件版本 |
| macAddress | string | 否 | MAC 地址 | | macAddress | string | 否 | MAC 地址 |
| bindToken | string | 否 | 设备绑定令牌 |
## 电量上报 ## 电量上报
@@ -99,7 +96,9 @@
```json ```json
{ {
"powerLevel": "86" "deviceName": "Waterer_01",
"powerLevel": "86",
"charging": 1
} }
``` ```
@@ -109,6 +108,29 @@
| --- | --- | --- | --- | | --- | --- | --- | --- |
| powerLevel | string | 是 | 电量值,后端当前按字符串保存 | | 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 ## 命令应答 ACK
| 项目 | 内容 | | 项目 | 内容 |
@@ -193,6 +215,17 @@ receive
} }
``` ```
## 设备编号下发
| 项目 | 内容 |
| --- | --- |
| 功能名称 | 设备编号下发 |
| 通信方向 | 后端发布,设备订阅 |
| Topic | `/{deviceMac}/subscriber/cmd` |
| Payload 类型 | JSON |
说明:这是首次注册后的唯一例外,后端按设备 MAC 回传设备编号;其余下行命令都使用 `deviceNo` 作为 Topic 第一段。
## 排程任务完成上报 ## 排程任务完成上报
| 项目 | 内容 | | 项目 | 内容 |
@@ -298,9 +331,10 @@ JSON 示例:
## 嵌入式侧实现要点 ## 嵌入式侧实现要点
1. 设备启动后发布注册消息到 `/{deviceNo}/publish/register` 1. 设备启动后使用 MAC 发布注册消息到 `/{mac}/publish/register`,取得服务端分配的 deviceNo
2. 设备定时或电量变化时发布电量到 `/{deviceNo}/publish/power` 2. MQTT 连接时配置离线遗嘱 `/{deviceNo}/publish/status`payload 为 `{"status":"offline"}``retain=false`
3. 设备订阅自己的命令 Topic`/{deviceNo}/subscriber/cmd` 3. 设备至少每 5 分钟发布一次电量到 `/{deviceNo}/publish/power`,电量未变化也要上报
4. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId` 4. 设备订阅自己的命令 Topic`/{deviceNo}/subscriber/cmd``/{deviceNo}/subscriber/schedule`
5. 排程或按键浇水完成后,分别发布`/{deviceNo}/publish/finish/schedule``/{deviceNo}/publish/finish/key` 5. 设备收到命令后立即返回 ACK `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`
6. 硬件异常时发布到 `/{deviceNo}/publish/error` 6. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule``/{deviceNo}/publish/finish/key`
7. 硬件异常时发布到 `/{deviceNo}/publish/error`

584
docs/mqtt-spec.md Normal file
View File

@@ -0,0 +1,584 @@
# Water MQTT 通信协议说明文档
## 1. 系统架构概览
```
┌─────────────┐ MQTT Broker ┌─────────────────┐
│ 物理设备 │ ◄══════════════════► │ Water 后端服务 │
│ (ESP32等) │ (SSL/TCP连接) │ (Spring Boot) │
└─────────────┘ └─────────────────┘
│ │
│ 上行 (publish) │ 下行 (subscriber)
│ /{identity}/publish/xxx │ /{deviceNo}/subscriber/xxx
│ │
▼ ▼
设备注册、数据上报、 命令下发、排程同步、
浇水完成、异常告警、ACK 设备绑定/解绑/初始化
```
### 核心组件
| 组件 | 包路径 | 职责 |
|------|--------|------|
| `MqttClientManager` | `org.dromara.mqtt` | MQTT 客户端连接管理、消息收发 |
| `MqttMessageDispatcher` | `org.dromara.app.mqtt` | 上行消息路由分发(策略模式) |
| `MqttTopicHandler` | `org.dromara.app.mqtt` | 上行消息处理器接口 |
| `DeviceMqttCommandPublisher` | `org.dromara.mqtt` | 下行命令发布器 |
| `DeviceCommandServiceImpl` | `org.dromara.app.service.impl` | 命令构造与业务编排 |
| `MqttCommandAckService` | `org.dromara.mqtt` | 命令 ACK 确认与重试 |
| `DeviceIdentityResolver` | `org.dromara.app.handler` | 设备标识解析MAC/设备编号 → deviceNo |
---
## 2. Topic 约定
### 2.1 上行 Topic设备 → 服务端)
服务端通过通配符订阅(如 `+/publish/#`),由 `MqttMessageDispatcher` 按正则分发到对应 Handler。
| Topic 模式 | 正则 | 处理器 | 说明 |
|------------|------|--------|------|
| `/{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` | 命令执行确认 |
> 首次注册可使用 MAC 地址;注册完成后的业务 Topic 使用 deviceNo。LWT 离线 Topic 兼容 MAC 地址,服务端通过 `DeviceIdentityResolver` 解析为 deviceNo。
### 2.2 下行 Topic服务端 → 设备)
| Topic 模式 | 用途 | 构造方式 |
|------------|------|---------|
| `/{deviceNo}/subscriber/cmd` | 通用命令下发 | `DeviceMqttCommandPublisher.buildCommandTopic()` |
| `/{deviceNo}/subscriber/schedule` | 排程专用下发 | `DeviceCommandServiceImpl.buildScheduleTopic()` |
> 可通过 `mqtt.topics.publish-prefix` 配置前缀,如配置为 `/water`,则实际 Topic 为 `/water/{deviceNo}/subscriber/cmd`。
> `deviceNo` 统一为小写格式。
---
## 3. 消息格式
### 3.1 下行命令通用结构DeviceCommand
所有下行命令通过 `DeviceCommand` 对象构造,最终以 `payload` 字段的 JSON 发送到 MQTT。
```java
// DeviceCommand 字段
String commandId; // 命令唯一标识UUID自动生成
String deviceNo; // 设备编号
String deviceMac; // 设备 MAC 地址
String commandType; // 命令类型
String topic; // 发送目标 Topic可自定义默认自动构建
Map<String, Object> payload; // 命令负载(最终序列化为 JSON 发送)
int retryCount; // 当前重试次数
long createdAt; // 创建时间戳
long lastSentAt; // 最后发送时间戳
long nextRetryAt; // 下次重试时间戳
```
发送时自动注入到 payload 的字段:
```json
{
"commandId": "自动生成的UUID",
"commandType": "命令类型",
// ... 其他业务字段
}
```
### 3.2 上行 ACK 结构DeviceCommandAck
```json
{
"commandId": "对应下发命令的commandId",
"status": "1",
"message": "可选的文本消息"
}
```
> 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配
---
## 4. 下行命令类型详解
### 4.1 设备开关命令 — `switchDevice`
**触发**: 用户手动开启/关闭设备浇水
**接口**: `PUT /app/v1/switchDevice`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "switchDevice",
"deviceNo": "01",
"cmd": "1",
"startTime": "2026-06-25 14:35:00",
"durationMin": 20
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `cmd` | String | `"1"` = 开启浇水,`"0"` = 停止浇水 |
| `startTime` | String | 开始时间(`yyyy-MM-dd HH:mm:ss``HH:mm` |
| `durationMin` | Integer | 持续时间(分钟),开启时必填且 > 0 |
**附加行为**:
- 开启时自动创建手动浇水日志记录
- 停止时自动结束进行中的手动浇水日志
---
### 4.2 设备绑定命令 — `bindDevice`
**触发**: 用户在 APP 绑定设备
**接口**: `POST /app/v1/addDevice`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "bindDevice",
"bindStatus": true,
"deviceNo": "01",
"cmd": -1
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `bindStatus` | Boolean | `true` = 已绑定 |
| `deviceNo` | String | 分配给设备的编号 |
| `cmd` | Integer | `-1` = 非浇水指令 |
---
### 4.3 设备初始化/解绑命令 — `initDevice`
**触发**: 用户删除/解绑设备
**接口**: `DELETE /app/v1/deleteDevice/{deviceNos}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "initDevice",
"deviceNo": "01",
"initStatus": true,
"cmd": "init"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `initStatus` | Boolean | `true` = 需要恢复出厂设置 |
| `cmd` | String | `"init"` = 初始化指令 |
**附加行为**: 服务端同时清除用户绑定、排程关联、浇水日志。
---
### 4.4 设备编号下发 — `registerDeviceNo`
**触发**: 设备通过 MQTT 注册上线后,服务端自动回复
**接口**: 无(`DeviceRegisterHandler` 自动触发)
**Topic**: `/{deviceMac}/subscriber/cmd`
> 这是唯一保留 MAC 作为 Topic 第一段的下行命令;其余下行命令统一使用 `deviceNo`。
```json
{
"commandId": "xxx",
"commandType": "registerDeviceNo",
"deviceNo": "01",
"deviceMac": "aa:bb:cc:dd:ee:ff"
}
```
---
### 4.5 排程绑定命令 — `bindSchedule`
**触发**: 用户绑定排程到设备 / 修改排程内容 / 修改排程状态
**接口**: `POST /app/v1/addScheduleDevice` · `PUT /app/v1/updataschedule` · `PUT /app/v1/editScheduleStatus`
**Topic**: `/{deviceNo}/subscriber/schedule`(排程专用 Topic
```json
{
"commandId": "xxx",
"commandType": "bindSchedule",
"cmd": -1,
"deviceNo": "01",
"schedule": {
"id": 1,
"name": "每日浇水",
"status": "1"
},
"details": [
{
"id": 10,
"weekday": "1",
"timeData": [
{ "startTime": "08:00", "durationMin": 15 },
{ "startTime": "18:00", "durationMin": 10 }
],
"triggerType": "0",
"status": "1"
}
]
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `schedule.id` | Long | 排程 ID |
| `schedule.name` | String | 排程名称 |
| `schedule.status` | String | `"1"` = 启用,`"0"` = 停用 |
| `details[].weekday` | String | 星期几(`1`-`7` |
| `details[].timeData` | Array | 时间段列表 |
| `details[].triggerType` | String | 触发类型 |
| `details[].status` | String | 明细状态 |
---
### 4.6 排程解绑命令 — `unbindSchedule`
**触发**: 用户删除排程与设备的绑定关系 / 删除排程
**接口**: `DELETE /app/v1/deleteScheduleDevice` · `DELETE /app/v1/deleteschedule/{ids}`
**Topic**: `/{deviceNo}/subscriber/schedule`
```json
{
"commandId": "xxx",
"commandType": "unbindSchedule",
"cmd": -1,
"deviceNo": "01",
"scheduleId": 1,
"unbind": true
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `scheduleId` | Long | 要解绑的排程 ID |
| `unbind` | Boolean | `true` = 解绑 |
---
### 4.7 自定义命令 — 任意 `commandType`
**触发**: 通过 `sendCustomCommand` 接口下发
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "自定义类型",
// ... 自定义扩展字段
}
```
---
## 5. 上行消息类型详解
### 5.1 设备注册 — `/{identity}/publish/register`
设备上电或重连后发送注册消息。
```json
{
"deviceName": "花园浇水器",
"deviceMac": "aa:bb:cc:dd:ee:ff",
"powerLevel": "85",
"deviceEm": "型号",
"deviceSn": "序列号",
"version": "1.0.0"
}
```
**服务端处理**:
1. 通过 MAC 或设备编号解析入库设备
2. 更新设备注册信息(名称、电量、固件版本等)
3. 自动回复设备编号(`registerDeviceNo` 命令)
> 注册成功即将设备标记为在线;设备取得 deviceNo 后仍必须定时上报电量,以持续刷新在线心跳。
---
### 5.2 电量上报 — `/{identity}/publish/power`
```json
{
"powerLevel": "78"
}
```
**服务端处理**: 更新设备电量,将数据库设备状态改为在线,并将 Redis 在线心跳刷新为 600 秒。
设备必须周期上报,即使电量没有变化也要发送。建议每 5 分钟上报一次,连续 10 分钟未收到有效电量消息且期间无 ACK 刷新时,服务端将设备判定为离线。
---
### 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",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
"triggerON": "schedule 或其他"
}
```
**服务端处理**:
- `triggerON = "schedule"` → 排程触发,调用 `confirmScheduleLog`
- 其他 → 手动触发,调用 `insertByBo` 创建新记录
---
### 5.5 排程浇水完成 — `/{identity}/publish/finish/schedule`
```json
{
"deviceNo": "01",
"commandId": "对应的命令ID",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
"triggerON": "mqtt on 或其他"
}
```
**服务端处理**: 调用 `confirmScheduleLog` 记录排程浇水日志。
`triggerON = "mqtt on"` 时标记为手动触发类型。
---
### 5.6 设备异常告警 — `/{identity}/publish/error`
```json
{
"errorCode": "E001",
"message": "水泵故障"
}
```
**服务端处理**: 当前仅日志记录,未做告警推送。
---
### 5.7 命令 ACK — `/{identity}/publish/ack`
```json
{
"commandId": "对应的命令ID",
"status": "1",
"message": "执行成功"
}
```
**服务端处理**:
1. 从 Redis 移除 pending 命令
2. 缓存 ACK 结果
3. 刷新设备在线状态
---
## 6. 命令可靠性机制
### 6.1 命令生命周期
```
构造命令 → 存入 Redis(pending) → 发布到 MQTT → 等待 ACK
↓ ↓
定时扫描 收到 ACK
↓ ↓
超时未确认? 从 pending 移除
↓ 存入 Redis(ack)
重试(最多 3 次)
达到上限? → 放弃并记录日志
```
### 6.2 重试策略
| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `maxRetryCount` | 3 | 最大重试次数 |
| `retryIntervalMs` | 5000 | 重试间隔ms |
| `scanIntervalMs` | 5000 | 定时扫描间隔ms |
| `ackLockWaitMs` | 3000 | ACK 等待同一命令重试锁的最长时间ms |
| `pendingTtlSeconds` | 86400 | pending 命令 TTL |
| `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL |
### 6.3 离线处理
- 重试前检查设备在线状态Redis 缓存)
- 设备离线时不执行 MQTT 发布,仅递增重试计数
- 达到最大重试次数后自动放弃
### 6.4 Redis 键规则
| Key 模式 | 说明 |
|---------|------|
| `mqtt:command:pending:{commandId}` | 待确认命令 |
| `mqtt:command:ack:{commandId}` | ACK 确认结果 |
| `mqtt:command:pending:ids` | 待确认命令 ID 集合Set |
| `lock:mqtt:command:retry:{commandId}` | 命令操作分布式锁 |
| `mqtt:device:status:{deviceNo}` | 设备在线状态缓存 |
| `lock:mqtt:device:status:{deviceNo}` | 设备状态写入锁 |
---
## 7. 接口 → MQTT 命令映射表
| 接口 | 路由 | MQTT 命令类型 | 备注 |
|------|------|--------------|------|
| 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 |
| 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) |
| 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 |
| 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 |
| 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 |
| 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 |
| 修改排程内容 | `PUT /updataschedule` | `bindSchedule` | 向所有绑定设备重新下发 |
| 删除排程 | `DELETE /deleteschedule/{ids}` | `unbindSchedule` | 删除前通知所有绑定设备 |
| 设备注册(自动) | — | `registerDeviceNo` | 设备上线后自动回复 |
---
## 8. 配置参考
```yaml
mqtt:
enabled: true
broker-url: ssl://your-broker:8883
client-id: water-server-01
username: server
password: xxxxxx
qos: 1
keep-alive: 60
connection-timeout: 30
max-inflight: 1000
clean-session: false
automatic-reconnect: true
tls:
enabled: true
skip-verify: false
topics:
subscribe:
- "+/publish/#"
publish-prefix: "" # 为空时 Topic = /{deviceNo}/subscriber/cmd
async:
core-pool-size: 8
max-pool-size: 32
consumer-count: 16
queue-capacity: 5000
batch-size: 100
offer-timeout-ms: 50
poll-timeout-ms: 100
command-ack:
enabled: true
max-retry-count: 3
retry-interval-ms: 5000
scan-interval-ms: 5000
pending-ttl-seconds: 86400
ack-ttl-seconds: 86400
pending-key-prefix: "mqtt:command:pending:"
ack-key-prefix: "mqtt:command:ack:"
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: 600
offline-check:
enabled: true
ttl-compat-enabled: true
interval-ms: 30000
```
---
## 9. 扩展指南
### 新增上行消息处理器
1. 实现 `MqttTopicHandler` 接口
2. 添加 `@Component` 注解
3. 定义 `topicPattern()` 返回匹配正则(必须包含一个捕获组提取设备标识)
4. 实现 `handle(deviceIdentity, payload)` 方法
```java
@Component
public class MyNewHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/mytype$");
@Override
public Pattern topicPattern() { return PATTERN; }
@Override
public void handle(String deviceIdentity, String payload) {
// 处理逻辑
}
}
```
> 无需修改 `MqttMessageDispatcher`,新 Handler 注册为 Bean 后自动发现。
### 新增下行命令类型
1.`IDeviceCommandService` 接口添加方法签名
2.`DeviceCommandServiceImpl` 实现命令构造与下发
3. 在 Controller 调用新方法
---
## 10. 设备在线状态检测
设备在线状态由电量上报和离线遗嘱共同管理:
- **上线时机**: 注册成功或收到包含 `powerLevel` 的有效电量上报时刷新
- **ACK 兼容逻辑**: 保留现有 ACK 在线刷新逻辑,收到有效 ACK 也会延长在线缓存
- **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }`
- **TTL**: 默认 600 秒10 分钟)
- **超时离线**: 每 30 秒扫描一次非离线设备,缓存过期后更新数据库状态为离线
- **遗嘱离线**: 收到非 retained 的 LWT `status=offline/0` 时立即离线
- **忽略在线状态**: `status=online/1` 不会刷新在线状态
- **并发保护**: 使用分布式锁(`lock:mqtt:device:status:{deviceNo}`)防止并发写入

View File

@@ -133,7 +133,7 @@ sequenceDiagram
```mermaid ```mermaid
flowchart TB flowchart TB
REDIS[("Redis")] 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["mqtt:command:pending:{commandId}<br/>待ACK命令详情<br/>TTL: 86400s"]
PENDING_IDS["mqtt:command:pending:ids<br/>待ACK commandId 集合"] PENDING_IDS["mqtt:command:pending:ids<br/>待ACK commandId 集合"]
ACK["mqtt:command:ack:{commandId}<br/>设备ACK结果<br/>TTL: 86400s"] ACK["mqtt:command:ack:{commandId}<br/>设备ACK结果<br/>TTL: 86400s"]
@@ -176,7 +176,7 @@ flowchart TB
B --> C["后端单/少量订阅客户端消费通配 topic"] B --> C["后端单/少量订阅客户端消费通配 topic"]
C --> D["有界队列吸收突发流量"] C --> D["有界队列吸收突发流量"]
D --> E["批量消费降低线程调度成本"] D --> E["批量消费降低线程调度成本"]
E --> F["状态写 Redis,避免心跳打数据库"] E --> F["电量心跳写 Redis 并同步设备状态"]
F --> G["数据/告警后续建议批量落库"] F --> G["数据/告警后续建议批量落库"]
``` ```

View File

@@ -0,0 +1,267 @@
# Bind Schedule Device Dispatch 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:** When the app binds a schedule to one or more devices, immediately publish that schedule's detail payload to each bound device.
**Architecture:** Keep the change inside the existing app module flow. `AppController.addScheduleDevice` remains the entry point, persists the relation as it does today, then reuses the existing `IDeviceCommandService.sendScheduleBindCommand(...)` path to publish a `bindSchedule` command with a small payload assembled from current schedule-detail data. Shared payload shaping should stay private to `AppController` unless implementation pressure proves otherwise.
**Tech Stack:** Java 17, Spring Boot 3, existing app controller/service layer, MQTT command publishing through `IDeviceCommandService`, Maven
## Global Constraints
- Only touch the schedule-device binding flow in `water-modules/water-app`
- Reuse existing `IDeviceCommandService.sendScheduleBindCommand(...)`
- Do not modify MQTT publisher, ACK handling, or topic routing
- Published payload must contain `deviceNo` and `details`
- `details` must follow the current device-facing schedule detail structure used by `buildScheduleBindPayload`
- Binding uses synchronous failure semantics: command publish failure must fail the API call
- Keep changes ASCII unless the target file already uses non-ASCII
---
### Task 1: Add a focused regression test for schedule-device bind dispatch
**Files:**
- Create: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
- Modify: `water-modules/water-app/pom.xml`
**Interfaces:**
- Consumes: `AppController.addScheduleDevice(String body)`
- Produces: a regression test proving that binding a device triggers `deviceCommandService.sendScheduleBindCommand(deviceNo, payload)`
- [ ] **Step 1: Add the test dependency baseline for the app module**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
```
- [ ] **Step 2: Write the failing controller regression test**
```java
package org.dromara.app.controller;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.AppScheduleDetailVo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IAppScheduleDetailService;
import org.dromara.app.service.IAppScheduleService;
import org.dromara.app.service.IAppSchedulingDeviceService;
import org.dromara.app.service.IAppWateringLogService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.domain.R;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AppControllerTest {
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@InjectMocks
private AppController controller;
@Test
void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(10L);
schedule.setUserId(99L);
schedule.setName("Morning");
schedule.setStatus("1");
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
device.setUserId(99L);
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setId(101L);
detail.setWeekday("1");
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType("0");
detail.setStatus("1");
when(appScheduleService.queryById(10L)).thenReturn(schedule);
when(appDeviceService.queryById("D01")).thenReturn(device);
when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of(detail));
when(appSchedulingDeviceService.insertByBo(any(AppSchedulingDeviceBo.class))).thenReturn(true);
when(deviceCommandService.sendScheduleBindCommand(eq("D01"), anyMap())).thenReturn("cmd-1");
R<Void> result = controller.addScheduleDevice("{\"scheduleId\":10,\"deviceNos\":[\"D01\"]}");
assertThat(result.getCode()).isEqualTo(200);
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture());
Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
}
}
```
- [ ] **Step 3: Run the test to verify it fails for the expected reason**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: FAIL because `sendScheduleBindCommand(...)` is never invoked and payload assertions cannot be satisfied yet
- [ ] **Step 4: Commit the failing test baseline**
```bash
git add water-modules/water-app/pom.xml water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java
git commit -m "test: cover schedule bind device dispatch"
```
### Task 2: Implement schedule payload assembly and dispatch in AppController
**Files:**
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java`
- Test: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
**Interfaces:**
- Consumes: `IAppScheduleService.queryById(Long)`, `IAppScheduleDetailService.queryByScheduleIdByStatus(Long)`, `IDeviceCommandService.sendScheduleBindCommand(String, Map<String, Object>)`
- Produces: `addScheduleDevice(...)` publishes a `bindSchedule` command after each successful relation insert
- [ ] **Step 1: Implement the minimum controller change to make the test pass**
```java
for (String deviceNo : deviceNos) {
if (StringUtils.isBlank(deviceNo)) {
continue;
}
assertDeviceOwned(deviceNo);
AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo();
schedulingDeviceBo.setDeviceNo(deviceNo);
schedulingDeviceBo.setScheduleId(scheduleId);
appSchedulingDeviceService.insertByBo(schedulingDeviceBo);
deviceCommandService.sendScheduleBindCommand(deviceNo, buildScheduleBindPayload(scheduleId, deviceNo));
}
```
```java
private Map<String, Object> buildScheduleBindPayload(Long scheduleId, String deviceNo) {
List<AppScheduleDetailVo> details = appScheduleDetailService.queryByScheduleIdByStatus(scheduleId);
List<Map<String, Object>> detailPayload = new ArrayList<>();
for (AppScheduleDetailVo detail : details) {
List<Object> timeSlots = new ArrayList<>();
JSONArray timeArray = JSONUtil.parseArray(detail.getTimeData());
for (Object timeSlot : timeArray) {
timeSlots.add(timeSlot);
}
Map<String, Object> item = new HashMap<>();
item.put("weekday", detail.getWeekday());
item.put("timeData", timeSlots);
item.put("triggerType", detail.getTriggerType());
item.put("status", detail.getStatus());
detailPayload.add(item);
}
Map<String, Object> payload = new HashMap<>();
payload.put("deviceNo", deviceNo);
payload.put("details", detailPayload);
return payload;
}
```
- [ ] **Step 2: Run the focused regression test to verify it passes**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS with `AppControllerTest` green
- [ ] **Step 3: Refactor only if needed to remove duplication inside schedule bind payload shaping**
```java
private List<Map<String, Object>> toScheduleDetailPayload(List<AppScheduleDetailVo> details) {
List<Map<String, Object>> detailPayload = new ArrayList<>();
for (AppScheduleDetailVo detail : details) {
List<Object> timeSlots = new ArrayList<>();
JSONArray timeArray = JSONUtil.parseArray(detail.getTimeData());
for (Object timeSlot : timeArray) {
timeSlots.add(timeSlot);
}
Map<String, Object> item = new HashMap<>();
item.put("weekday", detail.getWeekday());
item.put("timeData", timeSlots);
item.put("triggerType", detail.getTriggerType());
item.put("status", detail.getStatus());
detailPayload.add(item);
}
return detailPayload;
}
```
- [ ] **Step 4: Re-run the focused regression test after refactor**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS
- [ ] **Step 5: Commit the implementation**
```bash
git add 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
git commit -m "feat: dispatch schedule payload when binding device"
```
### Task 3: Run module-level verification and review edge behavior
**Files:**
- Modify: none required unless verification reveals defects
**Interfaces:**
- Consumes: completed controller change and regression test
- Produces: fresh verification evidence for the implementation claim
- [ ] **Step 1: Run the focused controller regression test**
Run: `mvn -pl water-modules/water-app -DskipTests=false -Dtest=AppControllerTest test`
Expected: PASS
- [ ] **Step 2: Run a compile pass for the app module with tests skipped**
Run: `mvn -pl water-modules/water-app -DskipTests compile`
Expected: BUILD SUCCESS
- [ ] **Step 3: Inspect the diff to confirm scope stayed local**
Run: `git diff --stat`
Expected: only the app controller, app module test baseline, the new controller test, and planning/spec docs changed for this task
- [ ] **Step 4: Commit any verification-driven follow-up if needed**
```bash
git add -A
git commit -m "chore: verify schedule bind dispatch change"
```

View File

@@ -0,0 +1,73 @@
# App Version Check Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an app-facing version check endpoint so the mobile app can compare its current version with the latest enabled app version record and prompt updates when they differ.
**Architecture:** Store version records in the new `app_version` table. `AppController` validates the request, asks `IAppVersionService` for the latest enabled record by platform, and returns whether an update is available.
**Tech Stack:** Spring Boot MVC, existing `R<T>` response wrapper, MyBatis Plus `BaseMapperPlus`, JUnit 5, Mockito, AssertJ.
## Global Constraints
- Keep the endpoint under `/app/v1`.
- Use exact version equality: `currentVersion` differs from configured `latestVersion` means update is available.
- Store version data in `app_version`; do not use `sys_config` for this feature.
- Do not change unrelated schedule, MQTT, auth, or OSS upload behavior.
---
### Task 1: Version Check Contract
**Files:**
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/domain/AppVersion.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppVersionVo.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/mapper/AppVersionMapper.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppVersionService.java`
- Create: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppVersionServiceImpl.java`
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java`
- Create: `script/sql/update/add_app_version.sql`
**Interfaces:**
- Consumes: `IAppVersionService.queryLatestByPlatform(String platform)`
- Produces: `AppController.checkVersion(String platform, String currentVersion): R<AppVersionVo>`
- Produces: `AppVersionVo` fields `platform`, `currentVersion`, `latestVersion`, `versionCode`, `updateAvailable`, `forceUpdate`, `downloadUrl`, `releaseNotes`
- [ ] **Step 1: Write failing tests**
Add `IAppVersionService` as a mocked dependency and add tests for update available, no update, unsupported platform, missing current version, and missing version record.
- [ ] **Step 2: Run tests and verify RED**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=AppControllerTest#checkVersion*" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: compilation or test failure because `IAppVersionService` and table-backed implementation do not exist yet.
- [ ] **Step 3: Implement minimal production code**
Create `AppVersion`, `AppVersionVo`, `AppVersionMapper`, `IAppVersionService`, `AppVersionServiceImpl`, inject `IAppVersionService` into `AppController`, and keep `GET /checkVersion`.
- [ ] **Step 4: Run targeted tests and verify GREEN**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=AppControllerTest#checkVersion*" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: targeted version check tests pass.
- [ ] **Step 5: Run compile/package verification**
Run:
```bash
mvn -pl water-modules/water-app -am "-DskipTests" package
```
Expected: module and dependencies compile/package successfully.

View File

@@ -0,0 +1,134 @@
# Device Status LWT MAC Resolution 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:** Verify and document that `/MAC/publish/status` last-will messages resolve the MAC address to `deviceNo` before updating device status.
**Architecture:** Keep `DeviceStatusHandler` focused on status parsing and delegate Topic identity resolution to the existing `DeviceIdentityResolver`. Add a focused integration-style unit test using the real resolver and a mocked mapper so the complete MAC-to-device-number path is covered without duplicating database access in the handler.
**Tech Stack:** Java 17, Spring Boot, JUnit 5, Mockito, AssertJ, MyBatis-Plus
## Global Constraints
- Only `/MAC/publish/status` may use a MAC address for this change.
- Other MQTT communication continues to use `deviceNo`.
- A missing MAC mapping must not update device status.
- Preserve all existing uncommitted workspace changes.
---
### Task 1: Cover MAC Last-Will Resolution
**Files:**
- Modify: `water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java`
**Interfaces:**
- Consumes: `DeviceIdentityResolver(AppDeviceMapper)` and `DeviceStatusHandler.handle(String, String, boolean)`
- Produces: Regression coverage proving a Topic MAC is converted to `AppDevice.deviceNo`
- [ ] **Step 1: Add the mapper mock and MAC last-will test**
Add imports and a mapper mock:
```java
import org.dromara.app.domain.AppDevice;
import org.dromara.app.mapper.AppDeviceMapper;
@Mock
private AppDeviceMapper appDeviceMapper;
```
Add this test:
```java
@Test
void handleResolvesLastWillTopicMacToDeviceNo() {
String macAddress = "DC:DA:0C:FA:29:5E";
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(device);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(appDeviceMapper).selectByMac("dc:da:0c:fa:29:5e");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOffline(macAddress, "设备 MQTT 状态离线");
}
```
Add the missing-device guard test:
```java
@Test
void handleDoesNotUpdateStatusWhenLastWillMacIsUnknown() {
String macAddress = "DC:DA:0C:FA:29:5E";
when(appDeviceMapper.selectById(macAddress)).thenReturn(null);
when(appDeviceMapper.selectByMac("dc:da:0c:fa:29:5e")).thenReturn(null);
DeviceIdentityResolver resolver = new DeviceIdentityResolver(appDeviceMapper);
DeviceStatusHandler handler = new DeviceStatusHandler(resolver, deviceStatusService, new ObjectMapper());
handler.handle(macAddress, "{\"status\":\"offline\"}", false);
verify(deviceStatusService, never()).markOffline(anyString(), anyString());
verify(deviceStatusService, never()).markOnline(anyString());
}
```
- [ ] **Step 2: Run the focused test**
Run:
```powershell
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceStatusHandlerTest#handleResolvesLastWillTopicMacToDeviceNo+handleDoesNotUpdateStatusWhenLastWillMacIsUnknown" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: both tests PASS. The requested production path already exists in `DeviceIdentityResolver`; these characterization tests make that behavior explicit and prevent regression.
### Task 2: Clarify Device Status Topic Identity
**Files:**
- Modify: `water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java:15-19`
**Interfaces:**
- Consumes: Existing `DeviceIdentityResolver.resolveDeviceNo(String)` behavior
- Produces: Javadoc that accurately documents device-number and MAC Topic identities
- [ ] **Step 1: Update handler Javadoc**
Replace the class description with:
```java
/**
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* <p>
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。
*/
```
- [ ] **Step 2: Run all handler tests**
Run:
```powershell
mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceStatusHandlerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test
```
Expected: all `DeviceStatusHandlerTest` tests PASS with zero failures and errors.
- [ ] **Step 3: Check the final diff**
Run:
```powershell
git diff --check -- water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java
```
Expected: exit code 0 and no whitespace errors.
- [ ] **Step 4: Leave implementation changes uncommitted for review**
Both implementation files already contain user changes. Do not create a commit that would mix those changes with this task.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -0,0 +1,59 @@
# 绑定排程时向设备下发排程信息设计
## 背景
当前 `AppController.addScheduleDevice` 只负责建立排程与设备的关联关系,不会把排程详情同步下发给设备。这样会导致设备绑定成功后仍然缺少最新排程配置。
## 目标
在 APP 端绑定排程与设备成功时,服务端同步向对应设备下发该排程详情信息,确保设备立即拿到当前排程配置。
## 设计
### 入口与改动范围
- 入口保持在 `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java``addScheduleDevice`
- 复用现有 `IDeviceCommandService.sendScheduleBindCommand(...)`
- 不修改现有 MQTT 发布器、ACK 处理器和设备主题路由
### 下发时机
对请求中的每个 `deviceNo`
1. 校验设备归属
2. 写入排程设备关联
3. 读取当前排程详情
4. 同步下发排程命令
### 命令协议
- `commandType`: `bindSchedule`
- `payload.deviceNo`: 当前设备编号
- `payload.details`: 当前排程详情列表
`payload.details` 每项沿用当前 APP 查询排程详情时对外返回的结构,包含:
- `weekday`
- `timeData`
- `triggerType`
- `status`
如果排程详情里存在 `zones` 字段且当前查询对象可直接取到,则一并下发;否则不额外改造现有排程详情出参结构。
### 失败策略
绑定接口采用同步失败策略:
- 只要某台设备的排程命令下发失败,接口直接返回失败
- 不吞掉下发异常
- 前端可以明确感知“排程绑定/同步下发”未完全成功
本次不额外引入补偿、重试编排或异步任务。
## 测试与验证
- 优先补最小范围的自动化测试;如果模块当前没有现成测试基线,则至少执行模块编译或定向测试命令做回归验证
- 手工验证重点:
- 绑定排程后关联表仍正常写入
- MQTT 下发 payload 包含 `deviceNo``details`
- 设备未登记 MAC 或命令下发异常时,接口返回失败

View File

@@ -0,0 +1,25 @@
# Device Status LWT MAC Resolution Design
## Goal
When a device publishes its MQTT last-will status on `/MAC/publish/status`, resolve the MAC address to the stored `deviceNo` before updating device status.
## Design
- Keep `DeviceStatusHandler` responsible for parsing status payloads and invoking status updates.
- Reuse `DeviceIdentityResolver` to translate the Topic identity into an `AppDevice` and then its `deviceNo`.
- The resolver first supports an existing `deviceNo` Topic and falls back to a case-insensitive `mac_address` lookup. This preserves compatibility while handling LWT Topics that contain a MAC address.
- If no device matches the Topic identity, do not update any record; keep the existing warning log.
- Update the handler documentation to state that the Topic identity may be either a device number or a MAC address.
## Verification
Add a focused unit test using the real `DeviceIdentityResolver` with a mocked `AppDeviceMapper`. The test publishes an offline status with a MAC identity and verifies that:
1. The mapper looks up the MAC address.
2. `MqttDeviceStatusService.markOffline` receives the resolved `deviceNo`.
3. No status update uses the MAC address as a device number.
## Scope
This change only covers `/MAC/publish/status`. Other MQTT communication continues to use `deviceNo` according to the existing protocol.

View File

@@ -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.

View File

@@ -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.

View 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

View File

@@ -1,327 +0,0 @@
2026-06-13 08:59:11 [master:housekeeper] WARN com.zaxxer.hikari.pool.HikariPool - master - Thread starvation or clock leap detected (housekeeper delta=14h57m13s457ms410µs100ns).
2026-06-13 08:59:32 [MQTT Ping: water-server-8081] ERROR o.e.p.c.mqttv3.internal.ClientState - water-server-8081: Timed out as no write activity, keepAlive=60,000,000,000 lastOutboundActivity=150,167,329,517,400 lastInboundActivity=150,167,356,610,400 time=204,053,810,500,400 lastPing=150,167,329,537,400
2026-06-13 08:59:32 [MQTT Ping: water-server-8081] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开Timed out while waiting to write messages to the server
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://service.reinkun.com:8883
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-06-13 08:59:34 [mqtt-consumer-7] WARN o.d.a.handler.ScheduleFinishHandler - [MQTT] 排程完成上报未找到设备绑定的排程 时间=2026-06-13 08:59:34 设备编号=01
2026-06-13 08:59:34 [mqtt-consumer-4] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 50,
"charging": 0
}
2026-06-13 08:59:34 [mqtt-consumer-7] INFO o.d.a.handler.ScheduleFinishHandler - [MQTT] 浇水排程上报已保存 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceNo": "01",
"startWeek": 5,
"startTime": "2026-06-11 8:20",
"durationMin": 20,
"triggerType": "mqtt on",
"triggerON":"mqtt on",
"triggerOFF":"mqtt off"
}
2026-06-13 08:59:34 [mqtt-consumer-8] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 63,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:00:02 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:00:02 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[19]毫秒
2026-06-13 09:00:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:00:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:00:52 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:00:52 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[9]毫秒
2026-06-13 09:01:28 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:01:28 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:02:04 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:02:04 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:02:06 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:02:06 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:05:12 [schedule-pool-10] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[3]毫秒
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:06:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:06:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[8]毫秒
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:08:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9c"}]
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[2]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:08:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:08:49 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:49 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:08:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:08:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 09:08:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:09:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:09:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:09:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:09:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:11:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:11:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:12:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:12:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:12:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:12:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 09:13:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:13:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:15:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:15:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:15:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[5]毫秒
2026-06-13 09:15:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/scheduleDeviceList/2062827585485807618],无参数
2026-06-13 09:15:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/scheduleDeviceList/2062827585485807618],耗时:[7]毫秒
2026-06-13 09:15:36 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addScheduleDevice],参数类型[JSON],参数:[{"scheduleId":"2062827585485807618","deviceNos":["1779176626954"]}]
2026-06-13 09:15:36 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addScheduleDevice],耗时:[20]毫秒
2026-06-13 09:15:37 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:37 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[11]毫秒
2026-06-13 09:15:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[4]毫秒
2026-06-13 09:15:46 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:46 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[6]毫秒
2026-06-13 09:15:50 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:15:50 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:15:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[6]毫秒
2026-06-13 09:17:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:17:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:17:47 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:17:47 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[8]毫秒
2026-06-13 09:17:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:17:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[9]毫秒
2026-06-13 09:17:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:17:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:24:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:24:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:24:17 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:24:17 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:26:05 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:26:05 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:26:07 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:26:07 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:27:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:27:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:27:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:27:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[22]毫秒
2026-06-13 09:46:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:46:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[26]毫秒
2026-06-13 09:46:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[16]毫秒
2026-06-13 09:46:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[20]毫秒
2026-06-13 09:46:58 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:58 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:47:10 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:47:10 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:47:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:47:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:49:16 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:49:16 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:49:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:49:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["666"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[12]毫秒
2026-06-13 09:49:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:51:11 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 09:51:11 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[34]毫秒
2026-06-13 09:51:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:51:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:51:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:51:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:51:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:51:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:51:33 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:51:33 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[7]毫秒
2026-06-13 09:53:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:53:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9c"}]
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[4]毫秒
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:54:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:21 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9e"}]
2026-06-13 09:54:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[1]毫秒
2026-06-13 09:54:21 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:46 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:46 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[4]毫秒
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:55:12 [mqtt-consumer-16] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 09:55:12 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:55:12 [mqtt-consumer-13] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 09:55:12 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 09:55:12 [mqtt-consumer-14] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"浇水设备007","wifiName":"研发部","wifiPassword":"ckkj123456","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 09:55:38 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"73669072eb924e9f9772d80fb9be57f8","bindStatus":true}
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=73669072eb924e9f9772d80fb9be57f8
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[39]毫秒
2026-06-13 09:55:38 [mqtt-consumer-11] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=73669072eb924e9f9772d80fb9be57f8 状态=null
2026-06-13 09:55:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:55:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:55:59 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:56:00 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[1]毫秒
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:57:15 [mqtt-consumer-8] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 09:57:15 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:57:15 [mqtt-consumer-8] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 09:57:15 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 09:57:15 [mqtt-consumer-12] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"智能浇水","wifiName":"测试机","wifiPassword":"66668888","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 09:57:39 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"a783262582fd41fcb09d59d55a07c9e3","bindStatus":true}
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=a783262582fd41fcb09d59d55a07c9e3
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[35]毫秒
2026-06-13 09:57:40 [mqtt-consumer-3] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=a783262582fd41fcb09d59d55a07c9e3 状态=null
2026-06-13 09:57:40 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:57:40 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:59:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:59:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:59:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:59:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:02:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:02:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:02:11 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:02:11 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 10:02:12 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:02:12 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 10:03:42 [schedule-pool-5] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 10:04:26 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:04:26 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:11:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:11:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 10:12:03 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:12:03 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 10:12:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:12:23 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:23 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[13]毫秒
2026-06-13 10:12:28 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:28 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 10:12:36 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:36 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[7]毫秒
2026-06-13 10:12:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["66"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 10:12:42 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:42 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:16:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:16:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[7]毫秒
2026-06-13 10:16:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:16:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:16:46 [mqtt-consumer-1] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 10:16:46 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 10:16:46 [mqtt-consumer-16] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 10:16:46 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 10:16:47 [mqtt-consumer-16] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"还是浇水设备","wifiName":"研发部","wifiPassword":"ckkj123456","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 10:16:57 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"00119ae0fc844c1c88d61052e1ded66b","bindStatus":true}
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=00119ae0fc844c1c88d61052e1ded66b
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[36]毫秒
2026-06-13 10:16:57 [mqtt-consumer-6] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=00119ae0fc844c1c88d61052e1ded66b 状态=null
2026-06-13 10:16:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:16:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[5]毫秒
2026-06-13 10:17:04 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:17:04 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:18:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:18:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:23:12 [schedule-pool-4] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 10:34:56 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:34:56 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[22]毫秒
2026-06-13 10:34:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:34:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:37:22 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:37:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:37:22 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 10:37:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:02 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 10:38:02 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[14]毫秒
2026-06-13 10:38:06 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:38:06 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[12]毫秒
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:38:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:38:13 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:13 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[9]毫秒
2026-06-13 10:38:14 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:14 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:38:16 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:16 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[6]毫秒
2026-06-13 10:38:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:19 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:19 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[5]毫秒
2026-06-13 10:38:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:38:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
2026-06-13 08:59:32 [MQTT Ping: water-server-8081] ERROR o.e.p.c.mqttv3.internal.ClientState - water-server-8081: Timed out as no write activity, keepAlive=60,000,000,000 lastOutboundActivity=150,167,329,517,400 lastInboundActivity=150,167,356,610,400 time=204,053,810,500,400 lastPing=150,167,329,537,400

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,323 +0,0 @@
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://service.reinkun.com:8883
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-06-13 08:59:34 [MQTT Call: water-server-8081] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-06-13 08:59:34 [mqtt-consumer-4] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 50,
"charging": 0
}
2026-06-13 08:59:34 [mqtt-consumer-7] INFO o.d.a.handler.ScheduleFinishHandler - [MQTT] 浇水排程上报已保存 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceNo": "01",
"startWeek": 5,
"startTime": "2026-06-11 8:20",
"durationMin": 20,
"triggerType": "mqtt on",
"triggerON":"mqtt on",
"triggerOFF":"mqtt off"
}
2026-06-13 08:59:34 [mqtt-consumer-8] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 08:59:34 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 63,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:00:02 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:00:02 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[19]毫秒
2026-06-13 09:00:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:00:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:00:52 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:00:52 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[9]毫秒
2026-06-13 09:01:28 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:01:28 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:02:04 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:02:04 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:02:06 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:02:06 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:05:12 [schedule-pool-10] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[3]毫秒
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:05:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:06:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:06:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[8]毫秒
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:07:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:08:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9c"}]
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[2]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:08:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:08:49 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:49 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:08:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:08:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:08:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 09:08:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:08:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:09:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:09:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:09:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:09:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:11:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:11:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:12:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:12:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:12:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:12:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 09:13:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:13:56 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 09:15:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:15:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:15:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[5]毫秒
2026-06-13 09:15:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/scheduleDeviceList/2062827585485807618],无参数
2026-06-13 09:15:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/scheduleDeviceList/2062827585485807618],耗时:[7]毫秒
2026-06-13 09:15:36 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addScheduleDevice],参数类型[JSON],参数:[{"scheduleId":"2062827585485807618","deviceNos":["1779176626954"]}]
2026-06-13 09:15:36 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addScheduleDevice],耗时:[20]毫秒
2026-06-13 09:15:37 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:37 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[11]毫秒
2026-06-13 09:15:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[4]毫秒
2026-06-13 09:15:46 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:46 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[6]毫秒
2026-06-13 09:15:50 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:15:50 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:15:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:15:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[6]毫秒
2026-06-13 09:17:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:17:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:17:47 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:17:47 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[8]毫秒
2026-06-13 09:17:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedule/2062827585485807618],无参数
2026-06-13 09:17:48 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedule/2062827585485807618],耗时:[9]毫秒
2026-06-13 09:17:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:17:51 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:24:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:24:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:24:17 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:24:17 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:26:05 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:26:05 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:26:07 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:26:07 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:27:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:27:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:27:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:27:40 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[22]毫秒
2026-06-13 09:46:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:46:39 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[26]毫秒
2026-06-13 09:46:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:42 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[16]毫秒
2026-06-13 09:46:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:54 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[20]毫秒
2026-06-13 09:46:58 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:46:58 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:47:10 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:47:10 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[11]毫秒
2026-06-13 09:47:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:47:14 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:49:16 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:49:16 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:49:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:18 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:49:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["666"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[12]毫秒
2026-06-13 09:49:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:49:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 09:51:11 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 09:51:11 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[34]毫秒
2026-06-13 09:51:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 09:51:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 09:51:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:51:24 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:51:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:51:32 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:51:33 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:51:33 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[7]毫秒
2026-06-13 09:53:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 09:53:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:23 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9c"}]
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[4]毫秒
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:53:53 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:54:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:12 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:21 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"dc:da:0c:fa:28:9e"}]
2026-06-13 09:54:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[1]毫秒
2026-06-13 09:54:21 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:46 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:46 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[4]毫秒
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:54:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 09:55:12 [mqtt-consumer-16] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 09:55:12 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:55:12 [mqtt-consumer-13] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 09:55:12 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 09:55:12 [mqtt-consumer-14] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"浇水设备007","wifiName":"研发部","wifiPassword":"ckkj123456","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 09:55:38 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"73669072eb924e9f9772d80fb9be57f8","bindStatus":true}
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=73669072eb924e9f9772d80fb9be57f8
2026-06-13 09:55:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[39]毫秒
2026-06-13 09:55:38 [mqtt-consumer-11] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=73669072eb924e9f9772d80fb9be57f8 状态=null
2026-06-13 09:55:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:55:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:55:59 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:56:00 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/bindDeviceStatus],参数类型[JSON],参数:[{"macAddress":"DC:DA:0C:FA:28:9E"}]
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/bindDeviceStatus],耗时:[1]毫秒
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:56:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 09:57:15 [mqtt-consumer-8] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 09:57:15 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 09:57:15 [mqtt-consumer-8] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 09:57:15 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 09:57:15 [mqtt-consumer-12] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"智能浇水","wifiName":"测试机","wifiPassword":"66668888","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 09:57:39 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"a783262582fd41fcb09d59d55a07c9e3","bindStatus":true}
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=a783262582fd41fcb09d59d55a07c9e3
2026-06-13 09:57:39 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[35]毫秒
2026-06-13 09:57:40 [mqtt-consumer-3] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=a783262582fd41fcb09d59d55a07c9e3 状态=null
2026-06-13 09:57:40 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 09:57:40 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 09:59:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:59:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 09:59:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 09:59:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:02:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:02:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:02:11 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:02:11 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[3]毫秒
2026-06-13 10:02:12 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:02:12 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 10:03:42 [schedule-pool-5] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 10:04:26 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:04:26 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:11:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:11:55 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[2]毫秒
2026-06-13 10:12:03 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:12:03 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 10:12:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:12:23 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["fuck"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:23 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[13]毫秒
2026-06-13 10:12:28 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:28 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 10:12:36 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:36 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[7]毫秒
2026-06-13 10:12:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":["66"],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:38 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[10]毫秒
2026-06-13 10:12:42 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:12:42 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:16:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:16:20 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[7]毫秒
2026-06-13 10:16:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:16:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:16:46 [mqtt-consumer-1] INFO o.d.a.handler.DeviceRegisterHandler - [MQTT] 设备注册 时间=2026-06-13 10:16:46 设备编号=01 消息体={
"deviceNo": "01",
"deviceName": "Waterer_01",
"powerLevel": 48,
"deviceEm": "11667796A-Y43-2606",
"deviceSn": "260601",
"deviceMac": "dc:da:0c:fa:28:9c",
"version": "1.0"
}
2026-06-13 10:16:46 [mqtt-consumer-16] INFO o.d.app.handler.DeviceDataHandler - [MQTT] 设备电量更新 时间=2026-06-13 10:16:46 设备编号=01 消息体={
"deviceName": "Waterer_01",
"powerLevel": 48,
"charging": 0
}
2026-06-13 10:16:47 [mqtt-consumer-16] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=2ab3a6e58d1b4a89919d218fda63b3f7 状态=null
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[POST /app/v1/addDevice],参数类型[JSON],参数:[{"deviceName":"还是浇水设备","wifiName":"研发部","wifiPassword":"ckkj123456","macAddress":"DC:DA:0C:FA:28:9E","deviceNo":"01"}]
2026-06-13 10:16:57 [XNIO-1 task-4] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 消息已发布 主题=/01/subscriber/cmd 消息体={"commandType":"bindDevice","deviceNo":"01","cmd":-1,"commandId":"00119ae0fc844c1c88d61052e1ded66b","bindStatus":true}
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.a.s.i.DeviceCommandServiceImpl - [命令] 命令已下发 设备编号=01 命令类型=bindDevice 命令编号=00119ae0fc844c1c88d61052e1ded66b
2026-06-13 10:16:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[POST /app/v1/addDevice],耗时:[36]毫秒
2026-06-13 10:16:57 [mqtt-consumer-6] INFO o.dromara.mqtt.MqttCommandAckService - [MQTT] 收到命令确认 设备编号=01 命令编号=00119ae0fc844c1c88d61052e1ded66b 状态=null
2026-06-13 10:16:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/device/01],无参数
2026-06-13 10:16:58 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/device/01],耗时:[5]毫秒
2026-06-13 10:17:04 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:17:04 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:18:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:18:48 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:23:12 [schedule-pool-4] INFO o.d.app.task.DeviceOfflineCheckTask - [设备离线] 检测到 1 台设备离线,已更新数据库状态,设备编号:[01]
2026-06-13 10:34:56 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:34:56 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[22]毫秒
2026-06-13 10:34:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:34:57 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:35:05 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:37:22 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:37:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:37:22 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[18]毫秒
2026-06-13 10:37:22 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:02 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 10:38:02 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[14]毫秒
2026-06-13 10:38:06 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:38:06 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/waterLogList],参数类型[表单参数],参数:[{"pageNum":["1"]}]
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/waterLogList],耗时:[12]毫秒
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/schedulelist],参数类型[表单参数],参数:[{"name":[""],"pageSize":["10"],"pageNum":["1"]}]
2026-06-13 10:38:08 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/schedulelist],耗时:[9]毫秒
2026-06-13 10:38:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:09 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[15]毫秒
2026-06-13 10:38:13 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:13 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[9]毫秒
2026-06-13 10:38:14 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:14 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:38:16 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:16 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[6]毫秒
2026-06-13 10:38:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:19 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/getUserInfo],无参数
2026-06-13 10:38:19 [XNIO-1 task-4] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/getUserInfo],耗时:[5]毫秒
2026-06-13 10:38:19 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[16]毫秒
2026-06-13 10:38:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:21 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[17]毫秒
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:25 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[13]毫秒
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]开始请求 => URL[GET /app/v1/deviceList],无参数
2026-06-13 10:38:27 [XNIO-1 task-3] INFO o.d.c.w.i.PlusWebInvokeTimeInterceptor - [请求]结束请求 => URL[GET /app/v1/deviceList],耗时:[14]毫秒

41
overview.md Normal file
View 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 零容忍

View File

@@ -0,0 +1,18 @@
CREATE TABLE `app_version` (
`id` bigint NOT NULL COMMENT '主键',
`platform` varchar(20) NOT NULL COMMENT '平台 android/ios',
`latest_version` varchar(50) NOT NULL COMMENT '最新版本号',
`version_code` int NOT NULL COMMENT '版本序号,用于排序取最新版本',
`force_update` char(1) NOT NULL DEFAULT '0' COMMENT '是否强制更新 0否 1是',
`download_url` varchar(500) DEFAULT NULL COMMENT '下载地址',
`release_notes` varchar(1000) DEFAULT NULL COMMENT '更新说明',
`status` char(1) NOT NULL DEFAULT '1' COMMENT '状态 0停用 1启用',
`create_dept` bigint DEFAULT NULL COMMENT '创建部门',
`create_by` bigint DEFAULT NULL COMMENT '创建者',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` bigint DEFAULT NULL COMMENT '更新者',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
KEY `idx_app_version_platform_status_code` (`platform`, `status`, `version_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='APP版本表';

View File

@@ -20,7 +20,8 @@ INSERT INTO sys_menu VALUES ('11654', '流程变量查询', '11621', '2', '#', '
INSERT INTO sys_menu VALUES ('11655', '流程变量修改', '11621', '3', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:variable', '#', 103, 1, sysdate(), null, null, ''); INSERT INTO sys_menu VALUES ('11655', '流程变量修改', '11621', '3', '#', '', '', 1, 0, 'F', '0', '0', 'workflow:instance:variable', '#', 103, 1, sysdate(), null, null, '');
-- Device binding and device_no primary key migration. -- Device binding and device_no primary key migration.
ALTER TABLE app_device ADD COLUMN bind_token_hash varchar(255) NULL COMMENT '设备绑定令牌哈希' AFTER device_no; ALTER TABLE app_device ADD COLUMN qrcode varchar(1000) NULL COMMENT '设备二维码' AFTER device_img;
ALTER TABLE app_watering_log ADD COLUMN remark varchar(500) NULL COMMENT '备注' AFTER trigger_type;
ALTER TABLE app_scheduling_device ADD COLUMN device_no varchar(64) NULL COMMENT '设备编号' AFTER schedule_id; ALTER TABLE app_scheduling_device ADD COLUMN device_no varchar(64) NULL COMMENT '设备编号' AFTER schedule_id;
UPDATE app_scheduling_device sd UPDATE app_scheduling_device sd
INNER JOIN app_device d ON sd.device_id = d.id INNER JOIN app_device d ON sd.device_id = d.id

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,595 @@
{
"schema_version": 1,
"created_at": "2026-07-16T06:34:52.640872+00:00",
"run_dir": "D:\\code\\water\\tmp\\hatch-pet\\omen-cat\\run",
"primary_generation_skill": "$imagegen",
"jobs": [
{
"id": "base",
"kind": "base-pet",
"status": "pending",
"prompt_file": "prompts/base-pet.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
}
],
"output_path": "decoded/base.png",
"depends_on": [],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false
},
{
"id": "idle",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/idle.md",
"retry_prompt_file": "prompts/row-retries/idle.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/idle.png",
"role": "layout guide for 6 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/idle.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "no deterministic derivation is configured for this state"
},
"mirror_policy": {}
},
{
"id": "running-right",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/running-right.md",
"retry_prompt_file": "prompts/row-retries/running-right.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/running-right.png",
"role": "layout guide for 8 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/running-right.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "no deterministic derivation is configured for this state"
},
"mirror_policy": {}
},
{
"id": "running-left",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/running-left.md",
"retry_prompt_file": "prompts/row-retries/running-left.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/running-left.png",
"role": "layout guide for 8 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
},
{
"path": "decoded/running-right.png",
"role": "rightward gait reference for leftward row decision"
}
],
"output_path": "decoded/running-left.png",
"depends_on": [
"base",
"running-right"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base",
"running-right"
],
"derivation_policy": {
"may_derive": true,
"may_derive_from": "running-right",
"derivation": "framewise-horizontal-mirror-preserving-order",
"requires_explicit_approval": true,
"fallback_generation_skill": "$imagegen"
},
"mirror_policy": {
"may_derive": true,
"may_derive_from": "running-right",
"derivation": "framewise-horizontal-mirror-preserving-order",
"requires_explicit_approval": true,
"fallback_generation_skill": "$imagegen"
}
},
{
"id": "waving",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/waving.md",
"retry_prompt_file": "prompts/row-retries/waving.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/waving.png",
"role": "layout guide for 4 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/waving.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "jumping",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/jumping.md",
"retry_prompt_file": "prompts/row-retries/jumping.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/jumping.png",
"role": "layout guide for 5 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/jumping.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "failed",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/failed.md",
"retry_prompt_file": "prompts/row-retries/failed.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/failed.png",
"role": "layout guide for 8 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/failed.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "waiting",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/waiting.md",
"retry_prompt_file": "prompts/row-retries/waiting.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/waiting.png",
"role": "layout guide for 6 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/waiting.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "running",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/running.md",
"retry_prompt_file": "prompts/row-retries/running.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/running.png",
"role": "layout guide for 6 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/running.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "review",
"kind": "row-strip",
"status": "pending",
"prompt_file": "prompts/rows/review.md",
"retry_prompt_file": "prompts/row-retries/review.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/review.png",
"role": "layout guide for 6 frame slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
}
],
"output_path": "decoded/review.png",
"depends_on": [
"base"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"parallelizable_after": [
"base"
],
"derivation_policy": {
"may_derive": false,
"reason": "state requires its own generated animation semantics"
},
"mirror_policy": {}
},
{
"id": "look-cardinals",
"kind": "look-cardinal-strip",
"status": "pending",
"prompt_file": "prompts/look-cardinals.md",
"repair_prompt_files": {
"000": "prompts/look-anchor-repairs/000.md",
"090": "prompts/look-anchor-repairs/090.md",
"180": "prompts/look-anchor-repairs/180.md",
"270": "prompts/look-anchor-repairs/270.md"
},
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/look-cardinals.png",
"role": "layout guide for four cardinal slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
},
{
"path": "qa/contact-sheet.png",
"role": "approved standard-row identity, scale, and baseline reference"
}
],
"output_path": "decoded/look-cardinals.png",
"extracted_output_paths": [
"decoded/look-anchors/000.png",
"decoded/look-anchors/090.png",
"decoded/look-anchors/180.png",
"decoded/look-anchors/270.png"
],
"approved_strip_path": "decoded/look-anchors-approved.png",
"depends_on": [
"idle",
"running-right",
"running-left",
"waving",
"jumping",
"failed",
"waiting",
"running",
"review"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"look_mechanics_file": "qa/look-mechanics.md",
"directions": [
"000",
"090",
"180",
"270"
],
"packaging_eligible": false,
"parallelizable_after": [
"idle",
"running-right",
"running-left",
"waving",
"jumping",
"failed",
"waiting",
"running",
"review"
],
"derivation_policy": {
"may_derive": false,
"reason": "cardinal directions require grounded pet-specific generation"
}
},
{
"id": "look-row-9",
"kind": "look-row-strip",
"status": "pending",
"prompt_file": "prompts/rows/look-row-9.md",
"retry_prompt_file": "prompts/row-retries/look-row-9.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/look-row-9.png",
"role": "layout guide for 8 direction slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
},
{
"path": "qa/contact-sheet.png",
"role": "approved standard-row identity, scale, and baseline reference"
},
{
"path": "decoded/look-anchors-approved.png",
"role": "approved cardinal reference strip in order 000 up, 090 screen-right, 180 down, 270 screen-left; interpolate intermediate directions evenly"
}
],
"output_path": "decoded/look-row-9.png",
"depends_on": [
"look-cardinals"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"look_mechanics_file": "qa/look-mechanics.md",
"directions": [
"000",
"022.5",
"045",
"067.5",
"090",
"112.5",
"135",
"157.5"
],
"parallelizable_after": [
"look-cardinals"
],
"derivation_policy": {
"may_derive": false,
"reason": "look directions require grounded pet-specific generation"
},
"coherent_synthesis_required": true,
"individual_cell_packaging_allowed": false,
"packaging_eligible": true
},
{
"id": "look-row-10",
"kind": "look-row-strip",
"status": "pending",
"prompt_file": "prompts/rows/look-row-10.md",
"retry_prompt_file": "prompts/row-retries/look-row-10.md",
"input_images": [
{
"path": "references\\reference-01.png",
"role": "pet reference"
},
{
"path": "references/layout-guides/look-row-10.png",
"role": "layout guide for 8 direction slots; use for spacing only, do not copy guide lines"
},
{
"path": "references/canonical-base.png",
"role": "canonical identity reference"
},
{
"path": "qa/contact-sheet.png",
"role": "approved standard-row identity, scale, and baseline reference"
},
{
"path": "decoded/look-anchors-approved.png",
"role": "approved cardinal reference strip in order 000 up, 090 screen-right, 180 down, 270 screen-left; interpolate intermediate directions evenly"
},
{
"path": "decoded/look-row-9.png",
"role": "completed first half of the clockwise look loop for row 10 continuity"
}
],
"output_path": "decoded/look-row-10.png",
"depends_on": [
"look-cardinals",
"look-row-9"
],
"generation_skill": "$imagegen",
"requires_grounded_generation": true,
"allow_prompt_only_generation": false,
"identity_reference_paths": [
"references/canonical-base.png"
],
"look_mechanics_file": "qa/look-mechanics.md",
"directions": [
"180",
"202.5",
"225",
"247.5",
"270",
"292.5",
"315",
"337.5"
],
"parallelizable_after": [
"look-cardinals",
"look-row-9"
],
"derivation_policy": {
"may_derive": false,
"reason": "look directions require grounded pet-specific generation"
},
"coherent_synthesis_required": true,
"individual_cell_packaging_allowed": false,
"packaging_eligible": true
}
]
}

View File

@@ -0,0 +1,280 @@
{
"pet_id": "youmiao",
"display_name": "\u5e7d\u55b5",
"description": "\u4e00\u53ea\u62ab\u7740\u6697\u7d2b\u5e7d\u5f71\u515c\u5e3d\u3001\u4ee5\u51b7\u9752\u9762\u7eb9\u89c2\u5bdf\u4efb\u52a1\u7684\u6c89\u9759\u732b\u5f62\u4f19\u4f34.",
"created_at": "2026-07-16T06:34:52.627259+00:00",
"sprite_version_number": 2,
"atlas": {
"columns": 8,
"rows": 11,
"cell_width": 192,
"cell_height": 208,
"width": 1536,
"height": 2288
},
"rows": [
{
"state": "idle",
"row": 0,
"frames": 6,
"purpose": "calm resting, breathing, and blinking loop"
},
{
"state": "running-right",
"row": 1,
"frames": 8,
"purpose": "rightward drag movement loop"
},
{
"state": "running-left",
"row": 2,
"frames": 8,
"purpose": "leftward drag movement loop"
},
{
"state": "waving",
"row": 3,
"frames": 4,
"purpose": "greeting or attention gesture"
},
{
"state": "jumping",
"row": 4,
"frames": 5,
"purpose": "hover or playful jump"
},
{
"state": "failed",
"row": 5,
"frames": 8,
"purpose": "blocked, failed, or cancelled reaction"
},
{
"state": "waiting",
"row": 6,
"frames": 6,
"purpose": "waiting for approval, help, or user input"
},
{
"state": "running",
"row": 7,
"frames": 6,
"purpose": "active task work or processing"
},
{
"state": "review",
"row": 8,
"frames": 6,
"purpose": "ready or completed output review"
},
{
"state": "look-row-9",
"row": 9,
"frames": 8,
"directions": [
"000",
"022.5",
"045",
"067.5",
"090",
"112.5",
"135",
"157.5"
],
"purpose": "clockwise look directions from up through down-right"
},
{
"state": "look-row-10",
"row": 10,
"frames": 8,
"directions": [
"180",
"202.5",
"225",
"247.5",
"270",
"292.5",
"315",
"337.5"
],
"purpose": "clockwise look directions from down through up-left"
}
],
"layout_guides": [
{
"state": "idle",
"path": "references\\layout-guides\\idle.png",
"width": 1152,
"height": 208,
"frames": 6,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "running-right",
"path": "references\\layout-guides\\running-right.png",
"width": 1536,
"height": 208,
"frames": 8,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "running-left",
"path": "references\\layout-guides\\running-left.png",
"width": 1536,
"height": 208,
"frames": 8,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "waving",
"path": "references\\layout-guides\\waving.png",
"width": 768,
"height": 208,
"frames": 4,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "jumping",
"path": "references\\layout-guides\\jumping.png",
"width": 960,
"height": 208,
"frames": 5,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "failed",
"path": "references\\layout-guides\\failed.png",
"width": 1536,
"height": 208,
"frames": 8,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "waiting",
"path": "references\\layout-guides\\waiting.png",
"width": 1152,
"height": 208,
"frames": 6,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "running",
"path": "references\\layout-guides\\running.png",
"width": 1152,
"height": 208,
"frames": 6,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "review",
"path": "references\\layout-guides\\review.png",
"width": 1152,
"height": 208,
"frames": 6,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "look-row-9",
"path": "references\\layout-guides\\look-row-9.png",
"width": 1536,
"height": 208,
"frames": 8,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "look-row-10",
"path": "references\\layout-guides\\look-row-10.png",
"width": 1536,
"height": 208,
"frames": 8,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
},
{
"state": "look-cardinals",
"path": "references\\layout-guides\\look-cardinals.png",
"width": 768,
"height": 208,
"frames": 4,
"cell_width": 192,
"cell_height": 208,
"safe_margin_x": 18,
"safe_margin_y": 16,
"usage": "layout guide input only; do not copy visible guide lines into generated sprite strips"
}
],
"references": [
{
"path": "D:\\code\\water\\tmp\\hatch-pet\\omen-cat\\run\\references\\reference-01.png",
"width": 364,
"height": 354,
"mode": "RGB",
"format": "PNG",
"source_path": "D:\\code\\water\\tmp\\hatch-pet\\omen-cat\\reference\\omen-cat-source.png",
"copied_path": "D:\\code\\water\\tmp\\hatch-pet\\omen-cat\\run\\references\\reference-01.png"
}
],
"chroma_key": {
"hex": "#FFFF00",
"rgb": [
255,
255,
0
],
"name": "yellow",
"selection": "auto",
"score": 217.85
},
"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",
"style_contract": "Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.",
"brand_name": "",
"brand_brief": "",
"brand_sources": [],
"pet_safe_style": "Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction.",
"primary_generation_skill": "$imagegen"
}

View File

@@ -0,0 +1,7 @@
Create one clean full-body reference sprite for Codex pet 幽喵.
Pet identity: 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: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Place a single centered pose on a perfectly flat pure yellow #FFFF00 chroma-key background. Keep the full pet visible, compact, readable at 192x208, and easy to animate. Preserve approved reference identity cues. No scenery, text, borders, checkerboard transparency, shadows, glows, detached effects, or extra props. Keep #FFFF00 and close colors out of the pet, props, highlights, and effects.

View File

@@ -0,0 +1,7 @@
Repair one cardinal anchor for Codex pet `youmiao`: `000` means looking up.
Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. Keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge. Screen coordinates are viewer-relative.
Output one centered complete full-body pose on a flat pure yellow #FFFF00 background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,7 @@
Repair one cardinal anchor for Codex pet `youmiao`: `090` means looking right.
Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. Put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center. Screen coordinates are viewer-relative.
Output one centered complete full-body pose on a flat pure yellow #FFFF00 background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,7 @@
Repair one cardinal anchor for Codex pet `youmiao`: `180` means looking down.
Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. Keep the face broadly frontal and point the eyes and natural head mechanism toward the BOTTOM edge. Screen coordinates are viewer-relative.
Output one centered complete full-body pose on a flat pure yellow #FFFF00 background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,7 @@
Repair one cardinal anchor for Codex pet `youmiao`: `270` means looking left.
Use the canonical base, completed standard contact sheet, approved cardinal-strip cells, and `qa/look-mechanics.md` for identity, scale, registration, and pet-specific gaze mechanics. Put the nose tip, pupils, face surface, or natural aiming feature on the screen-left side of the head center. Screen coordinates are viewer-relative.
Output one centered complete full-body pose on a flat pure yellow #FFFF00 background with generous padding. Keep the feet/base and lower body registered to the approved anchors. The requested cardinal must be unmistakable at final 192x208 display size.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, arrows, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,11 @@
Create one horizontal four-cardinal anchor strip for Codex pet `youmiao`.
Use the attached canonical base, completed standard contact sheet, and layout guide for exact identity, style, scale, baseline, face construction, materials, palette, markings, props, and spacing. Read `qa/look-mechanics.md` and use the pet's natural gaze mechanism.
Output exactly four centered complete full-body poses in this exact left-to-right order: `000 up`, `090 screen-right`, `180 down`, `270 screen-left`. Screen-left and screen-right always mean the viewer's image edges, never the character's own left or right.
For `000`, keep the face broadly frontal and point the eyes and natural head mechanism toward the TOP edge. For `090`, put the nose tip, pupils, face surface, or natural aiming feature on the screen-right side of the head center. For `180`, keep the face broadly frontal and point toward the BOTTOM edge. For `270`, apply the inverse screen-left landmark rule. Every cardinal must be unmistakable without labels.
Place one pose in each invisible equal-width slot on a flat pure yellow #FFFF00 background with generous padding. Keep scale, feet/base, lower body, and registration consistent across all four slots.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement eyes, labels, degree text, arrows, boxes, guide marks, shadows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,14 @@
Create Codex pet row `failed` for `youmiao`: exactly 8 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.
State requirements:
- Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.
- Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.
- Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,18 @@
Create Codex pet row `idle` for `youmiao`: exactly 6 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.
State requirements:
- CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.
- Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.
- Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.
- Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.
- Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.
- Feet, base, body, or object anchor should remain planted or nearly planted.
- The first and last frames should be very close visually so the loop feels calm and does not pop.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,14 @@
Create Codex pet row `jumping` for `youmiao`: exactly 5 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.
State requirements:
- Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.
- Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.
- Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,28 @@
Create Codex v2 pet look row 10 for `youmiao` as exactly 8 full-body frames in this order: 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5.
Use the canonical base, standard contact sheet, layout guide, approved four-cardinal strip, and `qa/look-mechanics.md`. Draw the complete eight-pose row as one coherent animation family, interpolating even 22.5-degree steps between the cardinal pose families. Keep the same pet identity, face construction, materials, palette, markings, and props. Each direction must read correctly at pet size and join continuously at the 000 and 180 boundaries.
DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates:
1. `180`: vertical DOWN; no horizontal requirement.
2. `202.5`: horizontal SCREEN-LEFT and vertical DOWN.
3. `225`: horizontal SCREEN-LEFT and vertical DOWN.
4. `247.5`: horizontal SCREEN-LEFT and vertical DOWN.
5. `270`: horizontal SCREEN-LEFT; no vertical requirement.
6. `292.5`: horizontal SCREEN-LEFT and vertical UP.
7. `315`: horizontal SCREEN-LEFT and vertical UP.
8. `337.5`: horizontal SCREEN-LEFT and vertical UP.
Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.
HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline.
Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently.
Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite.
ROW-BOUNDARY LOCK: 180 must continue directly from row 9's 157.5, matching its body size, baseline, planted anchor, expression, and construction. 337.5 must be one even 22.5-degree step before 000: nearly up-facing while remaining on the overall left-hand arc. Do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component.
PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or 180 does not continue from 157.5 or 337.5 does not flow evenly into 000. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation.
Use a flat pure yellow #FFFF00 background. One complete unclipped pose per invisible slot. No whole-sprite rotation, replacement eyes, labels, guide marks, shadows, glows, scenery, detached effects, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,28 @@
Create Codex v2 pet look row 9 for `youmiao` as exactly 8 full-body frames in this order: 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5.
Use the canonical base, standard contact sheet, layout guide, approved four-cardinal strip, and `qa/look-mechanics.md`. Draw the complete eight-pose row as one coherent animation family, interpolating even 22.5-degree steps between the cardinal pose families. Keep the same pet identity, face construction, materials, palette, markings, and props. Each direction must read correctly at pet size and join continuously at the 000 and 180 boundaries.
DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates:
1. `000`: vertical UP; no horizontal requirement.
2. `022.5`: horizontal SCREEN-RIGHT and vertical UP.
3. `045`: horizontal SCREEN-RIGHT and vertical UP.
4. `067.5`: horizontal SCREEN-RIGHT and vertical UP.
5. `090`: horizontal SCREEN-RIGHT; no vertical requirement.
6. `112.5`: horizontal SCREEN-RIGHT and vertical DOWN.
7. `135`: horizontal SCREEN-RIGHT and vertical DOWN.
8. `157.5`: horizontal SCREEN-RIGHT and vertical DOWN.
Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.
HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline.
Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently.
Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite.
ROW-BOUNDARY LOCK: 157.5 must be one even 22.5-degree step before 180. Match the approved 180 pose's body size, baseline, planted anchor, expression, and construction. Preserve the overall right-hand arc, but do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component.
PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or 157.5 does not flow evenly into 180. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation.
Use a flat pure yellow #FFFF00 background. One complete unclipped pose per invisible slot. No whole-sprite rotation, replacement eyes, labels, guide marks, shadows, glows, scenery, detached effects, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,13 @@
Create Codex pet row `review` for `youmiao`: exactly 6 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.
State requirements:
- Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.
- Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,15 @@
Create Codex pet row `running-left` for `youmiao`: exactly 8 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Dragging-left loop: show directional movement to the left through body and limb poses only.
State requirements:
- Show directional drag movement to the left through body, limb, and prop movement only.
- The row must unmistakably face and travel left.
- The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.
- Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,15 @@
Create Codex pet row `running-right` for `youmiao`: exactly 8 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Dragging-right loop: show directional movement to the right through body and limb poses only.
State requirements:
- Show directional drag movement to the right through body, limb, and prop movement only.
- The row must unmistakably face and travel right.
- The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.
- Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,13 @@
Create Codex pet row `running` for `youmiao`: exactly 6 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.
State requirements:
- Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.
- Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,13 @@
Create Codex pet row `waiting` for `youmiao`: exactly 6 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Needs-input loop: expectant asking pose for approval, help, or user input.
State requirements:
- Show that Codex needs approval, help, or user input through an expectant asking pose.
- Keep the motion patient and readable, without turning it into ordinary idle or review.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,13 @@
Create Codex pet row `waving` for `youmiao`: exactly 4 full-body frames in one horizontal strip on flat pure yellow #FFFF00.
Use the attached canonical base for identity and the layout guide only for spacing. Same pet in every frame: 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. Preserve silhouette, face, palette, material, proportions, markings, and props.
Keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`.
Action: Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.
State requirements:
- Show the greeting through paw, hand, wing, or limb pose only.
- Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.
One centered complete pose per invisible slot. No text, boxes, guide marks, scenery, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or #FFFF00 colors in the pet.

View File

@@ -0,0 +1,18 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `failed`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 8 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 8 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Blocked/failed loop: slumped or deflated reaction with sad or closed eyes.
State requirements:
- Show failure through slumped pose, drooping ears/limbs, closed or sad eyes, and lower body position.
- Tears, small smoke puffs, or tiny stars are allowed only if attached to or overlapping the pet silhouette and kept inside the same frame slot.
- Do not draw red X marks, floating symbols, detached stars, separated smoke clouds, falling tear drops, dust, or other loose effects.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,22 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `idle`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 6 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 6 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Calm low-distraction resting loop: subtle breathing, tiny blink, slight head/body bob, and only quiet persona-preserving motion.
State requirements:
- CRITICAL: idle is the low-distraction baseline state and the first frame is also used as the reduced-motion static pet.
- Use only subtle idle motion: gentle breathing, a tiny blink, a slight head or body bob, a very small material sway, or another quiet motion that fits the pet persona.
- Keep the pet essentially in the same pose, facing direction, silhouette, markings, palette, and prop state across all 6 frames.
- Idle variation must stay calm but still read as animation; do not repeat effectively identical copies across the loop.
- Do not show waving, walking, running, jumping, talking, working, reviewing, emotional reactions, large gestures, item interactions, or new props.
- Feet, base, body, or object anchor should remain planted or nearly planted.
- The first and last frames should be very close visually so the loop feels calm and does not pop.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,18 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `jumping`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 5 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 5 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Hover jump loop: anticipation, lift, airborne peak, descent, and settle through body height.
State requirements:
- Show the jump through pose and vertical body position only: anticipation, lift, airborne peak, descent, settle.
- Do not draw ground shadows, contact shadows, drop shadows, oval shadows, landing marks, dust, smears, bounce pads, or motion marks under the pet.
- Keep the background outside the pet perfectly flat chroma key with no darker key-colored patches.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,36 @@
Create one horizontal look-direction strip for Codex pet `youmiao`, atlas row 10.
Use the attached canonical base, completed standard contact sheet, layout guide, and approved four-cardinal strip for identity, scale, registration, spacing, direction semantics, and cross-row continuity. Read `qa/look-mechanics.md` and follow its pet-specific movement and eye/prop mechanics. The approved cardinal strip and completed coherent row 9 are authoritative. Use the cardinals for direction meaning and row 9 for cross-row identity, scale, registration, and continuity.
COHERENT SYNTHESIS LOCK: produce one unified eight-pose row. Do not paste, tile, or independently restyle individual cells. Every final cell must be drawn together with the same face construction, body proportions, line/render quality, lighting, materials, scale, baseline, and registration.
Output exactly 8 complete full-body frames in this exact left-to-right order: 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5. Degrees are clockwise: 000 is up, 090 right, 180 down, and 270 left. Neutral/front is not part of this row.
DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates:
1. `180`: vertical DOWN; no horizontal requirement.
2. `202.5`: horizontal SCREEN-LEFT and vertical DOWN.
3. `225`: horizontal SCREEN-LEFT and vertical DOWN.
4. `247.5`: horizontal SCREEN-LEFT and vertical DOWN.
5. `270`: horizontal SCREEN-LEFT; no vertical requirement.
6. `292.5`: horizontal SCREEN-LEFT and vertical UP.
7. `315`: horizontal SCREEN-LEFT and vertical UP.
8. `337.5`: horizontal SCREEN-LEFT and vertical UP.
Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.
SCREEN-COORDINATE LOCK: screen-left means the viewer's left image edge, never the character's own left. The row should travel naturally through the left half of the loop. Near-vertical 202.5 and 337.5 may have subtle horizontal cues; prioritize a coherent arc over exact pupil or nose placement.
HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline.
Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently.
Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite.
Place one centered pose in each invisible equal-width slot on flat pure yellow #FFFF00. Change only the natural parts needed to express gaze: eyes, eyelids, head, face, neck, upper body, appendages, and constrained prop follow-through. Keep identity, silhouette, materials, palette, markings, and props consistent.
ROW-BOUNDARY LOCK: 180 must continue directly from row 9's 157.5, matching its body size, baseline, planted anchor, expression, and construction. 337.5 must be one even 22.5-degree step before 000: nearly up-facing while remaining on the overall left-hand arc. Do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component.
PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or 180 does not continue from 157.5 or 337.5 does not flow evenly into 000. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement/googly eyes, labels, degree text, arrows, clocks, grids, shadows, glows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,36 @@
Create one horizontal look-direction strip for Codex pet `youmiao`, atlas row 9.
Use the attached canonical base, completed standard contact sheet, layout guide, and approved four-cardinal strip for identity, scale, registration, spacing, direction semantics, and cross-row continuity. Read `qa/look-mechanics.md` and follow its pet-specific movement and eye/prop mechanics. The approved cardinal strip is authoritative for the up, screen-right, down, and screen-left pose families. Interpolate the intermediate directions as even 22.5-degree steps between those anchors.
COHERENT SYNTHESIS LOCK: produce one unified eight-pose row. Do not paste, tile, or independently restyle individual cells. Every final cell must be drawn together with the same face construction, body proportions, line/render quality, lighting, materials, scale, baseline, and registration.
Output exactly 8 complete full-body frames in this exact left-to-right order: 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5. Degrees are clockwise: 000 is up, 090 right, 180 down, and 270 left. Neutral/front is not part of this row.
DIRECTION TARGETS — use these to shape the coherent row, not as pixel-level landmark gates:
1. `000`: vertical UP; no horizontal requirement.
2. `022.5`: horizontal SCREEN-RIGHT and vertical UP.
3. `045`: horizontal SCREEN-RIGHT and vertical UP.
4. `067.5`: horizontal SCREEN-RIGHT and vertical UP.
5. `090`: horizontal SCREEN-RIGHT; no vertical requirement.
6. `112.5`: horizontal SCREEN-RIGHT and vertical DOWN.
7. `135`: horizontal SCREEN-RIGHT and vertical DOWN.
8. `157.5`: horizontal SCREEN-RIGHT and vertical DOWN.
Cardinals must be unmistakable. Intermediate poses should broadly occupy the intended quadrant and advance naturally through the ordered loop. Minor pupil, nose, eyelid, or aiming-feature deviations are acceptable when the overall direction, continuity, identity, and motion remain coherent. Do not deform the character merely to make every intermediate axis independently obvious.
SCREEN-COORDINATE LOCK: screen-right means the viewer's right image edge, never the character's own right. The row should travel naturally through the right half of the loop. Near-vertical 022.5 and 157.5 may have subtle horizontal cues; prioritize a coherent arc over exact pupil or nose placement.
HARD LAYOUT AND CONTINUITY CONTRACT — DETERMINISTIC REGISTRATION: draw exactly eight separated pose groups in left-to-right direction order. Keep enough chroma-only space between neighboring poses that each complete pose can be detected without cutting through foreground. Approximate the guide's equal spacing, but do not distort a pose merely to hit an exact source-canvas coordinate; deterministic assembly will crop the eight ordered groups, then apply one shared scale and baseline.
Use the same body height, head size, baseline, and planted-body position across the generated family. Never overlap neighboring poses, merge two poses into one connected group, crop foreground at the outer canvas edge, or resize one pose independently.
Keep the feet, base, or lower torso planted at the same coordinates across all eight frames. Express direction through the eyes, face, head, upper body, and physically appropriate prop movement, not by moving, rotating, or rescaling the entire sprite.
Place one centered pose in each invisible equal-width slot on flat pure yellow #FFFF00. Change only the natural parts needed to express gaze: eyes, eyelids, head, face, neck, upper body, appendages, and constrained prop follow-through. Keep identity, silhouette, materials, palette, markings, and props consistent.
ROW-BOUNDARY LOCK: 157.5 must be one even 22.5-degree step before 180. Match the approved 180 pose's body size, baseline, planted anchor, expression, and construction. Preserve the overall right-hand arc, but do not distort pupils, nose, or body geometry merely to exaggerate the subtle horizontal component.
PRE-RETURN CHECK: reject this result if it does not contain eight separated pose groups in the required order; neighboring poses overlap; foreground is cropped at the outer canvas edge; any frame changes sprite scale, body or head size, baseline, or planted-body position; the row visibly reverses into the wrong half of the loop; or 157.5 does not flow evenly into 180. Minor intermediate pupil or nose deviations are not rejection reasons. Exact cell cropping, resizing, and recentering happen deterministically after generation.
Do not rotate, skew, or tilt the whole sprite to fake gaze. Do not add replacement/googly eyes, labels, degree text, arrows, clocks, grids, shadows, glows, scenery, detached effects, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,17 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `review`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 6 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 6 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Ready-review loop: focused inspection of completed output with lean, blink, narrowed eyes, head tilt, or paw pose.
State requirements:
- Show review through lean, blink, narrowed eyes, head tilt, or paw/hand position.
- Do not add magnifying glasses, papers, code, UI, punctuation, symbols, or other new props unless they already exist in the base pet identity.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,19 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `running-left`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 8 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 8 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Dragging-left loop: show directional movement to the left through body and limb poses only.
State requirements:
- Show directional drag movement to the left through body, limb, and prop movement only.
- The row must unmistakably face and travel left.
- The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.
- Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,19 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `running-right`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 8 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 8 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Dragging-right loop: show directional movement to the right through body and limb poses only.
State requirements:
- Show directional drag movement to the right through body, limb, and prop movement only.
- The row must unmistakably face and travel right.
- The movement cadence must alternate visibly across the 8 frames instead of repeating one nearly static stride.
- Do not draw speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,17 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `running`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 6 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 6 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Working loop: focused active-task processing, thinking, typing, scanning, or effortful concentration; not literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, or directional travel.
State requirements:
- Show the pet actively working or processing, as if running a task: focused posture, busy hands or paws, purposeful bobbing, thinking motion, tool or prop motion only if already part of the pet identity, or other non-locomotion activity.
- Do not show literal foot-running, jogging, sprinting, treadmill motion, raised knees, long steps, pumping arms, directional travel, speed lines, dust clouds, floor shadows, motion trails, or detached motion effects.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,17 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `waiting`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 6 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 6 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Needs-input loop: expectant asking pose for approval, help, or user input.
State requirements:
- Show that Codex needs approval, help, or user input through an expectant asking pose.
- Keep the motion patient and readable, without turning it into ordinary idle or review.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

View File

@@ -0,0 +1,17 @@
Create one horizontal animation strip for Codex pet `youmiao`, state `waving`.
Use the attached canonical base for identity. Use the attached layout guide only for slot count, spacing, centering, and padding; do not draw the guide.
Output exactly 4 full-body frames in one left-to-right row on flat pure yellow #FFFF00. Treat the row as 4 invisible equal-width slots: one centered complete pose per slot, evenly spaced, with no overlap, clipping, empty slots, labels, or borders.
Identity: same pet in every frame: 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. Preserve silhouette, face, proportions, markings, palette, material, style, and props.
Style: Pet-safe sprite: compact full-body mascot, readable in a 192x208 cell, clear silhouette, simple face, stable palette/materials, and crisp edges for chroma-key extraction. Style `flat-vector`: Flat vector-style mascot with simple geometric forms, crisp color areas, clean outline, and minimal shading. User style notes: Dark flat illustration with restrained cel shading, broad readable armor shapes, crisp opaque cyan marks, compact sprite-safe silhouette.
Animation continuity: keep apparent pet scale and baseline stable within the row unless the state itself intentionally changes vertical position, such as `jumping`. Move the pose within the slot instead of redrawing the pet larger or smaller frame to frame.
State action: Greeting loop: paw or limb down, raised, tilted, and returning in a friendly attention gesture.
State requirements:
- Show the greeting through paw, hand, wing, or limb pose only.
- Do not draw wave marks, motion arcs, lines, sparkles, symbols, or floating effects around the gesture.
Clean extraction: crisp opaque edges, safe padding, no scenery, text, guide marks, checkerboard, shadows, glows, motion blur, speed lines, dust, detached effects, stray pixels, or chroma-key colors inside the pet.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -15,10 +15,7 @@ import me.zhyd.oauth.request.AuthRequest;
import me.zhyd.oauth.utils.AuthStateUtils; import me.zhyd.oauth.utils.AuthStateUtils;
import org.dromara.common.core.constant.SystemConstants; import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.ForgotLoginBody; import org.dromara.common.core.domain.model.*;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.user.UserException; import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.*; import org.dromara.common.core.utils.*;
import org.dromara.common.encrypt.annotation.ApiEncrypt; import org.dromara.common.encrypt.annotation.ApiEncrypt;
@@ -42,10 +39,7 @@ import org.dromara.system.service.ISysTenantService;
import org.dromara.web.domain.vo.LoginTenantVo; import org.dromara.web.domain.vo.LoginTenantVo;
import org.dromara.web.domain.vo.LoginVo; import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.domain.vo.TenantListVo; import org.dromara.web.domain.vo.TenantListVo;
import org.dromara.web.service.ForgotPasswordService; import org.dromara.web.service.*;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.dromara.web.service.SysRegisterService;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -79,6 +73,7 @@ public class AuthController {
private final ISysClientService clientService; private final ISysClientService clientService;
private final ScheduledExecutorService scheduledExecutorService; private final ScheduledExecutorService scheduledExecutorService;
private final ForgotPasswordService forgotPasswordService; private final ForgotPasswordService forgotPasswordService;
private final AccountCancellationService accountCancellationService;
/** /**
* 登录方法 * 登录方法
@@ -189,6 +184,18 @@ public class AuthController {
return R.ok("退出成功"); return R.ok("退出成功");
} }
/**
* 注销当前账号
*/
@DeleteMapping("/account")
public R<Void> cancelAccount(@Validated @RequestBody AccountCancelBody body) {
StpUtil.checkLogin();
Long userId = LoginHelper.getUserId();
accountCancellationService.cancelCurrentAccount(body.getCode());
StpUtil.logout(userId);
return R.ok("注销成功");
}
/** /**
* 用户注册 * 用户注册
*/ */

View File

@@ -1,6 +1,7 @@
package org.dromara.web.controller; package org.dromara.web.controller;
import cn.dev33.satoken.annotation.SaIgnore; import cn.dev33.satoken.annotation.SaIgnore;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.captcha.generator.CodeGenerator; import cn.hutool.captcha.generator.CodeGenerator;
import cn.hutool.captcha.generator.MathGenerator; import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.captcha.generator.RandomGenerator; import cn.hutool.captcha.generator.RandomGenerator;
@@ -21,11 +22,14 @@ import org.dromara.common.mail.utils.MailUtils;
import org.dromara.common.ratelimiter.annotation.RateLimiter; import org.dromara.common.ratelimiter.annotation.RateLimiter;
import org.dromara.common.ratelimiter.enums.LimitType; import org.dromara.common.ratelimiter.enums.LimitType;
import org.dromara.common.redis.utils.RedisUtils; import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.core.WaveAndCircleCaptcha; import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.web.config.properties.CaptchaProperties; import org.dromara.common.web.config.properties.CaptchaProperties;
import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse; import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory; import org.dromara.sms4j.core.factory.SmsFactory;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.service.ISysUserService;
import org.dromara.web.domain.vo.CaptchaVo; import org.dromara.web.domain.vo.CaptchaVo;
import org.springframework.expression.Expression; import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser; import org.springframework.expression.ExpressionParser;
@@ -52,6 +56,7 @@ public class CaptchaController {
private final CaptchaProperties captchaProperties; private final CaptchaProperties captchaProperties;
private final MailProperties mailProperties; private final MailProperties mailProperties;
private final ISysUserService userService;
/** /**
@@ -66,20 +71,20 @@ public class CaptchaController {
String code = RandomUtil.randomNumbers(6); String code = RandomUtil.randomNumbers(6);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
// if (Validator.isMobile(username)){ if (Validator.isMobile(username)){
// // 验证码模板id 自行处理 (查数据库或写死均可) // 验证码模板id 自行处理 (查数据库或写死均可)
// String templateId = "SMS_333877107"; String templateId = "SMS_333877107";
// LinkedHashMap<String, String> map = new LinkedHashMap<>(1); LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
// map.put("code", code); map.put("code", code);
// SmsBlend smsBlend = SmsFactory.getSmsBlend("config1"); SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
// SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map); SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map);
// if (!smsResponse.isSuccess()) { if (!smsResponse.isSuccess()) {
// log.error("验证码短信发送异常 => {}", smsResponse); log.error("验证码短信发送异常 => {}", smsResponse);
// return R.fail(smsResponse.getData().toString()); return R.fail(smsResponse.getData().toString());
// } }
// }else { }else {
// emailCodeImpl(username); emailCodeImpl(username);
// } }
return R.ok(code); return R.ok(code);
} }
@@ -95,7 +100,50 @@ public class CaptchaController {
@RateLimiter(key = "#phonenumber", time = 60, count = 1) @RateLimiter(key = "#phonenumber", time = 60, count = 1)
@GetMapping("/resource/sms/code") @GetMapping("/resource/sms/code")
public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) { public R<Void> smsCode(@NotBlank(message = "{user.phonenumber.not.blank}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber; R<Void> result = sendSmsCode(phonenumber, phonenumber);
if (R.isError(result)) {
return result;
}
return R.ok();
}
/**
* 注销账号验证码
*/
@RateLimiter(key = "#{T(org.dromara.common.satoken.utils.LoginHelper).getUserId()}", time = 60, count = 1)
@GetMapping("/resource/account/cancel/code")
public R<Void> accountCancelCode() {
StpUtil.checkLogin();
Long userId = LoginHelper.getUserId();
String username = LoginHelper.getUsername();
if (userId == null || StringUtils.isBlank(username)) {
return R.fail("用户未登录");
}
SysUserVo user = userService.selectUserById(userId);
if (user == null) {
return R.fail("用户不存在");
}
if (Validator.isMobile(user.getPhonenumber())) {
return sendSmsCode(user.getPhonenumber(), username);
}
if (StringUtils.isNotBlank(user.getEmail())) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
SpringUtils.getAopProxy(this).emailCodeImpl(user.getEmail(), username);
return R.ok("操作成功");
}
return R.fail("当前账号未绑定手机号或邮箱");
}
/**
* 发送短信验证码
*
* @param phonenumber 接收手机号
* @param cacheKey 验证码缓存标识
*/
public R<Void> sendSmsCode(String phonenumber, String cacheKey) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + cacheKey;
String code = RandomUtil.randomNumbers(6); String code = RandomUtil.randomNumbers(6);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
// 验证码模板id 自行处理 (查数据库或写死均可) // 验证码模板id 自行处理 (查数据库或写死均可)
@@ -131,7 +179,15 @@ public class CaptchaController {
*/ */
@RateLimiter(key = "#email", time = 60, count = 1) @RateLimiter(key = "#email", time = 60, count = 1)
public void emailCodeImpl(String email) { public void emailCodeImpl(String email) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + email; emailCodeImpl(email, email);
}
/**
* 邮箱验证码
*/
@RateLimiter(key = "#email", time = 60, count = 1)
public void emailCodeImpl(String email, String cacheKey) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + cacheKey;
String code = RandomUtil.randomNumbers(6); String code = RandomUtil.randomNumbers(6);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION)); RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
try { try {

View File

@@ -0,0 +1,36 @@
package org.dromara.web.service;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.springframework.stereotype.Service;
/**
* 注销账号验证码服务
*/
@RequiredArgsConstructor
@Service
public class AccountCancelCodeService {
public void validate(String username, String code) {
String cacheCode = RedisUtils.getCacheObject(buildKey(username));
if (StringUtils.isBlank(cacheCode)) {
throw new CaptchaExpireException();
}
if (!StringUtils.equals(cacheCode, code)) {
throw new UserException("验证码无效");
}
}
public void delete(String username) {
RedisUtils.deleteObject(buildKey(username));
}
private String buildKey(String username) {
return GlobalConstants.CAPTCHA_CODE_KEY + username;
}
}

View File

@@ -0,0 +1,156 @@
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.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysSocial;
import org.dromara.system.domain.SysUserPost;
import org.dromara.system.domain.SysUserRole;
import org.dromara.system.mapper.SysSocialMapper;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.mapper.SysUserPostMapper;
import org.dromara.system.mapper.SysUserRoleMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
@RequiredArgsConstructor
@Service
public class AccountCancellationService {
private final SysUserMapper sysUserMapper;
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;
@Transactional(rollbackFor = Exception.class)
public void cancelCurrentAccount(String code) {
Long userId = LoginHelper.getUserId();
if (userId == null) {
throw new ServiceException("用户未登录");
}
if (LoginHelper.isSuperAdmin(userId)) {
throw new ServiceException("超级管理员账号不允许注销");
}
String username = getCurrentUsername();
accountCancelCodeService.validate(username, code);
List<String> deviceNos = queryUserDeviceNos(userId);
List<Long> scheduleIds = queryUserScheduleIds(userId);
sendInitDeviceCommands(deviceNos);
deleteAppScheduleData(userId, scheduleIds);
deleteAppDeviceData(userId, deviceNos);
deleteSystemUserData(userId);
accountCancelCodeService.delete(username);
}
private String getCurrentUsername() {
String username = LoginHelper.getUsername();
if (StringUtils.isBlank(username)) {
throw new ServiceException("用户未登录");
}
return username;
}
private List<String> queryUserDeviceNos(Long userId) {
return appDeviceMapper.selectList(
Wrappers.<AppDevice>lambdaQuery()
.select(AppDevice::getDeviceNo)
.eq(AppDevice::getUserId, userId)
).stream()
.map(AppDevice::getDeviceNo)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
}
private List<Long> queryUserScheduleIds(Long userId) {
return appScheduleMapper.selectList(
Wrappers.<AppSchedule>lambdaQuery()
.select(AppSchedule::getId)
.eq(AppSchedule::getUserId, userId)
).stream()
.map(AppSchedule::getId)
.filter(Objects::nonNull)
.distinct()
.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(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.in(AppSchedulingDevice::getScheduleId, scheduleIds)
);
appScheduleDetailMapper.delete(
Wrappers.<AppScheduleDetail>lambdaQuery()
.in(AppScheduleDetail::getScheduleId, scheduleIds)
);
}
appScheduleMapper.delete(
Wrappers.<AppSchedule>lambdaQuery()
.eq(AppSchedule::getUserId, userId)
);
}
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)
);
}
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getUserId, userId)
);
appDeviceMapper.delete(
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getUserId, userId)
);
}
private void deleteSystemUserData(Long userId) {
sysSocialMapper.delete(
Wrappers.<SysSocial>lambdaQuery()
.eq(SysSocial::getUserId, userId)
);
sysUserRoleMapper.delete(
Wrappers.<SysUserRole>lambdaQuery()
.eq(SysUserRole::getUserId, userId)
);
sysUserPostMapper.delete(
Wrappers.<SysUserPost>lambdaQuery()
.eq(SysUserPost::getUserId, userId)
);
int rows = sysUserMapper.deleteById(userId);
if (rows < 1) {
throw new ServiceException("注销账号失败");
}
}
}

View File

@@ -3,7 +3,6 @@ package org.dromara.web.service;
import cn.hutool.core.lang.Validator; import cn.hutool.core.lang.Validator;
import cn.hutool.crypto.digest.BCrypt; import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.Constants; import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants; import org.dromara.common.core.constant.GlobalConstants;
@@ -67,7 +66,7 @@ public class SysRegisterService {
} }
SysUserBo sysUser = new SysUserBo(); SysUserBo sysUser = new SysUserBo();
// sysUser.setUserName(username); sysUser.setUserName(username);
sysUser.setNickName("用户"+new Date().getTime()); sysUser.setNickName("用户"+new Date().getTime());
@@ -76,6 +75,7 @@ public class SysRegisterService {
boolean exist; boolean exist;
if(Validator.isEmail(username)){ if(Validator.isEmail(username)){
sysUser.setEmail(username); sysUser.setEmail(username);
exist = TenantHelper.dynamic(tenantId, () -> { exist = TenantHelper.dynamic(tenantId, () -> {
return userMapper.exists(new LambdaQueryWrapper<SysUser>() return userMapper.exists(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getEmail, sysUser.getEmail())); .eq(SysUser::getEmail, sysUser.getEmail()));

View File

@@ -58,6 +58,7 @@ public class SmsAuthStrategy implements IAuthStrategy {
}); });
loginUser.setClientKey(client.getClientKey()); loginUser.setClientKey(client.getClientKey());
loginUser.setDeviceType(client.getDeviceType()); loginUser.setDeviceType(client.getDeviceType());
//loginUser.setUsername(phonenumber);
SaLoginParameter model = new SaLoginParameter(); SaLoginParameter model = new SaLoginParameter();
model.setDeviceType(client.getDeviceType()); model.setDeviceType(client.getDeviceType());
// 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置 // 自定义分配 不同用户体系 不同 token 授权时间 不设置默认走全局 yml 配置

View File

@@ -47,9 +47,9 @@ spring:
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
# jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562 # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
# rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
url: jdbc:mysql://localhost:3306/water?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true url: jdbc:mysql://47.97.217.123:3306/water?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root username: root
password: 123456 password: 267a976b31c7a649
# # 从库数据源 # # 从库数据源
# slave: # slave:
# lazy: true # lazy: true
@@ -96,13 +96,13 @@ spring:
spring.data: spring.data:
redis: redis:
# 地址 # 地址
host: localhost host: 47.97.217.123
# 端口默认为6379 # 端口默认为6379
port: 6379 port: 6379
# 数据库索引 # 数据库索引
database: 0 database: 0
# redis 密码必须配置 # redis 密码必须配置
#password: password: waterx123
# 连接超时时间 # 连接超时时间
timeout: 10s timeout: 10s
# 是否开启ssl # 是否开启ssl

View File

@@ -4,7 +4,7 @@ spring.servlet.multipart.location: /water/server/temp
--- # 监控中心配置 --- # 监控中心配置
spring.boot.admin.client: spring.boot.admin.client:
# 增加客户端开关 # 增加客户端开关
enabled: true enabled: false
url: http://localhost:9090/admin url: http://localhost:9090/admin
instance: instance:
service-host-type: IP service-host-type: IP
@@ -16,7 +16,7 @@ spring.boot.admin.client:
--- # snail-job 配置 --- # snail-job 配置
snail-job: snail-job:
enabled: true enabled: false
# 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务 # 需要在 SnailJob 后台组管理创建对应名称的组,然后创建任务的时候选择对应的组,才能正确分派任务
group: "water_group" group: "water_group"
# SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表 # SnailJob 接入验证令牌 详见 script/sql/ry_job.sql `sj_group_config`表
@@ -50,9 +50,9 @@ spring:
driverClassName: com.mysql.cj.jdbc.Driver driverClassName: com.mysql.cj.jdbc.Driver
# jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562 # jdbc 所有参数配置参考 https://lionli.blog.csdn.net/article/details/122018562
# rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题) # rewriteBatchedStatements=true 批处理优化 大幅提升批量插入更新删除性能(对数据库有性能损耗 使用批量操作应考虑性能问题)
url: jdbc:mysql://localhost:3306/ry-vue?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true url: jdbc:mysql://localhost:3306/water?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&autoReconnect=true&rewriteBatchedStatements=true&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true
username: root username: root
password: root password: 267a976b31c7a649
# # 从库数据源 # # 从库数据源
# slave: # slave:
# lazy: true # lazy: true
@@ -105,7 +105,7 @@ spring.data:
# 数据库索引 # 数据库索引
database: 0 database: 0
# redis 密码必须配置 # redis 密码必须配置
password: water123 password: waterx123
# 连接超时时间 # 连接超时时间
timeout: 10s timeout: 10s
# 是否开启ssl # 是否开启ssl
@@ -136,17 +136,17 @@ redisson:
--- # mail 邮件发送 --- # mail 邮件发送
mail: mail:
enabled: false enabled: true
host: smtp.163.com host: smtp.163.com
port: 465 port: 465
# 是否需要用户名密码验证 # 是否需要用户名密码验证
auth: true auth: true
# 发送方遵循RFC-822标准 # 发送方遵循RFC-822标准
from: xxx@163.com from: watexdevice@163.com
# 用户名注意如果使用foxmail邮箱此处user为qq号 # 用户名注意如果使用foxmail邮箱此处user为qq号
user: xxx@163.com user: watexdevice@163.com
# 密码注意某些邮箱需要为SMTP服务单独设置密码详情查看相关帮助 # 密码注意某些邮箱需要为SMTP服务单独设置密码详情查看相关帮助
pass: xxxxxxxxxx pass: XZVDXjRdVrjiX8N3
# 使用 STARTTLS安全连接STARTTLS是对纯文本通信协议的扩展。 # 使用 STARTTLS安全连接STARTTLS是对纯文本通信协议的扩展。
starttlsEnable: true starttlsEnable: true
# 使用SSL安全连接 # 使用SSL安全连接
@@ -175,10 +175,10 @@ sms:
# 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分 # 框架定义的厂商名称标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分
supplier: alibaba supplier: alibaba
# 有些称为accessKey有些称之为apiKey也有称为sdkKey或者appId。 # 有些称为accessKey有些称之为apiKey也有称为sdkKey或者appId。
access-key-id: 您的accessKey access-key-id: LTAI5t7xHqxFKG9DwMgurvrn
# 称为accessSecret有些称之为apiSecret # 称为accessSecret有些称之为apiSecret
access-key-secret: 您的accessKeySecret access-key-secret: nNFjR8YwNJUcMrrtdc4pkpPsHmYtfp
signature: 您的短信签名 signature: 山东春雨润集雨节灌设备
sdk-app-id: 您的sdkAppId sdk-app-id: 您的sdkAppId
config2: config2:
# 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分 # 厂商标识,标定此配置是哪个厂商,详细请看厂商标识介绍部分

View File

@@ -1,7 +1,7 @@
# 开发环境配置 # 开发环境配置
server: server:
# 服务器的HTTP端口默认为8080 # 服务器的HTTP端口默认为8080
port: 8081 port: 8082
servlet: servlet:
# 应用的访问路径 # 应用的访问路径
context-path: / context-path: /
@@ -22,12 +22,13 @@ server:
--- # mqtt配置信息 --- # mqtt配置信息
mqtt: mqtt:
enabled: ${MQTT_ENABLED:true} enabled: ${MQTT_ENABLED:true}
broker-url: ssl://service.reinkun.com:8883 broker-url: ssl://www.mmipco.cn:8883
#broker-url: tcp://47.97.217.123
username: admin username: admin
password: yukun_mqtt password: 61e6062129e9
client-id: water-server-${server.port} client-id: water-server-1${server.port}
qos: 1 qos: 1
keep-alive: 60 keep-alive: 300
connection-timeout: 30 connection-timeout: 30
max-inflight: 5000 max-inflight: 5000
clean-session: false clean-session: false
@@ -46,25 +47,30 @@ mqtt:
command-ack: command-ack:
enabled: true enabled: true
max-retry-count: 3 max-retry-count: 3
retry-interval-ms: 5000 retry-interval-ms: 30000
scan-interval-ms: 5000 scan-interval-ms: 5000
pending-ttl-seconds: 86400 pending-ttl-seconds: 86400
ack-ttl-seconds: 86400 ack-ttl-seconds: 86400
pending-key-prefix: "mqtt:command:pending:" pending-key-prefix: "mqtt:command:pending:"
ack-key-prefix: "mqtt:command:ack:" ack-key-prefix: "mqtt:command:ack:"
pending-set-key: "mqtt:command:pending:ids" pending-set-key: "mqtt:command:pending:ids"
ack-lock-wait-ms: 3000
device-status-cache-prefix: "mqtt:device:status:" device-status-cache-prefix: "mqtt:device:status:"
device-status-cache-ttl-seconds: 300 device-status-cache-ttl-seconds: 600
offline-check: offline-check:
enabled: true enabled: true
interval-ms: 90000 ttl-compat-enabled: true
interval-ms: 30000
topics: topics:
# 首次注册使用 MAC注册后的业务主题使用 deviceNoLWT 离线主题兼容 MAC
subscribe: subscribe:
- /+/publish/finish/schedule #排程任务完成上报 - /+/publish/finish/schedule #排程任务完成上报
- /+/publish/register #设备注册 - /+/publish/register #设备注册
- /+/publish/power #电量 - /+/publish/status #设备 LWT 离线状态(仅处理 offline
- /+/publish/power #电量及在线心跳
- /+/publish/ack #应答 - /+/publish/ack #应答
- /+/publish/start #开始浇水上报
- /+/publish/finish/key #按键浇水完成 - /+/publish/finish/key #按键浇水完成
- /+/publish/error #硬件故障 - /+/publish/error #硬件故障
# - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水 # - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水
@@ -122,9 +128,9 @@ spring:
servlet: servlet:
multipart: multipart:
# 单个文件大小 # 单个文件大小
max-file-size: 10MB max-file-size: 100MB
# 设置总上传的文件大小 # 设置总上传的文件大小
max-request-size: 20MB max-request-size: 100MB
mvc: mvc:
# 设置静态资源路径 防止所有请求都去查静态资源 # 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/** static-path-pattern: /static/**
@@ -233,7 +239,7 @@ api-decrypt:
springdoc: springdoc:
api-docs: api-docs:
# 是否开启接口文档 # 是否开启接口文档
enabled: true enabled: false
info: info:
# 标题 # 标题
title: '标题water-Vue-Plus多租户管理系统_接口文档' title: '标题water-Vue-Plus多租户管理系统_接口文档'
@@ -303,7 +309,7 @@ websocket:
--- # warm-flow工作流配置 --- # warm-flow工作流配置
warm-flow: warm-flow:
# 是否开启工作流默认true # 是否开启工作流默认true
enabled: true enabled: false
# 是否开启设计器ui # 是否开启设计器ui
ui: true ui: true
# 是否显示流程图顶部文字 # 是否显示流程图顶部文字

View File

@@ -0,0 +1,65 @@
package org.dromara.web.config;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.io.InputStream;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("dev")
class MqttCommandAckConfigUnitTest {
@Test
void commandAckMaxRetryCountIsThree() throws Exception {
Integer maxRetryCount = null;
Yaml yaml = new Yaml();
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
for (Object document : yaml.loadAll(inputStream)) {
if (!(document instanceof Map<?, ?> root)) {
continue;
}
Object mqtt = root.get("mqtt");
if (!(mqtt instanceof Map<?, ?> mqttConfig)) {
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();
}
}
}
assertThat(maxRetryCount).isEqualTo(3);
}
@Test
void offlineCheckTtlCompatibilityIsEnabled() throws Exception {
Boolean ttlCompatEnabled = null;
Yaml yaml = new Yaml();
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
for (Object document : yaml.loadAll(inputStream)) {
if (!(document instanceof Map<?, ?> root)) {
continue;
}
Object mqtt = root.get("mqtt");
if (!(mqtt instanceof Map<?, ?> mqttConfig)) {
continue;
}
Object offlineCheck = mqttConfig.get("offline-check");
if (offlineCheck instanceof Map<?, ?> offlineCheckConfig) {
ttlCompatEnabled = (Boolean) offlineCheckConfig.get("ttl-compat-enabled");
}
}
}
assertThat(ttlCompatEnabled).isTrue();
}
}

View File

@@ -0,0 +1,55 @@
package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.AccountCancelBody;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.web.service.AccountCancellationService;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AuthControllerUnitTest {
@Mock
private AccountCancellationService accountCancellationService;
@Test
void cancelAccount_checksLoginCancelsAccountAndLogsOut() {
AuthController controller = new AuthController(
null,
null,
null,
null,
null,
null,
null,
null,
null,
accountCancellationService
);
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
AccountCancelBody body = new AccountCancelBody();
body.setCode("123456");
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
R<Void> result = controller.cancelAccount(body);
assertThat(result.getCode()).isEqualTo(200);
verify(accountCancellationService).cancelCurrentAccount("123456");
stpUtil.verify(StpUtil::checkLogin);
stpUtil.verify(() -> StpUtil.logout(100L));
}
}
}

View File

@@ -0,0 +1,119 @@
package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.mail.config.properties.MailProperties;
import org.dromara.common.ratelimiter.annotation.RateLimiter;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysUserVo;
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.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class CaptchaControllerUnitTest {
@Mock
private MailProperties mailProperties;
@Mock
private ISysUserService userService;
@Test
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setPhonenumber("13305376054");
user.setEmail("demo@example.com");
when(userService.selectUserById(100L)).thenReturn(user);
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "zhangsan");
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(200);
stpUtil.verify(StpUtil::checkLogin);
verify(controller).sendSmsCode("13305376054", "zhangsan");
verify(controller, never()).emailCodeImpl("demo@example.com");
}
}
@Test
void accountCancelCode_sendsEmailCodeWhenCurrentUserHasNoPhone() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setEmail("demo@example.com");
when(userService.selectUserById(100L)).thenReturn(user);
when(mailProperties.getEnabled()).thenReturn(true);
doNothing().when(controller).emailCodeImpl("demo@example.com", "zhangsan");
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(200);
stpUtil.verify(StpUtil::checkLogin);
springUtils.verify(() -> SpringUtils.getAopProxy(controller));
verify(controller).emailCodeImpl("demo@example.com", "zhangsan");
verify(controller, never()).sendSmsCode("demo@example.com", "zhangsan");
}
}
@Test
void accountCancelCode_hasPerUserRateLimiter() throws NoSuchMethodException {
Method method = CaptchaController.class.getDeclaredMethod("accountCancelCode");
RateLimiter rateLimiter = method.getAnnotation(RateLimiter.class);
assertThat(rateLimiter).isNotNull();
assertThat(rateLimiter.time()).isEqualTo(60);
assertThat(rateLimiter.count()).isEqualTo(1);
assertThat(rateLimiter.key())
.isEqualTo("#{T(org.dromara.common.satoken.utils.LoginHelper).getUserId()}");
}
@Test
void accountCancelCode_failsWhenCurrentUserHasNoPhoneOrEmail() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
when(userService.selectUserById(100L)).thenReturn(user);
try (MockedStatic<StpUtil> stpUtil = mockStatic(StpUtil.class);
MockedStatic<SpringUtils> springUtils = mockStatic(SpringUtils.class);
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
springUtils.when(() -> SpringUtils.getAopProxy(controller)).thenReturn(controller);
R<Void> result = controller.accountCancelCode();
assertThat(result.getCode()).isEqualTo(500);
assertThat(result.getMsg()).isEqualTo("当前账号未绑定手机号或邮箱");
stpUtil.verify(StpUtil::checkLogin);
verify(controller, never()).sendSmsCode("13305376054", "zhangsan");
verify(controller, never()).emailCodeImpl("demo@example.com", "zhangsan");
}
}
}

View File

@@ -0,0 +1,215 @@
package org.dromara.web.service;
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.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysSocial;
import org.dromara.system.domain.SysUserPost;
import org.dromara.system.domain.SysUserRole;
import org.dromara.system.mapper.SysSocialMapper;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.mapper.SysUserPostMapper;
import org.dromara.system.mapper.SysUserRoleMapper;
import org.junit.jupiter.api.BeforeAll;
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 java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AccountCancellationServiceUnitTest {
@Mock
private SysUserMapper sysUserMapper;
@Mock
private SysUserRoleMapper sysUserRoleMapper;
@Mock
private SysUserPostMapper sysUserPostMapper;
@Mock
private SysSocialMapper sysSocialMapper;
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private AppScheduleMapper appScheduleMapper;
@Mock
private AppScheduleDetailMapper appScheduleDetailMapper;
@Mock
private AppSchedulingDeviceMapper appSchedulingDeviceMapper;
@Mock
private AppWateringLogMapper appWateringLogMapper;
@Mock
private AccountCancelCodeService accountCancelCodeService;
@Mock
private IDeviceCommandService deviceCommandService;
@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);
initTableInfo(assistant, AppWateringLog.class);
initTableInfo(assistant, SysSocial.class);
initTableInfo(assistant, SysUserRole.class);
initTableInfo(assistant, SysUserPost.class);
}
private static void initTableInfo(MapperBuilderAssistant assistant, Class<?> entityClass) {
TableInfoHelper.remove(entityClass);
TableInfoHelper.initTableInfo(assistant, entityClass);
}
@Test
void cancelCurrentAccount_deletesCurrentUserAndRelatedData() {
AccountCancellationService service = newService();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
AppSchedule schedule = new AppSchedule();
schedule.setId(10L);
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule));
when(sysUserMapper.deleteById(100L)).thenReturn(1);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
service.cancelCurrentAccount("123456");
verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(deviceCommandService).sendInitDeviceCommand("D01");
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));
verify(sysUserMapper).deleteById(100L);
verify(accountCancelCodeService).delete("zhangsan");
}
}
@Test
void cancelCurrentAccount_rejectsSuperAdmin() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(1L);
loginHelper.when(() -> LoginHelper.isSuperAdmin(1L)).thenReturn(true);
assertThatThrownBy(() -> service.cancelCurrentAccount("123456"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("超级管理员");
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper,
accountCancelCodeService
);
}
}
@Test
void cancelCurrentAccount_rejectsInvalidCodeWithoutDeletingData() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
doThrow(new UserException("验证码无效"))
.when(accountCancelCodeService).validate("zhangsan", "654321");
assertThatThrownBy(() -> service.cancelCurrentAccount("654321"))
.isInstanceOf(UserException.class);
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper
);
verify(accountCancelCodeService).validate("zhangsan", "654321");
verify(accountCancelCodeService, never()).delete("zhangsan");
}
}
@Test
void cancelCurrentAccount_rejectsExpiredCodeWithoutDeletingData() {
AccountCancellationService service = newService();
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(100L);
loginHelper.when(LoginHelper::getUsername).thenReturn("zhangsan");
loginHelper.when(() -> LoginHelper.isSuperAdmin(100L)).thenReturn(false);
doThrow(new CaptchaExpireException())
.when(accountCancelCodeService).validate("zhangsan", "123456");
assertThatThrownBy(() -> service.cancelCurrentAccount("123456"))
.isInstanceOf(CaptchaExpireException.class);
verifyNoInteractions(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper
);
verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(accountCancelCodeService, never()).delete("zhangsan");
}
}
private AccountCancellationService newService() {
return new AccountCancellationService(
sysUserMapper,
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper,
accountCancelCodeService,
deviceCommandService
);
}
}

View File

@@ -0,0 +1,24 @@
package org.dromara.common.core.domain.model;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 注销账号请求体
*/
@Data
public class AccountCancelBody implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 验证码
*/
@NotBlank(message = "{sms.code.not.blank}")
private String code;
}

View File

@@ -33,9 +33,9 @@ public class JacksonConfig {
public Module registerJavaTimeModule() { public Module registerJavaTimeModule() {
// 全局配置序列化返回 JSON 处理 // 全局配置序列化返回 JSON 处理
JavaTimeModule javaTimeModule = new JavaTimeModule(); JavaTimeModule javaTimeModule = new JavaTimeModule();
javaTimeModule.addSerializer(Long.class, BigNumberSerializer.INSTANCE); javaTimeModule.addSerializer(Long.class, ToStringSerializer.instance);
javaTimeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE); javaTimeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE);
javaTimeModule.addSerializer(BigInteger.class, BigNumberSerializer.INSTANCE); javaTimeModule.addSerializer(BigInteger.class, ToStringSerializer.instance);
javaTimeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance); javaTimeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter)); javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter));

View File

@@ -43,5 +43,10 @@
<groupId>org.dromara</groupId> <groupId>org.dromara</groupId>
<artifactId>water-app</artifactId> <artifactId>water-app</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -3,6 +3,7 @@ package org.dromara.mqtt;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.mqtt.DeviceCommand; import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.service.IDeviceCommandPublisher; import org.dromara.app.service.IDeviceCommandPublisher;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils; import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.dromara.mqtt.config.properties.MqttProperties; import org.dromara.mqtt.config.properties.MqttProperties;
@@ -44,14 +45,18 @@ public class DeviceMqttCommandPublisher implements IDeviceCommandPublisher {
} }
private String buildCommandTopic(String deviceNo) { private String buildCommandTopic(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) {
throw new ServiceException("命令下发缺少设备编号,无法拼接主题");
}
String device = deviceNo.trim().toLowerCase();
String prefix = mqttProperties.getTopics().getPublishPrefix(); String prefix = mqttProperties.getTopics().getPublishPrefix();
if (StringUtils.isBlank(prefix)) { if (StringUtils.isBlank(prefix)) {
return "/" + deviceNo + "/subscriber/cmd"; return "/" + device + "/subscriber/cmd";
} }
String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix; String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix;
if (normalizedPrefix.endsWith("/")) { if (normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1); normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
} }
return normalizedPrefix + "/" + deviceNo + "/subscriber/cmd"; return normalizedPrefix + "/" + device + "/subscriber/cmd";
} }
} }

View File

@@ -37,65 +37,18 @@ public class MqttClientManager implements DisposableBean {
private final MqttMessageDispatcher dispatcher; private final MqttMessageDispatcher dispatcher;
private MqttAsyncClient client; private MqttAsyncClient client;
private ThreadPoolExecutor consumerExecutor; private ThreadPoolExecutor consumerExecutor;
private BlockingQueue<InboundMessage> messageQueue; private List<BlockingQueue<InboundMessage>> messageQueues;
private volatile boolean running; private volatile boolean running;
private int consumerCount;
@Bean static int deviceIdentityHash(String topic) {
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true") if (topic == null) {
public MqttAsyncClient mqttConnect() throws MqttException { return 0;
if (StringUtils.isBlank(props.getBrokerUrl())) {
throw new ServiceException("启用 MQTT 时必须配置 broker-url");
} }
if (StringUtils.isBlank(props.getClientId())) { int start = topic.startsWith("/") ? 1 : 0;
throw new ServiceException("启用 MQTT 时必须配置 client-id"); int end = topic.indexOf('/', start);
} String identity = end < 0 ? topic.substring(start) : topic.substring(start, end);
messageQueue = new ArrayBlockingQueue<>(Math.max(1, props.getAsync().getQueueCapacity())); return identity.hashCode();
consumerExecutor = createConsumerExecutor();
running = true;
startConsumers();
client = new MqttAsyncClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(props.getUsername());
if (props.getPassword() != null) {
options.setPassword(props.getPassword().toCharArray());
}
options.setKeepAliveInterval(props.getKeepAlive());
options.setConnectionTimeout(props.getConnectionTimeout());
options.setAutomaticReconnect(props.isAutomaticReconnect());
options.setCleanSession(props.isCleanSession());
options.setMaxInflight(props.getMaxInflight());
configureTls(options);
client.setCallback(new MqttCallbackExtended() {
@Override
public void connectionLost(Throwable cause) {
log.warn("[MQTT] 连接已断开:{}", cause == null ? "" : cause.getMessage());
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
log.info("[MQTT] 已重新连接:{}", serverURI);
subscribeTopics();
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
enqueue(topic, payload);
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
client.connect(options).waitForCompletion();
log.info("[MQTT] 已连接:{}", props.getBrokerUrl());
subscribeTopics();
return client;
} }
private void configureTls(MqttConnectOptions options) throws MqttException { private void configureTls(MqttConnectOptions options) throws MqttException {
@@ -171,39 +124,97 @@ public class MqttClientManager implements DisposableBean {
}; };
} }
@Bean
@ConditionalOnProperty(prefix = "mqtt", name = "enabled", havingValue = "true")
public MqttAsyncClient mqttConnect() throws MqttException {
if (StringUtils.isBlank(props.getBrokerUrl())) {
throw new ServiceException("启用 MQTT 时必须配置 broker-url");
}
if (StringUtils.isBlank(props.getClientId())) {
throw new ServiceException("启用 MQTT 时必须配置 client-id");
}
consumerCount = Math.max(1, props.getAsync().getConsumerCount());
messageQueues = createMessageQueues();
consumerExecutor = createConsumerExecutor();
running = true;
startConsumers();
client = new MqttAsyncClient(props.getBrokerUrl(), props.getClientId(), new MemoryPersistence());
MqttConnectOptions options = new MqttConnectOptions();
options.setUserName(props.getUsername());
if (props.getPassword() != null) {
options.setPassword(props.getPassword().toCharArray());
}
options.setKeepAliveInterval(props.getKeepAlive());
options.setConnectionTimeout(props.getConnectionTimeout());
options.setAutomaticReconnect(props.isAutomaticReconnect());
options.setCleanSession(props.isCleanSession());
options.setMaxInflight(props.getMaxInflight());
configureTls(options);
client.setCallback(new MqttCallbackExtended() {
@Override
public void connectionLost(Throwable cause) {
log.warn("[MQTT] 连接已断开:{}", cause == null ? "" : cause.getMessage());
}
@Override
public void connectComplete(boolean reconnect, String serverURI) {
if (reconnect) {
log.info("[MQTT] 已重新连接:{}", serverURI);
subscribeTopics();
}
}
@Override
public void messageArrived(String topic, MqttMessage message) {
String payload = new String(message.getPayload(), StandardCharsets.UTF_8);
enqueue(topic, payload, message.isRetained());
}
@Override
public void deliveryComplete(IMqttDeliveryToken token) {
}
});
client.connect(options).waitForCompletion();
log.info("[MQTT] 已连接:{}", props.getBrokerUrl());
subscribeTopics();
return client;
}
private void startConsumers() { private void startConsumers() {
int consumerCount = Math.max(1, props.getAsync().getConsumerCount());
for (int i = 0; i < consumerCount; i++) { for (int i = 0; i < consumerCount; i++) {
consumerExecutor.execute(this::consumeLoop); final int consumerIndex = i;
consumerExecutor.execute(() -> consumeLoop(consumerIndex));
} }
} }
private void enqueue(String topic, String payload) { private void enqueue(String topic, String payload, boolean retained) {
InboundMessage inboundMessage = new InboundMessage(topic, payload); InboundMessage inboundMessage = new InboundMessage(topic, payload, retained);
try { try {
if (!messageQueue.offer(inboundMessage, props.getAsync().getOfferTimeoutMs(), TimeUnit.MILLISECONDS)) { messageQueues.get(queueIndex(topic)).put(inboundMessage);
log.warn("[MQTT] 上行消息队列已满,丢弃 主题={}", topic);
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.warn("[MQTT] 上行消息入队被中断 主题={}", topic); log.warn("[MQTT] 上行消息入队被中断 主题={}", topic);
} }
} }
private void consumeLoop() { private void consumeLoop(int consumerIndex) {
MqttProperties.Async async = props.getAsync(); MqttProperties.Async async = props.getAsync();
int batchSize = Math.max(1, async.getBatchSize()); int batchSize = Math.max(1, async.getBatchSize());
long pollTimeoutMs = Math.max(1, async.getPollTimeoutMs()); long pollTimeoutMs = Math.max(1, async.getPollTimeoutMs());
List<InboundMessage> batch = new java.util.ArrayList<>(batchSize); List<InboundMessage> batch = new java.util.ArrayList<>(batchSize);
BlockingQueue<InboundMessage> queue = messageQueues.get(consumerIndex);
while (running || !messageQueue.isEmpty()) { while (running || !queue.isEmpty()) {
try { try {
InboundMessage first = messageQueue.poll(pollTimeoutMs, TimeUnit.MILLISECONDS); InboundMessage first = queue.poll(pollTimeoutMs, TimeUnit.MILLISECONDS);
if (first == null) { if (first == null) {
continue; continue;
} }
batch.add(first); batch.add(first);
messageQueue.drainTo(batch, batchSize - 1); queue.drainTo(batch, batchSize - 1);
for (InboundMessage message : batch) { for (InboundMessage message : batch) {
dispatch(message); dispatch(message);
} }
@@ -218,12 +229,26 @@ public class MqttClientManager implements DisposableBean {
private void dispatch(InboundMessage message) { private void dispatch(InboundMessage message) {
try { try {
dispatcher.dispatch(message.topic(), message.payload()); dispatcher.dispatch(message.topic(), message.payload(), message.retained());
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 消息分发失败 主题={}", message.topic(), e); log.error("[MQTT] 消息分发失败 主题={}", message.topic(), e);
} }
} }
private List<BlockingQueue<InboundMessage>> createMessageQueues() {
int capacity = Math.max(1, props.getAsync().getQueueCapacity());
int queueCapacity = Math.max(1, (capacity + Math.max(1, consumerCount) - 1) / Math.max(1, consumerCount));
List<BlockingQueue<InboundMessage>> queues = new java.util.ArrayList<>(consumerCount);
for (int i = 0; i < consumerCount; i++) {
queues.add(new ArrayBlockingQueue<>(queueCapacity));
}
return queues;
}
private int queueIndex(String topic) {
return Math.floorMod(deviceIdentityHash(topic), Math.max(1, consumerCount));
}
private void subscribeTopics() { private void subscribeTopics() {
try { try {
if (client == null || !client.isConnected()) { if (client == null || !client.isConnected()) {
@@ -276,6 +301,6 @@ public class MqttClientManager implements DisposableBean {
} }
} }
private record InboundMessage(String topic, String payload) { private record InboundMessage(String topic, String payload, boolean retained) {
} }
} }

View File

@@ -22,6 +22,7 @@ import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.*; import java.util.*;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@Slf4j @Slf4j
@Service @Service
@@ -38,16 +39,28 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:"; private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
public void savePending(DeviceCommand command) { static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds())); return StringUtils.isNotBlank(topicDeviceNo)
pendingIds().add(command.getCommandId()); && pendingCommand != null
&& topicDeviceNo.equals(pendingCommand.getDeviceNo());
} }
public void removePending(String commandId) { static boolean resolveMissingCommandId(DeviceCommandAck ack, List<DeviceCommand> pendingCommands) {
if (StringUtils.isBlank(commandId)) { if (ack == null || StringUtils.isNotBlank(ack.getCommandId())) {
return; return ack != null;
} }
withCommandLock(commandId, () -> deletePending(commandId)); if (pendingCommands == null || pendingCommands.size() != 1) {
return false;
}
DeviceCommand command = pendingCommands.get(0);
if (command == null || StringUtils.isBlank(command.getCommandId())) {
return false;
}
ack.setCommandId(command.getCommandId());
if (StringUtils.isBlank(ack.getStatus())) {
ack.setStatus("1");
}
return true;
} }
@Override @Override
@@ -70,17 +83,63 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
log.warn("[MQTT] ACK JSON 格式错误 设备编号={} 消息体={}", deviceNo, payload, e); log.warn("[MQTT] ACK JSON 格式错误 设备编号={} 消息体={}", deviceNo, payload, e);
return; return;
} }
if (ack == null || ack.getCommandId() == null) { if (ack == null) {
log.warn("[MQTT] ACK 缺少命令编号 设备编号={} 消息体={}", deviceNo, payload); log.warn("[MQTT] ACK 缺少命令编号 设备编号={} 消息体={}", deviceNo, payload);
return; return;
} }
ack.setDeviceNo(deviceNo); ack.setDeviceNo(deviceNo);
withCommandLock(ack.getCommandId(), () -> { if (StringUtils.isBlank(ack.getCommandId())) {
List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo);
if (!resolveMissingCommandId(ack, pendingCommands)) {
log.warn("[MQTT] ACK 缺少命令编号且无法唯一匹配待确认命令 设备编号={} 待确认数量={} 消息体={}",
deviceNo, pendingCommands.size(), payload);
return;
}
}
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());
return false;
}
if (!ackMatchesPendingCommand(deviceNo, pending)) {
log.warn("[MQTT] ACK 设备编号与待确认命令不匹配,已拒绝 设备编号={} 命令编号={} 命令设备={}",
deviceNo, ack.getCommandId(), pending.getDeviceNo());
return false;
}
deletePending(ack.getCommandId()); deletePending(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
}); });
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); refreshDeviceOnline(deviceNo);
log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} 状态={}", deviceNo, ack.getCommandId(), ack.getStatus()); 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 void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) {
return;
}
withCommandLock(commandId, () -> {
deletePending(commandId);
return true;
});
} }
private void handlePlainAck(String deviceNo, String payload) { private void handlePlainAck(String deviceNo, String payload) {
@@ -99,13 +158,40 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
ack.setStatus("1"); ack.setStatus("1");
ack.setMessage(payload); ack.setMessage(payload);
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);
return false;
}
if (!ackMatchesPendingCommand(deviceNo, pending)) {
log.warn("[MQTT] 非 JSON ACK 设备编号与待确认命令不匹配,已拒绝 设备编号={} 命令编号={} 命令设备={}",
deviceNo, commandId, pending.getDeviceNo());
return false;
}
deletePending(commandId); deletePending(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
}); });
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); 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) { private List<DeviceCommand> findPendingCommandsByDeviceNo(String deviceNo) {
List<DeviceCommand> commands = new ArrayList<>(); List<DeviceCommand> commands = new ArrayList<>();
for (String commandId : pendingIds().readAll()) { for (String commandId : pendingIds().readAll()) {
@@ -126,7 +212,10 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
} }
private void retryCommandIfLocked(String commandId, long now) { private void retryCommandIfLocked(String commandId, long now) {
withCommandLock(commandId, () -> retryCommand(commandId, now)); withCommandLock(commandId, () -> {
retryCommand(commandId, now);
return true;
});
} }
private void retryCommand(String commandId, long now) { private void retryCommand(String commandId, long now) {
@@ -144,24 +233,24 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return; return;
} }
if (!isDeviceOnline(command.getDeviceNo())) { if (!isDeviceOnline(command.getDeviceNo())) {
command.setRetryCount(command.getRetryCount() + 1);
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
deletePending(commandId);
log.warn("[MQTT] 设备离线,命令达到最大等待次数,已删除待确认命令 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return;
}
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs()); command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command); savePending(command);
log.debug("[MQTT] 设备离线,命令延后重试 设备编号={} 命令编号={} 等待次数={}", command.getDeviceNo(), commandId, command.getRetryCount()); log.debug("[MQTT] 设备离线,命令延后重试 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return; return;
} }
command.setRetryCount(command.getRetryCount() + 1); command.setRetryCount(command.getRetryCount() + 1);
command.setLastSentAt(now); command.setLastSentAt(now);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs()); command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload())); try {
savePending(command); mqttClientManager.publish(command.getTopic(), JsonUtils.toJsonString(command.getPayload()));
log.info("[MQTT] 命令已重新下发 设备编号={} 命令编号={} 重试次数={}", command.getDeviceNo(), commandId, command.getRetryCount()); savePending(command);
log.info("[MQTT] 命令已重新下发 设备编号={} 命令编号={} 重试次数={}", command.getDeviceNo(), commandId, command.getRetryCount());
} catch (RuntimeException e) {
savePending(command);
log.warn("[MQTT] 命令重新下发失败,已延后重试 设备编号={} 命令编号={} 重试次数={}",
command.getDeviceNo(), commandId, command.getRetryCount(), e);
}
} }
private boolean isDeviceOnline(String deviceNo) { private boolean isDeviceOnline(String deviceNo) {
@@ -177,8 +266,11 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo); RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false; boolean locked = false;
try { try {
lock.lock(10, TimeUnit.SECONDS); locked = lock.tryLock(0, 10, TimeUnit.SECONDS);
locked = true; if (!locked) {
// 其他线程正在刷新,本次跳过;状态写入幂等,下条 ACK 仍会兜底刷新
return;
}
Map<String, Object> statusCache = new HashMap<>(); Map<String, Object> statusCache = new HashMap<>();
statusCache.put("deviceNo", deviceNo); statusCache.put("deviceNo", deviceNo);
statusCache.put("status", "1"); statusCache.put("status", "1");
@@ -193,6 +285,9 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
.set(AppDevice::getStatus, "1") .set(AppDevice::getStatus, "1")
.eq(AppDevice::getDeviceNo, deviceNo) .eq(AppDevice::getDeviceNo, deviceNo)
); );
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("[MQTT] 刷新设备在线状态被中断 设备编号={}", deviceNo);
} finally { } finally {
if (locked && lock.isHeldByCurrentThread()) { if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); lock.unlock();
@@ -209,20 +304,22 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId); pendingIds().remove(commandId);
} }
private void withCommandLock(String commandId, Runnable action) { private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId); RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false; boolean locked = false;
try { try {
locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS); locked = lock.tryLock(waitTimeMs, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS);
if (!locked) { if (!locked) {
return; return CommandLockResult.LOCK_UNAVAILABLE;
} }
action.run(); return action.getAsBoolean() ? CommandLockResult.SUCCESS : CommandLockResult.ACTION_FAILED;
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId); log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId);
return CommandLockResult.INTERRUPTED;
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e); log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e);
return CommandLockResult.ACTION_FAILED;
} finally { } finally {
if (locked && lock.isHeldByCurrentThread()) { if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); lock.unlock();
@@ -230,6 +327,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
} }
} }
private enum CommandLockResult {
SUCCESS,
LOCK_UNAVAILABLE,
INTERRUPTED,
ACTION_FAILED
}
private String pendingKey(String commandId) { private String pendingKey(String commandId) {
return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId; return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId;
} }

View File

@@ -18,7 +18,7 @@ public class MqttProperties {
private String password; private String password;
private String clientId; private String clientId;
private int qos = 1; private int qos = 1;
private int keepAlive = 60; private int keepAlive = 300;
private int connectionTimeout = 30; private int connectionTimeout = 30;
private int maxInflight = 1000; private int maxInflight = 1000;
private boolean cleanSession = false; private boolean cleanSession = false;
@@ -64,8 +64,9 @@ public class MqttProperties {
private String pendingSetKey = "mqtt:command:pending:ids"; private String pendingSetKey = "mqtt:command:pending:ids";
private String retryLockKeyPrefix = "lock:mqtt:command:retry:"; private String retryLockKeyPrefix = "lock:mqtt:command:retry:";
private long retryLockTtlMs = 30000; private long retryLockTtlMs = 30000;
private long ackLockWaitMs = 3000;
private String deviceStatusCachePrefix = "mqtt:device:status:"; private String deviceStatusCachePrefix = "mqtt:device:status:";
private long deviceStatusCacheTtlSeconds = 300; private long deviceStatusCacheTtlSeconds = 900;
} }
} }

View File

@@ -0,0 +1,26 @@
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.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
@Tag("dev")
class DeviceMqttCommandPublisherTest {
@Test
void buildCommandTopicDefaultsToDeviceNo() {
MqttProperties properties = new MqttProperties();
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
null,
null,
properties
);
String topic = invokeMethod(publisher, "buildCommandTopic", "D01");
assertThat(topic).isEqualTo("/d01/subscriber/cmd");
}
}

Some files were not shown because too many files have changed in this diff Show More