Compare commits

..

4 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
86 changed files with 6835 additions and 583 deletions

1
.gitignore vendored
View File

@@ -63,3 +63,4 @@
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/docs/

12
.idea/compiler.xml generated
View File

@@ -25,18 +25,6 @@
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" /> <entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
</processorPath> </processorPath>
<module name="water-app" /> <module name="water-app" />
<module name="water-common-excel" /> <module name="water-common-excel" />

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

@@ -16,8 +16,8 @@
| --- | --- | | --- | --- |
| Broker | `mqtt.broker-url` | | Broker | `mqtt.broker-url` |
| QoS | `mqtt.qos`,当前默认 `1` | | QoS | `mqtt.qos`,当前默认 `1` |
| 设备标识 | 上行 Topic 第一段可为设备 **MAC**`deviceNo`;下行命令 Topic 第一段统一使用 `deviceNo`,仅 `registerDeviceNo` 例外仍使用 MAC | | 设备标识 | 首次注册 Topic 使用设备 **MAC**;注册完成后的上、下行业务 Topic 统一使用 `deviceNo`LWT 离线 Topic 兼容 MAC |
| Topic 变量 | 上行文档中的 `{deviceNo}` 表示设备标识占位符;下行文档中的 `{deviceNo}` 设备编号 | | Topic 变量 | 文档中的 `{deviceNo}` 指服务端分配的设备编号,`{mac}` 设备 MAC 地址 |
| JSON 编码 | UTF-8 | | JSON 编码 | UTF-8 |
| 时间格式 | `yyyy-MM-dd HH:mm:ss` | | 时间格式 | `yyyy-MM-dd HH:mm:ss` |
@@ -26,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
@@ -38,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 | 当前记录日志 |
@@ -51,7 +53,7 @@
| --- | --- | | --- | --- |
| 功能名称 | 设备注册 | | 功能名称 | 设备注册 |
| 通信方向 | 设备发布,后端订阅 | | 通信方向 | 设备发布,后端订阅 |
| Topic | `/{deviceNo}/publish/register` | | Topic | `/{mac}/publish/register` |
| Payload 类型 | JSON | | Payload 类型 | JSON |
后端以 Topic 中的设备标识作为解析入口payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。 后端以 Topic 中的设备标识作为解析入口payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。
@@ -106,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
| 项目 | 内容 | | 项目 | 内容 |
@@ -306,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` `/{deviceNo}/subscriber/schedule` 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`

View File

@@ -38,14 +38,15 @@
| Topic 模式 | 正则 | 处理器 | 说明 | | Topic 模式 | 正则 | 处理器 | 说明 |
|------------|------|--------|------| |------------|------|--------|------|
| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册/上线 | | `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册 |
| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报 | | `/{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/key` | `^/([^/]+)/publish/finish/key$` | `KeyFinishHandler` | 按键/手动浇水完成 |
| `/{identity}/publish/finish/schedule` | `^/([^/]+)/publish/finish/schedule$` | `ScheduleFinishHandler` | 排程浇水完成 | | `/{identity}/publish/finish/schedule` | `^/([^/]+)/publish/finish/schedule$` | `ScheduleFinishHandler` | 排程浇水完成 |
| `/{identity}/publish/error` | `^/([^/]+)/publish/error$` | `ErromesHandler` | 设备异常告警 | | `/{identity}/publish/error` | `^/([^/]+)/publish/error$` | `ErromesHandler` | 设备异常告警 |
| `/{identity}/publish/ack` | `^/([^/]+)/publish/ack$` | `DeviceCommandAckHandler` | 命令执行确认 | | `/{identity}/publish/ack` | `^/([^/]+)/publish/ack$` | `DeviceCommandAckHandler` | 命令执行确认 |
> `{identity}` 可以是设备编号deviceNo MAC 地址, `DeviceIdentityResolver` 统一解析为 deviceNo。 > 首次注册可使用 MAC 地址;注册完成后的业务 Topic 使用 deviceNo。LWT 离线 Topic 兼容 MAC 地址,服务端通过 `DeviceIdentityResolver` 解析为 deviceNo。
### 2.2 下行 Topic服务端 → 设备) ### 2.2 下行 Topic服务端 → 设备)
@@ -304,8 +305,9 @@ long nextRetryAt; // 下次重试时间戳
**服务端处理**: **服务端处理**:
1. 通过 MAC 或设备编号解析入库设备 1. 通过 MAC 或设备编号解析入库设备
2. 更新设备注册信息(名称、电量、固件版本等) 2. 更新设备注册信息(名称、电量、固件版本等)
3. 刷新设备在线状态到 Redis 3. 自动回复设备编号(`registerDeviceNo` 命令)
4. 自动回复设备编号(`registerDeviceNo` 命令)
> 注册成功即将设备标记为在线;设备取得 deviceNo 后仍必须定时上报电量,以持续刷新在线心跳。
--- ---
@@ -317,11 +319,36 @@ long nextRetryAt; // 下次重试时间戳
} }
``` ```
**服务端处理**: 更新设备电量 + 刷新在线状态 **服务端处理**: 更新设备电量,将数据库设备状态改为在线,并将 Redis 在线心跳刷新为 600 秒
设备必须周期上报,即使电量没有变化也要发送。建议每 5 分钟上报一次,连续 10 分钟未收到有效电量消息且期间无 ACK 刷新时,服务端将设备判定为离线。
--- ---
### 5.3 按键浇水完成 — `/{identity}/publish/finish/key` ### 5.3 设备离线遗嘱 — `/{identity}/publish/status`
```json
{
"status": "offline"
}
```
**服务端处理**: 收到非 retained 的 `offline``0` 后立即将设备改为离线。`online``1` 不再用于上线,服务端会忽略,上线状态只由电量心跳维护。
设备应将该消息配置为 MQTT LWT建议 `retain=false`。Topic 第一段优先使用 deviceNo设备注册前无法取得 deviceNo 时可使用 MAC 地址。
兼容设备也可以发送以下格式,服务端会使用 `deviceMac` 解析设备,并将 `offline=true`(字符串或布尔值)按离线处理:
```json
{
"deviceMac": "AA:BB:CC:DD:EE:FF",
"offline": "true"
}
```
---
### 5.4 按键浇水完成 — `/{identity}/publish/finish/key`
```json ```json
{ {
@@ -340,7 +367,7 @@ long nextRetryAt; // 下次重试时间戳
--- ---
### 5.4 排程浇水完成 — `/{identity}/publish/finish/schedule` ### 5.5 排程浇水完成 — `/{identity}/publish/finish/schedule`
```json ```json
{ {
@@ -358,7 +385,7 @@ long nextRetryAt; // 下次重试时间戳
--- ---
### 5.5 设备异常告警 — `/{identity}/publish/error` ### 5.6 设备异常告警 — `/{identity}/publish/error`
```json ```json
{ {
@@ -371,7 +398,7 @@ long nextRetryAt; // 下次重试时间戳
--- ---
### 5.6 命令 ACK — `/{identity}/publish/ack` ### 5.7 命令 ACK — `/{identity}/publish/ack`
```json ```json
{ {
@@ -411,6 +438,7 @@ long nextRetryAt; // 下次重试时间戳
| `maxRetryCount` | 3 | 最大重试次数 | | `maxRetryCount` | 3 | 最大重试次数 |
| `retryIntervalMs` | 5000 | 重试间隔ms | | `retryIntervalMs` | 5000 | 重试间隔ms |
| `scanIntervalMs` | 5000 | 定时扫描间隔ms | | `scanIntervalMs` | 5000 | 定时扫描间隔ms |
| `ackLockWaitMs` | 3000 | ACK 等待同一命令重试锁的最长时间ms |
| `pendingTtlSeconds` | 86400 | pending 命令 TTL | | `pendingTtlSeconds` | 86400 | pending 命令 TTL |
| `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL | | `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL |
@@ -495,8 +523,14 @@ mqtt:
pending-set-key: "mqtt:command:pending:ids" pending-set-key: "mqtt:command:pending:ids"
retry-lock-key-prefix: "lock:mqtt:command:retry:" retry-lock-key-prefix: "lock:mqtt:command:retry:"
retry-lock-ttl-ms: 30000 retry-lock-ttl-ms: 30000
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:
enabled: true
ttl-compat-enabled: true
interval-ms: 30000
``` ```
--- ---
@@ -538,10 +572,13 @@ public class MyNewHandler implements MqttTopicHandler {
## 10. 设备在线状态检测 ## 10. 设备在线状态检测
设备在线状态通过 Redis 缓存管理: 设备在线状态由电量上报和离线遗嘱共同管理:
- **写入时机**: 设备注册、电量上报、ACK 确认时刷新 - **上线时机**: 注册成功或收到包含 `powerLevel` 的有效电量上报时刷新
- **ACK 兼容逻辑**: 保留现有 ACK 在线刷新逻辑,收到有效 ACK 也会延长在线缓存
- **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }` - **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }`
- **TTL**: 默认 300 秒(5 分钟) - **TTL**: 默认 600 秒(10 分钟)
- **离线判定**: 缓存过期 = 设备离线 - **超时离线**: 每 30 秒扫描一次非离线设备,缓存过期后更新数据库状态为离线
- **遗嘱离线**: 收到非 retained 的 LWT `status=offline/0` 时立即离线
- **忽略在线状态**: `status=online/1` 不会刷新在线状态
- **并发保护**: 使用分布式锁(`lock:mqtt:device:status:{deviceNo}`)防止并发写入 - **并发保护**: 使用分布式锁(`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,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,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

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 零容忍

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

