14 Commits

Author SHA1 Message Date
yuhaiming
509e760f3d 设备bug修改 新增设备解绑功能 2026-08-25 16:18:34 +08:00
yuhaiming
b789c1c07e fix: default register client id 2026-08-14 10:35:42 +08:00
yuhaiming
2f7597ed53 fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
2026-08-13 11:01:57 +08:00
yuhaiming
215d6ba167 fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
2026-07-20 15:45:13 +08:00
yuhaiming
b6c1edaeba chore: ignore local worktrees 2026-07-20 15:14:54 +08:00
yuhaiming
e3f4f0e98f docs: plan repository agent initialization 2026-07-20 15:11:21 +08:00
yuhaiming
b448eb83a4 docs: design repository agent instructions 2026-07-20 15:05:38 +08:00
yuhaiming
a1b12f4241 fix: gate mqtt retries until startup cleanup 2026-07-17 17:11:52 +08:00
yuhaiming
3728f9a009 fix: wait ten seconds before mqtt command retry 2026-07-17 16:38:17 +08:00
yuhaiming
96941abc0d fix: clear mqtt pending commands after startup 2026-07-17 16:28:57 +08:00
yuhaiming
8b0612f744 fix: clear mqtt pending commands on request 2026-07-17 16:20:47 +08:00
yuhaiming
1c122dfc68 docs: plan mqtt pending cleanup and retry timing 2026-07-17 16:14:48 +08:00
yuhaiming
38f6cc06d4 docs: define mqtt pending cleanup and retry timing 2026-07-17 16:07:29 +08:00
yuhaiming
97862b8d52 docs: define app password recovery flow 2026-07-17 14:19:47 +08:00
161 changed files with 6111 additions and 1848 deletions

5
.gitignore vendored
View File