@@ -22,8 +22,8 @@ server:
--- # mqtt配置信息 --- # mqtt配置信息
mqtt: mqtt:
enabled: ${MQTT_ENABLED:true} enabled: ${MQTT_ENABLED:true}
#broker-url: ssl://www.mmipco.cn:8883 broker-url: ssl://www.mmipco.cn:8883
broker-url: tcp://47.97.217.123 #broker-url: tcp://47.97.217.123
username: admin username: admin
password: 61e6062129e9 password: 61e6062129e9
client-id: water-server-1${server.port} client-id: water-server-1${server.port}
@@ -54,20 +54,21 @@ mqtt:
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: 900 device-status-cache-ttl-seconds: 600
offline-check: offline-check:
enabled: true enabled: true
ttl-compat-enabled: true ttl-compat-enabled: true
interval-ms: 90000 interval-ms: 30000
topics: topics:
# 订阅/下发主题第一段为设备 MAC小写、去分隔符上行业务侧由 DeviceIdentityResolver 解析为设备编号 # 首次注册使用 MAC注册后的业务主题使用 deviceNoLWT 离线主题兼容 MAC
subscribe: subscribe:
- /+/publish/finish/schedule #排程任务完成上报 - /+/publish/finish/schedule #排程任务完成上报
- /+/publish/register #设备注册 - /+/publish/register #设备注册
- /+/publish/status #设备在线/离线状态online/offline离线配合设备 LWT - /+/publish/status #设备 LWT 离线状态(仅处理 offline
- /+/publish/power #电量 - /+/publish/power #电量及在线心跳
- /+/publish/ack #应答 - /+/publish/ack #应答
- /+/publish/start #开始浇水上报 - /+/publish/start #开始浇水上报
- /+/publish/finish/key #按键浇水完成 - /+/publish/finish/key #按键浇水完成

View File

@@ -39,11 +39,6 @@ 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) {
RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds()));
pendingIds().add(command.getCommandId());
}
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) { static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
return StringUtils.isNotBlank(topicDeviceNo) return StringUtils.isNotBlank(topicDeviceNo)
&& pendingCommand != null && pendingCommand != null
@@ -68,16 +63,6 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return true; return true;
} }
public void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) {
return;
}
withCommandLock(commandId, () -> {
deletePending(commandId);
return true;
});
}
@Override @Override
public void handleAck(String deviceNo, String payload) { public void handleAck(String deviceNo, String payload) {
if (StringUtils.isBlank(payload)) { if (StringUtils.isBlank(payload)) {
@@ -111,7 +96,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return; return;
} }
} }
boolean ackSaved = withCommandLock(ack.getCommandId(), () -> { long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
CommandLockResult ackResult = withCommandLock(ack.getCommandId(), lockWaitMs, () -> {
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId())); DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId()));
if (pending == null) { if (pending == null) {
log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId()); log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId());
@@ -126,14 +112,36 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true; return true;
}); });
if (!ackSaved) { if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId()); log.warn("[MQTT] ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
deviceNo, ack.getCommandId(), lockWaitMs);
return;
}
if (ackResult == CommandLockResult.INTERRUPTED) {
return;
}
if (ackResult == CommandLockResult.ACTION_FAILED) {
return; return;
} }
refreshDeviceOnline(deviceNo); refreshDeviceOnline(deviceNo);
log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} message={}", deviceNo, ack.getCommandId(), ack.getMessage()); 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) {
List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo); List<DeviceCommand> pendingCommands = findPendingCommandsByDeviceNo(deviceNo);
refreshDeviceOnline(deviceNo); refreshDeviceOnline(deviceNo);
@@ -150,7 +158,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
ack.setStatus("1"); ack.setStatus("1");
ack.setMessage(payload); ack.setMessage(payload);
boolean ackSaved = withCommandLock(commandId, () -> { long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs();
CommandLockResult ackResult = withCommandLock(commandId, lockWaitMs, () -> {
DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId)); DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId));
if (pending == null) { if (pending == null) {
log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId); log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId);
@@ -165,13 +174,24 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true; return true;
}); });
if (!ackSaved) { if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) {
log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId); log.warn("[MQTT] 非 JSON ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}",
deviceNo, commandId, lockWaitMs);
return;
}
if (ackResult == CommandLockResult.INTERRUPTED) {
return;
}
if (ackResult == CommandLockResult.ACTION_FAILED) {
return; 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()) {
@@ -284,22 +304,22 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId); pendingIds().remove(commandId);
} }
private boolean withCommandLock(String commandId, BooleanSupplier 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 false; return CommandLockResult.LOCK_UNAVAILABLE;
} }
return action.getAsBoolean(); 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 false; return CommandLockResult.INTERRUPTED;
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e); log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e);
return false; return CommandLockResult.ACTION_FAILED;
} finally { } finally {
if (locked && lock.isHeldByCurrentThread()) { if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); lock.unlock();
@@ -307,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

@@ -64,6 +64,7 @@ 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 = 900; private long deviceStatusCacheTtlSeconds = 900;
} }

View File

@@ -2,6 +2,7 @@ package org.dromara.mqtt;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
@@ -16,6 +17,7 @@ import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic; import org.mockito.MockedStatic;
import org.redisson.api.RLock; import org.redisson.api.RLock;
import org.redisson.api.RSet;
import org.redisson.api.RedissonClient; import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
@@ -24,6 +26,7 @@ import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -42,6 +45,7 @@ class MqttCommandAckServiceTest {
} }
applicationContext = new GenericApplicationContext(); applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class)); applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh(); applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext); new SpringUtil().setApplicationContext(applicationContext);
} }
@@ -93,6 +97,44 @@ class MqttCommandAckServiceTest {
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse(); assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
} }
@Test
void handleAckWaitsBrieflyForCommandLock() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RLock commandLock = mock(RLock.class);
RLock statusLock = mock(RLock.class);
RSet<String> pendingIds = mock(RSet.class);
DeviceCommand pending = new DeviceCommand();
pending.setCommandId("cmd-1");
pending.setDeviceNo("D01");
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
verify(commandLock).tryLock(3000, 30000, TimeUnit.MILLISECONDS);
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:command:ack:cmd-1"),
any(DeviceCommandAck.class),
eq(Duration.ofSeconds(86400))
));
verify(pendingIds).remove("cmd-1");
verify(commandLock).unlock();
}
}
@Test @Test
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception { void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
MqttProperties properties = new MqttProperties(); MqttProperties properties = new MqttProperties();

View File

@@ -52,6 +52,12 @@
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId> <artifactId>hutool-crypto</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

@@ -12,7 +12,9 @@ import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.SseException; import org.dromara.common.core.exception.SseException;
import org.dromara.common.core.exception.base.BaseException; import org.dromara.common.core.exception.base.BaseException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.StreamUtils; import org.dromara.common.core.utils.StreamUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils; import org.dromara.common.json.utils.JsonUtils;
import org.springframework.context.MessageSourceResolvable; import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable;
@@ -57,9 +59,11 @@ public class GlobalExceptionHandler {
*/ */
@ExceptionHandler(ServiceException.class) @ExceptionHandler(ServiceException.class)
public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) { public R<Void> handleServiceException(ServiceException e, HttpServletRequest request) {
log.error(e.getMessage()); String requestURI = request.getRequestURI();
String message = resolveErrorMessage(e.getMessage());
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
Integer code = e.getCode(); Integer code = e.getCode();
return ObjectUtil.isNotNull(code) ? R.fail(code, e.getMessage()) : R.fail(e.getMessage()); return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message);
} }
/** /**
@@ -88,8 +92,10 @@ public class GlobalExceptionHandler {
*/ */
@ExceptionHandler(BaseException.class) @ExceptionHandler(BaseException.class)
public R<Void> handleBaseException(BaseException e, HttpServletRequest request) { public R<Void> handleBaseException(BaseException e, HttpServletRequest request) {
log.error(e.getMessage()); String requestURI = request.getRequestURI();
return R.fail(e.getMessage()); String message = e.getMessage();
log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e);
return R.fail(message);
} }
/** /**
@@ -150,7 +156,7 @@ public class GlobalExceptionHandler {
public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) { public R<Void> handleRuntimeException(RuntimeException e, HttpServletRequest request) {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生未知异常.", requestURI, e); log.error("请求地址'{}',发生未知异常.", requestURI, e);
return R.fail(e.getMessage()); return R.fail();
} }
/** /**
@@ -160,7 +166,7 @@ public class GlobalExceptionHandler {
public R<Void> handleException(Exception e, HttpServletRequest request) { public R<Void> handleException(Exception e, HttpServletRequest request) {
String requestURI = request.getRequestURI(); String requestURI = request.getRequestURI();
log.error("请求地址'{}',发生系统异常.", requestURI, e); log.error("请求地址'{}',发生系统异常.", requestURI, e);
return R.fail(e.getMessage()); return R.fail();
} }
/** /**
@@ -232,4 +238,12 @@ public class GlobalExceptionHandler {
return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败" + e.getMessage()); return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败" + e.getMessage());
} }
private String resolveErrorMessage(String message) {
if (StringUtils.isBlank(message)) {
return MessageUtils.message("operation.fail");
}
String i18nMessage = MessageUtils.message(message);
return message.equals(i18nMessage) ? message : i18nMessage;
}
} }

View File

@@ -0,0 +1,89 @@
package org.dromara.common.web.handler;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.base.BaseException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.context.support.StaticMessageSource;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class GlobalExceptionHandlerTest {
@Mock
private HttpServletRequest request;
private GlobalExceptionHandler handler;
private GenericApplicationContext applicationContext;
@BeforeEach
void setUp() {
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("operation.fail", Locale.getDefault(), "操作失败");
applicationContext = new GenericApplicationContext();
applicationContext.registerBean("messageSource", StaticMessageSource.class, () -> messageSource);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
handler = new GlobalExceptionHandler();
when(request.getRequestURI()).thenReturn("/app/v1/device/D01");
}
@AfterEach
void tearDown() {
applicationContext.close();
}
@Test
void handleRuntimeException_hidesInternalMessage() {
R<Void> result = handler.handleRuntimeException(
new IllegalStateException("jdbc:mysql://internal-host/water"), request);
assertThat(result.getCode()).isEqualTo(R.FAIL);
assertThat(result.getMsg()).doesNotContain("internal-host");
}
@Test
void handleException_hidesInternalMessage() {
R<Void> result = handler.handleException(
new Exception("oss accessKeySecret=internal-secret"), request);
assertThat(result.getCode()).isEqualTo(R.FAIL);
assertThat(result.getMsg()).doesNotContain("internal-secret");
}
@Test
void handleBaseException_logsThrowableAndRequestUri() {
Logger logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class);
ListAppender<ILoggingEvent> appender = new ListAppender<>();
appender.start();
logger.addAppender(appender);
try {
handler.handleBaseException(new BaseException("user", "业务校验失败"), request);
ILoggingEvent event = appender.list.get(appender.list.size() - 1);
assertThat(event.getLevel()).isEqualTo(Level.WARN);
assertThat(event.getFormattedMessage()).contains("/app/v1/device/D01");
assertThat(event.getThrowableProxy()).isNotNull();
} finally {
logger.detachAppender(appender);
}
}
}

View File

@@ -1,15 +1,17 @@
package org.dromara.app.domain.vo; package org.dromara.app.domain.vo;
import jakarta.validation.constraints.NotEmpty;
import org.dromara.app.domain.AppSchedule;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated; import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import io.github.linpeilie.annotations.AutoMapper; import io.github.linpeilie.annotations.AutoMapper;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data; import lombok.Data;
import org.dromara.app.domain.AppSchedule;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
@@ -47,7 +49,7 @@ public class AppScheduleVo implements Serializable {
/** /**
* 设备ids * 设备ids
*/ */
private List deviceNos; private List<AppDeviceVo> deviceNos;
/** /**
* 状态 0-关闭 1 开启 * 状态 0-关闭 1 开启
*/ */
@@ -55,7 +57,7 @@ public class AppScheduleVo implements Serializable {
private String status; private String status;
@NotEmpty(message = "至少需要配置一天") @NotEmpty(message = "至少需要配置一天")
private List<AppScheduleDetailVo> details; private List<Map<String, Object>> details;
} }

View File

@@ -13,7 +13,7 @@ import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power * 设备电量及在线心跳处理器,匹配 /{deviceNo}/publish/power
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -23,6 +23,7 @@ public class DeviceDataHandler implements MqttTopicHandler {
private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$"); private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$");
private final IAppDeviceService appDeviceService; private final IAppDeviceService appDeviceService;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -44,13 +45,15 @@ public class DeviceDataHandler implements MqttTopicHandler {
log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload);
return; return;
} }
// 更新设备电量 + 同步在线状态到数据库 // 有效电量上报同时作为设备在线心跳。
AppDeviceBo appDeviceBo = new AppDeviceBo(); AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setDeviceNo(deviceNo); appDeviceBo.setDeviceNo(deviceNo);
appDeviceBo.setPowerLevel(dto.get("powerLevel").toString()); appDeviceBo.setPowerLevel(dto.get("powerLevel").toString());
appDeviceBo.setPowerLevelUpdatatime(new Date()); appDeviceBo.setPowerLevelUpdatatime(new Date());
appDeviceService.updateByBo(appDeviceBo); appDeviceService.updateByBo(appDeviceBo);
log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); deviceStatusService.markOnline(deviceNo);
log.info("[MQTT] 设备电量更新并刷新在线心跳 时间={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceNo, payload);
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}", log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e); HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e);