@@ -46,6 +46,7 @@
/.trae/
/.windsurf/
/.workbuddy/
/.worktrees/
# IDE
.idea/
@@ -63,4 +64,6 @@
# OS
.DS_Store
Thumbs.db
/docs/
/docs/*
!/docs/adr/
!/docs/adr/*.md

36
.idea/compiler.xml generated
View File

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

65
CONTEXT.md Normal file
View File

@@ -0,0 +1,65 @@
# Watex Device Management
This context defines how Watex manages device identity, ownership, and user-generated device data throughout a device's lifecycle.
## Language
**Active Device Binding**:
The exclusive relationship that grants one user access to a device's live state, details, data, and controls and permits binding-scoped MQTT reports to create or update data. A device has at most one active binding at a time.
_Avoid_: Device ownership, shared binding
**User Device Relationship**:
The single lifecycle relationship between one user and one device that drives the user's list state. It is reactivated rather than duplicated when the same user binds the device again, while the device may have relationships with different users over time.
_Avoid_: Binding history, device ownership
**Binding in Progress**:
The exclusive reservation created while a binding identifier is being delivered to the device. It appears as "binding" in the user's list and grants no device access until the device acknowledges that identifier.
_Avoid_: Active binding, bound device
**Binding Failed**:
A non-active list state reached when binding delivery exhausts its retries without device acknowledgment. It grants no device access, does not reserve the device, and allows the user to retry or remove the list entry.
_Avoid_: Active binding, binding in progress
**Device Unbinding**:
The source-independent workflow that terminates a device's active binding, revokes the former user's device access, and clears user-specific or runtime data while retaining the device's inventory identity and an unbound device entry for that user.
_Avoid_: Device deletion, device removal
**Unbinding in Progress**:
A binding state entered as soon as a valid unbinding command is accepted. It appears as a non-interactive "unbinding" item in the former user's device list, grants no device access, prevents any new binding while cleanup is incomplete, and never returns to active after cleanup failure.
_Avoid_: Active binding, completed unbinding
**Unbinding Recovery**:
The durable continuation of cleanup for a binding in progress. It survives server restarts, retries without depending on another device message, and keeps the device unavailable until cleanup succeeds.
_Avoid_: Binding rollback, device-triggered retry
**Device-Initiated Unbinding**:
Device unbinding initiated when the server receives an MQTT unbinding message from the device. It follows the same state and cleanup rules as user-initiated and administrator-initiated unbinding; offline button behavior and messages not received by the server are outside this workflow.
_Avoid_: Offline unbinding, local reset
**Unbound Device Entry**:
A display-only item retained in a former user's device list with the device name and number captured at unbinding, the unbound status, and the unbinding time. If the same user binds the device again, this item becomes the active list item instead of creating a binding-history entry; it grants no access while unbound.
_Avoid_: Binding history, archived device, inactive binding
**List Entry Removal**:
The physical removal of the user's `UNBOUND` or `BIND_FAILED` relationship from their own device list. It does not remove command or audit records, delete the device's inventory identity, or affect another user's active binding; a later binding creates a new relationship.
_Avoid_: Device deletion, device unbinding
**Binding-Scoped Device Data**:
User configuration and runtime data belonging to an active binding, including the user's device name, Wi-Fi credentials, watering records, telemetry state, pending commands, and runtime caches. It is permanently cleared when that binding is terminated and is never part of an unbound device entry.
_Avoid_: Device history, archived device data
**Device Inventory Identity**:
The stable device information retained across bindings: device number, MAC address, factory name, model, serial number, firmware version, QR code, image, and expiration time. It allows an unbound device to remain managed and bindable without retaining a former user's data.
_Avoid_: User device data, device binding
**User Schedule**:
A user-owned watering plan whose timing and configuration exist independently of any one device. Unbinding a device removes that device's schedule associations but retains the schedule, its details, and associations with other devices.
_Avoid_: Device schedule, device-owned schedule
**Device Deletion**:
The permanent removal of an unbound device's inventory identity together with its related data. A bound device must be unbound before it can be deleted.
_Avoid_: Device unbinding
**Device Initialization**:
The physical reset that removes user configuration from a device after unbinding. Device-initiated unbinding completes it after an `applied` result, while user-initiated or administrator-initiated unbinding requests it asynchronously from the server.
_Avoid_: Device unbinding

View File

@@ -89,17 +89,26 @@ long nextRetryAt; // 下次重试时间戳
}
```
与用户绑定关系有关的命令(`switchDevice`、排程命令、`queryPower``otaUpgrade` 和自定义命令)还会携带当前关系的 `bindingId`。设备应在对应上报或 ACK 中原样返回 `bindingId`;服务端只接受与当前 ACTIVE 绑定一致的报文,旧绑定报文会被丢弃。`bindDevice` 使用命令自身携带的目标 `bindingId``initDevice` 不携带绑定标识。
### 3.2 上行 ACK 结构DeviceCommandAck
```json
{
"deviceNo": "设备编号",
"commandId": "对应下发命令的commandId",
"status": "1",
"message": "可选的文本消息"
"bindingId": "对应下发命令的绑定标识(有则原样返回)",
"ack": "receive",
"status": "兼容旧协议的确认标志",
"message": "可选的文本消息",
"powerLevel": 4,
"charging": 0
}
```
> 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配
> - 设备确认标志优先使用 `ack` 字段(以 `"receive"` 开头即视为接受/成功,如 `"receive"`、`"receive deviceNo"``status` 为兼容旧协议的字段
> - `powerLevel` / `charging` 仅在 `queryPower` 命令的 ACK 中返回,为**数字类型**,服务端会自动转为字符串后分别更新到设备表的 `power_level` 和 `power_status` 字段。
> - 也支持非 JSON 格式的纯文本 ACK当设备只有一条待确认命令时可自动匹配
---
@@ -116,6 +125,7 @@ long nextRetryAt; // 下次重试时间戳
"commandId": "xxx",
"commandType": "switchDevice",
"deviceNo": "01",
"bindingId": "当前绑定标识",
"cmd": "1",
"startTime": "2026-06-25 14:35:00",
"durationMin": 20
@@ -214,6 +224,7 @@ long nextRetryAt; // 下次重试时间戳
"commandType": "bindSchedule",
"cmd": -1,
"deviceNo": "01",
"bindingId": "当前绑定标识",
"schedule": {
"id": 1,
"name": "每日浇水",
@@ -258,6 +269,7 @@ long nextRetryAt; // 下次重试时间戳
"commandType": "unbindSchedule",
"cmd": -1,
"deviceNo": "01",
"bindingId": "当前绑定标识",
"scheduleId": 1,
"unbind": true
}
@@ -285,6 +297,83 @@ long nextRetryAt; // 下次重试时间戳
---
### 4.8 电量查询命令 — `queryPower`
**触发**: 用户在 APP 主动查询设备电量
**接口**: `POST /app/v1/queryPower/{deviceNo}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "queryPower",
"deviceNo": "01",
"bindingId": "当前绑定标识"
}
```
**设备 ACK 回复**`/{deviceNo}/publish/ack`
```json
{
"deviceNo": "2074069050702561282",
"commandId": "xxx",
"bindingId": "当前绑定标识",
"powerLevel": 4,
"charging": 0,
"ack": "receive"
}
```
**附加行为**: 服务端收到 ACK 后,将 `powerLevel` 更新到设备表的 `power_level` 字段、`charging` 更新到 `power_status` 字段,并刷新 `power_level_updatatime``powerLevel`/`charging` 为数字类型(服务端自动转字符串入库),电量查询 ACK 不依赖 `status` 字段。
---
### 4.9 设备 OTA 升级命令 — `otaUpgrade`
**触发**: 管理员在设备列表选择设备(或全部在线设备)下发固件升级
**接口**: `POST /app/firmware/upgrade` · `POST /app/firmware/upgradeAll`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "otaUpgrade",
"deviceNo": "01",
"firmwareUrl": "https://api.example.com/app/firmware/download/0123456789abcdef0123456789abcdef.bin",
"firmwareVersion": "1.0.0",
"md5": "可选,固件 MD5 校验值"
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `firmwareUrl` | String | 服务器固件下载地址 |
| `firmwareVersion` | String | 目标固件版本号 |
| `md5` | String | 固件 MD5 校验值(可选) |
**升级结果回报**: 设备收到命令后回 ACK 确认,下载固件并升级,重启后重新走 `/publish/register` 上报新 `version`,服务端据此更新设备表的 `fw_ver` 字段。
---
### 4.10 设备恢复出厂命令 — `factoryReset`
**触发**: 管理员在设备列表控制设备恢复出厂
**接口**: `POST /app/device/factory-reset/{deviceNo}`
**Topic**: `/{deviceNo}/subscriber/cmd`
```json
{
"commandId": "xxx",
"commandType": "factoryReset",
"deviceNo": "01"
}
```
服务端只下发 MQTT 命令并等待设备 ACK不清理服务端的用户绑定、排程或浇水日志。
---
## 5. 上行消息类型详解
### 5.1 设备注册 — `/{identity}/publish/register`
@@ -315,7 +404,8 @@ long nextRetryAt; // 下次重试时间戳
```json
{
"powerLevel": "78"
"powerLevel": "78",
"bindingId": "当前绑定标识"
}
```
@@ -354,6 +444,7 @@ long nextRetryAt; // 下次重试时间戳
{
"deviceNo": "01",
"commandId": "对应的命令ID",
"bindingId": "当前绑定标识",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
@@ -373,6 +464,7 @@ long nextRetryAt; // 下次重试时间戳
{
"deviceNo": "01",
"commandId": "对应的命令ID",
"bindingId": "当前绑定标识",
"startTime": "2026-06-25 08:00:00",
"endTime": "2026-06-25 08:15:00",
"durationMin": 15,
@@ -390,7 +482,8 @@ long nextRetryAt; // 下次重试时间戳
```json
{
"errorCode": "E001",
"message": "水泵故障"
"message": "水泵故障",
"bindingId": "当前绑定标识"
}
```
@@ -468,6 +561,9 @@ long nextRetryAt; // 下次重试时间戳
| 绑定设备 | `POST /addDevice` | `bindDevice` | Service 层下发 |
| 解绑设备 | `DELETE /deleteDevice/{deviceNos}` | `initDevice` | Service 层下发(每台设备) |
| 开关设备 | `PUT /switchDevice` | `switchDevice` | Service 层下发 + 浇水日志 |
| 查询电量 | `POST /queryPower/{deviceNo}` | `queryPower` | Service 层下发ACK 回复电量 |
| OTA 升级 | `POST /firmware/upgrade` · `POST /firmware/upgradeAll` | `otaUpgrade` | Service 层下发,重启注册后更新 fw_ver |
| 恢复出厂 | `POST /device/factory-reset/{deviceNo}` | `factoryReset` | 仅下发 MQTT 命令,不修改服务端关系数据 |
| 绑定排程设备 | `POST /addScheduleDevice` | `bindSchedule` | Controller 层下发 |
| 解绑排程设备 | `DELETE /deleteScheduleDevice` | `unbindSchedule` | Controller 层下发 |
| 修改排程状态 | `PUT /editScheduleStatus` | `bindSchedule` | 向所有绑定设备重新下发 |

View File

@@ -0,0 +1,59 @@
# MQTT Pending Cleanup And Retry Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 单实例应用启动完成后清理旧 MQTT pending 命令,并让新命令等待 10 秒后才进行 ACK 重试。
**Architecture:** `MqttCommandAckService` 提供幂等清理方法,独立的 `ApplicationReadyEvent` 监听器在 Redis 初始化完成后调用。命令发送继续由 `DeviceMqttCommandPublisher` 设置 `nextRetryAt`,只调整默认值和配置为 10000 毫秒。
**Tech Stack:** Spring Boot、ApplicationReadyEvent、Spring `@EventListener`、Redisson `RSet`、RedisUtils、JUnit 5、Mockito、Maven。
## Global Constraints
- 当前按单实例部署,启动时清理全部 pending 命令。
- 只删除 pending ID 集合和对应命令缓存,不删除 ACK 历史缓存。
- 清理失败记录完整异常,不阻止应用启动。
- `retry-interval-ms` 使用 `10000``scan-interval-ms` 保持 `5000`
- 最大重试次数保持 3 次,不包含首次发送。
- 不修改 MQTT Topic、ACK 消息格式和 Payload。
---
### Task 1: Add Pending Cleanup Behavior
**Files:** Modify `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java`; test `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java`.
**Interface:** add `public int clearPendingCommands()` using existing `pendingIds()`, `pendingKey(String)`, and Redis key conventions.
- [ ] Write a failing test: stub `RSet.readAll()` with `cmd-1` and `cmd-2`; call the method; assert return `2`, verify deletion of both `mqtt:command:pending:{id}` objects, and verify `pendingIds.clear()`. Add an empty-set case returning `0`.
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=MqttCommandAckServiceTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect the missing-method failure.
- [ ] Implement by reading `Set<String> commandIds = pendingIds().readAll()`, deleting every `pendingKey(commandId)`, clearing the set after iteration, and returning `commandIds.size()`. Do not call `deletePending` while iterating.
- [ ] Rerun the focused command; expect all `MqttCommandAckServiceTest` tests to pass.
- [ ] Commit only these two files with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java && git commit -m "fix: clear mqtt pending commands on request"`.
### Task 2: Clear Pending Commands After Application Startup
**Files:** create `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttPendingCommandCleanupListener.java` and `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttPendingCommandCleanupListenerTest.java`.
**Interface:** constructor consumes `MqttCommandAckService`; `onApplicationReady(ApplicationReadyEvent)` returns `void`.
- [ ] Write failing tests: verify a ready event calls `clearPendingCommands()`; stub it to throw `IllegalStateException("redis unavailable")` and assert the listener does not throw.
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=MqttPendingCommandCleanupListenerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect the class-missing failure.
- [ ] Implement `@Slf4j @Component @RequiredArgsConstructor` with `@EventListener(ApplicationReadyEvent.class)`. Call the service, log the count, catch `RuntimeException`, and log `log.error("[MQTT] 应用启动清理待确认命令失败", e)` without rethrowing.
- [ ] Rerun the listener test; expect both tests to pass.
- [ ] Commit only the listener files with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttPendingCommandCleanupListener.java water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttPendingCommandCleanupListenerTest.java && git commit -m "fix: clear mqtt pending commands after startup"`.
### Task 3: Set New Retry Interval To Ten Seconds
**Files:** modify `water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java`, `water-admin/src/main/resources/application.yml`; test `water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/DeviceMqttCommandPublisherTest.java`.
- [ ] Add a publisher timing assertion: after `send(command)`, assert `command.getNextRetryAt() - command.getLastSentAt()` equals `10000`.
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=DeviceMqttCommandPublisherTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect failure against the current 5000 millisecond default.
- [ ] Change `CommandAck.retryIntervalMs` default from `5000` to `10000`; change application YAML `retry-interval-ms` from `30000` to `10000`; leave scan interval `5000` and max retry count `3` unchanged.
- [ ] Run `mvn -pl water-admin -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=DeviceMqttCommandPublisherTest,MqttCommandAckServiceTest,MqttPendingCommandCleanupListenerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test`; expect `BUILD SUCCESS`.
- [ ] Commit configuration and publisher test with `git add water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java water-admin/src/main/resources/application.yml water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/DeviceMqttCommandPublisherTest.java && git commit -m "fix: wait ten seconds before mqtt command retry"`.
### Task 4: Final Verification
- [ ] Run `mvn -pl water-common/water-common-mqtt -am "-DskipTests=false" "-Dmaven.test.skip=false" test`; expect `BUILD SUCCESS` and zero failures.
- [ ] Run `git diff --check` and `git status --short`; expect no whitespace errors and no reversion of unrelated worktree changes.
- [ ] Rebuild and restart the single application instance; confirm one startup cleanup log and that a newly sent command is retried no earlier than roughly 10 seconds later.

View File

@@ -0,0 +1,203 @@
# Repository AGENTS.md Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Create a comprehensive root `AGENTS.md` with repository-specific development, safety, workflow, and verification instructions for the water backend.
**Architecture:** Use one root instruction file as the default policy for all Maven modules. Keep operational rules and commands in that file, while linking to the existing detailed review standard and deferring workflow phase details to the selected Comet skill.
**Tech Stack:** Markdown, Java 17, Spring Boot 3.5.12, Maven, JUnit 5, Mockito, AssertJ, OpenSpec/Comet
## Global Constraints
- Create only the root `AGENTS.md`; do not modify application code, POM files, configuration, or existing workflow assets.
- Preserve all unrelated tracked and untracked working-tree changes.
- Keep Java 17 and Spring Boot 3.5.12 as the documented baseline.
- State explicitly that the root POM defaults `skipTests` to `true`.
- Default human-facing communication to Simplified Chinese while preserving code, commands, paths, API names, and errors verbatim.
- Link to `docs/code-review-standards.md` rather than duplicating its full checklist.
- Do not add credentials, tokens, personal data, production endpoints, or other secrets.
---
### Task 1: Create And Validate Root Repository Instructions
**Files:**
- Create: `AGENTS.md`
- Reference: `pom.xml`
- Reference: `docs/code-review-standards.md`
- Reference: `docs/superpowers/specs/2026-07-20-repository-agents-init-design.md`
**Interfaces:**
- Consumes: Maven module names, root test defaults, existing CodeGraph rule, and installed OpenSpec/Comet workflows.
- Produces: Root-scoped natural-language instructions consumed by Codex for every file in the repository unless a future nested `AGENTS.md` overrides them.
- [ ] **Step 1: Confirm the expected precondition and referenced paths**
Run:
```powershell
if (Test-Path 'AGENTS.md') { throw 'AGENTS.md already exists; inspect it before continuing.' }
$requiredPaths = @(
'pom.xml',
'water-admin',
'water-common',
'water-common/water-common-mqtt',
'water-modules/water-app',
'water-modules/water-system',
'water-extend',
'docs/code-review-standards.md',
'openspec',
'.codex/skills/comet/SKILL.md'
)
$missingPaths = $requiredPaths | Where-Object { -not (Test-Path $_) }
if ($missingPaths) { throw "Missing required paths: $($missingPaths -join ', ')" }
'Preconditions: PASS'
```
Expected: `Preconditions: PASS`.
- [ ] **Step 2: Create the complete root instruction file**
Create `AGENTS.md` with exactly this content:
```markdown
# AGENTS.md
## 交流与适用范围
- 本文件适用于整个仓库;更深目录中的 `AGENTS.md` 可为其子树补充或覆盖规则。
- 默认使用简体中文交流。代码、命令、文件路径、API 名称和错误原文保持原样。
- 只修改当前任务需要的内容。保留并避开工作区中与任务无关的已跟踪或未跟踪改动。
## CodeGraph
- 如果仓库根目录存在 `.codegraph/`,在定位或理解代码时,先使用 CodeGraph再使用文本搜索或直接读取大量文件。
- MCP 可用时优先使用 `codegraph_explore` 回答代码问题,使用 `codegraph_node` 查看符号、调用者或带行号的文件。
- MCP 不可用时使用 Shell 命令,例如 `codegraph explore "MqttCommandAckService callers and retry flow"``codegraph node MqttCommandAckService`
- 如果不存在 `.codegraph/`,不要自行建立索引;使用 `rg``rg --files`
## 项目概览
- 本项目是智能灌溉系统后端,基于 Java 17、Spring Boot 3.5.12 和 Maven 多模块构建。
- `water-admin`:可执行 Web 应用、入口控制器及应用配置。
- `water-common`跨业务模块共享的基础设施MQTT 位于 `water-common/water-common-mqtt`,安全基础设施位于 `water-common/water-common-security`
- `water-modules/water-app`App、设备、灌溉及设备消息业务。
- `water-modules/water-system`:用户、权限及系统管理能力。
- `water-extend`:监控、任务调度等独立辅助服务。
## 修改前准备
- 先检查 `git status --short`,识别用户已有改动;不得回退、覆盖或顺手格式化无关文件。
- 修改共享行为前,追踪调用方、配置绑定、数据库或 Redis 状态、MQTT 消息路径及相关测试。
- 优先复用现有分层、包结构、工具类和领域类型。没有明确收益时不引入新抽象或跨模块重构。
- 变更范围涉及多个模块时,先确认真正的所有者模块和依赖方向,避免复制共享逻辑。
## 架构与编码约束
- 遵循现有 controller、service、mapper、domain 分层和相邻代码风格。
- 共享基础能力放入其所属 `water-common-*` 模块;业务规则保留在对应业务模块。
- 除非任务明确允许破坏性变更,否则保持跨模块接口、配置键、消息字段和持久化格式向后兼容。
- 新代码不得吞掉异常;日志应包含排障上下文,但不得记录密码、验证码、令牌、密钥或完整个人信息。
- 新代码使用明确泛型,避免循环查询造成 N+1避免共享非线程安全格式化器并用枚举、常量或领域类型表达状态值。
- 不为通过测试而削弱生产行为、删除断言或扩大公开访问范围。
## 安全敏感区域
- 修改登录、验证码、找回密码、注销账号或公开路由时,必须检查限流、输入校验、租户隔离、数据权限和敏感信息处理。
- 修改安全排除路径时,同时检查配置绑定和拦截器的最终生效路径,并添加聚焦测试证明只开放目标端点。
- 配置文件不得写入真实凭据、令牌、个人数据或生产地址;沿用环境变量、占位符或既有外部配置方式。
- 涉及密码更新时,保持密码散列、验证码一次性消费和失败分支行为一致,避免泄露账号是否存在。
## MQTT 与设备命令
- MQTT 或设备命令变更必须同时考虑协议字段兼容、ACK 匹配、重试间隔与次数、启动清理、Redis 键与 TTL、重复投递和并发状态转换。
- 不得在未验证设备兼容性的情况下重命名消息字段、改变状态含义或调整 topic 结构。
- 对时序门控、ACK 缺失、清理失败、重复 ACK、达到重试上限和并发完成等边界添加或更新测试。
- 涉及 Redis 临时状态时,明确创建、读取、续期和删除时机,避免永久残留或提前删除。
## 构建与测试
- 所有 Maven 命令从仓库根目录运行。
- 根 POM 默认配置 `skipTests=true``mvn clean package` 会跳过测试,不能据此声称测试通过。
- 全量测试:`mvn -DskipTests=false test`
- 带测试完整验证:`mvn clean verify -DskipTests=false`
- App 模块测试:`mvn -pl water-modules/water-app -am -DskipTests=false test`
- MQTT 模块测试:`mvn -pl water-common/water-common-mqtt -am -DskipTests=false test`
- 单个测试示例:`mvn -pl water-admin -am -DskipTests=false -Dtest=CaptchaControllerUnitTest -Dsurefire.failIfNoSpecifiedTests=false test`
- 其他模块或测试类沿用上述命令结构,替换为 Maven reactor 中的真实模块路径和测试类名。
- 行为变更先添加或更新能够失败的聚焦测试,再修改实现。共享模块或跨模块变更应扩大到受影响 reactor 范围。
- 安全、并发、Redis 和 MQTT 变更必须覆盖失败路径及兼容性;不能运行依赖外部基础设施的验证时,执行最窄的隔离验证并报告缺口。
## OpenSpec 与 Comet
- 新能力或较大行为变更使用 `/comet`
- 不引入新能力的缺陷修复使用 `/comet-hotfix`
- 文档、文案、提示词或局部配置调整使用 `/comet-tweak`
- 选定工作流后,以对应 skill 的阶段、确认点和归档规则为准,不在任务中绕过其门禁。
## 完成标准
- 完成前检查 `git diff``git status`,确认只包含任务范围内的改动。
- 运行与风险范围匹配的测试,并在相关时运行编译、打包或配置验证。
- 只有看到当前会话中的命令成功输出后,才能声称测试通过、构建成功或问题已修复。
- 最终说明实际运行的命令、结果以及未能执行的验证;保留关键错误原文。
- 代码审查遵循 `docs/code-review-standards.md`,重点检查正确性、安全、并发、性能、异常处理、测试和日志。
```
- [ ] **Step 3: Verify structure, required guidance, and referenced paths**
Run:
```powershell
$content = Get-Content -Raw 'AGENTS.md'
$requiredText = @(
'## CodeGraph',
'Java 17',
'Spring Boot 3.5.12',
'water-common/water-common-mqtt',
'skipTests=true',
'mvn -DskipTests=false test',
'/comet-hotfix',
'docs/code-review-standards.md'
)
$missingText = $requiredText | Where-Object { -not $content.Contains($_) }
if ($missingText) { throw "Missing required guidance: $($missingText -join ', ')" }
$references = @(
'docs/code-review-standards.md',
'water-common/water-common-mqtt',
'water-common/water-common-security',
'water-modules/water-app',
'water-modules/water-system'
)
$missingReferences = $references | Where-Object { -not (Test-Path $_) }
if ($missingReferences) { throw "Broken references: $($missingReferences -join ', ')" }
'Content validation: PASS'
```
Expected: `Content validation: PASS`.
- [ ] **Step 4: Review the isolated diff and Markdown formatting**
Run:
```powershell
git diff --check -- AGENTS.md
git diff --stat -- AGENTS.md
git status --short -- AGENTS.md
```
Expected: `git diff --check` prints no errors; the stat reports one new file; status reports only `?? AGENTS.md` for this task.
- [ ] **Step 5: Commit the initialized repository instructions**
Run:
```powershell
git add -- AGENTS.md
git diff --cached --check
git diff --cached --name-only
git commit -m "docs: initialize repository agent guidance" -- AGENTS.md
```
Expected: the cached file list contains only `AGENTS.md`, and the commit succeeds.

View File

@@ -0,0 +1,56 @@
# App Forgot Password Design
## Goal
Make `POST /app/v1/retrievePassword` a secure unauthenticated password recovery endpoint based on an account identifier, verification code, and new password.
## Request Contract
The encrypted JSON request contains:
```json
{
"username": "phone, email, or account name",
"code": "123456",
"password": "new password"
}
```
For backward compatibility, `userName` is accepted as an alias of `username`, and `smsCode` is accepted as an alias of `code`.
## Endpoint Behavior
- Preserve `POST /app/v1/retrievePassword` and `@ApiEncrypt`.
- Add method-level `@SaIgnore` so a logged-out user can recover their password.
- Validate required fields and the existing password length rule before invoking the service.
- Do not read `LoginHelper` or rely on a token.
## Service Behavior
Add a shared `ISysUserService.resetPasswordByVerificationCode(username, code, password)` operation:
1. Detect email, mobile number, or account name and query the matching active user record.
2. Read `GlobalConstants.CAPTCHA_CODE_KEY + username` from Redis.
3. Reject missing/expired and mismatched codes without updating the database.
4. BCrypt-hash the new password and update only the matched user ID.
5. Delete the verification code only after a successful database update so it cannot be replayed.
The existing `/auth/forgot` service delegates to the same operation to avoid two conflicting reset implementations.
## Verification Code Delivery
Keep `GET /resource/code?username=...`. For a mobile number or email, send directly as before. For an account name, load the account and send to its bound mobile number, otherwise its bound email, while storing the code under the original account-name cache key. Do not return the generated code to the client.
## Error Handling
- Missing account: user-facing account-not-registered error.
- Missing Redis code: existing `CaptchaExpireException`.
- Wrong code: user-facing invalid-code error.
- Failed database update: `ServiceException` handled by the global exception handler.
## Testing
- Controller test proves recovery works without login state and delegates all three values.
- System service tests cover success, wrong code, expired code, account lookup, BCrypt hashing, and one-time code deletion.
- Captcha controller test covers account-name delivery to a bound contact with the original username as cache key.
- Run focused tests, then `water-app`, `water-admin`, and relevant reactor tests.

View File

@@ -0,0 +1,53 @@
# MQTT 待确认命令启动清理与重试间隔设计
## 背景
MQTT 待 ACK 命令保存在 Redis 中,默认保留 24 小时。应用重启后,旧命令的
`nextRetryAt` 通常已经早于当前时间,因此重试扫描任务启动后会立即重新下发旧命令。
当前服务按单实例部署处理。目标是启动时清除旧待确认命令,并将新命令等待 ACK 的
时间从 30 秒调整为 10 秒。
## 行为约定
- 应用每次启动完成后清理全部 pending 命令。
- 清理范围包括 pending ID 集合及集合中每个命令对应的缓存对象。
- 不清理已经收到的 ACK 历史缓存。
- 启动清理失败只记录完整异常,不阻止应用启动。
- 新命令首次下发后等待 10 秒才具备重试条件。
- 重试扫描周期保持 5 秒,因此实际重发时间约为下发后的 10 至 15 秒。
- 最大重试次数保持 3 次,不包含首次发送。
## 设计
### 清理服务
`MqttCommandAckService` 增加公开的启动清理方法。方法读取 pending ID 集合,逐一
删除 `mqtt:command:pending:{commandId}`,然后清空 pending ID 集合,并返回清理数量。
清理操作应可重复执行,空集合返回 0。
### 启动时机
新增 MQTT 启动清理监听器,在 `ApplicationReadyEvent` 到达后执行一次。此时 Spring、
Redis 和 MQTT 相关 Bean 已完成初始化。监听器捕获运行时异常并记录完整异常栈,避免
Redis 短暂不可用导致整个应用启动失败。
### 重试配置
`mqtt.command-ack.retry-interval-ms` 设置为 `10000`。首次发送和每次重试后均继续使用
该配置计算 `nextRetryAt``scan-interval-ms` 保持 `5000`
## 测试
- 清理方法删除集合中每个 pending 缓存并清空集合。
- 空 pending 集合清理成功且返回 0。
- 启动监听器在应用就绪事件后调用一次清理方法。
- 清理异常不会从监听器继续抛出。
- 配置绑定后的重试间隔为 10000 毫秒。
- 保留现有命令发送和三次重试测试。
## 非目标
- 不修改 ACK 消息格式、MQTT Topic 或命令 Payload。
- 不清理 ACK 历史缓存。
- 不设计多实例命令归属;若后续改为多实例部署,需要重新设计启动清理策略。

View File

@@ -0,0 +1,101 @@
# Repository AGENTS.md Initialization Design
## Goal
Create one root-level `AGENTS.md` that gives Codex durable, repository-specific instructions for safely changing and verifying the water backend. The file must be useful on its own while linking to detailed review guidance instead of duplicating it.
## Scope
The change adds only the root `AGENTS.md`. It does not change application code, Maven configuration, module-specific instructions, or existing OpenSpec/Comet assets.
The instructions apply to the entire repository. A future nested `AGENTS.md` may add narrower module rules, but none are needed for this initialization.
## Repository Facts
- The project is an intelligent irrigation backend based on Java 17, Spring Boot 3.5, and Maven.
- The root reactor contains `water-admin`, `water-common`, `water-modules`, and `water-extend`.
- `water-admin` is the executable web application.
- `water-common` contains shared infrastructure, including MQTT support in `water-common-mqtt`.
- `water-modules` contains business modules; `water-app` handles app/device workflows and `water-system` handles system and user capabilities.
- `water-extend` contains auxiliary services such as monitoring and job scheduling.
- The root POM sets `skipTests=true`, so verification commands must explicitly use `-DskipTests=false`.
- The repository already contains OpenSpec/Comet workflows and detailed review guidance in `docs/code-review-standards.md`.
## AGENTS.md Structure
The root instructions will contain these sections in order:
1. Project overview and technology baseline.
2. Module map and ownership boundaries.
3. Code discovery and change preparation.
4. Architecture and implementation constraints.
5. Security-sensitive areas.
6. MQTT and device-command constraints.
7. Testing and verification commands.
8. OpenSpec/Comet workflow selection.
9. Definition of done and review reference.
## Instruction Design
### Code Discovery
When `.codegraph/` exists, agents must use CodeGraph before text search or broad file reads. Otherwise they should use `rg` or `rg --files`. Before changing shared behavior, agents should identify callers, configuration bindings, persistence or Redis state, and relevant tests.
Agents must preserve unrelated working-tree changes and avoid broad cleanup or refactoring unless required by the task.
### Architecture and Code Quality
Changes should follow the existing controller/service/mapper/domain layering and established package patterns. Shared behavior belongs in the owning common module rather than being copied into business modules. Cross-module APIs should remain backward compatible unless the task explicitly authorizes a breaking change.
The instructions will prohibit swallowed exceptions, raw collection types in new code, hidden N+1 queries, unsafe shared formatters, unexplained magic status values, and sensitive values in source or logs. Detailed review criteria remain in `docs/code-review-standards.md`.
### Security Boundaries
Changes involving authentication, captcha, password recovery, account cancellation, or public route exclusions must preserve rate limiting, input validation, tenant and data-permission behavior, and sensitive-data handling. Public-route changes require focused tests and inspection of the effective security exclusion configuration.
Configuration files must not receive real credentials, tokens, personal data, or production endpoints. Existing placeholders and environment-driven configuration patterns should be retained.
### MQTT Boundaries
MQTT and device-command changes must consider protocol compatibility, ACK matching, retry timing and limits, startup cleanup, Redis key and TTL behavior, duplicate delivery, and concurrent state transitions. Relevant unit tests must cover changed behavior, especially timing gates and cleanup failure paths.
### Build and Test Commands
The file will document these commands:
```powershell
mvn clean package
mvn -DskipTests=false test
mvn -pl <module> -am -DskipTests=false test
mvn -pl <module> -am -DskipTests=false -Dtest=<TestClass> -Dsurefire.failIfNoSpecifiedTests=false test
```
`mvn clean package` reflects the repository's default package behavior and skips tests because of the root property. Any claim that tests pass must come from a command containing `-DskipTests=false`.
Behavior changes should add or update focused tests before implementation. Shared or cross-module changes require broader reactor testing. High-risk security, concurrency, Redis, or MQTT changes require focused failure-path and compatibility coverage.
### Workflow Selection
- Use `/comet` for new capabilities or substantial behavior changes.
- Use `/comet-hotfix` for defect corrections that do not introduce a new capability.
- Use `/comet-tweak` for documentation, copy, prompt, or narrowly scoped configuration changes.
The root instructions will not duplicate each workflow's phase rules; the selected skill remains authoritative.
### Definition of Done
Before reporting completion, agents must inspect `git diff`, run tests proportional to the change, confirm compilation or packaging when relevant, and check configuration compatibility. They must report commands actually run and any verification that could not be performed.
## Error Handling
If a required build or test depends on unavailable infrastructure, the agent should run the narrowest isolated verification available, preserve the original error output, and clearly report the remaining gap. It must not weaken production behavior or tests merely to obtain a passing command.
## Validation
The implementation will be validated by:
1. Confirming the root `AGENTS.md` exists and is readable Markdown.
2. Checking every documented path against the repository.
3. Checking Maven commands against the root reactor and explicit test override.
4. Scanning for placeholders, contradictions, duplicated rules, and accidental secrets.
5. Reviewing the final diff to ensure no unrelated files were changed.

View File

@@ -0,0 +1,19 @@
workflow: hotfix
phase: verify
context_compression: off
build_mode: direct
build_pause: null
subagent_dispatch: null
tdd_mode: direct
isolation: branch
verify_mode: full
auto_transition: true
base_ref: 2f7597ed53736b965191d4e48ee2c73d09ff71c0
design_doc: null
plan: null
verify_result: pending
verification_report: docs/superpowers/reports/2026-08-14-fix-register-body-validation-verify.md
branch_status: pending
created_at: 2026-08-14
verified_at: null
archived: false

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-14

View File

@@ -0,0 +1,27 @@
## Context
`AuthController.register` 当前依赖 Spring MVC 的 `@Validated` 自动校验。该校验发生在控制器方法调用之前,所以方法体无法为缺失的 `clientId` 补默认值。
## Goals / Non-Goals
**Goals:**
- 空白 `clientId` 使用固定注册客户端 ID `32324a04c0175c8d61ac8a282553aea1`
- 默认值补充后仍执行 `RegisterBody` 的全部 Bean Validation 约束。
- 保持注册开关检查和注册服务调用顺序不变。
**Non-Goals:**
- 不调整 `RegisterBody` 的继承结构或其他登录字段。
- 不改变登录接口和认证客户端配置。
## Decisions
- 移除注册方法参数上的 `@Validated`,在方法体开始处补默认 `clientId` 后调用现有 `ValidatorUtils.validate(user)`。该工具已被认证控制器和各登录策略使用,异常处理行为与现有认证流程一致。
- 使用 `StringUtils.isBlank`,同时覆盖 `null`、空字符串和纯空白字符串。
- 通过 `AuthController` 单元测试观察注册服务收到的请求对象,验证公共接口行为。
## Risks / Trade-offs
- 固定客户端 ID 变更时需要同步修改代码和测试;本次按明确业务值实现。
- 手动校验依赖控制器方法内调用顺序;测试覆盖默认值补充发生在校验和业务调用之前。

View File

@@ -0,0 +1,25 @@
## Why
`/auth/register` 使用 `@Validated` 在进入控制器方法前校验 `RegisterBody`,缺少 `clientId` 的请求会直接返回“认证客户端id不能为空”因此控制器无法为注册请求补充系统默认客户端 ID。注册入口需要先补默认值再执行原有参数校验。
## What Changes
- 注册接口收到空白 `clientId` 时,将其设置为 `32324a04c0175c8d61ac8a282553aea1`
- 默认值补充完成后,通过现有 `ValidatorUtils` 执行 `RegisterBody` 的完整 Bean Validation。
- 增加控制器回归测试,覆盖默认客户端 ID 和注册服务调用。
## Capabilities
### New Capabilities
- `auth-registration`: 为已有注册入口补充默认认证客户端 ID 的行为规范。
### Modified Capabilities
无。仓库当前没有注册流程的既有 OpenSpec。
## Impact
- 影响 `water-admin` 中的 `/auth/register` 控制器及其单元测试。
- `/auth/register` 允许调用方省略或传入空白 `clientId`,其他注册参数约束保持不变。
- 不新增依赖,不修改数据库结构,也不新增公共端点。

View File

@@ -0,0 +1,11 @@
## ADDED Requirements
### Requirement: 注册请求使用默认认证客户端
系统 SHALL 在校验注册请求前,为缺失或空白的 `clientId` 设置默认值 `32324a04c0175c8d61ac8a282553aea1`,并在补值后执行 `RegisterBody` 的全部参数约束。
#### Scenario: 注册请求未提供客户端 ID
- **WHEN** 调用方提交的注册请求中 `clientId` 缺失或为空白
- **THEN** 系统将 `clientId` 设置为 `32324a04c0175c8d61ac8a282553aea1`
- **AND** 系统在调用注册服务前校验补值后的注册请求

View File

@@ -0,0 +1,4 @@
## 1. 注册默认客户端 ID
- [x] 1.1 在控制器单元测试中覆盖空白 `clientId` 自动补默认值的注册场景,并确认修改前失败。
- [x] 1.2 调整 `/auth/register` 的校验顺序,补默认 `clientId` 后执行完整参数校验,并运行相关测试。

View File

@@ -0,0 +1,19 @@
-- 设备固件版本表OTA 升级MySQL 8+。
CREATE TABLE app_firmware (
id bigint NOT NULL COMMENT '主键',
firmware_version varchar(50) NOT NULL COMMENT '固件版本号',
file_name varchar(255) NOT NULL COMMENT '原始文件名',
file_url varchar(500) NOT NULL COMMENT '服务器固件下载地址',
file_size bigint NULL COMMENT '文件大小(字节)',
md5 varchar(64) NULL COMMENT 'MD5校验值',
release_notes varchar(1000) NULL COMMENT '更新说明',
status char(1) NOT NULL DEFAULT '1' COMMENT '状态 0停用 1启用',
create_dept bigint NULL COMMENT '创建部门',
create_by bigint NULL COMMENT '创建者',
create_time datetime NULL COMMENT '创建时间',
update_by bigint NULL COMMENT '更新者',
update_time datetime NULL COMMENT '更新时间',
remark varchar(500) NULL COMMENT '备注',
PRIMARY KEY (id),
KEY idx_app_firmware_version_status (firmware_version, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='设备固件版本表';

View File

@@ -0,0 +1,63 @@
-- User-device lifecycle and device-initiated unbinding support (MySQL 8+).
CREATE TABLE app_device_binding (
id bigint NOT NULL,
device_no varchar(64) NOT NULL COMMENT '设备编号',
user_id bigint NOT NULL COMMENT '用户ID',
binding_id varchar(64) NOT NULL COMMENT '本次绑定标识',
binding_status varchar(16) NOT NULL COMMENT 'BINDING/ACTIVE/BIND_FAILED/UNBINDING/UNBOUND',
binding_started_time datetime NULL COMMENT '本次绑定开始时间',
last_bind_command_time datetime NULL COMMENT '最近一次绑定命令下发时间',
device_name_snapshot varchar(255) NULL COMMENT '列表显示名称快照',
unbound_time datetime NULL COMMENT '解绑完成时间',
unbind_source varchar(16) NULL COMMENT 'DEVICE/APP/ADMIN',
unbind_command_id varchar(64) NULL COMMENT '触发解绑的命令编号',
cleanup_attempts int NOT NULL DEFAULT 0 COMMENT '清理尝试次数',
cleanup_error varchar(500) NULL COMMENT '最近清理错误',
create_dept bigint NULL,
create_by bigint NULL,
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
exclusive_device_no varchar(64) GENERATED ALWAYS AS (
CASE WHEN binding_status IN ('BINDING', 'ACTIVE', 'UNBINDING') THEN device_no ELSE NULL END
) STORED,
PRIMARY KEY (id),
UNIQUE KEY uk_app_device_binding_user_device (user_id, device_no),
UNIQUE KEY uk_app_device_binding_binding_id (binding_id),
UNIQUE KEY uk_app_device_binding_exclusive (exclusive_device_no),
KEY idx_app_device_binding_device_status (device_no, binding_status),
KEY idx_app_device_binding_recovery (binding_status, binding_started_time, last_bind_command_time)
) COMMENT='用户设备绑定生命周期';
CREATE TABLE app_device_unbind_command (
id bigint NOT NULL,
device_no varchar(64) NOT NULL COMMENT '设备编号',
command_id varchar(64) NOT NULL COMMENT '设备解绑命令编号',
binding_id varchar(64) NOT NULL COMMENT '命令目标绑定标识',
command_status varchar(16) NOT NULL COMMENT 'PROCESSING/APPLIED/STALE/FAILED',
message varchar(500) NULL COMMENT '处理结果',
attempt_count int NOT NULL DEFAULT 0 COMMENT '处理次数',
completed_time datetime NULL COMMENT '完成时间',
create_dept bigint NULL,
create_by bigint NULL,
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by bigint NULL,
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_app_device_unbind_command (device_no, command_id),
KEY idx_app_device_unbind_binding (device_no, binding_id)
) COMMENT='设备解绑命令处理记录';
-- Existing bindings must re-establish the device-side bindingId before becoming ACTIVE.
INSERT INTO app_device_binding (
id, device_no, user_id, binding_id, binding_status, device_name_snapshot,
binding_started_time, cleanup_attempts, create_time, update_time
)
SELECT
UUID_SHORT(), device_no, user_id, REPLACE(UUID(), '-', ''), 'BINDING', device_name,
NOW(), 0, NOW(), NOW()
FROM app_device
WHERE user_id IS NOT NULL;
-- Keep the legacy column during the rollout, but remove it as a relationship source.
UPDATE app_device SET user_id = NULL WHERE user_id IS NOT NULL;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -1,595 +0,0 @@
{
"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

@@ -1,280 +0,0 @@
{
"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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,7 +0,0 @@
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

@@ -1,11 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,14 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,28 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,15 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,22 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,36 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,17 +0,0 @@
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

@@ -1,17 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,66 @@
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
2026-08-18 17:55:47 [main] WARN c.b.m.c.injector.DefaultSqlInjector - class org.dromara.app.domain.AppSchedulingDevice ,Not found @TableId annotation, Cannot use Mybatis-Plus 'xxById' Method.
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceDataHandler -> ^/([^/]+)/publish/power$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceRegisterHandler -> ^/([^/]+)/publish/register$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceStatusHandler -> ^/([^/]+)/publish/status$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ErromesHandler -> ^/([^/]+)/publish/error$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器StartWaterHandler -> ^/([^/]+)/publish/start$
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:55 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:55 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
2026-08-18 17:55:56 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:57 [mqtt-consumer-6] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087348193597157377 消息体={"deviceMac":"dc:da:0c:fa:27:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:57 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:57 设备编号=2087366163811627009 消息体={"deviceMac":"dc:da:0c:fa:14:7a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:58 [mqtt-consumer-8] WARN o.d.app.handler.DeviceStatusHandler - [MQTT] 忽略 retained 离线状态 时间=2026-08-18 17:55:58 设备编号=2074069050702561282 消息体={"deviceMac":"dc:da:0c:fa:27:5a","offline":"true","reason":"poweroff"}
2026-08-18 17:55:59 [MQTT Rec: water-server-28082] WARN org.dromara.mqtt.MqttClientManager - [MQTT] 连接已断开:已断开连接
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of close method failed on bean with name 'mqttConnect': 已在进行连接 (32110)
2026-08-18 17:56:00 [SpringApplicationShutdownHook] WARN o.s.b.f.s.DisposableBeanAdapter - Invocation of destroy method failed on bean with name 'org.dromara.mqtt.MqttClientManager': 已在进行连接 (32110)
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye

View File

View File

@@ -0,0 +1,55 @@
2026-08-18 17:55:39 [background-preinit] INFO o.h.validator.internal.util.Version - HV000001: Hibernate Validator 8.0.3.Final
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - Starting TagUnitTest using Java 17.0.17 with PID 29612 (started by admin in D:\code\watex\water\water-admin)
2026-08-18 17:55:39 [main] INFO org.dromara.test.TagUnitTest - The following 1 profile is active: "dev"
2026-08-18 17:55:44 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource detect P6SPY plugin and enabled it
2026-08-18 17:55:44 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Starting...
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.pool.HikariPool - master - Added connection com.mysql.cj.jdbc.ConnectionImpl@1a18e68a
2026-08-18 17:55:45 [main] INFO com.zaxxer.hikari.HikariDataSource - master - Start completed.
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource - add a datasource named [master] success
2026-08-18 17:55:45 [main] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource initial loaded [1] datasource,primary datasource named [master]
2026-08-18 17:55:48 [main] INFO o.d.common.json.config.JacksonConfig - 初始化JSON序列化配置
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 消息分发器初始化完成,处理器数量=9
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceCommandAckHandler -> ^/([^/]+)/publish/ack$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceDataHandler -> ^/([^/]+)/publish/power$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceRegisterHandler -> ^/([^/]+)/publish/register$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceStatusHandler -> ^/([^/]+)/publish/status$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器DeviceUnbindHandler -> ^/([^/]+)/publish/unbind$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ErromesHandler -> ^/([^/]+)/publish/error$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器KeyFinishHandler -> ^/([^/]+)/publish/finish/key$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器ScheduleFinishHandler -> ^/([^/]+)/publish/finish/schedule$
2026-08-18 17:55:48 [main] INFO o.d.app.mqtt.MqttMessageDispatcher - [MQTT] 已注册处理器StartWaterHandler -> ^/([^/]+)/publish/start$
2026-08-18 17:55:49 [main] INFO o.d.common.redis.config.RedisConfig - 初始化 Redis 配置
2026-08-18 17:55:49 [main] INFO org.redisson.Version - Redisson 3.52.0
2026-08-18 17:55:49 [redisson-netty-1-1] INFO o.r.connection.ConnectionsHolder - 1 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:50 [redisson-netty-1-3] INFO o.r.connection.ConnectionsHolder - 8 connections initialized for 47.97.217.123/47.97.217.123:6379
2026-08-18 17:55:51 [main] INFO c.b.m.e.s.MybatisPlusApplicationContextAware - Register ApplicationContext instances org.springframework.web.context.support.GenericWebApplicationContext@212dfd39
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:54 [main] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:55:55 [main] INFO org.dromara.test.TagUnitTest - Started TagUnitTest in 17.013 seconds (process running for 18.345)
2026-08-18 17:55:55 [main] INFO o.d.c.sse.listener.SseTopicListener - 初始化SSE主题订阅监听器成功
2026-08-18 17:55:56 [main] INFO o.d.s.runner.SystemApplicationRunner - 初始化OSS配置成功
2026-08-18 17:55:56 [main] INFO o.d.m.MqttPendingCommandCleanupListener - [MQTT] 应用启动清理待确认命令完成,清理数量: 1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已重新连接ssl://www.mmipco.cn:8883
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/schedule 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/register 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/status 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/power 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/ack 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/start 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/finish/key 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/error 服务质量等级=1
2026-08-18 17:55:57 [MQTT Call: water-server-28082] INFO org.dromara.mqtt.MqttClientManager - [MQTT] 已订阅 主题=/+/publish/unbind 服务质量等级=1
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO o.d.c.core.config.ThreadPoolConfig - ====关闭后台任务任务线程池====
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource start closing ....
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown initiated...
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO com.zaxxer.hikari.HikariDataSource - master - Shutdown completed.
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.d.DefaultDataSourceDestroyer - dynamic-datasource close the datasource named [master] success,
2026-08-18 17:56:00 [SpringApplicationShutdownHook] INFO c.b.d.d.DynamicRoutingDataSource - dynamic-datasource all closed success,bye

View File

@@ -201,7 +201,11 @@ public class AuthController {
*/
@ApiEncrypt
@PostMapping("/register")
public R<Void> register(@Validated @RequestBody RegisterBody user) {
public R<Void> register(@RequestBody RegisterBody user) {
if (StringUtils.isBlank(user.getClientId())) {
user.setClientId("32324a04c0175c8d61ac8a282553aea1");
}
ValidatorUtils.validate(user);
if (!configService.selectRegisterEnabled(user.getTenantId())) {
return R.fail("当前系统没有开启注册功能!");
}

View File

@@ -67,26 +67,32 @@ public class CaptchaController {
@RateLimiter(key = "#username", time = 60, count = 1)
@GetMapping("/resource/code")
public R<Void> code(@NotBlank(message = "{user.username.not.blank}") String username) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + username;
String code = RandomUtil.randomNumbers(6);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
if (Validator.isMobile(username)) {
return sendSmsCode(username, username);
}
if (Validator.isEmail(username)) {
return sendEmailCode(username, username);
}
if (Validator.isMobile(username)){
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "SMS_333877107";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(smsResponse.getData().toString());
}
}else {
emailCodeImpl(username);
}
SysUserVo user = userService.selectUserByUserName(username);
if (user == null) {
return R.fail("账号未注册");
}
if (Validator.isMobile(user.getPhonenumber())) {
return sendSmsCode(user.getPhonenumber(), username);
}
if (Validator.isEmail(user.getEmail())) {
return sendEmailCode(user.getEmail(), username);
}
return R.fail("当前账号未绑定手机号或邮箱");
}
return R.ok(code);
private R<Void> sendEmailCode(String email, String cacheKey) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
emailCodeImpl(email, cacheKey);
return R.ok("操作成功");
}