View File

@@ -33,6 +33,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
private final AppDeviceMapper appDeviceMapper; private final AppDeviceMapper appDeviceMapper;
private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider; private final ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
private final DeviceIdentityResolver deviceIdentityResolver; private final DeviceIdentityResolver deviceIdentityResolver;
private final MqttDeviceStatusService deviceStatusService;
@Override @Override
public Pattern topicPattern() { public Pattern topicPattern() {
@@ -60,8 +61,9 @@ DeviceRegisterHandler implements MqttTopicHandler {
} }
AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto); AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto);
appDeviceService.registerByMqtt(device); appDeviceService.registerByMqtt(device);
deviceStatusService.markOnline(deviceNo);
sendDeviceNoToDevice(deviceNo, normalizedDeviceMac); sendDeviceNoToDevice(deviceNo, normalizedDeviceMac);
log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}", log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}",
HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload); HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload);
} catch (Exception e) { } catch (Exception e) {
log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e); log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e);

View File

@@ -13,10 +13,10 @@ import java.util.Map;
import java.util.regex.Pattern; import java.util.regex.Pattern;
/** /**
* 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。 * 设备遗嘱离线状态处理器,匹配 /{deviceIdentity}/publish/status。
* <p> * <p>
* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。 * deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。
* 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。 * 在线状态由电量上报刷新;本处理器仅通过 LWT status=offline 标记异常离线。
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -42,17 +42,18 @@ public class DeviceStatusHandler implements MqttTopicHandler {
@Override @Override
public void handle(String deviceIdentity, String payload, boolean retained) { public void handle(String deviceIdentity, String payload, boolean retained) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity); Map<String, Object> body = parsePayload(payload);
String deviceNo = resolveDeviceNo(deviceIdentity, body);
if (deviceNo == null) { if (deviceNo == null) {
log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}", log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}",
HandlerLogTime.now(), deviceIdentity, payload); HandlerLogTime.now(), deviceIdentity, payload);
return; return;
} }
String status = parseStatus(payload); String status = parseStatus(payload, body);
if ("online".equals(status) || "1".equals(status)) { if ("online".equals(status) || "1".equals(status)) {
deviceStatusService.markOnline(deviceNo); log.debug("[MQTT] 忽略设备主动在线状态,在线状态由电量心跳维护 时间={} 设备编号={} 消息体={}",
log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); HandlerLogTime.now(), deviceNo, payload);
return; return;
} }
if ("offline".equals(status) || "0".equals(status)) { if ("offline".equals(status) || "0".equals(status)) {
@@ -69,22 +70,52 @@ public class DeviceStatusHandler implements MqttTopicHandler {
HandlerLogTime.now(), deviceNo, payload); HandlerLogTime.now(), deviceNo, payload);
} }
private String parseStatus(String payload) { private String resolveDeviceNo(String deviceIdentity, Map<String, Object> body) {
String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity);
if (deviceNo != null || body == null) {
return deviceNo;
}
Object deviceMac = body.get("deviceMac");
if (deviceMac == null || StringUtils.isBlank(String.valueOf(deviceMac))) {
return null;
}
return deviceIdentityResolver.resolveDeviceNo(String.valueOf(deviceMac));
}
private Map<String, Object> parsePayload(String payload) {
if (StringUtils.isBlank(payload)) { if (StringUtils.isBlank(payload)) {
return null; return null;
} }
String trimmed = payload.trim(); String trimmed = payload.trim();
if (!trimmed.startsWith("{")) { if (!trimmed.startsWith("{")) {
return trimmed.toLowerCase(Locale.ROOT); return null;
} }
try { try {
Map<String, Object> body = objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() { return objectMapper.readValue(trimmed, new TypeReference<Map<String, Object>>() {
}); });
Object status = body.get("status");
return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT);
} catch (Exception e) { } catch (Exception e) {
log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e); log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e);
return null; return null;
} }
} }
private String parseStatus(String payload, Map<String, Object> body) {
if (body == null) {
if (StringUtils.isBlank(payload)) {
return null;
}
String trimmed = payload.trim();
return trimmed.startsWith("{") ? null : trimmed.toLowerCase(Locale.ROOT);
}
Object status = body.get("status");
if (status != null) {
return String.valueOf(status).trim().toLowerCase(Locale.ROOT);
}
Object offline = body.get("offline");
if (offline != null && "true".equalsIgnoreCase(String.valueOf(offline).trim())) {
return "offline";
}
return null;
}
} }

View File

@@ -32,7 +32,7 @@ public class MqttDeviceStatusService {
@Value("${mqtt.command-ack.device-status-cache-prefix}") @Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix; private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}") @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds; private long deviceStatusCacheTtlSeconds;
public void markOnline(String deviceNo) { public void markOnline(String deviceNo) {

View File

@@ -29,6 +29,14 @@ public interface IAppDeviceService {
*/ */
AppDeviceVo queryById(String deviceNo); AppDeviceVo queryById(String deviceNo);
/**
* 按设备编号批量查询设备。
*
* @param deviceNos 设备编号集合
* @return 设备列表
*/
List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos);
/** /**
* 分页查询设备信息 * 分页查询设备信息
列表 列表

View File

@@ -8,6 +8,7 @@ import org.dromara.common.mybatis.core.page.TableDataInfo;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Set;
public interface IAppSchedulingDeviceService { public interface IAppSchedulingDeviceService {
@@ -25,6 +26,14 @@ public interface IAppSchedulingDeviceService {
List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo); List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo);
/**
* 批量查询已绑定排程的设备编号。
*
* @param deviceNos 待检查的设备编号
* @return 已绑定排程的设备编号
*/
Set<String> findBoundDeviceNos(Collection<String> deviceNos);
Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo); Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo);
AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo); AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo);

View File

@@ -73,6 +73,14 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return baseMapper.selectVoById(deviceNo); return baseMapper.selectVoById(deviceNo);
} }
@Override
public List<AppDeviceVo> queryByDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return List.of();
}
return baseMapper.selectVoByIds(deviceNos);
}
@Override @Override
public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) { public TableDataInfo<AppDeviceVo> queryPageList(AppDeviceBo bo, PageQuery pageQuery) {
Page<AppDeviceVo> page = pageQuery.build(); Page<AppDeviceVo> page = pageQuery.build();
@@ -307,7 +315,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService {
return params; return params;
} }
params.put("bindDevice", exists); params.put("bindDevice", exists);
if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) { if (exists.getUserId() == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) {
params.put("bindDeviceStatus", 305); params.put("bindDeviceStatus", 305);
params.put("bindDeviceStatusName", "设备未配网,请先去配置网络"); params.put("bindDeviceStatusName", "设备未配网,请先去配置网络");
return params; return params;

View File

@@ -165,10 +165,10 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
return conflicts; return conflicts;
} }
private List saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) { private List<Map<String, Object>> saveDetails(Long scheduleId, List<AppScheduleBo.AppScheduleDetail> details) {
List list = new ArrayList<>(); List<Map<String, Object>> detailList = new ArrayList<>();
if (details == null) { if (details == null) {
return list; return detailList;
} }
for (AppScheduleBo.AppScheduleDetail item : details) { for (AppScheduleBo.AppScheduleDetail item : details) {
AppScheduleDetail detail = new AppScheduleDetail(); AppScheduleDetail detail = new AppScheduleDetail();
@@ -181,13 +181,13 @@ public class AppScheduleServiceImpl implements IAppScheduleService {
detail.setTriggerType(item.getTriggerType()); detail.setTriggerType(item.getTriggerType());
detail.setStatus(item.getStatus()); detail.setStatus(item.getStatus());
scheduleDetailMapper.insert(detail); scheduleDetailMapper.insert(detail);
list.add(toDetailMap(detail, item.getTimeData())); detailList.add(toDetailMap(detail, item.getTimeData()));
} }
return list; return detailList;
} }
private List updateDetails(List<AppScheduleBo.AppScheduleDetail> details) { private List<Map<String, Object>> updateDetails(List<AppScheduleBo.AppScheduleDetail> details) {
List detailList = new ArrayList<>(); List<Map<String, Object>> detailList = new ArrayList<>();
if (details == null) { if (details == null) {
return detailList; return detailList;
} }

View File

@@ -21,6 +21,8 @@ import org.springframework.stereotype.Service;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -106,4 +108,19 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi
public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) { public List<AppSchedulingDeviceVo> findByDeviceNo(String deviceNo) {
return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo)); return baseMapper.selectVoList(new QueryWrapper<AppSchedulingDevice>().eq("device_no", deviceNo));
} }
@Override
public Set<String> findBoundDeviceNos(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return Set.of();
}
return baseMapper.selectList(
new QueryWrapper<AppSchedulingDevice>()
.select("device_no")
.in("device_no", deviceNos))
.stream()
.map(AppSchedulingDevice::getDeviceNo)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
}
} }

View File

@@ -18,8 +18,8 @@ import java.util.List;
/** /**
* 设备离线检测定时任务 * 设备离线检测定时任务
* <p> * <p>
* 逻辑:默认通过设备 LWT 离线消息更新状态 * 逻辑:电量上报刷新 Redis 在线 KeyKey 超时后判定设备离线
* 兼容旧设备时,可打开 ttl-compat-enabled继续使用 Redis 在线 Key 过期兜底离线。 * 设备 LWT 离线消息仍可立即更新离线状态,本任务用于处理未收到遗嘱的异常断线。
* 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在, * 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在,
* 则将数据库 status 更新为 0离线 * 则将数据库 status 更新为 0离线
* </p> * </p>
@@ -37,19 +37,19 @@ public class DeviceOfflineCheckTask {
@Value("${mqtt.command-ack.device-status-cache-prefix}") @Value("${mqtt.command-ack.device-status-cache-prefix}")
private String deviceStatusCachePrefix; private String deviceStatusCachePrefix;
@Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}") @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}")
private long deviceStatusCacheTtlSeconds; private long deviceStatusCacheTtlSeconds;
@Value("${mqtt.offline-check.enabled:true}") @Value("${mqtt.offline-check.enabled:true}")
private boolean enabled; private boolean enabled;
@Value("${mqtt.offline-check.ttl-compat-enabled:false}") @Value("${mqtt.offline-check.ttl-compat-enabled:true}")
private boolean ttlCompatEnabled; private boolean ttlCompatEnabled;
/** /**
* 每 90 秒检查一次。默认不开启 TTL 兼容离线检测,避免低功耗长连接设备被误判离线 * 每 30 秒检查一次电量心跳是否超时
* 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms * 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms
*/ */
@Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}") @Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:30000}")
public void checkOfflineDevices() { public void checkOfflineDevices() {
if (!enabled || !ttlCompatEnabled) { if (!enabled || !ttlCompatEnabled) {
return; return;
@@ -96,7 +96,7 @@ public class DeviceOfflineCheckTask {
List<String> updatedDeviceNos = new ArrayList<>(); List<String> updatedDeviceNos = new ArrayList<>();
for (String deviceNo : offlineDeviceNos) { for (String deviceNo : offlineDeviceNos) {
if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备掉线")) { if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备电量心跳超时")) {
updatedDeviceNos.add(deviceNo); updatedDeviceNos.add(deviceNo);
} }
} }

View File

@@ -0,0 +1,306 @@
package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.vo.AppScheduleDetailVo;
import org.dromara.app.domain.vo.AppScheduleVo;
import org.dromara.app.domain.vo.AppSchedulingDeviceVo;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class AppControllerConcurrencyTest {
private static final int WORKER_COUNT = 32;
private static final int GROUP_TIMEOUT_SECONDS = 30;
private static final Long USER_ID = 99L;
private static final DateTimeFormatter STRICT_SECOND_FORMATTER =
DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT);
@Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService;
@Mock private ISysOssService ossService;
@Mock private IAppVersionService appVersionService;
private GenericApplicationContext applicationContext;
@BeforeEach
void setUp() {
applicationContext = new GenericApplicationContext();
Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterEach
void tearDown() {
applicationContext.close();
}
@Test
void switchDevice_formatsAllStartTimesCorrectlyUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 100;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
ConcurrentLinkedQueue<String> startTimes = new ConcurrentLinkedQueue<>();
AtomicInteger successfulResponses = new AtomicInteger();
when(appDeviceService.switchDevice(eq("D01"), eq("1"), anyString(), eq(10)))
.thenAnswer(invocation -> {
startTimes.add(invocation.getArgument(2, String.class));
return true;
});
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
R<Void> result = controller.switchDevice(
"{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}");
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
successfulResponses.incrementAndGet();
});
assertThat(successfulResponses.get()).isEqualTo(expectedCalls);
assertThat(startTimes).hasSize(expectedCalls).allSatisfy(startTime ->
assertThatCode(() -> LocalDateTime.parse(startTime, STRICT_SECOND_FORMATTER))
.doesNotThrowAnyException());
verify(appDeviceService, times(expectedCalls))
.switchDevice(eq("D01"), eq("1"), anyString(), eq(10));
}
@Test
void uploadImage_validatesAllSupportedFormatsUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 50;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
List<ImageFixture> fixtures = supportedImages();
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setOriginalName("plant-image");
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
ImageFixture fixture = fixtures.get((worker + iteration) % fixtures.size());
MockMultipartFile file = new MockMultipartFile(
"file", fixture.fileName(), fixture.contentType(), fixture.content());
R<SysOssUploadVo> result = controller.uploadImage(file);
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
assertThat(result.getData().getOssId()).isEqualTo("123");
assertThat(result.getData().getUrl()).isEqualTo(oss.getUrl());
});
verify(ossService, times(expectedCalls)).upload(any(MultipartFile.class));
}
@Test
void editScheduleStatus_keepsMqttPayloadsIsolatedUnderConcurrency() throws Exception {
AppController controller = newController();
int iterationsPerWorker = 20;
int expectedCalls = WORKER_COUNT * iterationsPerWorker;
ConcurrentHashMap<String, Map<String, Object>> payloadsByDevice = new ConcurrentHashMap<>();
when(appScheduleService.queryById(anyLong())).thenAnswer(invocation -> {
Long scheduleId = invocation.getArgument(0, Long.class);
AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(scheduleId);
schedule.setUserId(USER_ID);
schedule.setStatus("1");
return schedule;
});
when(appScheduleService.updateStatusByBo(any(AppScheduleBo.class))).thenReturn(true);
when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenAnswer(invocation -> {
AppSchedulingDeviceBo bo = invocation.getArgument(0, AppSchedulingDeviceBo.class);
AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo();
binding.setScheduleId(bo.getScheduleId());
binding.setDeviceNo(deviceNo(bo.getScheduleId()));
return List.of(binding);
});
when(appScheduleDetailService.queryByScheduleIdByStatus(anyLong())).thenAnswer(invocation -> {
Long scheduleId = invocation.getArgument(0, Long.class);
AppScheduleDetailVo detail = new AppScheduleDetailVo();
detail.setScheduleId(scheduleId);
detail.setWeekday((int) ((scheduleId - 1) % 7) + 1);
detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]");
detail.setTriggerType(detailMarker(scheduleId));
detail.setStatus("1");
return List.of(detail);
});
when(deviceCommandService.sendScheduleBindCommand(anyString(), any())).thenAnswer(invocation -> {
String deviceNo = invocation.getArgument(0, String.class);
Map<String, Object> payload = invocation.getArgument(1);
assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull();
return "cmd-" + deviceNo;
});
runConcurrently(iterationsPerWorker, (worker, iteration) -> {
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
loginHelper.when(LoginHelper::getUserId).thenReturn(USER_ID);
Long scheduleId = (long) worker * iterationsPerWorker + iteration + 1;
AppScheduleBo bo = new AppScheduleBo();
bo.setId(scheduleId);
bo.setStatus("1");
R<Void> result = controller.editScheduleStatus(bo);
assertThat(result.getCode()).isEqualTo(R.SUCCESS);
}
});
assertThat(payloadsByDevice).hasSize(expectedCalls);
for (long scheduleId = 1; scheduleId <= expectedCalls; scheduleId++) {
String deviceNo = deviceNo(scheduleId);
Map<String, Object> payload = payloadsByDevice.get(deviceNo);
assertThat(payload).as(deviceNo).isNotNull();
assertThat(payload.get("deviceNo")).isEqualTo(deviceNo);
List<?> details = asList(payload.get("details"));
assertThat(details).hasSize(1);
Map<?, ?> detail = asMap(details.get(0));
assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId));
assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1);
}
verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any());
verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong());
}
private void runConcurrently(int iterationsPerWorker, ConcurrentOperation operation) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT);
CountDownLatch ready = new CountDownLatch(WORKER_COUNT);
CountDownLatch start = new CountDownLatch(1);
List<Future<Void>> futures = new ArrayList<>(WORKER_COUNT);
try {
for (int worker = 0; worker < WORKER_COUNT; worker++) {
int workerIndex = worker;
futures.add(executor.submit(() -> {
ready.countDown();
if (!start.await(10, TimeUnit.SECONDS)) {
throw new TimeoutException("并发工作线程等待启动超时");
}
for (int iteration = 0; iteration < iterationsPerWorker; iteration++) {
operation.execute(workerIndex, iteration);
}
return null;
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).as("32个工作线程应全部就绪").isTrue();
start.countDown();
awaitAll(futures);
} finally {
start.countDown();
executor.shutdownNow();
assertThat(executor.awaitTermination(10, TimeUnit.SECONDS))
.as("并发测试线程池应按时结束")
.isTrue();
}
}
private void awaitAll(List<Future<Void>> futures) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(GROUP_TIMEOUT_SECONDS);
for (Future<Void> future : futures) {
long remainingNanos = deadline - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("并发测试组执行超过" + GROUP_TIMEOUT_SECONDS + "");
}
future.get(remainingNanos, TimeUnit.NANOSECONDS);
}
}
private AppController newController() {
return new AppController(
appDeviceService,
appScheduleService,
appScheduleDetailService,
appSchedulingDeviceService,
userService,
appWateringLogService,
deviceCommandService,
ossService,
appVersionService
);
}
private List<ImageFixture> supportedImages() {
return List.of(
new ImageFixture("plant.jpg", "image/jpeg", new byte[]{
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
}),
new ImageFixture("plant.png", "image/png", new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
}),
new ImageFixture("plant.gif", "image/gif",
new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}),
new ImageFixture("plant.webp", "image/webp", new byte[]{
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
}),
new ImageFixture("plant.bmp", "image/bmp",
new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00})
);
}
private String deviceNo(Long scheduleId) {
return "D" + scheduleId;
}
private String detailMarker(Long scheduleId) {
return "schedule-" + scheduleId;
}
private List<?> asList(Object value) {
assertThat(value).isInstanceOf(List.class);
return (List<?>) value;
}
private Map<?, ?> asMap(Object value) {
assertThat(value).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
@FunctionalInterface
private interface ConcurrentOperation {
void execute(int worker, int iteration) throws Exception;
}
private record ImageFixture(String fileName, String contentType, byte[] content) {
}
}