View File

@@ -2,9 +2,16 @@ package org.dromara.web.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.*;
import org.dromara.app.mapper.*;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.satoken.utils.LoginHelper;
@@ -29,13 +36,12 @@ public class AccountCancellationService {
private final SysUserRoleMapper sysUserRoleMapper;
private final SysUserPostMapper sysUserPostMapper;
private final SysSocialMapper sysSocialMapper;
private final AppDeviceMapper appDeviceMapper;
private final AppScheduleMapper appScheduleMapper;
private final AppScheduleDetailMapper appScheduleDetailMapper;
private final AppSchedulingDeviceMapper appSchedulingDeviceMapper;
private final AppWateringLogMapper appWateringLogMapper;
private final AccountCancelCodeService accountCancelCodeService;
private final IDeviceCommandService deviceCommandService;
private final IAppDeviceBindingService deviceBindingService;
@Transactional(rollbackFor = Exception.class)
public void cancelCurrentAccount(String code) {
@@ -52,9 +58,12 @@ public class AccountCancellationService {
List<String> deviceNos = queryUserDeviceNos(userId);
List<Long> scheduleIds = queryUserScheduleIds(userId);
sendInitDeviceCommands(deviceNos);
if (!deviceNos.isEmpty()) {
deviceBindingService.unbindByUser(deviceNos, userId);
}
deviceBindingService.removeAllListEntries(userId);
deleteAppScheduleData(userId, scheduleIds);
deleteAppDeviceData(userId, deviceNos);
deleteAppUserData(userId);
deleteSystemUserData(userId);
accountCancelCodeService.delete(username);
}
@@ -68,12 +77,8 @@ public class AccountCancellationService {
}
private List<String> queryUserDeviceNos(Long userId) {
return appDeviceMapper.selectList(
Wrappers.<AppDevice>lambdaQuery()
.select(AppDevice::getDeviceNo)
.eq(AppDevice::getUserId, userId)
).stream()
.map(AppDevice::getDeviceNo)
return deviceBindingService.queryActiveDevices(userId).stream()
.map(AppDeviceVo::getDeviceNo)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
@@ -91,12 +96,6 @@ public class AccountCancellationService {
.toList();
}
private void sendInitDeviceCommands(List<String> deviceNos) {
for (String deviceNo : deviceNos) {
deviceCommandService.sendInitDeviceCommand(deviceNo);
}
}
private void deleteAppScheduleData(Long userId, List<Long> scheduleIds) {
if (!scheduleIds.isEmpty()) {
appSchedulingDeviceMapper.delete(
@@ -114,25 +113,11 @@ public class AccountCancellationService {
);
}
private void deleteAppDeviceData(Long userId, List<String> deviceNos) {
if (!deviceNos.isEmpty()) {
appSchedulingDeviceMapper.delete(
Wrappers.<AppSchedulingDevice>lambdaQuery()
.in(AppSchedulingDevice::getDeviceNo, deviceNos)
);
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.in(AppWateringLog::getDeviceNo, deviceNos)
);
}
private void deleteAppUserData(Long userId) {
appWateringLogMapper.delete(
Wrappers.<AppWateringLog>lambdaQuery()
.eq(AppWateringLog::getUserId, userId)
);
appDeviceMapper.delete(
Wrappers.<AppDevice>lambdaQuery()
.eq(AppDevice::getUserId, userId)
);
}
private void deleteSystemUserData(Long userId) {

View File

@@ -1,20 +1,9 @@
package org.dromara.web.service;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.domain.model.ForgotLoginBody;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MapstructUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.service.ISysUserService;
import org.springframework.stereotype.Service;
@@ -22,41 +11,14 @@ import org.springframework.stereotype.Service;
@Service
public class ForgotPasswordService {
private final SysUserMapper userMapper;
private final ISysUserService userService;
public boolean forgotPasswordService(ForgotLoginBody loginBody) {
SysUserVo appUserVo =new SysUserVo();
if(Validator.isEmail(loginBody.getUsername())){
userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, loginBody.getUsername()));
}else if (Validator.isMobile(loginBody.getUsername())){
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, loginBody.getUsername()));
}else {
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, loginBody.getUsername()));
}
if (ObjectUtil.isNull(appUserVo)){
throw new UserException("账号未注册");
}
boolean validateFlag = validateSmsCode(loginBody.getUsername(), loginBody.getSmsCode());
if (!validateFlag){
throw new UserException("验证码无效");
}
appUserVo.setPassword(BCrypt.hashpw(loginBody.getPassword()));
SysUser update = MapstructUtils.convert(appUserVo, SysUser.class);
userMapper.updateById(update);
return userMapper.updateById(update) > 0;
}
/**
* 校验短信验证码
*/
private boolean validateSmsCode(String username, String smsCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + username);
if (StringUtils.isBlank(code)) {
throw new CaptchaExpireException();
}
return code.equals(smsCode);
String verificationCode = StringUtils.isNotBlank(loginBody.getSmsCode())
? loginBody.getSmsCode()
: loginBody.getCode();
return userService.resetPasswordByVerificationCode(
loginBody.getUsername(), verificationCode, loginBody.getPassword());
}
}