View File

@@ -3,12 +3,14 @@ package org.dromara.app.controller;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import cn.hutool.json.JSONObject; import cn.hutool.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppScheduleBo; import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo; import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.vo.*; import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*; import org.dromara.app.service.*;
import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.vo.SysOssUploadVo; import org.dromara.system.domain.vo.SysOssUploadVo;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
@@ -17,22 +19,23 @@ import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.*;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext; import org.springframework.context.support.GenericApplicationContext;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.Calendar; import java.util.Calendar;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.function.Supplier; import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -42,13 +45,13 @@ public class AppControllerTest {
@Mock private IAppDeviceService appDeviceService; @Mock private IAppDeviceService appDeviceService;
@Mock private IAppScheduleService appScheduleService; @Mock private IAppScheduleService appScheduleService;
@Mock private IAppScheduleDetailService appScheduleDetailService; @Mock private IAppScheduleDetailService appScheduleDetailService;
@Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl;
@Mock private IAppSchedulingDeviceService appSchedulingDeviceService; @Mock private IAppSchedulingDeviceService appSchedulingDeviceService;
@Mock private ISysUserService userService; @Mock private ISysUserService userService;
@Mock private IAppWateringLogService appWateringLogService; @Mock private IAppWateringLogService appWateringLogService;
@Mock private IDeviceCommandService deviceCommandService; @Mock private IDeviceCommandService deviceCommandService;
@Mock private ISysOssService ossService; @Mock private ISysOssService ossService;
@Mock private IAppVersionService appVersionService; @Mock private IAppVersionService appVersionService;
@Captor private ArgumentCaptor<Map<String, Object>> schedulePayloadCaptor;
@Test @Test
public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() { public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() {
@@ -100,10 +103,9 @@ public class AppControllerTest {
public void checkVersion_rejectsMissingCurrentVersion() { public void checkVersion_rejectsMissingCurrentVersion() {
AppController controller = newController(); AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", " ")); assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", " ")))
.isInstanceOf(ServiceException.class)
assertThat(result.getCode()).isEqualTo(500); .hasMessage("当前版本不能为空");
assertThat(result.getMsg()).isEqualTo("当前版本不能为空");
verify(appVersionService, never()).queryLatestByPlatform(any()); verify(appVersionService, never()).queryLatestByPlatform(any());
} }
@@ -111,10 +113,9 @@ public class AppControllerTest {
public void checkVersion_rejectsUnsupportedPlatform() { public void checkVersion_rejectsUnsupportedPlatform() {
AppController controller = newController(); AppController controller = newController();
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")); assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")))
.isInstanceOf(ServiceException.class)
assertThat(result.getCode()).isEqualTo(500); .hasMessage("平台类型仅支持 android 或 ios");
assertThat(result.getMsg()).isEqualTo("平台类型仅支持 android 或 ios");
verify(appVersionService, never()).queryLatestByPlatform(any()); verify(appVersionService, never()).queryLatestByPlatform(any());
} }
@@ -123,10 +124,9 @@ public class AppControllerTest {
AppController controller = newController(); AppController controller = newController();
when(appVersionService.queryLatestByPlatform("android")).thenReturn(null); when(appVersionService.queryLatestByPlatform("android")).thenReturn(null);
R<AppVersionCheckVo> result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")); assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")))
.isInstanceOf(ServiceException.class)
assertThat(result.getCode()).isEqualTo(500); .hasMessage("版本配置不存在");
assertThat(result.getMsg()).isEqualTo("版本配置不存在");
} }
@Test @Test
@@ -136,7 +136,7 @@ public class AppControllerTest {
"file", "file",
"plant.png", "plant.png",
"image/png", "image/png",
new byte[]{1, 2, 3} pngBytes()
); );
SysOssVo oss = new SysOssVo(); SysOssVo oss = new SysOssVo();
oss.setOssId(123L); oss.setOssId(123L);
@@ -153,6 +153,29 @@ public class AppControllerTest {
verify(ossService).upload(file); verify(ossService).upload(file);
} }
@Test
public void uploadImage_acceptsAllSupportedBinaryImageFormats() {
AppController controller = newController();
SysOssVo oss = new SysOssVo();
oss.setOssId(123L);
oss.setOriginalName("plant-image");
oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image");
when(ossService.upload(any(MultipartFile.class))).thenReturn(oss);
List<MockMultipartFile> files = List.of(
new MockMultipartFile("file", "plant.jpg", "image/jpeg", jpegBytes()),
new MockMultipartFile("file", "plant.gif", "image/gif", gifBytes()),
new MockMultipartFile("file", "plant.webp", "image/webp", webpBytes()),
new MockMultipartFile("file", "plant.bmp", "image/bmp", bmpBytes())
);
for (MockMultipartFile file : files) {
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file));
assertThat(result.getCode()).as(file.getOriginalFilename()).isEqualTo(200);
}
verify(ossService, times(files.size())).upload(any(MultipartFile.class));
}
@Test @Test
public void uploadImage_rejectsNonImageFile() { public void uploadImage_rejectsNonImageFile() {
AppController controller = newController(); AppController controller = newController();
@@ -163,13 +186,67 @@ public class AppControllerTest {
new byte[]{1, 2, 3} new byte[]{1, 2, 3}
); );
R<SysOssUploadVo> result = callWithLogin(1L, () -> controller.uploadImage(file)); assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file)))
.isInstanceOf(ServiceException.class)
assertThat(result.getCode()).isEqualTo(500); .hasMessage("只能上传图片文件");
assertThat(result.getMsg()).isEqualTo("只能上传图片文件");
verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class)); verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class));
} }
@Test
public 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
public 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
public 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));
}
@Test
public void getDeviceInfo_propagatesUnexpectedException() {
AppController controller = newController();
IllegalStateException failure = new IllegalStateException("database unavailable");
when(appDeviceService.queryById("D01")).thenThrow(failure);
assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01")))
.isSameAs(failure);
}
@Test @Test
public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception { public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception {
AppController controller = newController(); AppController controller = newController();
@@ -186,6 +263,47 @@ public class AppControllerTest {
assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00"); assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00");
} }
@Test
public 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(result.getData().get("deviceList")).isEqualTo(List.of(second));
verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString());
}
@Test
public 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");
AppDeviceVo firstDevice = ownedDevice("D01", 99L);
AppDeviceVo secondDevice = ownedDevice("D02", 99L);
List<AppDeviceVo> queriedDevices = List.of(secondDevice, firstDevice);
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(queriedDevices);
R<AppScheduleVo> result = callWithLogin(99L, () -> controller.getScheduleInfo(10L));
assertThat(result.getData().getDeviceNos())
.extracting(AppDeviceVo::getDeviceNo)
.containsExactly("D01", "D02");
verify(appDeviceService, never()).queryById("D01");
verify(appDeviceService, never()).queryById("D02");
}
@Test @Test
public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() { public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() {
AppController controller = newController(); AppController controller = newController();
@@ -217,23 +335,21 @@ public class AppControllerTest {
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class); verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture());
verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture()); Map<String, Object> payload = schedulePayloadCaptor.getValue();
Map<String, Object> payload = payloadCaptor.getValue();
assertThat(payload.get("deviceNo")).isEqualTo("D01"); assertThat(payload.get("deviceNo")).isEqualTo("D01");
assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule"); assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule");
List<Map<String, Object>> details = (List<Map<String, Object>>) payload.get("details"); List<?> details = asList(payload.get("details"));
assertThat(details).hasSize(1); assertThat(details).hasSize(1);
Map<String, Object> detailPayload = details.get(0); Map<?, ?> detailPayload = asMap(details.get(0));
assertThat(detailPayload) assertThat(detailPayload.get("weekday")).isEqualTo(1);
.containsEntry("weekday", 1) assertThat(detailPayload.get("triggerType")).isEqualTo("0");
.containsEntry("triggerType", "0") assertThat(detailPayload.get("status")).isEqualTo("1");
.containsEntry("status", "1") assertThat(detailPayload.containsKey("id")).isFalse();
.doesNotContainKey("id") assertThat(detailPayload.containsKey("zones")).isFalse();
.doesNotContainKey("zones");
List<Object> timeData = (List<Object>) detailPayload.get("timeData"); List<?> timeData = asList(detailPayload.get("timeData"));
assertThat(timeData).hasSize(1); assertThat(timeData).hasSize(1);
JSONObject timeSlot = (JSONObject) timeData.get(0); JSONObject timeSlot = (JSONObject) timeData.get(0);
assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00"); assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00");
@@ -275,7 +391,7 @@ public class AppControllerTest {
R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L})); R<Void> result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L}));
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
var ordered = inOrder(appScheduleService, deviceCommandService); InOrder ordered = inOrder(appScheduleService, deviceCommandService);
ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true); ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true);
ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L); ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L);
} }
@@ -348,6 +464,29 @@ public class AppControllerTest {
verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L); verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L);
} }
@Test
public 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());
}
@Test @Test
public void latestWaterLog_returnsLatestRunningLogById() throws Exception { public void latestWaterLog_returnsLatestRunningLogById() throws Exception {
AppController controller = newController(); AppController controller = newController();
@@ -387,7 +526,6 @@ public class AppControllerTest {
appDeviceService, appDeviceService,
appScheduleService, appScheduleService,
appScheduleDetailService, appScheduleDetailService,
appScheduleServiceimpl,
appSchedulingDeviceService, appSchedulingDeviceService,
userService, userService,
appWateringLogService, appWateringLogService,
@@ -400,7 +538,8 @@ public class AppControllerTest {
private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) { private <T> R<T> callWithLogin(Long userId, Supplier<R<T>> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext(); try (GenericApplicationContext applicationContext = new GenericApplicationContext();
MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) { MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new); Supplier<ObjectMapper> objectMapperSupplier = ObjectMapper::new;
applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier);
applicationContext.refresh(); applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext); new SpringUtil().setApplicationContext(applicationContext);
loginHelper.when(LoginHelper::getUserId).thenReturn(userId); loginHelper.when(LoginHelper::getUserId).thenReturn(userId);
@@ -408,6 +547,16 @@ public class AppControllerTest {
} }
} }
private List<?> asList(Object value) {
assertThat(value).isInstanceOf(List.class);
return (List<?>) value;
}
private Map<?, ?> asMap(Object value) {
assertThat(value).isInstanceOf(Map.class);
return (Map<?, ?>) value;
}
private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) { private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) {
AppScheduleVo schedule = new AppScheduleVo(); AppScheduleVo schedule = new AppScheduleVo();
schedule.setId(scheduleId); schedule.setId(scheduleId);
@@ -422,6 +571,35 @@ public class AppControllerTest {
return device; return device;
} }
private byte[] pngBytes() {
return new byte[]{
(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52
};
}
private byte[] jpegBytes() {
return new byte[]{
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00
};
}
private byte[] gifBytes() {
return new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61};
}
private byte[] webpBytes() {
return new byte[]{
0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20
};
}
private byte[] bmpBytes() {
return new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00};
}
private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException { private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException {
while (true) { while (true) {
int second = Calendar.getInstance().get(Calendar.SECOND); int second = Calendar.getInstance().get(Calendar.SECOND);

View File

@@ -0,0 +1,64 @@
package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.service.IAppDeviceService;
import org.junit.jupiter.api.Tag;
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 org.springframework.context.support.GenericApplicationContext;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class DeviceDataHandlerTest {
@Mock
private IAppDeviceService appDeviceService;
@Mock
private DeviceIdentityResolver deviceIdentityResolver;
@Mock
private MqttDeviceStatusService deviceStatusService;
@InjectMocks
private DeviceDataHandler handler;
@Test
void handleMarksDeviceOnlineAndUpdatesPowerLevel() {
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
withJsonContext(() -> {
handler.handle("D01", "{\"powerLevel\":\"86\"}");
return null;
});
ArgumentCaptor<AppDeviceBo> deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).updateByBo(deviceCaptor.capture());
AppDeviceBo updatedDevice = deviceCaptor.getValue();
assertThat(updatedDevice.getDeviceNo()).isEqualTo("D01");
assertThat(updatedDevice.getPowerLevel()).isEqualTo("86");
assertThat(updatedDevice.getPowerLevelUpdatatime()).isNotNull();
verify(deviceStatusService).markOnline("D01");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
}
}

View File

@@ -1,5 +1,7 @@
package org.dromara.app.handler; package org.dromara.app.handler;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.mqtt.DeviceCommand; import org.dromara.app.domain.mqtt.DeviceCommand;
@@ -13,8 +15,10 @@ import org.mockito.ArgumentCaptor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.support.GenericApplicationContext;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
@@ -30,6 +34,7 @@ class DeviceRegisterHandlerTest {
@Mock private ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider; @Mock private ObjectProvider<IDeviceCommandPublisher> commandPublisherProvider;
@Mock private IDeviceCommandPublisher commandPublisher; @Mock private IDeviceCommandPublisher commandPublisher;
@Mock private DeviceIdentityResolver deviceIdentityResolver; @Mock private DeviceIdentityResolver deviceIdentityResolver;
@Mock private MqttDeviceStatusService deviceStatusService;
@Test @Test
void firstRegistrationReplyStillUsesMacTopic() throws Exception { void firstRegistrationReplyStillUsesMacTopic() throws Exception {
@@ -37,7 +42,8 @@ class DeviceRegisterHandlerTest {
appDeviceService, appDeviceService,
appDeviceMapper, appDeviceMapper,
commandPublisherProvider, commandPublisherProvider,
deviceIdentityResolver deviceIdentityResolver,
deviceStatusService
); );
when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher); when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher);
when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1"); when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1");
@@ -61,7 +67,8 @@ class DeviceRegisterHandlerTest {
appDeviceService, appDeviceService,
appDeviceMapper, appDeviceMapper,
commandPublisherProvider, commandPublisherProvider,
deviceIdentityResolver deviceIdentityResolver,
deviceStatusService
); );
AppDevice existing = new AppDevice(); AppDevice existing = new AppDevice();
existing.setDeviceNo("2075423638947475457"); existing.setDeviceNo("2075423638947475457");
@@ -69,11 +76,24 @@ class DeviceRegisterHandlerTest {
when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing); when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing);
when(commandPublisherProvider.getIfAvailable()).thenReturn(null); when(commandPublisherProvider.getIfAvailable()).thenReturn(null);
handler.handle("2075423638947475457", "{}"); withJsonContext(() -> {
handler.handle("2075423638947475457", "{}");
return null;
});
org.mockito.ArgumentCaptor<AppDeviceBo> captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class); org.mockito.ArgumentCaptor<AppDeviceBo> captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class);
verify(appDeviceService).registerByMqtt(captor.capture()); verify(appDeviceService).registerByMqtt(captor.capture());
assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457"); assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457");
assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc"); assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc");
verify(deviceStatusService).markOnline("2075423638947475457");
}
private <T> T withJsonContext(Supplier<T> action) {
try (GenericApplicationContext applicationContext = new GenericApplicationContext()) {
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
return action.get();
}
} }
} }

View File

@@ -35,13 +35,13 @@ class DeviceStatusHandlerTest {
} }
@Test @Test
void handleMarksDeviceOnlineFromPlainPayload() { void handleIgnoresOnlineStatusBecausePowerReportMarksDeviceOnline() {
DeviceStatusHandler handler = newHandler(); DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01");
handler.handle("D01", "online"); handler.handle("D01", "online");
verify(deviceStatusService).markOnline("D01"); verify(deviceStatusService, never()).markOnline(anyString());
verify(deviceStatusService, never()).markOffline(anyString(), anyString()); verify(deviceStatusService, never()).markOffline(anyString(), anyString());
} }
@@ -56,6 +56,19 @@ class DeviceStatusHandlerTest {
verify(deviceStatusService, never()).markOnline(anyString()); verify(deviceStatusService, never()).markOnline(anyString());
} }
@Test
void handleMarksDeviceOfflineFromOfflineFlagAndPayloadMac() {
DeviceStatusHandler handler = newHandler();
when(deviceIdentityResolver.resolveDeviceNo("unknown-topic-identity")).thenReturn(null);
when(deviceIdentityResolver.resolveDeviceNo("DC:DA:0C:FA:29:5E")).thenReturn("D01");
handler.handle("unknown-topic-identity", "{\"deviceMac\":\"DC:DA:0C:FA:29:5E\",\"offline\":\"true\"}");
verify(deviceIdentityResolver).resolveDeviceNo("DC:DA:0C:FA:29:5E");
verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线");
verify(deviceStatusService, never()).markOnline(anyString());
}
@Test @Test
void handleResolvesLastWillTopicMacToDeviceNo() { void handleResolvesLastWillTopicMacToDeviceNo() {
String macAddress = "DC:DA:0C:FA:29:5E"; String macAddress = "DC:DA:0C:FA:29:5E";

View File

@@ -76,7 +76,7 @@ class MqttDeviceStatusServiceTest {
redis.verify(() -> RedisUtils.setCacheObject( redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:device:status:D01"), eq("mqtt:device:status:D01"),
any(Map.class), any(Map.class),
eq(Duration.ofSeconds(900)) eq(Duration.ofSeconds(600))
)); ));
verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class)); verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class));
verify(lock).unlock(); verify(lock).unlock();
@@ -128,7 +128,7 @@ class MqttDeviceStatusServiceTest {
private MqttDeviceStatusService newService() { private MqttDeviceStatusService newService() {
MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService); MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService);
ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:"); ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 900L); ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L);
return service; return service;
} }
} }