View File

@@ -69,7 +69,7 @@ public class SysRegisterService {
sysUser.setUserName(username);
sysUser.setNickName("用户"+new Date().getTime());
sysUser.setNickName("Watex_"+new Date().getTime());
sysUser.setPassword(BCrypt.hashpw(password));
sysUser.setUserType(userType);
boolean exist;

View File

@@ -19,6 +19,13 @@ server:
io: 8
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
app:
firmware-storage:
# 固件持久化目录;生产环境应挂载到服务器持久化磁盘
directory: ${FIRMWARE_STORAGE_DIRECTORY:./data/firmware}
# 设备可访问的服务端公网地址;为空时根据上传请求生成
download-base-url: ${FIRMWARE_DOWNLOAD_BASE_URL:}
--- # mqtt配置信息
mqtt:
enabled: ${MQTT_ENABLED:true}
@@ -26,13 +33,14 @@ mqtt:
#broker-url: tcp://47.97.217.123
username: admin
password: 61e6062129e9
client-id: water-server-1${server.port}
client-id: water-server-2${server.port}
qos: 1
keep-alive: 300
connection-timeout: 30
max-inflight: 5000
clean-session: false
automatic-reconnect: true
register-device-no-dedup-seconds: 30
async:
core-pool-size: 16
max-pool-size: 64
@@ -47,10 +55,11 @@ mqtt:
command-ack:
enabled: true
max-retry-count: 3
retry-interval-ms: 30000
retry-interval-ms: 10000
scan-interval-ms: 5000
pending-ttl-seconds: 86400
ack-ttl-seconds: 86400
startup-cleanup-wait-ms: 30000
pending-key-prefix: "mqtt:command:pending:"
ack-key-prefix: "mqtt:command:ack:"
pending-set-key: "mqtt:command:pending:ids"
@@ -63,6 +72,8 @@ mqtt:
interval-ms: 30000
topics:
# 首次注册使用 MAC注册后的业务主题使用 deviceNoLWT 离线主题兼容 MAC
# 同一共享组内仅一个后端实例消费每条上行消息,防止重复下发和重复处理 ACK
shared-group: ${MQTT_SHARED_GROUP:water-backend}
subscribe:
- /+/publish/finish/schedule #排程任务完成上报
@@ -73,6 +84,7 @@ mqtt:
- /+/publish/start #开始浇水上报
- /+/publish/finish/key #按键浇水完成
- /+/publish/error #硬件故障
- /+/publish/unbind #设备长按解绑
# - /+/subscriber/cmd #下发命令 0关闭浇水 1开始浇水
publish-prefix: ""
@@ -172,6 +184,8 @@ security:
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
- /app/v1/retrievePassword
- /app/firmware/download/**
# 多租户配置
tenant:

View File

@@ -14,8 +14,8 @@ import static org.assertj.core.api.Assertions.assertThat;
class MqttCommandAckConfigUnitTest {
@Test
void commandAckMaxRetryCountIsThree() throws Exception {
Integer maxRetryCount = null;
void commandAckRetrySettingsMatchRuntimeRequirements() throws Exception {
Map<?, ?> commandAckConfig = null;
Yaml yaml = new Yaml();
try (InputStream inputStream = new ClassPathResource("application.yml").getInputStream()) {
for (Object document : yaml.loadAll(inputStream)) {
@@ -27,17 +27,16 @@ class MqttCommandAckConfigUnitTest {
continue;
}
Object commandAck = mqttConfig.get("command-ack");
if (!(commandAck instanceof Map<?, ?> commandAckConfig)) {
continue;
}
Object value = commandAckConfig.get("max-retry-count");
if (value instanceof Number number) {
maxRetryCount = number.intValue();
if (commandAck instanceof Map<?, ?> config) {
commandAckConfig = config;
}
}
}
assertThat(maxRetryCount).isEqualTo(3);
assertThat(commandAckConfig).isNotNull();
assertThat(commandAckConfig.get("retry-interval-ms")).isEqualTo(10000);
assertThat(commandAckConfig.get("scan-interval-ms")).isEqualTo(5000);
assertThat(commandAckConfig.get("max-retry-count")).isEqualTo(3);
}
@Test

View File

@@ -0,0 +1,60 @@
package org.dromara.web.config;
import org.dromara.common.security.config.SecurityConfig;
import org.dromara.common.security.config.properties.SecurityProperties;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.handler.MappedInterceptor;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("dev")
class SecurityExcludesConfigUnitTest {
@Test
void retrievePassword_isExcludedFromLoginAuthentication() throws Exception {
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
.load("application", new ClassPathResource("application.yml"));
MutablePropertySources propertySources = new MutablePropertySources();
for (PropertySource<?> source : sources) {
propertySources.addLast(source);
}
SecurityProperties properties = new Binder(ConfigurationPropertySources.from(propertySources))
.bind("security", Bindable.of(SecurityProperties.class))
.orElseThrow(() -> new AssertionError("security 配置未绑定"));
assertThat(properties.getExcludes()).contains("/app/v1/retrievePassword");
}
@Test
void securityInterceptor_alwaysExcludesRetrievePasswordWithoutExternalConfiguration() {
SecurityProperties properties = new SecurityProperties();
properties.setExcludes(new String[0]);
SecurityConfig config = new SecurityConfig(properties);
ReflectionTestUtils.setField(config, "ssePath", "/resource/sse");
ExposedInterceptorRegistry registry = new ExposedInterceptorRegistry();
config.addInterceptors(registry);
MappedInterceptor interceptor = (MappedInterceptor) registry.getRegisteredInterceptors().get(0);
assertThat(interceptor.getExcludePathPatterns()).contains("/app/v1/retrievePassword");
}
private static final class ExposedInterceptorRegistry extends InterceptorRegistry {
private List<Object> getRegisteredInterceptors() {
return super.getInterceptors();
}
}
}

View File

@@ -1,20 +1,28 @@
package org.dromara.web.controller;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.extra.spring.SpringUtil;
import jakarta.validation.Validation;
import jakarta.validation.ValidatorFactory;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.AccountCancelBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.service.ISysConfigService;
import org.dromara.web.service.AccountCancellationService;
import org.dromara.web.service.SysRegisterService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.support.GenericApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
@@ -23,6 +31,12 @@ class AuthControllerUnitTest {
@Mock
private AccountCancellationService accountCancellationService;
@Mock
private SysRegisterService registerService;
@Mock
private ISysConfigService configService;
@Test
void cancelAccount_checksLoginCancelsAccountAndLogsOut() {
AuthController controller = new AuthController(
@@ -52,4 +66,38 @@ class AuthControllerUnitTest {
stpUtil.verify(() -> StpUtil.logout(100L));
}
}
@Test
void register_defaultsClientIdWhenMissing() {
AuthController controller = new AuthController(
null,
null,
registerService,
configService,
null,
null,
null,
null,
null,
accountCancellationService
);
RegisterBody user = new RegisterBody();
user.setUsername("register-user");
user.setPassword("12345");
user.setGrantType("password");
when(configService.selectRegisterEnabled(user.getTenantId())).thenReturn(true);
try (ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
GenericApplicationContext context = new GenericApplicationContext()) {
context.getBeanFactory().registerSingleton("validator", validatorFactory.getValidator());
context.refresh();
new SpringUtil().setApplicationContext(context);
R<Void> result = controller.register(user);
assertThat(result.getCode()).isEqualTo(200);
assertThat(user.getClientId()).isEqualTo("32324a04c0175c8d61ac8a282553aea1");
verify(registerService).register(user);
}
}
}

View File

@@ -29,6 +29,52 @@ class CaptchaControllerUnitTest {
@Mock
private ISysUserService userService;
@Test
void code_resolvesAccountPhoneAndUsesAccountAsCacheKey() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setPhonenumber("13305376054");
when(userService.selectUserByUserName("alice")).thenReturn(user);
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "alice");
R<Void> result = controller.code("alice");
assertThat(result.getCode()).isEqualTo(200);
assertThat(result.getData()).isNull();
verify(userService).selectUserByUserName("alice");
verify(controller).sendSmsCode("13305376054", "alice");
}
@Test
void code_resolvesAccountEmailAndUsesAccountAsCacheKey() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
SysUserVo user = new SysUserVo();
user.setEmail("alice@example.com");
when(userService.selectUserByUserName("alice")).thenReturn(user);
when(mailProperties.getEnabled()).thenReturn(true);
doNothing().when(controller).emailCodeImpl("alice@example.com", "alice");
R<Void> result = controller.code("alice");
assertThat(result.getCode()).isEqualTo(200);
assertThat(result.getData()).isNull();
verify(userService).selectUserByUserName("alice");
verify(controller).emailCodeImpl("alice@example.com", "alice");
}
@Test
void code_keepsDirectMobileBehaviorWithoutReturningPlainCode() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "13305376054");
R<Void> result = controller.code("13305376054");
assertThat(result.getCode()).isEqualTo(200);
assertThat(result.getData()).isNull();
verify(controller).sendSmsCode("13305376054", "13305376054");
verifyNoInteractions(userService);
}
@Test
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));

View File

@@ -4,9 +4,16 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.app.domain.*;
import org.dromara.app.mapper.*;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.app.domain.AppSchedule;
import org.dromara.app.domain.AppScheduleDetail;
import org.dromara.app.domain.AppSchedulingDevice;
import org.dromara.app.domain.AppWateringLog;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.mapper.AppScheduleDetailMapper;
import org.dromara.app.mapper.AppScheduleMapper;
import org.dromara.app.mapper.AppSchedulingDeviceMapper;
import org.dromara.app.mapper.AppWateringLogMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
@@ -45,8 +52,6 @@ class AccountCancellationServiceUnitTest {
@Mock
private SysSocialMapper sysSocialMapper;
@Mock
private AppDeviceMapper appDeviceMapper;
@Mock
private AppScheduleMapper appScheduleMapper;
@Mock
private AppScheduleDetailMapper appScheduleDetailMapper;
@@ -57,12 +62,11 @@ class AccountCancellationServiceUnitTest {
@Mock
private AccountCancelCodeService accountCancelCodeService;
@Mock
private IDeviceCommandService deviceCommandService;
private IAppDeviceBindingService deviceBindingService;
@BeforeAll
static void initTableInfo() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
initTableInfo(assistant, AppDevice.class);
initTableInfo(assistant, AppSchedule.class);
initTableInfo(assistant, AppScheduleDetail.class);
initTableInfo(assistant, AppSchedulingDevice.class);
@@ -80,11 +84,11 @@ class AccountCancellationServiceUnitTest {
@Test
void cancelCurrentAccount_deletesCurrentUserAndRelatedData() {
AccountCancellationService service = newService();
AppDevice device = new AppDevice();
AppDeviceVo device = new AppDeviceVo();
device.setDeviceNo("D01");
AppSchedule schedule = new AppSchedule();
schedule.setId(10L);
when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device));
when(deviceBindingService.queryActiveDevices(100L)).thenReturn(List.of(device));
when(appScheduleMapper.selectList(any(Wrapper.class))).thenReturn(List.of(schedule));
when(sysUserMapper.deleteById(100L)).thenReturn(1);
@@ -96,12 +100,12 @@ class AccountCancellationServiceUnitTest {
service.cancelCurrentAccount("123456");
verify(accountCancelCodeService).validate("zhangsan", "123456");
verify(deviceCommandService).sendInitDeviceCommand("D01");
verify(deviceBindingService).unbindByUser(List.of("D01"), 100L);
verify(deviceBindingService).removeAllListEntries(100L);
verify(appSchedulingDeviceMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appScheduleDetailMapper).delete(any(Wrapper.class));
verify(appScheduleMapper).delete(any(Wrapper.class));
verify(appWateringLogMapper, atLeastOnce()).delete(any(Wrapper.class));
verify(appDeviceMapper).delete(any(Wrapper.class));
verify(sysSocialMapper).delete(any(Wrapper.class));
verify(sysUserRoleMapper).delete(any(Wrapper.class));
verify(sysUserPostMapper).delete(any(Wrapper.class));
@@ -127,7 +131,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
deviceBindingService,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
@@ -156,7 +160,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
deviceBindingService,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
@@ -186,7 +190,7 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
deviceBindingService,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
@@ -203,13 +207,12 @@ class AccountCancellationServiceUnitTest {
sysUserRoleMapper,
sysUserPostMapper,
sysSocialMapper,
appDeviceMapper,
appScheduleMapper,
appScheduleDetailMapper,
appSchedulingDeviceMapper,
appWateringLogMapper,
accountCancelCodeService,
deviceCommandService
deviceBindingService
);
}
}

View File

@@ -0,0 +1,39 @@
package org.dromara.web.service;
import org.dromara.common.core.domain.model.ForgotLoginBody;
import org.dromara.system.service.ISysUserService;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class ForgotPasswordServiceTest {
@Mock
private ISysUserService userService;
@InjectMocks
private ForgotPasswordService forgotPasswordService;
@Test
void forgotPasswordService_delegatesVerificationAndResetToUserService() {
ForgotLoginBody body = new ForgotLoginBody();
body.setUsername("alice");
body.setSmsCode("123456");
body.setPassword("newPassword");
when(userService.resetPasswordByVerificationCode("alice", "123456", "newPassword"))
.thenReturn(true);
boolean result = forgotPasswordService.forgotPasswordService(body);
assertThat(result).isTrue();
verify(userService).resetPasswordByVerificationCode("alice", "123456", "newPassword");
}
}

View File

@@ -21,6 +21,7 @@ public class DeviceMqttCommandPublisher implements IDeviceCommandPublisher {
@Override
public String send(DeviceCommand command) {
ackService.awaitStartupCleanup();
long now = System.currentTimeMillis();
if (StringUtils.isBlank(command.getCommandId())) {
command.setCommandId(UUID.randomUUID().toString().replace("-", ""));

View File

@@ -51,6 +51,18 @@ public class MqttClientManager implements DisposableBean {
return identity.hashCode();
}
static String subscriptionFilter(String topic, String sharedGroup) {
if (StringUtils.isBlank(topic) || StringUtils.isBlank(sharedGroup)
|| topic.startsWith("$share/") || topic.startsWith("$queue/")) {
return topic;
}
String group = sharedGroup.trim();
if (group.contains("/") || group.contains("+") || group.contains("#")) {
throw new ServiceException("MQTT 共享订阅组名称不能包含 /、+ 或 #");
}
return "$share/" + group + "/" + topic;
}
private void configureTls(MqttConnectOptions options) throws MqttException {
if (!isTlsEnabled()) {
return;
@@ -254,14 +266,18 @@ public class MqttClientManager implements DisposableBean {
if (client == null || !client.isConnected()) {
return;
}
List<String> topics = props.getTopics() == null ? List.of() : props.getTopics().getSubscribe();
MqttProperties.Topics topicProperties = props.getTopics();
List<String> topics = topicProperties == null ? List.of() : topicProperties.getSubscribe();
if (topics == null || topics.isEmpty()) {
log.warn("[MQTT] 未配置订阅主题");
return;
}
int[] qos = topics.stream().mapToInt(topic -> props.getQos()).toArray();
client.subscribe(topics.toArray(new String[0]), qos).waitForCompletion();
topics.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos()));
List<String> subscriptionFilters = topics.stream()
.map(topic -> subscriptionFilter(topic, topicProperties.getSharedGroup()))
.toList();
int[] qos = subscriptionFilters.stream().mapToInt(topic -> props.getQos()).toArray();
client.subscribe(subscriptionFilters.toArray(new String[0]), qos).waitForCompletion();
subscriptionFilters.forEach(topic -> log.info("[MQTT] 已订阅 主题={} 服务质量等级={}", topic, props.getQos()));
} catch (MqttException e) {
log.error("[MQTT] 订阅失败", e);
}

View File

@@ -7,7 +7,10 @@ import org.dromara.app.domain.AppDevice;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.app.domain.mqtt.DeviceCommandAck;
import org.dromara.app.mapper.AppDeviceMapper;
import org.dromara.app.service.IAppDeviceBindingService;
import org.dromara.app.service.IDeviceCommandAckHandler;
import org.dromara.app.service.IDeviceCommandLifecycleListener;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.common.redis.utils.RedisUtils;
@@ -21,6 +24,7 @@ import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@@ -37,6 +41,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
private MqttClientManager mqttClientManager;
private final MqttProperties mqttProperties;
private final AppDeviceMapper appDeviceMapper;
private final CountDownLatch startupCleanupLatch = new CountDownLatch(1);
@Autowired(required = false)
private List<IDeviceCommandLifecycleListener> lifecycleListeners = Collections.emptyList();
private volatile boolean startupCleanupReady;
@Lazy
@Autowired(required = false)
private IAppDeviceBindingService deviceBindingService;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
@@ -108,6 +119,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
deviceNo, ack.getCommandId(), pending.getDeviceNo());
return false;
}
notifyCommandAcknowledged(pending, ack);
deletePending(ack.getCommandId());
RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
@@ -132,14 +144,83 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().add(command.getCommandId());
}
public int clearPendingCommands() {
RSet<String> pendingIds = pendingIds();
Set<String> commandIds = pendingIds.readAll();
pendingIds.removeAll(commandIds);
RuntimeException cleanupFailure = null;
try {
for (String commandId : commandIds) {
try {
RedisUtils.deleteObject(pendingKey(commandId));
} catch (RuntimeException e) {
if (cleanupFailure == null) {
cleanupFailure = new RuntimeException("Failed to delete MQTT pending command object: " + commandId, e);
} else {
cleanupFailure.addSuppressed(e);
}
}
}
} finally {
startupCleanupReady = true;
startupCleanupLatch.countDown();
}
if (cleanupFailure != null) {
throw cleanupFailure;
}
return commandIds.size();
}
boolean isStartupCleanupReady() {
return startupCleanupReady;
}
void awaitStartupCleanup() {
if (startupCleanupReady) {
return;
}
long waitMs = Math.max(1000L, mqttProperties.getCommandAck().getStartupCleanupWaitMs());
try {
if (!startupCleanupLatch.await(waitMs, TimeUnit.MILLISECONDS)) {
throw new ServiceException("MQTT 启动清理未完成,暂不下发命令");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("等待 MQTT 启动清理被中断");
}
}
public void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) {
return;
}
withCommandLock(commandId, () -> {
CommandLockResult result = withCommandLock(commandId, () -> {
deletePending(commandId);
return true;
});
if (result != CommandLockResult.SUCCESS) {
throw new IllegalStateException("清理 MQTT 待确认命令失败,命令编号=" + commandId + ",结果=" + result);
}
}
@Override
public void clearPendingCommands(Collection<String> deviceNos) {
if (deviceNos == null || deviceNos.isEmpty()) {
return;
}
Set<String> targetDeviceNos = deviceNos.stream()
.filter(StringUtils::isNotBlank)
.collect(java.util.stream.Collectors.toSet());
if (targetDeviceNos.isEmpty()) {
return;
}
for (String commandId : pendingIds().readAll()) {
DeviceCommand command = RedisUtils.getCacheObject(pendingKey(commandId));
if (command != null && targetDeviceNos.contains(command.getDeviceNo())) {
removePending(commandId);
}
}
}
private void handlePlainAck(String deviceNo, String payload) {
@@ -170,6 +251,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
deviceNo, commandId, pending.getDeviceNo());
return false;
}
notifyCommandAcknowledged(pending, ack);
deletePending(commandId);
RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds()));
return true;
@@ -228,10 +310,19 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
return;
}
if (command.getRetryCount() >= mqttProperties.getCommandAck().getMaxRetryCount()) {
notifyCommandExpired(command);
deletePending(commandId);
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return;
}
if (Boolean.FALSE.equals(command.getRetryEnabled())) {
command.setRetryCount(command.getRetryCount() + 1);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command);
log.debug("[MQTT] 命令已关闭自动重发,等待 ACK 或到期清理 设备编号={} 命令编号={} 等待次数={}",
command.getDeviceNo(), commandId, command.getRetryCount());
return;
}
if (!isDeviceOnline(command.getDeviceNo())) {
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command);
@@ -263,6 +354,9 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
}
private void refreshDeviceOnline(String deviceNo) {
if (deviceBindingService != null && !deviceBindingService.isActive(deviceNo)) {
return;
}
RLock lock = RedisUtils.getClient().getLock(DEVICE_STATUS_LOCK_PREFIX + deviceNo);
boolean locked = false;
try {
@@ -304,6 +398,23 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().remove(commandId);
}
private void notifyCommandAcknowledged(DeviceCommand command, DeviceCommandAck ack) {
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
listener.onCommandAcknowledged(command, ack);
}
}
private void notifyCommandExpired(DeviceCommand command) {
for (IDeviceCommandLifecycleListener listener : lifecycleListeners) {
try {
listener.onCommandExpired(command);
} catch (RuntimeException e) {
log.error("[MQTT] 命令过期生命周期回调失败 设备编号={} 命令编号={}",
command.getDeviceNo(), command.getCommandId(), e);
}
}
}
private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) {
RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId);
boolean locked = false;

View File

@@ -16,7 +16,7 @@ public class MqttCommandRetryTask {
@Scheduled(fixedDelayString = "${mqtt.command-ack.scan-interval-ms:5000}")
public void retryExpiredCommands() {
if (mqttProperties.getCommandAck().isEnabled()) {
if (ackService.isStartupCleanupReady() && mqttProperties.getCommandAck().isEnabled()) {
// log.info("[设备命令重新下发] 定时器调用成功 ");
ackService.retryExpiredCommands();
}

View File

@@ -0,0 +1,52 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.mqtt.DeviceUnbindResult;
import org.dromara.app.service.IDeviceUnbindResultPublisher;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.json.utils.JsonUtils;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class MqttDeviceUnbindResultPublisher implements IDeviceUnbindResultPublisher {
private final MqttProperties mqttProperties;
@Lazy
@Autowired
private MqttClientManager mqttClientManager;
@Override
public void publish(DeviceUnbindResult result) {
if (result == null || StringUtils.isBlank(result.deviceNo())) {
throw new ServiceException("解绑回执缺少设备编号");
}
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("commandId", result.commandId());
payload.put("bindingId", result.bindingId());
payload.put("status", result.status());
payload.put("message", result.message());
mqttClientManager.publish(buildTopic(result.deviceNo()), JsonUtils.toJsonString(payload));
}
private String buildTopic(String deviceNo) {
String device = deviceNo.trim().toLowerCase(Locale.ROOT);
String prefix = mqttProperties.getTopics().getPublishPrefix();
if (StringUtils.isBlank(prefix)) {
return "/" + device + "/subscriber/unbind/ack";
}
String normalizedPrefix = prefix.startsWith("/") ? prefix : "/" + prefix;
if (normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix.substring(0, normalizedPrefix.length() - 1);
}
return normalizedPrefix + "/" + device + "/subscriber/unbind/ack";
}
}

View File

@@ -0,0 +1,25 @@
package org.dromara.mqtt;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class MqttPendingCommandCleanupListener {
private final MqttCommandAckService mqttCommandAckService;
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady(ApplicationReadyEvent event) {
try {
int clearedCount = mqttCommandAckService.clearPendingCommands();
log.info("[MQTT] 应用启动清理待确认命令完成,清理数量: {}", clearedCount);
} catch (RuntimeException e) {
log.error("[MQTT] 应用启动清理待确认命令失败", e);
}
}
}

View File

@@ -31,6 +31,7 @@ public class MqttProperties {
@Data
public static class Topics {
private List<String> subscribe = new ArrayList<>();
private String sharedGroup;
private String publishPrefix;
}
@@ -55,10 +56,11 @@ public class MqttProperties {
public static class CommandAck {
private boolean enabled = true;
private int maxRetryCount = 3;
private long retryIntervalMs = 5000;
private long retryIntervalMs = 10000;
private long scanIntervalMs = 5000;
private long pendingTtlSeconds = 86400;
private long ackTtlSeconds = 86400;
private long startupCleanupWaitMs = 30000;
private String pendingKeyPrefix = "mqtt:command:pending:";
private String ackKeyPrefix = "mqtt:command:ack:";
private String pendingSetKey = "mqtt:command:pending:ids";

View File

@@ -1,15 +1,40 @@
package org.dromara.mqtt;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.mqtt.DeviceCommand;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.context.support.GenericApplicationContext;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.springframework.test.util.ReflectionTestUtils.invokeMethod;
@Tag("dev")
class DeviceMqttCommandPublisherTest {
private static GenericApplicationContext applicationContext;
@BeforeAll
static void initializeJsonUtils() {
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(ObjectMapper.class, (Supplier<ObjectMapper>) ObjectMapper::new);
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeApplicationContext() {
applicationContext.close();
}
@Test
void buildCommandTopicDefaultsToDeviceNo() {
MqttProperties properties = new MqttProperties();
@@ -23,4 +48,40 @@ class DeviceMqttCommandPublisherTest {
assertThat(topic).isEqualTo("/d01/subscriber/cmd");
}
@Test
void sendSchedulesFirstRetryTenSecondsAfterInitialPublish() {
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
mock(MqttClientManager.class),
mock(MqttCommandAckService.class),
new MqttProperties()
);
DeviceCommand command = new DeviceCommand();
command.setDeviceNo("D01");
command.setCommandType("switch");
publisher.send(command);
assertThat(command.getNextRetryAt() - command.getLastSentAt()).isEqualTo(10000);
}
@Test
void sendWaitsForStartupCleanupBeforeSavingPendingCommand() {
MqttClientManager clientManager = mock(MqttClientManager.class);
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
DeviceMqttCommandPublisher publisher = new DeviceMqttCommandPublisher(
clientManager,
ackService,
new MqttProperties()
);
DeviceCommand command = new DeviceCommand();
command.setDeviceNo("D01");
command.setCommandType("switch");
publisher.send(command);
var order = inOrder(ackService, clientManager);
order.verify(ackService).awaitStartupCleanup();
order.verify(ackService).savePending(command);
}
}

View File

@@ -0,0 +1,38 @@
package org.dromara.mqtt;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Tag("dev")
class MqttClientManagerTest {
@Test
void subscriptionFilterWrapsTopicInSharedGroup() {
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", "water-backend"))
.isEqualTo("$share/water-backend//+/publish/register");
}
@Test
void subscriptionFilterKeepsExistingSharedSubscription() {
String topic = "$share/existing//+/publish/register";
assertThat(MqttClientManager.subscriptionFilter(topic, "water-backend")).isEqualTo(topic);
}
@Test
void subscriptionFilterKeepsTopicWhenSharedGroupIsNotConfigured() {
assertThat(MqttClientManager.subscriptionFilter("/+/publish/register", " "))
.isEqualTo("/+/publish/register");
}
@Test
void subscriptionFilterRejectsInvalidSharedGroup() {
assertThatThrownBy(() -> MqttClientManager.subscriptionFilter("/+/publish/register", "water/backend"))
.isInstanceOf(ServiceException.class)
.hasMessageContaining("共享订阅组名称");
}
}

View File

@@ -23,12 +23,16 @@ import org.springframework.context.support.GenericApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Duration;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@@ -97,6 +101,154 @@ class MqttCommandAckServiceTest {
assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse();
}
@Test
void clearPendingCommandsRemovesSnapshotBeforeDeletingPendingObjects() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
AtomicBoolean snapshotRemoved = new AtomicBoolean();
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
doAnswer(invocation -> {
snapshotRemoved.set(true);
return true;
}).when(pendingIds).removeAll(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.deleteObject(any(String.class))).thenAnswer(invocation -> {
assertThat(snapshotRemoved).isTrue();
return true;
});
int cleared = service.clearPendingCommands();
assertThat(cleared).isEqualTo(2);
assertThat(service.isStartupCleanupReady()).isTrue();
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsOpensRetryGateForEmptySet() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = Set.of();
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
int cleared = service.clearPendingCommands();
assertThat(cleared).isZero();
assertThat(service.isStartupCleanupReady()).isTrue();
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsKeepsRetryGateClosedWhenSnapshotRemovalFails() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = Set.of("cmd-1");
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
doThrow(new IllegalStateException("redis unavailable")).when(pendingIds).removeAll(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
assertThat(service.isStartupCleanupReady()).isFalse();
assertThatThrownBy(service::clearPendingCommands)
.isInstanceOf(IllegalStateException.class)
.hasMessage("redis unavailable");
assertThat(service.isStartupCleanupReady()).isFalse();
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
verify(pendingIds, never()).clear();
}
}
@Test
void clearPendingCommandsContinuesDeletingObjectsAndReportsFailures() {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
Set<String> snapshot = new LinkedHashSet<>(List.of("cmd-1", "cmd-2"));
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(snapshot);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"))
.thenThrow(new IllegalStateException("delete failed"));
assertThatThrownBy(service::clearPendingCommands)
.isInstanceOf(RuntimeException.class)
.hasMessageContaining("cmd-1")
.hasCauseInstanceOf(IllegalStateException.class);
verify(pendingIds).removeAll(snapshot);
verify(pendingIds, never()).clear();
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"));
assertThat(service.isStartupCleanupReady()).isTrue();
}
}
@Test
void clearPendingCommandsByDeviceKeepsOtherDeviceCommands() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RSet<String> pendingIds = mock(RSet.class);
RLock commandLock = mock(RLock.class);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1", "cmd-2"));
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(anyLong(), anyLong(), eq(TimeUnit.MILLISECONDS))).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
DeviceCommand first = new DeviceCommand();
first.setCommandId("cmd-1");
first.setDeviceNo("D01");
DeviceCommand second = new DeviceCommand();
second.setCommandId("cmd-2");
second.setDeviceNo("D02");
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(first);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-2")).thenReturn(second);
service.clearPendingCommands(List.of("D01"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-2"), never());
verify(pendingIds).remove("cmd-1");
verify(pendingIds, never()).remove("cmd-2");
verify(commandLock).unlock();
}
}
@Test
void handleAckWaitsBrieflyForCommandLock() throws Exception {
MqttProperties properties = new MqttProperties();
@@ -135,6 +287,127 @@ class MqttCommandAckServiceTest {
}
}
@Test
void retryExpiredCommandsAdvancesRetryDisabledCommandsWithoutPublishing() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RLock commandLock = mock(RLock.class);
RSet<String> pendingIds = mock(RSet.class);
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
DeviceCommand pending = new DeviceCommand();
pending.setCommandId("cmd-1");
pending.setDeviceNo("D01");
pending.setTopic("/aa:bb:cc/subscriber/cmd");
pending.setRetryEnabled(false);
pending.setNextRetryAt(0);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
service.retryExpiredCommands();
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
assertThat(pending.getRetryCount()).isEqualTo(1);
assertThat(pending.getNextRetryAt()).isGreaterThan(0);
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:command:pending:cmd-1"),
eq(pending),
eq(Duration.ofSeconds(86400))
));
verify(pendingIds).add("cmd-1");
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
verify(commandLock).unlock();
}
}
@Test
void retryExpiredCommandsDeletesRetryDisabledCommandsAtRetryLimit() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RLock commandLock = mock(RLock.class);
RSet<String> pendingIds = mock(RSet.class);
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
DeviceCommand pending = new DeviceCommand();
pending.setCommandId("cmd-1");
pending.setDeviceNo("D01");
pending.setTopic("/aa:bb:cc/subscriber/cmd");
pending.setRetryEnabled(false);
pending.setRetryCount(properties.getCommandAck().getMaxRetryCount());
pending.setNextRetryAt(0);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
service.retryExpiredCommands();
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
verify(pendingIds).remove("cmd-1");
verify(commandLock).unlock();
}
}
@Test
void handleAckDeletesRetryDisabledPendingCommand() throws Exception {
MqttProperties properties = new MqttProperties();
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
RedissonClient redissonClient = mock(RedissonClient.class);
RLock commandLock = mock(RLock.class);
RLock statusLock = mock(RLock.class);
RSet<String> pendingIds = mock(RSet.class);
DeviceCommand pending = new DeviceCommand();
pending.setCommandId("cmd-1");
pending.setDeviceNo("D01");
pending.setRetryEnabled(false);
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
verify(pendingIds).remove("cmd-1");
redis.verify(() -> RedisUtils.setCacheObject(
eq("mqtt:command:ack:cmd-1"),
any(DeviceCommandAck.class),
eq(Duration.ofSeconds(86400))
));
verify(commandLock).unlock();
}
}
@Test
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
MqttProperties properties = new MqttProperties();

View File

@@ -0,0 +1,36 @@
package org.dromara.mqtt;
import org.dromara.mqtt.config.properties.MqttProperties;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MqttCommandRetryTaskTest {
@Test
void retryExpiredCommandsSkipsRetryBeforeStartupCleanupIsReady() {
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
when(ackService.isStartupCleanupReady()).thenReturn(false);
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
task.retryExpiredCommands();
verify(ackService, never()).retryExpiredCommands();
}
@Test
void retryExpiredCommandsRetriesAfterStartupCleanupIsReady() {
MqttCommandAckService ackService = mock(MqttCommandAckService.class);
when(ackService.isStartupCleanupReady()).thenReturn(true);
MqttCommandRetryTask task = new MqttCommandRetryTask(ackService, new MqttProperties());
task.retryExpiredCommands();
verify(ackService).retryExpiredCommands();
}
}

View File

@@ -0,0 +1,35 @@
package org.dromara.mqtt;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MqttPendingCommandCleanupListenerTest {
@Test
void onApplicationReadyClearsPendingCommands() {
MqttCommandAckService service = mock(MqttCommandAckService.class);
when(service.clearPendingCommands()).thenReturn(3);
MqttPendingCommandCleanupListener listener = new MqttPendingCommandCleanupListener(service);
listener.onApplicationReady(mock(ApplicationReadyEvent.class));
verify(service).clearPendingCommands();
}
@Test
void onApplicationReadyDoesNotThrowWhenCleanupFails() {
MqttCommandAckService service = mock(MqttCommandAckService.class);
when(service.clearPendingCommands()).thenThrow(new IllegalStateException("redis unavailable"));
MqttPendingCommandCleanupListener listener = new MqttPendingCommandCleanupListener(service);
assertThatCode(() -> listener.onApplicationReady(mock(ApplicationReadyEvent.class)))
.doesNotThrowAnyException();
}
}

View File

@@ -38,6 +38,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {
private static final String APP_RETRIEVE_PASSWORD_PATH = "/app/v1/retrievePassword";
private final SecurityProperties securityProperties;
@Value("${sse.path}")
private String ssePath;
@@ -83,6 +85,7 @@ public class SecurityConfig implements WebMvcConfigurer {
})).addPathPatterns("/**")
// 排除不需要拦截的路径
.excludePathPatterns(securityProperties.getExcludes())
.excludePathPatterns(APP_RETRIEVE_PASSWORD_PATH)
.excludePathPatterns(ssePath);
}

View File

@@ -0,0 +1,22 @@
package org.dromara.app.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
/**
* OTA 固件本地存储配置。
*/
@Data
@Component
@ConfigurationProperties(prefix = "app.firmware-storage")
public class FirmwareStorageProperties {
/** 固件文件保存目录。 */
private Path directory = Path.of("./data/firmware");
/** 对设备公开的服务端基础地址;为空时根据上传请求生成。 */
private String downloadBaseUrl;
}

View File

@@ -1,22 +1,20 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.bo.*;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
@@ -92,10 +90,9 @@ public class AppController extends BaseController {
*/
@GetMapping("/deviceList")
public TableDataInfo<AppDeviceVo> list(AppDeviceBo bo, PageQuery pageQuery) {
bo.setUserId(LoginHelper.getUserId());
pageQuery.setOrderByColumn("createTime");
pageQuery.setIsAsc("desc");
return appDeviceService.queryPageList(bo, pageQuery);
return appDeviceService.queryUserDevicePage(LoginHelper.getUserId(), bo, pageQuery);
}
@@ -133,10 +130,16 @@ public class AppController extends BaseController {
@GetMapping("/device/{deviceNo}")
public R<AppDeviceVo> getDeviceInfo(@NotNull(message = "主键不能为空")
@PathVariable String deviceNo) {
return R.ok(getOwnedDevice(deviceNo));
return R.ok(getActiveDevice(deviceNo));
}
@PostMapping("/factoryReset/{deviceNo}")
public R<String> factoryReset(@NotBlank(message = "设备编号不能为空")
@PathVariable String deviceNo) {
return R.ok(deviceCommandService.sendFactoryResetCommand(deviceNo));
}
/**
* 修改设备信息
*
@@ -144,9 +147,9 @@ public class AppController extends BaseController {
@RepeatSubmit()
@PutMapping("/updateDeviceInfo")
public R<Void> updateDeviceInfo(@Validated @RequestBody AppDeviceBo bo) {
assertDeviceOwned(bo.getDeviceNo());
assertActiveDevice(bo.getDeviceNo());
bo.setUserId(LoginHelper.getUserId());
return toAjax(appDeviceService.updateByBo(bo));
return toAjax(appDeviceService.updateByBo(bo, LoginHelper.getUserId()));
}
/**
@@ -157,11 +160,22 @@ public class AppController extends BaseController {
public R<Void> removeDevice(@NotEmpty(message = "主键不能为空")
@PathVariable String[] deviceNos) {
for (String deviceNo : deviceNos) {
assertDeviceOwned(deviceNo);
assertActiveDevice(deviceNo);
}
return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId()));
}
@RepeatSubmit()
@PostMapping("/retryBindDevice/{deviceNo}")
public R<AppDeviceVo> retryBindDevice(@PathVariable String deviceNo) {
return R.ok(appDeviceService.retryBinding(deviceNo, LoginHelper.getUserId()));
}
@DeleteMapping("/removeDeviceEntry/{deviceNo}")
public R<Void> removeDeviceEntry(@PathVariable String deviceNo) {
return toAjax(appDeviceService.removeUserDeviceEntry(deviceNo, LoginHelper.getUserId()));
}
/**
* 手动开启关闭设备
@@ -182,6 +196,17 @@ public class AppController extends BaseController {
return toAjax(flag);
}
/**
* 查询设备电量(下发查询命令,设备在 ACK 中回复电量)
*/
@RepeatSubmit()
@PostMapping("/queryPower/{deviceNo}")
public R<Void> queryPower(@PathVariable String deviceNo) {
assertActiveDevice(deviceNo);
deviceCommandService.sendQueryPowerCommand(deviceNo, LoginHelper.getUserId());
return R.ok();
}
/**
* 排程关联设备查询
* @param
@@ -191,9 +216,7 @@ public class AppController extends BaseController {
public R<Map<String, Object>> scheduleDeviceList(@PathVariable Long scheduleId) {
Map<String, Object> retMap = new HashMap<>();
//所有设备列表
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setUserId(LoginHelper.getUserId());
List<AppDeviceVo> appDeviceVos = appDeviceService.queryList(appDeviceBo);
List<AppDeviceVo> appDeviceVos = appDeviceService.queryActiveDevices(LoginHelper.getUserId());
List<String> deviceNos = appDeviceVos.stream()
.map(AppDeviceVo::getDeviceNo)
.filter(StringUtils::isNotBlank)
@@ -227,16 +250,16 @@ public class AppController extends BaseController {
if (StringUtils.isBlank(deviceNo)) {
continue;
}
assertDeviceOwned(deviceNo);
assertActiveDevice(deviceNo);
if (appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(scheduleId, deviceNo) != null) {
continue;
}
AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo();
schedulingDeviceBo.setDeviceNo(deviceNo);
schedulingDeviceBo.setScheduleId(scheduleId);
if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo)) {
if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo, LoginHelper.getUserId())) {
deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails));
buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
}
}
return R.ok();
@@ -320,12 +343,13 @@ public class AppController extends BaseController {
}
Long scheduleIdValue = Long.valueOf(scheduleId);
assertScheduleOwned(scheduleIdValue);
assertDeviceOwned(deviceNo);
Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(scheduleIdValue, deviceNo);
assertActiveDevice(deviceNo);
Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(
scheduleIdValue, deviceNo, LoginHelper.getUserId());
if (deleted) {
// 通知设备解绑排程
try {
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue);
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue, LoginHelper.getUserId());
} catch (Exception mqttEx) {
log.warn("[排程通知] 删除排程设备绑定后下发解绑命令失败 排程ID={} 设备编号={} 原因={}",
scheduleIdValue, deviceNo, mqttEx.getMessage(), mqttEx);
@@ -384,7 +408,9 @@ public class AppController extends BaseController {
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
List<AppDeviceVo> queriedDevices = appDeviceService.queryByDeviceNos(deviceNos);
Map<String, AppDeviceVo> activeDevices = appDeviceService.queryActiveDevices(LoginHelper.getUserId()).stream()
.collect(java.util.stream.Collectors.toMap(AppDeviceVo::getDeviceNo, device -> device));
List<AppDeviceVo> queriedDevices = deviceNos.stream().map(activeDevices::get).filter(Objects::nonNull).toList();
Map<String, AppDeviceVo> devicesByNo = new HashMap<>();
for (AppDeviceVo device : queriedDevices) {
if (device != null && StringUtils.isNotBlank(device.getDeviceNo())) {
@@ -497,9 +523,7 @@ public class AppController extends BaseController {
pageQuery.setOrderByColumn("createTime");
pageQuery.setIsAsc("desc");
TableDataInfo<AppWateringLogVo> appWateringLogVoTableDataInfo = appWateringLogService.queryPageList(bo, pageQuery);
AppDeviceBo appDeviceBo = new AppDeviceBo();
appDeviceBo.setUserId(LoginHelper.getUserId());
List<AppDeviceVo> appDeviceVos = appDeviceService.queryList(appDeviceBo);
List<AppDeviceVo> appDeviceVos = appDeviceService.queryActiveDevices(LoginHelper.getUserId());
List<AppWateringLogVo> wateringLogs = new ArrayList<>();
for (AppWateringLogVo vo : appWateringLogVoTableDataInfo.getRows()){
for(AppDeviceVo appDeviceVo:appDeviceVos){
@@ -519,7 +543,7 @@ public class AppController extends BaseController {
*/
@GetMapping("/latestWaterLog")
public R<Map<String, Object>> latestWaterLog(@RequestParam String deviceNo) {
assertDeviceOwned(deviceNo);
assertActiveDevice(deviceNo);
AppWateringLogBo bo = new AppWateringLogBo();
bo.setUserId(LoginHelper.getUserId());
@@ -732,25 +756,12 @@ public class AppController extends BaseController {
/**
* app用户手机号/邮箱找回忘记密码
*/
@SaIgnore
@ApiEncrypt
@PostMapping("/retrievePassword")
public R<Void> forgot(@RequestBody String body) {
SysUserBo bo = JsonUtils.parseObject(body, SysUserBo.class);
bo.setUserId(LoginHelper.getUserId());
if(Validator.isEmail(bo.getUserName())){
boolean checkPhoneFlag = userService.checkEmailUnique(bo);
if (!checkPhoneFlag){
throw new UserException("user.email.not.username");
}
}else if (Validator.isMobile(bo.getUserName())){
boolean checkPhoneFlag = userService.checkPhoneUnique(bo);
if (!checkPhoneFlag){
throw new UserException("user.mobile.phone.number.not.username");
}
}
return toAjax(userService.updateUserPas(bo));
public R<Void> forgot(@Validated @RequestBody AppForgotPasswordBo request) {
return toAjax(userService.resetPasswordByVerificationCode(
request.getUsername(), request.getCode(), request.getPassword()));
}
private String formatSecondTime(Date time) {
@@ -760,20 +771,20 @@ public class AppController extends BaseController {
.format(SECOND_TIME_FORMATTER);
}
private AppDeviceVo getOwnedDevice(String deviceNo) {
private AppDeviceVo getActiveDevice(String deviceNo) {
if (StringUtils.isBlank(deviceNo)) {
throw new ServiceException("app.device.no.not.blank");
}
AppDeviceVo device = appDeviceService.queryById(deviceNo);
Long userId = LoginHelper.getUserId();
if (device == null || !Objects.equals(device.getUserId(), userId)) {
AppDeviceVo device = appDeviceService.queryActiveDevice(userId, deviceNo);
if (device == null) {
throw new ServiceException("app.device.not.exists.or.denied");
}
return device;
}
private void assertDeviceOwned(String deviceNo) {
getOwnedDevice(deviceNo);
private void assertActiveDevice(String deviceNo) {
getActiveDevice(deviceNo);
}
private AppScheduleVo getOwnedSchedule(Long scheduleId) {
@@ -798,7 +809,8 @@ public class AppController extends BaseController {
}
AppWateringLogVo log = appWateringLogService.queryById(id);
Long userId = LoginHelper.getUserId();
if (log == null || !Objects.equals(log.getUserId(), userId)) {
if (log == null || !Objects.equals(log.getUserId(), userId)
|| appDeviceService.queryActiveDevice(userId, log.getDeviceNo()) == null) {
throw new ServiceException("app.watering.log.not.exists.or.denied");
}
}
@@ -825,7 +837,7 @@ public class AppController extends BaseController {
for (String deviceNo : deviceNos) {
try {
deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails));
buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
} catch (Exception e) {
log.warn("[排程通知] 下发排程更新失败 排程ID={} 设备编号={} 原因={}",
scheduleId, deviceNo, e.getMessage(), e);
@@ -844,10 +856,10 @@ public class AppController extends BaseController {
for (String deviceNo : deviceNos) {
try {
if ("0".equals(status)) {
deviceCommandService.sendScheduleCanceledCommand(deviceNo, scheduleId);
deviceCommandService.sendScheduleCanceledCommand(deviceNo, scheduleId, LoginHelper.getUserId());
} else {
deviceCommandService.sendScheduleBindCommand(deviceNo,
buildScheduleBindPayload(deviceNo, scheduleDetails));
buildScheduleBindPayload(deviceNo, scheduleDetails), LoginHelper.getUserId());
}
} catch (Exception e) {
log.warn("[排程通知] 下发排程状态变更失败 排程ID={} 状态={} 设备编号={} 原因={}",
@@ -866,7 +878,7 @@ public class AppController extends BaseController {
private void notifyScheduleUnbind(Long scheduleId, List<String> deviceNos) {
for (String deviceNo : deviceNos) {
try {
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleId);
deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleId, LoginHelper.getUserId());
} catch (Exception e) {
log.warn("[排程通知] 下发排程解绑失败 排程ID={} 设备编号={} 原因={}",
scheduleId, deviceNo, e.getMessage(), e);

View File

@@ -2,16 +2,23 @@ package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.vo.AppDeviceImportVo;
import org.dromara.app.domain.vo.AppDeviceVo;
import org.dromara.app.domain.vo.DashboardStatsVo;
import org.dromara.app.listener.AppDeviceImportListener;
import org.dromara.app.service.IAppDeviceService;
import org.dromara.app.service.IDeviceCommandService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.file.FileUtils;
import org.dromara.common.core.validate.AddGroup;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.excel.core.ExcelResult;
import org.dromara.common.excel.utils.ExcelUtil;
import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log;
@@ -19,9 +26,14 @@ import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
@@ -38,6 +50,7 @@ import java.util.List;
public class AppDeviceController extends BaseController {
private final IAppDeviceService appDeviceService;
private final IDeviceCommandService deviceCommandService;
/**
* 查询设备信息
@@ -70,6 +83,27 @@ public class AppDeviceController extends BaseController {
ExcelUtil.exportExcel(list, "设备信息 ", AppDeviceVo.class, response);
}
/**
* 导入设备数据
*
* @param file 导入文件
*/
@SaCheckPermission("app:device:import")
@Log(title = "设备信息", businessType = BusinessType.IMPORT)
@PostMapping(value = "/importData", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<Void> importData(@RequestPart("file") MultipartFile file) throws Exception {
ExcelResult<AppDeviceImportVo> excelResult = ExcelUtil.importExcel(file.getInputStream(), AppDeviceImportVo.class, new AppDeviceImportListener());
return R.ok(excelResult.getAnalysis());
}
/**
* 获取设备导入模板
*/
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
ExcelUtil.exportExcel(new ArrayList<>(), "设备数据", AppDeviceImportVo.class, response);
}
/**
* 获取设备信息
详细信息
@@ -122,6 +156,50 @@ public class AppDeviceController extends BaseController {
return toAjax(appDeviceService.generateQrCode(List.of(ids)));
}
/**
* 批量下载设备二维码 ZIP。
*
* @param deviceNos 逗号分隔的设备编号
*/
@SaCheckPermission("app:device:edit")
@Log(title = "设备二维码", businessType = BusinessType.EXPORT)
@PostMapping("/downloadQrCodes")
public void downloadQrCodes(HttpServletResponse response,
@NotEmpty(message = "设备编号不能为空") @RequestParam("deviceNos") String deviceNos) throws IOException {
List<String> ids = Arrays.stream(deviceNos.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.toList();
response.reset();
FileUtils.setAttachmentResponseHeader(response, "device_qrcodes.zip");
response.setContentType("application/zip");
appDeviceService.downloadQrCodes(ids, response.getOutputStream());
}
/**
* 后台解绑设备:清理用户数据并保留设备主档,支持跨用户批量操作。
*/
@SaCheckPermission("app:device:unbind")
@Log(title = "设备解绑", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/unbind/{deviceNos}")
public R<Void> unbind(@NotEmpty(message = "设备编号不能为空")
@PathVariable String[] deviceNos) {
return toAjax(appDeviceService.unbindDevicesByAdmin(List.of(deviceNos)));
}
/**
* 后台向设备下发恢复出厂命令,不修改服务端设备关系数据。
*/
@SaCheckPermission("app:device:edit")
@Log(title = "设备恢复出厂", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/factory-reset/{deviceNo}")
public R<String> factoryReset(@NotBlank(message = "设备编号不能为空")
@PathVariable String deviceNo) {
return R.ok(deviceCommandService.sendFactoryResetCommand(deviceNo));
}
/**
* 删除设备信息

View File

@@ -0,0 +1,141 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaIgnore;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import org.dromara.app.domain.bo.AppFirmwareBo;
import org.dromara.app.domain.vo.AppFirmwareVo;
import org.dromara.app.service.IAppFirmwareService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.validate.EditGroup;
import org.dromara.common.idempotent.annotation.RepeatSubmit;
import org.dromara.common.log.annotation.Log;
import org.dromara.common.log.enums.BusinessType;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Arrays;
import java.util.List;
/**
* 设备固件版本
*
* @author water team
*/
@Validated
@RequiredArgsConstructor
@RestController
@RequestMapping("/app/firmware")
public class AppFirmwareController extends BaseController {
private final IAppFirmwareService appFirmwareService;
/**
* 查询固件版本列表
*/
@SaCheckPermission("app:firmware:list")
@GetMapping("/list")
public TableDataInfo<AppFirmwareVo> list(AppFirmwareBo bo, PageQuery pageQuery) {
return appFirmwareService.queryPageList(bo, pageQuery);
}
/**
* 上传固件 bin 文件并保存固件版本
*/
@SaCheckPermission("app:firmware:add")
@Log(title = "固件版本", businessType = BusinessType.INSERT)
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<AppFirmwareVo> upload(@RequestPart("file") MultipartFile file, AppFirmwareBo bo) {
return R.ok(appFirmwareService.uploadFirmware(file, bo));
}
/**
* 供设备下载服务器本地保存的固件。
*/
@SaIgnore
@GetMapping(value = "/download/{storedName:.+}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Resource> download(@PathVariable String storedName) throws Exception {
Resource resource = appFirmwareService.loadFirmware(storedName);
return ResponseEntity.ok()
.contentLength(resource.contentLength())
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(storedName).build().toString())
.body(resource);
}
/**
* 获取固件版本详情
*/
@SaCheckPermission("app:firmware:query")
@GetMapping("/{id}")
public R<AppFirmwareVo> getInfo(@NotNull(message = "主键不能为空")
@PathVariable Long id) {
return R.ok(appFirmwareService.queryById(id));
}
/**
* 修改固件版本
*/
@SaCheckPermission("app:firmware:edit")
@Log(title = "固件版本", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PutMapping()
public R<Void> edit(@Validated(EditGroup.class) @RequestBody AppFirmwareBo bo) {
return toAjax(appFirmwareService.updateByBo(bo));
}
/**
* 删除固件版本
*/
@SaCheckPermission("app:firmware:remove")
@Log(title = "固件版本", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public R<Void> remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ids) {
return toAjax(appFirmwareService.deleteWithValidByIds(List.of(ids), true));
}
/**
* 选择设备升级
*/
@SaCheckPermission("app:firmware:upgrade")
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/upgrade")
public R<Integer> upgrade(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId,
@NotEmpty(message = "设备编号不能为空") @RequestParam String deviceNos) {
List<String> nos = parseDeviceNos(deviceNos);
return R.ok(appFirmwareService.upgrade(firmwareId, nos));
}
/**
* 全部在线设备升级
*/
@SaCheckPermission("app:firmware:upgrade")
@Log(title = "固件升级", businessType = BusinessType.UPDATE)
@RepeatSubmit()
@PostMapping("/upgradeAll")
public R<Integer> upgradeAll(@NotNull(message = "固件版本ID不能为空") @RequestParam Long firmwareId) {
return R.ok(appFirmwareService.upgradeAll(firmwareId));
}
private List<String> parseDeviceNos(String deviceNos) {
return Arrays.stream(deviceNos.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.distinct()
.toList();
}
}

View File

@@ -23,6 +23,7 @@ public class AppDevice extends BaseEntity {
private Long userId;
private String deviceName;
private String deviceInitName;
private String deviceImg;
@@ -35,6 +36,8 @@ public class AppDevice extends BaseEntity {
private String powerLevel;
private String powerStatus;
private Date powerLevelUpdatatime;
private String wifiName;

View File

@@ -0,0 +1,34 @@
package org.dromara.app.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_device_binding")
public class AppDeviceBinding extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId("id")
private Long id;
private String deviceNo;
private Long userId;
private String bindingId;
private String bindingStatus;
private Date bindingStartedTime;
private Date lastBindCommandTime;
private String deviceNameSnapshot;
private Date unboundTime;
private String unbindSource;
private String unbindCommandId;
private Integer cleanupAttempts;
private String cleanupError;
}

View File

@@ -0,0 +1,29 @@
package org.dromara.app.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_device_unbind_command")
public class AppDeviceUnbindCommand extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId("id")
private Long id;
private String deviceNo;
private String commandId;
private String bindingId;
private String commandStatus;
private String message;
private Integer attemptCount;
private Date completedTime;
}

View File

@@ -0,0 +1,61 @@
package org.dromara.app.domain;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.io.Serial;
/**
* 设备固件版本对象 app_firmware
*
* @author water team
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("app_firmware")
public class AppFirmware extends BaseEntity {
@Serial
private static final long serialVersionUID = 1L;
@TableId(value = "id")
private Long id;
/**
* 固件版本号
*/
private String firmwareVersion;
/**
* 原始文件名
*/
private String fileName;
/**
* 固件下载地址
*/
private String fileUrl;
/**
* 文件大小(字节)
*/
private Long fileSize;
/**
* MD5校验值
*/
private String md5;
/**
* 更新说明
*/
private String releaseNotes;
/**
* 状态 0停用 1启用
*/
private String status;
}

View File

@@ -0,0 +1,17 @@
package org.dromara.app.domain;
import java.util.Set;
public final class DeviceBindingStatus {
public static final String BINDING = "BINDING";
public static final String ACTIVE = "ACTIVE";
public static final String BIND_FAILED = "BIND_FAILED";
public static final String UNBINDING = "UNBINDING";
public static final String UNBOUND = "UNBOUND";
public static final Set<String> EXCLUSIVE = Set.of(BINDING, ACTIVE, UNBINDING);
private DeviceBindingStatus() {
}
}

View File

@@ -0,0 +1,12 @@
package org.dromara.app.domain;
public final class DeviceUnbindCommandStatus {
public static final String PROCESSING = "PROCESSING";
public static final String APPLIED = "APPLIED";
public static final String STALE = "STALE";
public static final String FAILED = "FAILED";
private DeviceUnbindCommandStatus() {
}
}

View File

@@ -0,0 +1,11 @@
package org.dromara.app.domain;
public final class DeviceUnbindSource {
public static final String DEVICE = "DEVICE";
public static final String APP = "APP";
public static final String ADMIN = "ADMIN";
private DeviceUnbindSource() {
}
}

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