View File

@@ -3,6 +3,7 @@ package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper; import org.dromara.app.mapper.AppWateringLogMapper;
@@ -174,6 +175,22 @@ class AppDeviceServiceImplTest {
} }
} }
@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);
}
private int countNonWhitePixels(BufferedImage image, int startY, int endY) { private int countNonWhitePixels(BufferedImage image, int startY, int endY) {
int count = 0; int count = 0;
for (int y = startY; y < endY; y++) { for (int y = startY; y < endY; y++) {

View File

@@ -0,0 +1,40 @@
package org.dromara.app.service.impl;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
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));
}
}

View File

@@ -67,15 +67,31 @@ class DeviceOfflineCheckTaskTest {
task.checkOfflineDevices(); task.checkOfflineDevices();
redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(900))); redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(600)));
verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any()); verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any());
} }
} }
@Test
void checkOfflineDevicesMarksDeviceOfflineWhenPowerHeartbeatExpired() {
DeviceOfflineCheckTask task = newTask();
AppDevice device = new AppDevice();
device.setDeviceNo("D01");
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(false);
task.checkOfflineDevices();
verify(deviceStatusService).markOfflineIfCacheMissing("D01", "设备电量心跳超时");
}
}
private DeviceOfflineCheckTask newTask() { private DeviceOfflineCheckTask newTask() {
DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService); DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService);
ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:"); ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:");
ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 900L); ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 600L);
ReflectionTestUtils.setField(task, "enabled", true); ReflectionTestUtils.setField(task, "enabled", true);
ReflectionTestUtils.setField(task, "ttlCompatEnabled", true); ReflectionTestUtils.setField(task, "ttlCompatEnabled", true);
return task; return task;