From 70e2356f22ab49bb728ac7ed545bdd83d6dfe191 Mon Sep 17 00:00:00 2001 From: yuhaiming <40624989@qq.com> Date: Fri, 17 Jul 2026 08:20:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20=E4=BF=AE=E5=A4=8D=20AppController?= =?UTF-8?q?=20=E5=AE=89=E5=85=A8=E4=B8=8E=E6=9F=A5=E8=AF=A2=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划 --- .gitignore | 1 + .idea/compiler.xml | 12 - docs/AppController-API.md | 1820 +++++++++++++++++ docs/code-review-standards.md | 632 ++++++ docs/mqtt-embedded-communication.md | 48 +- docs/mqtt-spec.md | 67 +- docs/project-architecture.md | 4 +- ...6-07-16-app-controller-concurrency-test.md | 61 + ...07-16-app-controller-review-remediation.md | 689 +++++++ overview.md | 41 + .../omen-cat/reference/omen-cat-source.png | Bin 0 -> 68823 bytes tmp/hatch-pet/omen-cat/run/imagegen-jobs.json | 595 ++++++ tmp/hatch-pet/omen-cat/run/pet_request.json | 280 +++ .../omen-cat/run/prompts/base-pet.md | 7 + .../run/prompts/look-anchor-repairs/000.md | 7 + .../run/prompts/look-anchor-repairs/090.md | 7 + .../run/prompts/look-anchor-repairs/180.md | 7 + .../run/prompts/look-anchor-repairs/270.md | 7 + .../omen-cat/run/prompts/look-cardinals.md | 11 + .../run/prompts/row-retries/failed.md | 14 + .../omen-cat/run/prompts/row-retries/idle.md | 18 + .../run/prompts/row-retries/jumping.md | 14 + .../run/prompts/row-retries/look-row-10.md | 28 + .../run/prompts/row-retries/look-row-9.md | 28 + .../run/prompts/row-retries/review.md | 13 + .../run/prompts/row-retries/running-left.md | 15 + .../run/prompts/row-retries/running-right.md | 15 + .../run/prompts/row-retries/running.md | 13 + .../run/prompts/row-retries/waiting.md | 13 + .../run/prompts/row-retries/waving.md | 13 + .../omen-cat/run/prompts/rows/failed.md | 18 + .../omen-cat/run/prompts/rows/idle.md | 22 + .../omen-cat/run/prompts/rows/jumping.md | 18 + .../omen-cat/run/prompts/rows/look-row-10.md | 36 + .../omen-cat/run/prompts/rows/look-row-9.md | 36 + .../omen-cat/run/prompts/rows/review.md | 17 + .../omen-cat/run/prompts/rows/running-left.md | 19 + .../run/prompts/rows/running-right.md | 19 + .../omen-cat/run/prompts/rows/running.md | 17 + .../omen-cat/run/prompts/rows/waiting.md | 17 + .../omen-cat/run/prompts/rows/waving.md | 17 + .../run/references/layout-guides/failed.png | Bin 0 -> 2355 bytes .../run/references/layout-guides/idle.png | Bin 0 -> 1942 bytes .../run/references/layout-guides/jumping.png | Bin 0 -> 1740 bytes .../layout-guides/look-cardinals.png | Bin 0 -> 1522 bytes .../references/layout-guides/look-row-10.png | Bin 0 -> 2355 bytes .../references/layout-guides/look-row-9.png | Bin 0 -> 2355 bytes .../run/references/layout-guides/review.png | Bin 0 -> 1942 bytes .../references/layout-guides/running-left.png | Bin 0 -> 2355 bytes .../layout-guides/running-right.png | Bin 0 -> 2355 bytes .../run/references/layout-guides/running.png | Bin 0 -> 1942 bytes .../run/references/layout-guides/waiting.png | Bin 0 -> 1942 bytes .../run/references/layout-guides/waving.png | Bin 0 -> 1522 bytes .../omen-cat/run/references/reference-01.png | Bin 0 -> 68823 bytes .../src/main/resources/application.yml | 15 +- .../dromara/mqtt/MqttCommandAckService.java | 81 +- .../config/properties/MqttProperties.java | 1 + .../mqtt/MqttCommandAckServiceTest.java | 42 + water-common/water-common-web/pom.xml | 6 + .../web/handler/GlobalExceptionHandler.java | 26 +- .../handler/GlobalExceptionHandlerTest.java | 89 + .../dromara/app/controller/AppController.java | 779 ++++--- .../dromara/app/domain/vo/AppScheduleVo.java | 10 +- .../app/handler/DeviceDataHandler.java | 9 +- .../app/handler/DeviceRegisterHandler.java | 4 +- .../app/handler/DeviceStatusHandler.java | 53 +- .../app/handler/MqttDeviceStatusService.java | 2 +- .../app/service/IAppDeviceService.java | 8 + .../service/IAppSchedulingDeviceService.java | 9 + .../service/impl/AppDeviceServiceImpl.java | 10 +- .../service/impl/AppScheduleServiceImpl.java | 14 +- .../impl/AppSchedulingDeviceServiceImpl.java | 17 + .../app/task/DeviceOfflineCheckTask.java | 14 +- .../AppControllerConcurrencyTest.java | 306 +++ .../app/controller/AppControllerTest.java | 254 ++- .../app/handler/DeviceDataHandlerTest.java | 64 + .../handler/DeviceRegisterHandlerTest.java | 26 +- .../app/handler/DeviceStatusHandlerTest.java | 17 +- .../handler/MqttDeviceStatusServiceTest.java | 4 +- .../impl/AppDeviceServiceImplTest.java | 17 + .../AppSchedulingDeviceServiceImplTest.java | 40 + .../app/task/DeviceOfflineCheckTaskTest.java | 20 +- 82 files changed, 6070 insertions(+), 583 deletions(-) create mode 100644 docs/AppController-API.md create mode 100644 docs/code-review-standards.md create mode 100644 docs/superpowers/plans/2026-07-16-app-controller-concurrency-test.md create mode 100644 docs/superpowers/plans/2026-07-16-app-controller-review-remediation.md create mode 100644 overview.md create mode 100644 tmp/hatch-pet/omen-cat/reference/omen-cat-source.png create mode 100644 tmp/hatch-pet/omen-cat/run/imagegen-jobs.json create mode 100644 tmp/hatch-pet/omen-cat/run/pet_request.json create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/base-pet.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/look-anchor-repairs/000.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/look-anchor-repairs/090.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/look-anchor-repairs/180.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/look-anchor-repairs/270.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/look-cardinals.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/failed.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/idle.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/jumping.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/look-row-10.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/look-row-9.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/review.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/running-left.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/running-right.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/running.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/waiting.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/row-retries/waving.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/failed.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/idle.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/jumping.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/look-row-10.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/look-row-9.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/review.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/running-left.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/running-right.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/running.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/waiting.md create mode 100644 tmp/hatch-pet/omen-cat/run/prompts/rows/waving.md create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/failed.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/idle.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/jumping.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/look-cardinals.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/look-row-10.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/look-row-9.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/review.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/running-left.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/running-right.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/running.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/waiting.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/layout-guides/waving.png create mode 100644 tmp/hatch-pet/omen-cat/run/references/reference-01.png create mode 100644 water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java create mode 100644 water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java create mode 100644 water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceDataHandlerTest.java create mode 100644 water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java diff --git a/.gitignore b/.gitignore index a874fb2..a677129 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,4 @@ # OS .DS_Store Thumbs.db +/docs/ diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 2617d3d..84f75d9 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -25,18 +25,6 @@ - - - - - - - - - - - - diff --git a/docs/AppController-API.md b/docs/AppController-API.md new file mode 100644 index 0000000..912967d --- /dev/null +++ b/docs/AppController-API.md @@ -0,0 +1,1820 @@ +# Water IoT APP 接口文档 + +> **业务接口 Base URL**: `/app/v1` +> **认证接口前缀**: `/auth`、`/resource` +> **认证方式**: Sa-Token;`/app/v1/**` 业务接口需携带 `Authorization: Bearer `,登录/注册/普通验证码接口免登录 +> **响应编码**: UTF-8 +> **最后更新**: 2026-07-14 + +--- + +## 目录 + +- [通用说明](#通用说明) + - [统一响应结构 R\](#统一响应结构-rt) + - [分页响应结构 TableDataInfo\](#分页响应结构-tabledatainfot) + - [分页请求参数](#分页请求参数) + - [特殊标注说明](#特殊标注说明) +- [零、登录、注册与验证码](#零登录注册与验证码) + - [0.1 获取租户列表](#01-获取租户列表) + - [0.2 获取图形验证码](#02-获取图形验证码) + - [0.3 获取通用手机号或邮箱验证码](#03-获取通用手机号或邮箱验证码) + - [0.4 获取短信验证码](#04-获取短信验证码) + - [0.5 获取邮箱验证码](#05-获取邮箱验证码) + - [0.6 用户登录](#06-用户登录) + - [0.7 用户注册](#07-用户注册) + - [0.8 忘记密码](#08-忘记密码) + - [0.9 退出登录](#09-退出登录) + - [0.10 获取账号注销验证码](#010-获取账号注销验证码) + - [0.11 注销账号](#011-注销账号) +- [一、设备管理](#一设备管理) + - [1.1 查询设备列表(分页)](#11-查询设备列表分页) + - [1.2 绑定设备状态检查](#12-绑定设备状态检查) + - [1.3 绑定已上线设备](#13-绑定已上线设备) + - [1.4 获取设备详情](#14-获取设备详情) + - [1.5 修改设备信息](#15-修改设备信息) + - [1.6 删除设备(解绑)](#16-删除设备解绑) + - [1.7 手动开关设备](#17-手动开关设备) +- [二、排程管理](#二排程管理) + - [2.1 查询排程列表(分页)](#21-查询排程列表分页) + - [2.2 新增排程](#22-新增排程) + - [2.3 排程详情](#23-排程详情) + - [2.4 修改排程状态](#24-修改排程状态) + - [2.5 修改排程](#25-修改排程) + - [2.6 删除排程](#26-删除排程) +- [三、排程-设备关联](#三排程-设备关联) + - [3.1 查询排程可关联的设备](#31-查询排程可关联的设备) + - [3.2 绑定排程与设备](#32-绑定排程与设备) + - [3.3 删除排程与设备绑定](#33-删除排程与设备绑定) +- [四、浇水记录](#四浇水记录) + - [4.1 查询浇水记录列表(分页)](#41-查询浇水记录列表分页) + - [4.2 查询设备最新进行中的浇水记录](#42-查询设备最新进行中的浇水记录) + - [4.3 删除浇水记录](#43-删除浇水记录) +- [五、数据统计](#五数据统计) + - [5.1 数据统计](#51-数据统计) +- [六、用户管理](#六用户管理) + - [6.1 获取用户信息](#61-获取用户信息) + - [6.2 修改用户信息](#62-修改用户信息) + - [6.3 修改登录密码](#63-修改登录密码) + - [6.4 找回密码](#64-找回密码) +- [七、文件上传](#七文件上传) + - [7.1 上传图片](#71-上传图片) +- [八、版本检查](#八版本检查) + - [8.1 检查APP版本更新](#81-检查app版本更新) +- [九、内部调试接口](#九内部调试接口) + - [9.1 MQTT绑定命令测试](#91-mqtt绑定命令测试) +- [附录:数据模型](#附录数据模型) + +--- + +## 通用说明 + +### 统一响应结构 R\ + +大多数接口返回统一的 `R` 包装结构: + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | `int` | `200` 表示成功,`500` 表示失败 | +| `msg` | `string` | 提示消息(支持 i18n 国际化) | +| `data` | `T` | 业务数据,类型视接口而定;无数据时为 `null` | + +**判断逻辑**:`code === 200` 为成功,其余均为失败。 + +--- + +### 分页响应结构 TableDataInfo\ + +列表查询接口返回 `TableDataInfo`: + +```json +{ + "code": 200, + "msg": "查询成功", + "total": 100, + "rows": [ ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | `int` | `200` 表示成功 | +| `msg` | `string` | 提示消息 | +| `total` | `long` | 总记录数 | +| `rows` | `T[]` | 当前页数据列表 | + +--- + +### 分页请求参数 + +所有分页查询接口(GET 请求)支持以下 Query 参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pageNum` | `int` | 否 | 当前页码,默认 `1` | +| `pageSize` | `int` | 否 | 每页条数,默认查全部 | + +> **注意**:设备列表、排程列表、浇水记录列表的排序方向固定为 `createTime desc`(服务端强制设置),前端无需传排序参数。 + +--- + +### 特殊标注说明 + +| 标注 | 说明 | +|------|------| +| `@ApiEncrypt` | 启用 `api-decrypt` 后强制请求体加密;默认配置关闭时仍使用普通 JSON | +| `@RepeatSubmit` | 防重复提交,相同请求在短时间内只能提交一次 | +| `@Log` | 操作会被记录到系统日志 | + +> 当前默认配置 `api-decrypt.enabled=false`,因此 `@ApiEncrypt` 接口可直接发送普通 JSON。 +> 当部署环境启用接口加密后,标注 `@ApiEncrypt` 的 POST/PUT 接口必须携带 `encrypt-key` 请求头并发送加密后的 body;该标注不代表免登录。 + +--- + +## 零、登录、注册与验证码 + +本章节接口来自 `AuthController` 和 `CaptchaController`。两个控制器使用了 `@SaIgnore`,普通登录、注册和验证码接口免登录;退出登录、账号注销验证码和账号注销仍会在方法内部校验 Token。 + +默认 APP 客户端配置: + +| 参数 | 默认值 | 说明 | +|------|------|------| +| `clientId` | `428a8310cd442757ae699df5d894f051` | APP 客户端 ID,来源于 `sys_client` 初始化数据 | +| `tenantId` | `000000` | 默认租户 ID;关闭多租户时仍可沿用该值 | +| `userType` | `app_user` | APP 注册用户类型 | +| `grantType` | `password` / `sms` | 默认 APP 客户端支持密码、短信和社交授权 | + + + +--- + +### 0.1 获取图形验证码 + +``` +GET /auth/code +``` + +**认证**:免登录。验证码开启时按 IP 限流,每 60 秒最多 10 次。 + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "captchaEnabled": true, + "uuid": "54ef90b79e384d29a44d59f863f83a52", + "img": "data:image/png;base64,..." + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `captchaEnabled` | `boolean` | 是否启用图形验证码 | +| `uuid` | `string` | 验证码缓存标识 | +| `img` | `string` | Base64 图片数据 | + +> 当前密码登录和注册服务中的图形验证码校验代码已注释,APP 注册实际使用手机号/邮箱验证码。 + +--- + +### 0.2 获取通用手机号或邮箱验证码 + +根据 `username` 格式自动选择短信或邮件发送,主要用于注册和找回密码。 + +``` +GET /resource/code?username={username} +``` + +**认证**:免登录。相同 `username` 每 60 秒最多请求 1 次。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `username` | `string` | **是** | 手机号或邮箱;手机号发送短信,其他值按邮箱发送 | + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "123456", + "data": null +} +``` + +> 当前实现会把验证码同时放入响应 `msg`,生产环境存在验证码泄露风险,建议生产部署时改为固定提示文本。 + +--- + +### 0.3 获取短信验证码 + +用于短信验证码登录。 + +``` +GET /resource/sms/code?phonenumber={phonenumber} +``` + +**认证**:免登录。相同手机号每 60 秒最多请求 1 次。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `phonenumber` | `string` | **是** | 接收验证码的手机号 | + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +--- + +### 0.4 获取邮箱验证码 + +``` +GET /resource/email/code?email={email} +``` + +**认证**:免登录。相同邮箱每 60 秒最多请求 1 次。 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `email` | `string` | **是** | 接收验证码的邮箱地址 | + +**响应**:`R`。未开启邮件功能时返回 `当前系统没有开启邮箱功能!`。 + +--- + +### 0.5 用户登录 + +``` +POST /auth/login +``` + +**认证**:免登录。接口标注 `@ApiEncrypt`,加密规则参见通用说明。 + +**密码登录请求体**(`grantType=password`) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `clientId` | `string` | **是** | APP 客户端 ID | +| `grantType` | `string` | **是** | 固定为 `password` | +| `username` | `string` | **是** | 用户名、手机号或邮箱,长度 2~30 | +| `password` | `string` | **是** | 密码,长度 5~30 | + +```json +{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "password", + "username": "13800000000", + "password": "123456" +} +``` + +**短信登录请求体**(`grantType=sms`) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `clientId` | `string` | **是** | APP 客户端 ID | +| `grantType` | `string` | **是** | 固定为 `sms` | +| `phonenumber` | `string` | **是** | 手机号 | +| `smsCode` | `string` | **是** | `/resource/sms/code` 获取的验证码 | + +```json +{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "sms", + "phonenumber": "13800000000", + "smsCode": "123456" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "access_token": "登录Token", + "refresh_token": null, + "expire_in": 1800, + "refresh_expire_in": null, + "client_id": "428a8310cd442757ae699df5d894f051", + "scope": null, + "openid": null + } +} +``` + +后续业务请求头: + +```http +Authorization: Bearer 登录Token +``` + +--- + +### 0.6 用户注册 + +``` +POST /auth/register +``` + +**认证**:免登录。接口标注 `@ApiEncrypt`;系统配置必须允许注册。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|-----------------------------------------| +| `clientId` | `string` | **是** | APP 客户端 ID | +| `grantType` | `string` | **是** | 可传 `password、sms`,用于通过基础请求校验 | +| `username` | `string` | **是** | 手机号、邮箱或用户名,长度 2~30 | +| `password` | `string` | **是** | 密码,长度 5~30 | +| `userType` | `string` | **是** | APP 用户固定传 `app_user` | +| `code` | `string` | **是** | 调用 `/resource/code?username=...` 获取的验证码 | + +```json +{ + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "password", + "username": "13800000000", + "password": "123456", + "userType": "app_user", + "code": "123456" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> 注册验证码以 `username` 为缓存键,因此获取验证码和注册时的 `username` 必须完全一致。 + +--- + +### 0.7 忘记密码 + +``` +PUT /auth/forgot +``` + +**认证**:免登录。接口标注 `@ApiEncrypt`。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `username` | `string` | **是** | 已注册的用户名或手机号 | +| `smsCode` | `string` | **是** | 以 `username` 为缓存键的验证码,可通过 `/resource/code` 获取 | +| `password` | `string` | **是** | 新密码,长度 5~30 | + +```json +{ + "username": "13800000000", + "smsCode": "123456", + "password": "NewPass456" +} +``` + +**响应**:`R` + +> **当前实现限制**:邮箱分支查询结果未赋值给用户对象,因此邮箱找回密码当前会被判定为“账号未注册”;建议修复后再开放邮箱找回。 + +--- + +### 0.8 退出登录 + +``` +POST /auth/logout +``` + +**认证**:需要 `Authorization: Bearer `。 + +**响应**:`R`,成功消息为 `退出成功`。 + +--- + +### 0.9 获取账号注销验证码 + +根据当前登录账号优先向已绑定手机号发送短信;没有有效手机号时发送到邮箱。 + +``` +GET /resource/account/cancel/code +``` + +**认证**:需要 `Authorization: Bearer `。每个用户每 60 秒最多请求 1 次。 + +**响应**:`R` + +> 验证码以当前登录用户名为缓存键。账号未绑定手机号或邮箱时返回失败。 + +--- + +### 0.11 注销账号 + +``` +DELETE /auth/account +``` + +**认证**:需要 `Authorization: Bearer `。 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `code` | `string` | **是** | `/resource/account/cancel/code` 获取的验证码 | + +```json +{ + "code": "123456" +} +``` + +**响应**:`R`,成功消息为 `注销成功`。 + +> 注销成功后会解绑并初始化用户名下设备,删除排程、浇水记录及系统用户关联数据,并退出当前账号。该操作不可恢复,超级管理员账号不允许注销。 + +--- + +## 一、设备管理 + +### 1.1 查询设备列表(分页) + +查询当前登录用户名下的设备列表。 + +``` +GET /app/v1/deviceList +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pageNum` | `int` | 否 | 页码,默认 1 | +| `pageSize` | `int` | 否 | 每页条数 | +| `deviceNo` | `string` | 否 | 设备编号(精确查询) | +| `deviceName` | `string` | 否 | 设备名称(模糊查询) | +| `status` | `string` | 否 | 设备状态:`1`-在线 `0`-离线 `2`-到期 `3`-故障 | +| `wifiName` | `string` | 否 | WiFi 名称(模糊查询) | +| `deviceEm` | `string` | 否 | 设备型号(精确查询) | +| `deviceSn` | `string` | 否 | 设备序列号(精确查询) | +| `fwVer` | `string` | 否 | 固件版本(精确查询) | +| `macAddress` | `string` | 否 | MAC 地址(精确查询) | + +**响应**:`TableDataInfo` + +```json +{ + "code": 200, + "msg": "查询成功", + "total": 2, + "rows": [ + { + "deviceNo": "D20260101001", + "userId": 1, + "deviceName": "前院浇灌器", + "deviceInitName": "智能浇灌器Pro", + "deviceImg": "https://oss.example.com/device/xxx.png", + "qrcode": "D20260101001", + "status": "1", + "workStatus": "0", + "powerLevel": "85", + "powerLevelUpdatatime": "2026-07-14 08:30:00", + "wifiName": "Home-WiFi", + "wifiPassword": "12345678", + "deviceEm": "WATER-PRO-1", + "deviceSn": "SN20260101001", + "fwVer": "1.0.3", + "macAddress": "AA:BB:CC:DD:EE:FF", + "nickName": "张三", + "expirationTime": "2027-01-01 00:00:00" + } + ] +} +``` + +--- + +### 1.2 绑定设备状态检查 + +检查设备绑定状态(加密接口)。 + +``` +POST /app/v1/bindDeviceStatus +``` + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**(JSON;启用接口加密时发送加密后的 JSON 字符串) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | 否 | 设备编号 | +| `macAddress` | `string` | 否 | MAC 地址 | +| `deviceInitName` | `string` | 否 | 设备初始名称 | + +以上三个设备标识至少填写一个。查询顺序为 `deviceNo`、`macAddress`、`deviceInitName`。 + +**响应**:`R>` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "bindDeviceStatus": 304, + "bindDeviceStatusName": "设备未绑定,请先绑定用户", + "bindDevice": { + "deviceNo": "D20260101001", + "macAddress": "AA:BB:CC:DD:EE:FF", + "userId": null, + "status": "1", + "workStatus": "2" + } + } +} +``` + +`bindDeviceStatus` 业务状态码: + +| 状态码 | 说明 | +|------|------| +| `200` | 设备已绑定当前用户,可正常使用 | +| `300` | 设备信息为空 | +| `301` | 未提供设备编号、MAC 地址或设备初始名称 | +| `302` | 设备未入库注册 | +| `303` | 设备已被其他用户绑定 | +| `304` | 设备存在但尚未绑定用户,可以继续调用 `/addDevice` | +| `305` | 设备尚未完成配网 | + +> 这些是 `data.bindDeviceStatus` 的业务状态码;接口外层 `code` 在正常处理时仍为 `200`。 + +--- + +### 1.3 绑定已上线设备 + +绑定通过 MQTT 注册上线的设备到当前用户(加密接口)。 + +``` +POST /app/v1/addDevice +``` + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**(JSON;启用接口加密时发送加密后的 JSON 字符串) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | **是** | MQTT 注册后服务端分配的设备编号 | +| `deviceName` | `string` | 否 | 设备名称 | +| `status` | `string` | 否 | 绑定后保留/设置的设备在线状态 | +| `wifiName` | `string` | 否 | WiFi 名称 | +| `wifiPassword` | `string` | 否 | WiFi 密码 | + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "deviceNo": "D20260101001", + "userId": 1, + "deviceName": "前院浇灌器", + "deviceInitName": "智能浇灌器Pro", + "deviceImg": "https://oss.example.com/device/xxx.png", + "status": "1", + "workStatus": "0", + "macAddress": "AA:BB:CC:DD:EE:FF", + "expirationTime": "2027-01-01 00:00:00" + } +} +``` + +--- + +### 1.4 获取设备详情 + +``` +GET /app/v1/device/{deviceNo} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | **是** | 设备编号 | + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "deviceNo": "D20260101001", + "userId": 1, + "deviceName": "前院浇灌器", + "deviceInitName": "智能浇灌器Pro", + "deviceImg": "https://oss.example.com/device/xxx.png", + "qrcode": "D20260101001", + "status": "1", + "workStatus": "0", + "powerLevel": "85", + "powerLevelUpdatatime": "2026-07-14 08:30:00", + "wifiName": "Home-WiFi", + "wifiPassword": "12345678", + "deviceEm": "WATER-PRO-1", + "deviceSn": "SN20260101001", + "fwVer": "1.0.3", + "macAddress": "AA:BB:CC:DD:EE:FF", + "nickName": "张三", + "expirationTime": "2027-01-01 00:00:00" + } +} +``` + +> **权限**:只能查询当前用户名下的设备,否则返回错误。 + +--- + +### 1.5 修改设备信息 + +``` +PUT /app/v1/updateDeviceInfo +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`AppDeviceBo`(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | **是** | 设备编号(EditGroup 校验) | +| `deviceName` | `string` | 否 | 设备名称 | +| `deviceImg` | `string` | 否 | 设备图片 URL | +| `wifiName` | `string` | 否 | WiFi 名称 | +| `wifiPassword` | `string` | 否 | WiFi 密码 | + +```json +{ + "deviceNo": "D20260101001", + "deviceName": "后院浇灌器", + "deviceImg": "https://oss.example.com/device/new.png" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **权限**:只能修改当前用户名下的设备。 + +--- + +### 1.6 删除设备(解绑) + +解绑一个或多个设备。 + +``` +DELETE /app/v1/deleteDevice/{deviceNos} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNos` | `string` | **是** | 设备编号,多个用逗号分隔。例:`D001,D002` | + +**示例** + +``` +DELETE /app/v1/deleteDevice/D20260101001,D20260101002 +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **权限**:只能解绑当前用户名下的设备。 + +--- + +### 1.7 手动开关设备 + +手动开启或关闭设备工作状态,并可通过 MQTT 下发指令到设备。 + +``` +PUT /app/v1/switchDevice +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | **是** | 设备编号 | +| `workStatus` | `string` | **是** | 工作状态:`1`-开启 `0`-关闭 | +| `durationMin` | `int` | 条件必填 | 持续时长(分钟);`workStatus=1` 时必须大于 `0`,关闭时可不传 | + +```json +{ + "deviceNo": "D20260101001", + "workStatus": "1", + "durationMin": 20 +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **说明**:开启时 `startTime` 由服务端按当前时间生成,格式为 `yyyy-MM-dd HH:mm:ss`。 + +--- + +## 二、排程管理 + +### 2.1 查询排程列表(分页) + +``` +GET /app/v1/schedulelist +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pageNum` | `int` | 否 | 页码 | +| `pageSize` | `int` | 否 | 每页条数 | +| `name` | `string` | 否 | 排程名称(模糊查询) | +| `status` | `string` | 否 | 状态:`0`-关闭 `1`-开启 | + +**响应**:`TableDataInfo` + +```json +{ + "code": 200, + "msg": "查询成功", + "total": 1, + "rows": [ + { + "id": 1, + "userId": 1, + "name": "工作日早晨浇水", + "status": "1", + "details": null, + "deviceNos": null + } + ] +} +``` + +> **注意**:列表查询返回的 `details` 和 `deviceNos` 为 `null`,完整信息请通过 [2.3 排程详情](#23-排程详情) 获取。 + +--- + +### 2.2 新增排程 + +``` +POST /app/v1/addschedule +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`AppScheduleBo`(JSON,AddGroup 校验) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | `string` | 否 | 排程名称 | +| `status` | `string` | 否 | 状态:`0`-关闭 `1`-开启 | +| `details` | `array` | **是** | 排程详情列表(至少一天) | + +**`details` 数组元素结构(`AppScheduleDetail`)** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `weekday` | `int` | **是** | 星期几:`1`-周一 ~ `7`-周日 | +| `timeData` | `array` | **是** | 时间段列表 | +| `zones` | `int` | 否 | 浇水区域(位掩码) | +| `triggerType` | `string` | 否 | 触发类型:`0`-排程(默认) | +| `status` | `string` | 否 | 状态:`0`-关闭(默认) | + +**`timeData` 数组元素结构(`TimeSlot`)** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `startTime` | `string` | **是** | 开始时间,格式 `HH:mm` | +| `durationMin` | `int` | 否 | 持续时长(分钟) | + +```json +{ + "name": "工作日早晨浇水", + "status": "1", + "details": [ + { + "weekday": 1, + "timeData": [ + { "startTime": "06:30", "durationMin": 15 }, + { "startTime": "18:00", "durationMin": 20 } + ], + "triggerType": "0", + "status": "1" + }, + { + "weekday": 2, + "timeData": [ + { "startTime": "06:30", "durationMin": 15 } + ], + "triggerType": "0", + "status": "1" + } + ] +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "id": 10, + "userId": 1, + "name": "工作日早晨浇水", + "status": "1", + "details": [ + { + "id": 101, + "weekday": 1, + "timeData": [ + { "startTime": "06:30", "durationMin": 15 }, + { "startTime": "18:00", "durationMin": 20 } + ], + "triggerType": "0", + "status": "1" + } + ], + "deviceNos": null + } +} +``` + +--- + +### 2.3 排程详情 + +获取排程完整信息,包含排程详情列表和已绑定设备列表。 + +``` +GET /app/v1/schedule/{id} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | `long` | **是** | 排程 ID | + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "id": 10, + "userId": 1, + "name": "工作日早晨浇水", + "status": "1", + "details": [ + { + "id": 1, + "weekday": 1, + "timeData": [ + { "startTime": "06:30", "durationMin": 15 }, + { "startTime": "18:00", "durationMin": 20 } + ], + "triggerType": "0", + "status": "1" + } + ], + "deviceNos": [ + { + "deviceNo": "D20260101001", + "userId": 1, + "deviceName": "前院浇灌器", + "status": "1", + "workStatus": "0" + } + ] + } +} +``` + +> **说明**: +> - `details` 返回该排程当前查询到的全部明细,不在控制器中额外过滤状态。 +> - `deviceNos` 为已绑定设备的完整 `AppDeviceVo` 列表。 +> - `timeData` 已从 JSON 字符串解析为数组。 + +--- + +### 2.4 修改排程状态 + +开启或关闭排程,状态变更后通过 MQTT 通知所有已绑定设备。 + +``` +PUT /app/v1/editScheduleStatus +``` + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`AppScheduleBo`(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | `long` | **是** | 排程 ID | +| `status` | `string` | **是** | 目标状态:`0`-关闭 `1`-开启 | + +```json +{ + "id": 10, + "status": "0" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **MQTT 通知**: +> - 关闭(`status=0`):向绑定设备下发排程取消指令。 +> - 开启(`status=1`):向绑定设备重新下发完整排程信息。 + +--- + +### 2.5 修改排程 + +修改排程内容,更新后通过 MQTT 向所有绑定设备重新下发排程。 + +``` +PUT /app/v1/updataschedule +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`AppScheduleBo`(JSON,EditGroup 校验) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | `long` | **是** | 排程 ID(EditGroup 校验) | +| `name` | `string` | 否 | 排程名称 | +| `status` | `string` | 否 | 状态:`0`-关闭 `1`-开启 | +| `details` | `array` | 否 | 排程详情列表(同 [2.2 新增排程](#22-新增排程)) | + +```json +{ + "id": 10, + "name": "周末浇水计划", + "status": "1", + "details": [ + { + "id": 101, + "weekday": 6, + "timeData": [ + { "startTime": "08:00", "durationMin": 30 } + ], + "triggerType": "0", + "status": "1" + } + ] +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "id": 10, + "userId": 1, + "name": "周末浇水计划", + "status": "1", + "details": [ ] + } +} +``` + +> **说明**:`data` 为更新后的 `AppScheduleVo` 对象。修改已有排程明细时应传明细 `id`,否则服务端无法按主键更新该明细。 + +--- + +### 2.6 删除排程 + +删除一个或多个排程,并通知所有绑定设备解绑。 + +``` +DELETE /app/v1/deleteschedule/{ids} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `ids` | `long` | **是** | 排程 ID,多个用逗号分隔。例:`10,11` | + +**示例** + +``` +DELETE /app/v1/deleteschedule/10,11 +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **MQTT 通知**:删除成功后,向每个排程关联的所有设备下发解绑指令。 + +--- + +## 三、排程-设备关联 + +### 3.1 查询排程可关联的设备 + +查询当前用户名下**未被其他排程关联**的设备列表。 + +``` +GET /app/v1/scheduleDeviceList/{scheduleId} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `scheduleId` | `long` | **是** | 排程 ID | + +**响应**:`R>` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "deviceList": [ + { + "deviceNo": "D20260101003", + "userId": 1, + "deviceName": "阳台浇灌器", + "status": "1", + "workStatus": "0" + } + ] + } +} +``` + +> **说明**:返回的设备列表为当前用户名下未与任何排程关联的设备。已关联其他排程的设备不会出现在列表中。 + +--- + +### 3.2 绑定排程与设备 + +将一个或多个设备绑定到指定排程,绑定后通过 MQTT 向设备下发排程信息。 + +``` +POST /app/v1/addScheduleDevice +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `scheduleId` | `long` | **是** | 排程 ID | +| `deviceNos` | `array` | **是** | 设备编号列表。也可用 `deviceNo`(逗号分隔字符串)或 `deviceIds` | + +```json +{ + "scheduleId": 10, + "deviceNos": ["D20260101001", "D20260101003"] +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **说明**: +> - `deviceNos` 支持数组或逗号分隔字符串格式。 +> - 已绑定该排程的设备会自动跳过(幂等)。 +> - 绑定后,服务端通过 MQTT 向设备下发排程详情。 + +--- + +### 3.3 删除排程与设备绑定 + +解除排程与设备的绑定关系,并通知设备取消排程。 + +``` +DELETE /app/v1/deleteScheduleDevice +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `scheduleId` | `string` | **是** | 排程 ID | +| `deviceNo` | `string` | **是** | 设备编号 | + +**示例** + +``` +DELETE /app/v1/deleteScheduleDevice?scheduleId=10&deviceNo=D20260101001 +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **MQTT 通知**:解绑成功后,向设备下发排程解绑指令。若 MQTT 下发失败,不影响接口返回成功(仅记录日志警告)。 + +--- + +## 四、浇水记录 + +### 4.1 查询浇水记录列表(分页) + +``` +GET /app/v1/waterLogList +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pageNum` | `int` | 否 | 页码 | +| `pageSize` | `int` | 否 | 每页条数 | +| `deviceNo` | `string` | 否 | 设备编号 | +| `commandId` | `string` | 否 | MQTT 命令 ID | +| `scheduleId` | `long` | 否 | 关联排程 ID | +| `startTime` | `date` | 否 | 开始时间(精确匹配) | +| `endTime` | `date` | 否 | 结束时间(精确匹配) | +| `durationMin` | `string` | 否 | 持续时间(精确匹配) | +| `zones` | `string` | 否 | 浇水区域 | +| `triggerType` | `string` | 否 | 触发类型:`0`-排程 `1`-手动 | +| `remark` | `string` | 否 | 备注(模糊匹配) | +| `params[beginTime]` | `date` | 否 | 创建时间范围开始;需与 `params[endTime]` 同时传入 | +| `params[endTime]` | `date` | 否 | 创建时间范围结束;需与 `params[beginTime]` 同时传入 | + +**响应**:`TableDataInfo` + +```json +{ + "code": 200, + "msg": "查询成功", + "total": 50, + "rows": [ + { + "id": 1, + "userId": 1, + "deviceNo": "D20260101001", + "deviceName": "前院浇灌器", + "commandId": "CMD_20260714001", + "scheduleId": 10, + "startTime": "2026-07-14 06:30:00", + "endTime": "2026-07-14 06:45:00", + "durationMin": "15", + "zones": "1", + "triggerType": "0", + "remark": null, + "status": "0", + "createTime": "2026-07-14 06:30:00" + } + ] +} +``` + +> **说明**:响应中 `deviceName` 由服务端关联查询后填充。 + +--- + +### 4.2 查询设备最新进行中的浇水记录 + +``` +GET /app/v1/latestWaterLog +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `deviceNo` | `string` | **是** | 设备编号 | + +**响应**:`R>` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "startTime": "2026-07-14 08:00:00", + "endTime": "2026-07-14 08:20:00" + } +} +``` + +> **说明**:控制器按当前用户、设备编号和状态 `1` 发起查询,并取 ID 最大的一条记录。若无进行中的记录,`data` 为 `null`。时间格式为 `yyyy-MM-dd HH:mm:ss`。 +> +> **当前实现限制**:`AppWateringLogServiceImpl.buildQueryWrapper` 尚未把 `status` 加入查询条件,因此现有代码实际可能取到该设备 ID 最大但已结束的记录。客户端依赖“进行中”语义前,应先修复该服务查询条件。 + +--- + +### 4.3 删除浇水记录 + +``` +DELETE /app/v1/waterLog/{ids} +``` + +**路径参数** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `ids` | `long` | **是** | 记录 ID,多个用逗号分隔。例:`1,2,3` | + +**示例** + +``` +DELETE /app/v1/waterLog/1,2,3 +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **权限**:只能删除当前用户的浇水记录。 + +--- + +## 五、数据统计 + +### 5.1 数据统计 + +获取当前用户的浇水数据统计汇总。 + +``` +GET /app/v1/dataStatistics +``` + +**请求参数**:无 + +**响应**:`R>` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "count": 120, + "monCount": 15, + "weekCount": 4, + "schedule": 80, + "manualNum": 40, + "allTime": 3600, + "averageTime": 30 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `count` | `long` | 总浇水次数 | +| `monCount` | `long` | 本月浇水次数 | +| `weekCount` | `long` | 本周浇水次数 | +| `schedule` | `long` | 排程触发浇水次数 | +| `manualNum` | `long` | 手动触发浇水次数 | +| `allTime` | `long` | 总浇水时间(分钟) | +| `averageTime` | `long` | 平均每次浇水时间(分钟) | + +--- + +## 六、用户管理 + +### 6.1 获取用户信息 + +``` +GET /app/v1/getUserInfo +``` + +**请求参数**:无 + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "user": { + "userId": 1, + "deptId": 100, + "userName": "admin", + "nickName": "管理员", + "email": "admin@example.com", + "phonenumber": "138****8888", + "sex": "0", + "avatar": "https://oss.example.com/avatar/xxx.png", + "status": "0", + "loginIp": "192.168.1.1", + "loginDate": "2026-07-14 09:00:00", + "remark": null, + "createTime": "2026-01-01 00:00:00" + }, + "permissions": ["*:*:*"], + "roles": ["admin"] + } +} +``` + +> **说明**:`phonenumber` 和 `email` 字段做了脱敏处理(需 `system:user:edit` 权限才能看到完整内容)。 + +--- + +### 6.2 修改用户信息 + +``` +PUT /app/v1/updataUser +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`SysUserBo`(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `userId` | `long` | **是** | 用户 ID | +| `nickName` | `string` | 否 | 用户昵称(最长 30 字符) | +| `email` | `string` | 否 | 邮箱(需符合邮箱格式,最长 50 字符) | +| `phonenumber` | `string` | 否 | 手机号(需唯一) | +| `code` | `string` | 条件必填 | 修改手机号时使用的短信验证码 | +| `sex` | `string` | 否 | 性别:`0`-男 `1`-女 `2`-未知 | +| `avatar` | `string` | 否 | 头像 URL | + +```json +{ + "userId": 1, + "nickName": "新昵称", + "phonenumber": "13900001111", + "code": "123456", + "email": "new@example.com", + "sex": "0" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **校验**:手机号需全局唯一;请求中包含 `phonenumber` 时,服务层会校验 `code` 短信验证码。 + +--- + +### 6.3 修改登录密码 + +``` +PUT /app/v1/updataPassword +``` + +> `@RepeatSubmit` — 防重复提交 + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**:`SysUserVo`(JSON) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `oldPassword` | `string` | **是** | 旧密码(明文,服务端 BCrypt 校验) | +| `password` | `string` | **是** | 新密码(明文) | + +```json +{ + "oldPassword": "OldPass123", + "password": "NewPass456" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **说明**:旧密码使用 BCrypt 校验,不匹配则返回 `user.password.not.match` 错误。 + +--- + +### 6.4 找回密码 + +通过手机号或邮箱找回密码(加密接口)。 + +``` +POST /app/v1/retrievePassword +``` + +**请求头** + +``` +Content-Type: application/json +``` + +**请求体**(JSON;启用接口加密时发送加密后的 JSON 字符串) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `userName` | `string` | **是** | 用户名(手机号或邮箱) | +| `password` | `string` | **是** | 新密码 | +| `code` | `string` | 条件必填 | `userName` 为手机号时使用的短信验证码 | + +```json +{ + "userName": "13900001111", + "password": "NewPass456", + "code": "123456" +} +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": null +} +``` + +> **校验逻辑**: +> - 若 `userName` 为邮箱格式:校验邮箱是否已注册,未注册则报错 `user.email.not.username`。 +> - 若 `userName` 为手机号格式:校验手机号是否已注册及短信验证码,未注册则报错 `user.mobile.phone.number.not.username`。 +> - 当前实现仍从登录 Token 获取 `userId`,因此该接口不是匿名接口,并且控制器内未校验短信/邮箱验证码。 + +--- + +## 七、文件上传 + +### 7.1 上传图片 + +上传图片到 OSS 对象存储,仅支持图片类型。 + +``` +POST /app/v1/uploadImage +``` + +**请求头** + +``` +Content-Type: multipart/form-data +``` + +**请求参数(FormData)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `file` | `file` | **是** | 图片文件(Content-Type 必须以 `image/` 开头) | + +当前 Spring Multipart 配置允许单文件最大 `100MB`、单次请求最大 `100MB`;实际部署还可能受网关和 OSS 配置限制。 + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "url": "https://oss.example.com/2026/07/14/xxx.png", + "fileName": "screenshot.png", + "ossId": "123456" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `url` | `string` | 文件访问 URL | +| `fileName` | `string` | 原始文件名 | +| `ossId` | `string` | 对象存储记录 ID | + +--- + +## 八、版本检查 + +### 8.1 检查APP版本更新 + +``` +GET /app/v1/checkVersion +``` + +**请求参数(Query)** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `platform` | `string` | 否 | 平台类型:`android`(默认)或 `ios` | +| `currentVersion` | `string` | **是** | APP 当前版本号 | + +**示例** + +``` +GET /app/v1/checkVersion?platform=android¤tVersion=1.0.2 +``` + +**响应**:`R` + +```json +{ + "code": 200, + "msg": "操作成功", + "data": { + "platform": "android", + "currentVersion": "1.0.2", + "latestVersion": "1.0.3", + "versionCode": 3, + "updateAvailable": true, + "forceUpdate": false, + "downloadUrl": "https://oss.example.com/app/water-1.0.3.apk", + "releaseNotes": "1. 修复设备绑定问题\n2. 优化排程功能" + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `platform` | `string` | 平台类型 | +| `currentVersion` | `string` | 客户端当前版本 | +| `latestVersion` | `string` | 服务端最新版本 | +| `versionCode` | `int` | 版本序号(用于排序) | +| `updateAvailable` | `boolean` | 是否有可更新版本 | +| `forceUpdate` | `boolean` | 是否强制更新 | +| `downloadUrl` | `string` | 安装包下载地址 | +| `releaseNotes` | `string` | 更新说明 | + +> 当前版本判断采用版本字符串是否完全相等:`currentVersion` 与 `latestVersion` 不相等时,`updateAvailable=true`,未进行语义化版本大小比较。 + +--- + +## 九、内部调试接口 + +### 9.1 MQTT绑定命令测试 + +``` +GET /app/v1/test +``` + +该接口会直接调用 `sendBindDeviceCommand("01")`,向固定设备编号 `01` 下发绑定命令,并返回生成的命令 ID。 + +**响应**:`R`(运行时实际将命令 ID 作为消息文本返回) + +```json +{ + "code": 200, + "msg": "生成的命令ID", + "data": null +} +``` + +> 该路由仅用于后端联调,不属于 APP 正式业务接口。生产环境建议删除、关闭或增加管理员权限控制。 + +--- + +## 附录:数据模型 + +### AppDeviceVo(设备信息) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `deviceNo` | `string` | 设备编号 | +| `userId` | `long` | 用户 ID | +| `deviceName` | `string` | 设备名称 | +| `deviceInitName` | `string` | 设备初始名称 | +| `deviceImg` | `string` | 设备图片 URL | +| `qrcode` | `string` | 二维码内容 | +| `status` | `string` | 设备状态:`1`-在线 `0`-离线 `2`-到期 `3`-故障 | +| `workStatus` | `string` | 工作状态:`0`-休息 `1`-工作 `2`-初始/未配网 | +| `powerLevel` | `string` | 电量百分比 | +| `powerLevelUpdatatime` | `date` | 电量更新时间 | +| `wifiName` | `string` | WiFi 名称 | +| `wifiPassword` | `string` | WiFi 密码 | +| `deviceEm` | `string` | 设备型号 | +| `deviceSn` | `string` | 设备序列号 | +| `fwVer` | `string` | 固件版本 | +| `macAddress` | `string` | MAC 地址 | +| `nickName` | `string` | 用户昵称 | +| `expirationTime` | `date` | 到期时间 | + +### AppScheduleVo(排程信息) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `long` | 排程 ID | +| `userId` | `long` | 用户 ID | +| `name` | `string` | 排程名称 | +| `status` | `string` | 状态:`0`-关闭 `1`-开启 | +| `details` | `array` | 排程详情列表;新增和详情接口会返回,列表接口通常为空 | +| `deviceNos` | `array` | 已绑定设备列表(仅详情接口返回) | + +### AppScheduleDetail(排程详情) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `long` | 详情 ID | +| `weekday` | `int` | 星期几:`1`-周一 ~ `7`-周日 | +| `timeData` | `array` | 时间段列表 | +| `triggerType` | `string` | 触发类型:`0`-排程 `1`-手动 | +| `status` | `string` | 状态:`0`-关闭 `1`-开启 | + +### TimeSlot(时间段) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `startTime` | `string` | 开始时间(格式 `HH:mm`) | +| `durationMin` | `int` | 持续时长(分钟) | + +### AppWateringLogVo(浇水记录) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `long` | 记录 ID | +| `userId` | `long` | 用户 ID | +| `deviceNo` | `string` | 设备编号 | +| `deviceName` | `string` | 设备名称 | +| `commandId` | `string` | 命令 ID | +| `scheduleId` | `long` | 关联排程 ID | +| `startTime` | `date` | 开始时间 | +| `endTime` | `date` | 结束时间 | +| `durationMin` | `string` | 持续时间(分钟) | +| `zones` | `string` | 浇水区域 | +| `triggerType` | `string` | 触发类型:`0`-排程 `1`-手动 | +| `remark` | `string` | 备注 | +| `status` | `string` | 状态:`0`-已结束 `1`-进行中 | +| `createTime` | `date` | 创建时间 | + +### 枚举值速查 + +| 枚举 | 值 | 说明 | +|------|------|------| +| **设备状态 `status`** | `1` | 在线 | +| | `0` | 离线 | +| | `2` | 到期 | +| | `3` | 故障 | +| **工作状态 `workStatus`** | `0` | 休息 | +| | `1` | 工作 | +| | `2` | 初始/未配网 | +| **排程状态 `status`** | `0` | 关闭 | +| | `1` | 开启 | +| **触发类型 `triggerType`** | `0` | 排程触发 | +| | `1` | 手动触发 | +| **浇水记录状态 `status`** | `0` | 已结束 | +| | `1` | 进行中 | +| **星期 `weekday`** | `1`~`7` | 周一~周日 | +| **用户性别 `sex`** | `0` | 男 | +| | `1` | 女 | +| | `2` | 未知 | +| **强制更新 `forceUpdate`** | `0` | 否 | +| | `1` | 是 | + +--- + +> **联调提示**: +> 1. `/app/v1/**` 业务接口及明确标注“需要登录”的认证接口,请求头需携带 `Authorization: Bearer `;登录、注册、忘记密码、租户列表和普通验证码接口免登录。 +> 2. 当前 `api-decrypt.enabled=false` 时发送普通 JSON;启用后,标注 `@ApiEncrypt` 的接口(绑定设备状态检查、绑定设备、找回密码)需按约定加密请求体并携带 `encrypt-key`。 +> 3. 标注 `@RepeatSubmit` 的接口,短时间内重复提交会被拦截,前端应在收到响应前禁用提交按钮。 +> 4. 删除类接口的路径参数支持逗号分隔多个 ID。 +> 5. 分页接口排序固定为 `createTime desc`,前端无需传排序参数。 +> 6. 时间字段统一使用 `yyyy-MM-dd HH:mm:ss` 格式,时间段 `startTime` 使用 `HH:mm` 格式。 diff --git a/docs/code-review-standards.md b/docs/code-review-standards.md new file mode 100644 index 0000000..d85528e --- /dev/null +++ b/docs/code-review-standards.md @@ -0,0 +1,632 @@ +# Water 项目代码审查标准与流程 + +> **版本**: 1.0 | **适用项目**: water (IoT 智能灌溉设备管理系统) | **技术栈**: Spring Boot 3.5 + Java 17 + MyBatis Plus + MQTT + Redis + +--- + +## 目录 + +1. [代码审查标准](#1-代码审查标准) +2. [代码审查流程](#2-代码审查流程) +3. [代码审查检查清单](#3-代码审查检查清单) +4. [静态分析工具集成方案](#4-静态分析工具集成方案) +5. [附录:water 项目典型问题案例](#5-附录water-项目典型问题案例) + +--- + +## 1. 代码审查标准 + +### 1.1 问题分级体系 + +所有审查意见必须标注严重等级,审查者不得给出无分级的意见: + +| 等级 | 标记 | 含义 | 处理要求 | +|------|------|------|----------| +| **Blocker** | 🔴 | 存在安全漏洞、数据丢失风险、破坏 API 契约、竞态条件 | **必须修复后才能合并** | +| **Suggestion** | 🟡 | 缺失输入校验、命名混乱、缺少测试、性能问题、重复代码 | **应当修复,特殊情况可延期** | +| **Nit** | 💭 | 风格不一致、命名微调、文档缺失 | **建议改进,不阻塞合并** | + +### 1.2 架构规范 + +#### 🔴 Blocker + +- **禁止跨层调用**:Controller 不得直接操作 Mapper/数据库;Service 不得返回 `HttpEntity`/`ResponseEntity` 等 Web 层对象 +- **禁止在 Controller 中写业务逻辑**:Controller 只做参数接收、校验、调用 Service、组装返回值。逻辑超过 5 行的方法必须下沉到 Service +- **循环依赖必须根治**:禁止用 `@Lazy @Autowired` 掩盖循环依赖。如果存在循环依赖,说明模块划分有问题,应重构拆分 + +> **water 项目现状**:`MqttCommandAckService` 中 `@Lazy @Autowired MqttClientManager` 是典型的循环依赖掩盖。审查中遇到此类代码应标记为 🟡 Suggestion,要求补充重构计划 + +- **单一职责**:单个 Controller/Service 类行数不得超过 **500 行**。超过的必须拆分 + +> **water 项目现状**:`AppController` 达 940 行,包含设备、排程、浇水记录、用户、统计、版本等全部业务接口,是典型的上帝类。新代码不得再向此类添加方法 + +#### 🟡 Suggestion + +- **接口与实现分离**:Service 必须定义接口(`IXxxService`)+ 实现(`XxxServiceImpl`),Controller 依赖接口 +- **依赖注入使用构造器注入**:使用 `@RequiredArgsConstructor` + `final` 字段,禁止字段注入 `@Autowired`(除 `@Lazy` 场景外) +- **配置类使用 `@ConfigurationProperties`**:禁止用 `@Value` 散落配置读取 + +### 1.3 命名规范 + +#### 🔴 Blocker + +- **禁止 Raw Type**:集合必须使用泛型。`Map map = new HashMap()` 必须写为 `Map map = new HashMap<>()` + +> **water 项目现状**:`AppController` 中存在 `Map retMap = new HashMap()`、`List l = new ArrayList<>()`、`List deviceList = new ArrayList()` 等大量 Raw Type 用法。新代码**零容忍** + +- **变量名不得使用单字母**:除循环变量 `i/j/k` 外,变量名必须有意义。`List l` 必须写为 `List deviceList` + +#### 🟡 Suggestion + +- **接口与实现的命名一致**:注入字段名应与接口名对应。`IAppScheduleService` 注入为 `appScheduleService`,不得出现 `appScheduleServiceimpl`(小写 impl) +- **布尔变量用 is/has/can 前缀**:`flag` → `isSwitchSuccess`、`hasPermission` +- **常量全大写下划线**:`DEVICE_STATUS_LOCK_PREFIX`,禁止魔法值 + +#### 💭 Nit + +- **方法名动词开头**:`getDeviceInfo`、`bindDevice`、`assertDeviceOwned` +- **包名全小写**:不得使用大写字母或下划线 + +### 1.4 异常处理规范 + +#### 🔴 Blocker + +- **禁止 `catch (Exception e)` 吞异常**:Controller 中不得使用 `try-catch(Exception)` 包裹所有逻辑。应依赖全局异常处理器(`GlobalExceptionHandler`)统一处理 + +> **water 项目现状**:`AppController` 几乎每个方法都被 `try { ... } catch (Exception e) { return fail(e); }` 包裹。这会: +> 1. 吞掉异常栈,排查问题困难 +> 2. 与框架全局异常处理器功能重复 +> 3. 代码冗余严重 +> +> **正确做法**:移除 try-catch,让异常自然抛出由全局处理器捕获。仅在需要业务降级或资源清理时才 catch + +- **catch 块不得为空**:`catch (Exception e) {}` 是严重 bug。至少要记录日志 +- **不得 catch 后丢弃异常信息**:`catch (Exception e) { log.error("出错"); }` 丢失了堆栈,应使用 `log.error("出错", e)` + +#### 🟡 Suggestion + +- **异常分类处理**:业务异常用 `ServiceException`/`UserException`,系统异常让其传播 +- **自定义异常携带上下文**:抛出异常时包含设备编号、用户 ID 等关键信息 + +### 1.5 安全规范 + +#### 🔴 Blocker + +- **SQL 注入防护**:所有数据库查询必须使用 MyBatis Plus 的 `LambdaQueryWrapper` 或参数化查询,禁止字符串拼接 SQL +- **所有接口必须有权限控制**:使用 `@SaCheckLogin`/`@SaCheckPermission`/`@SaCheckRole` 或在方法内校验用户所有权 + +> **water 项目正面案例**:`AppController` 中 `assertDeviceOwned(deviceNo)` 校验设备归属权,这个模式是对的 + +- **敏感数据不得出现在日志中**:密码、token、手机号完整信息等不得记录到日志 +- **TLS 证书校验不得全局关闭**:`TrustAllManager` 等信任所有证书的代码必须有明确的安全边界注释,且仅限内网测试环境 + +> **water 项目现状**:`MqttClientManager.createTrustAllSslContext()` 创建了信任所有证书的 TrustManager。如果用于生产环境是 🔴 Blocker + +- **输入校验**:所有外部输入(HTTP 参数、MQTT 消息体)必须做格式校验和长度限制 + +### 1.6 性能规范 + +#### 🔴 Blocker + +- **禁止 N+1 查询**:在循环中查询数据库/Redis 必须改为批量查询 + +> **water 项目现状**:`MqttCommandAckService.findPendingCommandsByDeviceNo()` 遍历所有 pending commandId 逐个查 Redis,当 pending 命令多时性能差。应改用 Redis 的 `MGET` 或 Hash 结构批量获取 +> +> `AppController.scheduleDeviceList()` 在 for 循环中逐个设备调用 `appSchedulingDeviceService.findByDeviceNo()`,是典型的 N+1 查询 + +#### 🟡 Suggestion + +- **批量操作优先**:`deleteDevice` 中的 `for (deviceNo) { assertDeviceOwned(deviceNo); }` 应改为批量查询 +- **避免在热路径创建重对象**:`new SimpleDateFormat()` 应替换为 `DateTimeFormatter`(线程安全且无需重复创建) + +> **water 项目现状**:`AppController.switchDevice()` 中 `new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())` 每次请求都创建新实例。应改为: +> ```java +> private static final DateTimeFormatter DATE_TIME_FORMATTER = +> DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); +> // 使用 +> String startTime = LocalDateTime.now().format(DATE_TIME_FORMATTER); +> ``` + +- **Redis Key 设置 TTL**:所有写入 Redis 的缓存必须设置过期时间,防止内存泄漏 + +### 1.7 代码整洁规范 + +#### 🟡 Suggestion + +- **删除注释掉的代码**:被注释的代码应删除,版本历史由 Git 管理。`// @SaCheckRole("appadmin")` 这类应清理 +- **魔法值提取为常量或枚举**:状态值 `"0"`/`"1"` 应定义为枚举 + +> **water 项目正面案例**:`MqttCommandAckService` 中的 `CommandLockResult` 枚举是好的实践。命令状态 `"0"`/`"1"` 也应类似处理 + +- **重复代码提取**:相同逻辑出现 3 次以上必须提取为公共方法 + +> **water 项目现状**:`DeviceCommandServiceImpl.parseStartTime()` 有 95 行,包含三段几乎相同的 try-catch 块解析不同时间格式,应提取为通用方法 + +#### 💭 Nit + +- **方法行数不超过 50 行**:超过的考虑拆分 +- **方法参数不超过 5 个**:超过的考虑封装为参数对象 +- **import 不得使用通配符**:`import java.util.*` 应写明具体类 + +### 1.8 并发规范 + +#### 🔴 Blocker + +- **共享可变状态必须加锁**:多线程访问的可变字段必须使用同步机制保护 +- **锁必须设置超时**:`tryLock()` 必须带超时参数,禁止无限等待导致死锁 + +> **water 项目正面案例**:`MqttCommandAckService.withCommandLock()` 使用 Redisson 分布式锁并设置了超时,这是正确的做法 + +- **SimpleDateFormat 非线程安全**:禁止在多线程环境共享 `SimpleDateFormat` 实例 + +#### 🟡 Suggestion + +- **优先使用不可变对象**:能声明 `final` 的就声明 `final` +- **优先使用并发集合**:`ConcurrentHashMap` 代替 `HashMap` + synchronized + +### 1.9 测试规范 + +#### 🟡 Suggestion + +- **核心业务逻辑必须有单元测试**:Service 层的公共方法和 MQTT Handler 必须有测试覆盖 +- **测试命名规范**:`methodName_scenario_expectedResult`,如 `handleAck_validPayload_ackConfirmed` +- **测试必须独立**:不依赖执行顺序,不依赖外部状态 + +> **water 项目正面案例**:`MqttCommandAckService` 的静态方法 `ackMatchesPendingCommand` 和 `resolveMissingCommandId` 被设计为可测试的纯函数,这是好的实践 + +### 1.10 日志规范 + +#### 🔴 Blocker + +- **日志必须包含上下文**:MQTT 相关日志必须包含设备编号、命令编号等关键信息 + +> **water 项目正面案例**:`log.warn("[MQTT] 收到空 ACK 设备编号={}", deviceNo)` 包含了设备编号,格式统一 + +#### 🟡 Suggestion + +- **日志级别正确使用**:`ERROR` 用于系统异常、`WARN` 用于业务异常、`INFO` 用于关键业务流程、`DEBUG` 用于调试信息 +- **使用占位符而非字符串拼接**:`log.info("设备{}上线", deviceNo)` 而非 `log.info("设备" + deviceNo + "上线")` + +--- + +## 2. 代码审查流程 + +### 2.1 角色定义 + +| 角色 | 职责 | 资质要求 | +|------|------|----------| +| **提交者** (Author) | 编写代码、自测、提交 PR、响应审查意见 | 熟悉项目编码规范 | +| **审查者** (Reviewer) | 审查代码质量、提出改进意见、确认修复 | 熟悉相关模块业务逻辑 | +| **合并者** (Merger) | 最终确认、合并代码 | 技术负责人或模块 Owner | + +### 2.2 PR 提交前(自检阶段) + +提交者在创建 PR 前必须完成: + +1. **本地编译通过**:`mvn clean compile -DskipTests` +2. **单元测试通过**:`mvn test`(不得使用 `-DskipTests`) +3. **静态分析通过**:Checkstyle + SpotBugs 无 Error 级别问题 +4. **自检 Checklist**:逐项核对 [Section 3 检查清单](#3-代码审查检查清单) +5. **PR 描述**:包含变更说明、测试方式、影响范围 + +#### PR 描述模板 + +```markdown +## 变更说明 + + +## 测试方式 + + +## 影响范围 + + +## 关联 Issue + +``` + +### 2.3 审查阶段 + +#### 审查时间要求 + +| PR 规模 | 审查时限 | 说明 | +|---------|----------|------| +| 小型 (< 100 行) | 4 小时内 | 单个 bugfix 或小功能 | +| 中型 (100-500 行) | 1 个工作日内 | 常规功能开发 | +| 大型 (> 500 行) | 2 个工作日内 | 大功能或重构,建议拆分 | + +#### 审查步骤 + +``` +Step 1: 全局审视 + ├── PR 描述是否清晰? + ├── 变更范围是否合理? + └── 是否有对应的测试? + +Step 2: 逐文件审查 + ├── 按 [Section 3 检查清单] 逐项核对 + ├── 标注问题等级 (🔴/🟡/💭) + └── 给出具体的修改建议和原因 + +Step 3: 运行验证 + ├── 代码能否编译通过? + ├── 测试是否通过? + └── 静态分析是否有新问题? + +Step 4: 总结 + ├── Approve — 无 Blocker,可合并 + ├── Request Changes — 有 Blocker 或多个 Suggestion + └── Comment — 仅有 Nit 或讨论性问题 +``` + +### 2.4 合并标准 + +PR 必须满足以下**全部条件**才能合并: + +- [ ] **零 🔴 Blocker**:所有 Blocker 已修复 +- [ ] **Suggestion 有明确处理**:已修复或标注为延期(需技术负责人确认) +- [ ] **至少 1 个 Approve**:中型以上 PR 需要模块 Owner Approve +- [ ] **CI 通过**:编译、测试、静态分析全部通过 +- [ ] **无未解决的讨论**:所有 review comment 已 resolved + +### 2.5 冲突升级机制 + +| 冲突类型 | 升级路径 | +|----------|----------| +| 审查意见分歧 | 提交者与审查者协商 → 协商不成由模块 Owner 裁决 | +| 架构方案分歧 | 模块 Owner → 技术负责人(你)最终裁决 | +| 紧急修复绕过审查 | 需技术负责人批准,事后 24 小时内补审查 | + +### 2.6 紧急修复流程 + +生产环境紧急 bug 修复可走快速通道: + +1. 技术负责人批准走紧急流程 +2. 最少 1 人审查(可简化为只查 Blocker 级别问题) +3. 合并后 24 小时内补完整审查 +4. 记录在 `docs/hotfix-log.md` 中 + +--- + +## 3. 代码审查检查清单 + +> 审查者逐项核对,每项标记 ✅ 通过 / ❌ 未通过 / ➖ 不适用 + +### 3.1 安全 (Security) + +| # | 检查项 | 等级 | +|---|--------|------| +| S1 | 所有数据库查询使用参数化查询,无 SQL 注入风险 | 🔴 | +| S2 | 所有接口有权限控制(`@SaCheckLogin` / 所有权校验) | 🔴 | +| S3 | 敏感数据(密码、token)不出现在日志中 | 🔴 | +| S4 | 外部输入(HTTP 参数、MQTT 消息)做了格式校验和长度限制 | 🔴 | +| S5 | TLS 证书校验未在生产环境关闭 | 🔴 | +| S6 | 文件上传做了类型和大小限制 | 🟡 | + +### 3.2 正确性 (Correctness) + +| # | 检查项 | 等级 | +|---|--------|------| +| C1 | 代码逻辑是否实现了预期功能 | 🔴 | +| C2 | 边界条件是否处理(空值、空集合、零值、最大值) | 🔴 | +| C3 | 异常路径是否正确处理(不吞异常、不丢失异常栈) | 🔴 | +| C4 | 并发场景下数据一致性是否保证 | 🔴 | +| C5 | Redis Key 都设置了 TTL | 🟡 | +| C6 | 分布式锁设置了超时时间 | 🟡 | + +### 3.3 架构 (Architecture) + +| # | 检查项 | 等级 | +|---|--------|------| +| A1 | Controller 不含业务逻辑(仅参数接收+调用 Service) | 🔴 | +| A2 | 无跨层调用(Controller 不直接调 Mapper) | 🔴 | +| A3 | 无循环依赖(未使用 `@Lazy` 掩盖) | 🔴 | +| A4 | 单个类行数不超过 500 行 | 🟡 | +| A5 | Service 有接口定义 | 🟡 | +| A6 | 使用构造器注入(`@RequiredArgsConstructor`) | 🟡 | + +### 3.4 代码质量 (Code Quality) + +| # | 检查项 | 等级 | +|---|--------|------| +| Q1 | 集合使用泛型,无 Raw Type | 🔴 | +| Q2 | 无魔法值(字符串/数字硬编码),常量已提取 | 🟡 | +| Q3 | 无注释掉的代码 | 🟡 | +| Q4 | 重复代码已提取为公共方法 | 🟡 | +| Q5 | 变量命名有意义,无单字母变量 | 🟡 | +| Q6 | 方法行数不超过 50 行 | 💭 | +| Q7 | import 无通配符 | 💭 | + +### 3.5 性能 (Performance) + +| # | 检查项 | 等级 | +|---|--------|------| +| P1 | 无 N+1 查询(循环内不查数据库/Redis) | 🔴 | +| P2 | 批量操作使用批量接口 | 🟡 | +| P3 | 热路径无重对象创建(如 `SimpleDateFormat`) | 🟡 | +| P4 | 大集合操作考虑分页或流式处理 | 🟡 | + +### 3.6 异常处理 (Error Handling) + +| # | 检查项 | 等级 | +|---|--------|------| +| E1 | 无 `catch (Exception e)` 包裹全部逻辑 | 🔴 | +| E2 | catch 块不为空,至少记录日志 | 🔴 | +| E3 | 日志使用 `log.error("msg", e)` 保留异常栈 | 🔴 | +| E4 | 业务异常用 `ServiceException`,不混用 | 🟡 | + +### 3.7 测试 (Testing) + +| # | 检查项 | 等级 | +|---|--------|------| +| T1 | 核心业务逻辑有单元测试 | 🟡 | +| T2 | 测试覆盖正常路径和异常路径 | 🟡 | +| T3 | 测试命名规范,能表达意图 | 💭 | +| T4 | 测试独立运行,不依赖顺序 | 🟡 | + +### 3.8 日志 (Logging) + +| # | 检查项 | 等级 | +|---|--------|------| +| L1 | 关键操作有日志记录 | 🟡 | +| L2 | 日志包含上下文信息(设备号、用户ID等) | 🟡 | +| L3 | 日志级别使用正确 | 💭 | +| L4 | 使用占位符而非字符串拼接 | 💭 | + +--- + +## 4. 静态分析工具集成方案 + +### 4.1 工具选型 + +| 工具 | 作用 | 集成方式 | 优先级 | +|------|------|----------|--------| +| **Checkstyle** | 代码风格检查(命名、import、行数) | Maven 插件 | P0 立即集成 | +| **SpotBugs** | 潜在 bug 检测(空指针、资源泄漏) | Maven 插件 | P0 立即集成 | +| **SonarQube** | 综合质量平台(重复率、覆盖率、复杂度) | 独立服务 + Scanner | P1 二期集成 | + +### 4.2 Maven 集成配置 + +在根 `pom.xml` 的 `` 中添加以下配置: + +```xml + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.5.0 + + + com.puppycrawl.tools + checkstyle + 10.18.0 + + + + checkstyle.xml + true + true + true + + + + validate + validate + + check + + + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.8.6.4 + + + com.github.spotbugs + spotbugs + 4.8.6 + + + + Max + Low + true + spotbugs-exclude.xml + + + + spotbugs-check + verify + + check + + + + +``` + +### 4.3 实施路线 + +| 阶段 | 周期 | 目标 | +|------|------|------| +| **Phase 1** | 第 1 周 | 集成 Checkstyle,配置规则文件,CI 中运行 | +| **Phase 2** | 第 2 周 | 集成 SpotBugs,修复现有 High 级别问题 | +| **Phase 3** | 第 3-4 周 | 搭建 SonarQube 服务,建立质量基线 | +| **Phase 4** | 持续 | 将静态分析纳入 PR 检查,设置质量门禁 | + +### 4.4 过渡策略 + +> **重要**:集成静态分析工具时,现有代码会有大量违规。采取以下策略: + +1. **新代码零容忍**:PR 中新增/修改的代码必须通过检查 +2. **存量代码分期修复**:用 SonarQube 的 "New Code" 模式,只关注新增问题 +3. **基线快照**:记录当前问题数量作为基线,要求只减不增 + +--- + +## 5. 附录:water 项目典型问题案例 + +### 案例一:上帝控制器 + 异常吞噬 + +**文件**: `AppController.java` +**问题等级**: 🔴 Blocker (架构) + 🔴 Blocker (异常处理) + +```java +// ❌ 当前代码 — 940 行上帝类,每个方法 try-catch 包裹 +@PostMapping("/bindDeviceStatus") +public R bindDeviceStatus(@RequestBody String body) { + try { + AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); + appDevice.setUserId(LoginHelper.getUserId()); + Map map = appDeviceService.bindDeviceStatus(appDevice); + return R.ok(map); + } catch (Exception e) { + return fail(e); // 吞异常栈,与全局处理器重复 + } +} +``` + +```java +// ✅ 改进后 — 移到独立 Controller,移除 try-catch +@RestController +@RequestMapping("/app/v1/device") +@RequiredArgsConstructor +public class AppDeviceCommandController extends BaseController { + + private final IAppDeviceService appDeviceService; + + @ApiEncrypt + @PostMapping("/bindDeviceStatus") + public R> bindDeviceStatus(@RequestBody String body) { + AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); + appDevice.setUserId(LoginHelper.getUserId()); + return R.ok(appDeviceService.bindDeviceStatus(appDevice)); + } +} +``` + +### 案例二:Raw Type + 魔法值 + +**文件**: `AppController.java` +**问题等级**: 🔴 Blocker (Raw Type) + 🟡 Suggestion (魔法值) + +```java +// ❌ 当前代码 +Map retMap = new HashMap(); // Raw Type +List l = new ArrayList<>(); // 单字母变量 + 推断丢失泛型 +String status = map.get("workStatus"); // 魔法字符串 +ack.setStatus("1"); // 魔法值 +``` + +```java +// ✅ 改进后 +Map resultMap = new HashMap<>(); +List availableDevices = new ArrayList<>(); +String status = params.get(DeviceCommand.FIELD_WORK_STATUS); +ack.setStatus(CommandStatus.SUCCESS.getCode()); // 枚举 +``` + +### 案例三:N+1 查询 + +**文件**: `AppController.java` — `scheduleDeviceList()` 方法 +**问题等级**: 🔴 Blocker (性能) + +```java +// ❌ 当前代码 — 循环内逐个查询 +for (AppDeviceVo appDeviceVo : appDeviceVos) { + List scheduleDevices = + appSchedulingDeviceService.findByDeviceNo(appDeviceVo.getDeviceNo()); // N+1! + if (scheduleDevices.size() == 0) { + appDeviceVoList.add(appDeviceVo); + } +} +``` + +```java +// ✅ 改进后 — 批量查询 +List deviceNos = appDeviceVos.stream() + .map(AppDeviceVo::getDeviceNo) + .collect(Collectors.toList()); +Set scheduledDeviceNos = appSchedulingDeviceService + .findScheduledDeviceNos(deviceNos); // 一次批量查询 +List availableDevices = appDeviceVos.stream() + .filter(d -> !scheduledDeviceNos.contains(d.getDeviceNo())) + .collect(Collectors.toList()); +``` + +### 案例四:线程安全问题 + +**文件**: `AppController.java` — `switchDevice()` 方法 +**问题等级**: 🔴 Blocker (线程安全) + +```java +// ❌ 当前代码 — SimpleDateFormat 非线程安全 +String startTime = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); +``` + +```java +// ✅ 改进后 — 使用 DateTimeFormatter(线程安全) +private static final DateTimeFormatter DATE_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + +String startTime = LocalDateTime.now().format(DATE_TIME_FORMATTER); +``` + +### 案例五:正面案例 — MQTT 消息分发策略模式 + +**文件**: `MqttMessageDispatcher.java` + `MqttTopicHandler.java` +**评价**: 🟢 优秀设计 + +```java +// 策略接口 +public interface MqttTopicHandler { + String getTopicPattern(); + void handle(String topic, String payload); +} + +// 分发器 — 新增 Topic 处理器零改动 +// 这是开闭原则的标准实践,值得团队学习 +``` + +**值得推广的模式**: +1. 策略模式解耦消息分发 +2. 有界队列 + 多消费者的高并发架构 +3. 优雅停机设计(`DisposableBean` + `awaitTermination`) +4. 分布式锁保护并发操作 + +--- + +## 6. 推广与落地建议 + +### 6.1 分阶段实施 + +| 阶段 | 时间 | 目标 | 负责人 | +|------|------|------|--------| +| **宣贯** | 第 1 周 | 团队学习本文档,理解审查标准 | 技术负责人 | +| **工具** | 第 2 周 | 集成 Checkstyle + SpotBugs,修复 P0 问题 | 全员 | +| **试运行** | 第 3-4 周 | 所有 PR 执行审查流程,以学习为主 | 全员 | +| **正式执行** | 第 5 周起 | 严格执行 Blocker 零容忍 | 全员 | + +### 6.2 度量指标 + +| 指标 | 目标 | 度量方式 | +|------|------|----------| +| PR 审查覆盖率 | 100% | 所有合并的 PR 都经过审查 | +| Blocker 修复率 | 100% | 所有 Blocker 在合并前修复 | +| 平均审查响应时间 | < 1 工作日 | PR 提交到首次审查 | +| 静态分析问题数 | 逐月递减 | SonarQube 趋势图 | +| 单元测试覆盖率 | 核心模块 > 60% | JaCoCo 报告 | + +### 6.3 持续改进 + +- **每月复盘**:审查中发现的共性问题汇总,更新本标准 +- **季度培训**:针对高频问题组织技术分享 +- **标准迭代**:本文档每季度评审一次,根据团队反馈调整 + +--- + +*文档维护:技术负责人 | 最后更新:2026-07-16* diff --git a/docs/mqtt-embedded-communication.md b/docs/mqtt-embedded-communication.md index d62ac57..61af316 100644 --- a/docs/mqtt-embedded-communication.md +++ b/docs/mqtt-embedded-communication.md @@ -16,8 +16,8 @@ | --- | --- | | Broker | `mqtt.broker-url` | | QoS | `mqtt.qos`,当前默认 `1` | -| 设备标识 | 上行 Topic 第一段可为设备 **MAC** 或 `deviceNo`;下行命令 Topic 第一段统一使用 `deviceNo`,仅 `registerDeviceNo` 例外仍使用 MAC | -| Topic 变量 | 上行文档中的 `{deviceNo}` 表示设备标识占位符;下行文档中的 `{deviceNo}` 即设备编号 | +| 设备标识 | 首次注册 Topic 使用设备 **MAC**;注册完成后的上、下行业务 Topic 统一使用 `deviceNo`;LWT 离线 Topic 兼容 MAC | +| Topic 变量 | 文档中的 `{deviceNo}` 指服务端分配的设备编号,`{mac}` 指设备 MAC 地址 | | JSON 编码 | UTF-8 | | 时间格式 | `yyyy-MM-dd HH:mm:ss` | @@ -26,6 +26,7 @@ ```yaml - /+/publish/finish/schedule - /+/publish/register +- /+/publish/status - /+/publish/power - /+/publish/ack - /+/publish/finish/key @@ -38,8 +39,9 @@ | 功能名称 | 通信方向 | 订阅名称 / Topic | Payload 类型 | 后端处理 | | --- | --- | --- | --- | --- | | 排程任务完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/schedule` | JSON | 当前记录日志 | -| 设备注册 | 设备发布,后端订阅 | `/{deviceNo}/publish/register` | JSON | 注册或更新设备 | -| 电量上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量 | +| 设备注册 | 设备发布,后端订阅 | `/{mac}/publish/register` | JSON | 注册、标记上线并下发 deviceNo | +| 设备离线遗嘱 | 设备发布,后端订阅 | `/{deviceNo}/publish/status` | JSON | 收到 offline 后立即标记离线 | +| 电量及在线心跳 | 设备发布,后端订阅 | `/{deviceNo}/publish/power` | JSON | 更新设备电量并刷新 10 分钟在线心跳 | | 命令应答 ACK | 设备发布,后端订阅 | `/{deviceNo}/publish/ack` | JSON 或纯文本 | 清理待确认命令 | | 按键浇水完成上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/finish/key` | JSON | 当前记录日志 | | 硬件故障上报 | 设备发布,后端订阅 | `/{deviceNo}/publish/error` | JSON | 当前记录日志 | @@ -51,7 +53,7 @@ | --- | --- | | 功能名称 | 设备注册 | | 通信方向 | 设备发布,后端订阅 | -| Topic | `/{deviceNo}/publish/register` | +| Topic | `/{mac}/publish/register` | | Payload 类型 | JSON | 后端以 Topic 中的设备标识作为解析入口,payload 中的 `deviceNo` 即使传入也会被 Topic 覆盖。 @@ -106,6 +108,29 @@ | --- | --- | --- | --- | | powerLevel | string | 是 | 电量值,后端当前按字符串保存 | +注册成功后后端会立即将设备标记为在线。之后收到包含 `powerLevel` 的有效消息,会把在线心跳有效期刷新为 600 秒。设备必须周期上报,即使电量没有变化也要发送,建议每 5 分钟一次;连续 10 分钟未收到电量或 ACK 刷新时将被判定为离线。ACK 仍保留现有的在线缓存刷新逻辑。 + +## 设备离线遗嘱 + +| 项目 | 内容 | +| --- | --- | +| 功能名称 | 设备离线遗嘱 | +| 通信方向 | 设备发布,后端订阅 | +| Topic | `/{deviceNo}/publish/status`,注册前可使用 `/{mac}/publish/status` | +| Payload 类型 | JSON 或纯文本 | + +推荐将以下消息配置为 MQTT LWT,并设置 `retain=false`: + +```json +{ + "status": "offline" +} +``` + +后端收到 `offline` 或 `0` 后立即标记设备离线。`online` 或 `1` 不会标记在线,设备上线通过注册成功或电量心跳确认。 + +兼容设备可发送 `{"deviceMac":"AA:BB:CC:DD:EE:FF","offline":"true"}`,服务端会使用 `deviceMac` 解析设备并标记离线。 + ## 命令应答 ACK | 项目 | 内容 | @@ -306,9 +331,10 @@ JSON 示例: ## 嵌入式侧实现要点 -1. 设备启动后发布注册消息到 `/{deviceNo}/publish/register`。 -2. 设备定时或电量变化时发布电量到 `/{deviceNo}/publish/power`。 -3. 设备订阅自己的命令 Topic:`/{deviceNo}/subscriber/cmd` 和 `/{deviceNo}/subscriber/schedule`。 -4. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`。 -5. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule` 或 `/{deviceNo}/publish/finish/key`。 -6. 硬件异常时发布到 `/{deviceNo}/publish/error`。 +1. 设备启动后使用 MAC 发布注册消息到 `/{mac}/publish/register`,取得服务端分配的 deviceNo。 +2. MQTT 连接时配置离线遗嘱 `/{deviceNo}/publish/status`,payload 为 `{"status":"offline"}`,`retain=false`。 +3. 设备至少每 5 分钟发布一次电量到 `/{deviceNo}/publish/power`,电量未变化也要上报。 +4. 设备订阅自己的命令 Topic:`/{deviceNo}/subscriber/cmd` 和 `/{deviceNo}/subscriber/schedule`。 +5. 设备收到命令后立即返回 ACK 到 `/{deviceNo}/publish/ack`,推荐返回 JSON 并携带 `commandId`。 +6. 排程或按键浇水完成后,分别发布到 `/{deviceNo}/publish/finish/schedule` 或 `/{deviceNo}/publish/finish/key`。 +7. 硬件异常时发布到 `/{deviceNo}/publish/error`。 diff --git a/docs/mqtt-spec.md b/docs/mqtt-spec.md index b46bdc5..5955492 100644 --- a/docs/mqtt-spec.md +++ b/docs/mqtt-spec.md @@ -38,14 +38,15 @@ | Topic 模式 | 正则 | 处理器 | 说明 | |------------|------|--------|------| -| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册/上线 | -| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报 | +| `/{identity}/publish/register` | `^/([^/]+)/publish/register$` | `DeviceRegisterHandler` | 设备注册 | +| `/{identity}/publish/status` | `^/([^/]+)/publish/status$` | `DeviceStatusHandler` | 设备 LWT 离线通知(仅处理 offline) | +| `/{identity}/publish/power` | `^/([^/]+)/publish/power$` | `DeviceDataHandler` | 电量数据上报及在线心跳 | | `/{identity}/publish/finish/key` | `^/([^/]+)/publish/finish/key$` | `KeyFinishHandler` | 按键/手动浇水完成 | | `/{identity}/publish/finish/schedule` | `^/([^/]+)/publish/finish/schedule$` | `ScheduleFinishHandler` | 排程浇水完成 | | `/{identity}/publish/error` | `^/([^/]+)/publish/error$` | `ErromesHandler` | 设备异常告警 | | `/{identity}/publish/ack` | `^/([^/]+)/publish/ack$` | `DeviceCommandAckHandler` | 命令执行确认 | -> `{identity}` 可以是设备编号(deviceNo)或 MAC 地址,由 `DeviceIdentityResolver` 统一解析为 deviceNo。 +> 首次注册可使用 MAC 地址;注册完成后的业务 Topic 使用 deviceNo。LWT 离线 Topic 兼容 MAC 地址,服务端通过 `DeviceIdentityResolver` 解析为 deviceNo。 ### 2.2 下行 Topic(服务端 → 设备) @@ -304,8 +305,9 @@ long nextRetryAt; // 下次重试时间戳 **服务端处理**: 1. 通过 MAC 或设备编号解析入库设备 2. 更新设备注册信息(名称、电量、固件版本等) -3. 刷新设备在线状态到 Redis -4. 自动回复设备编号(`registerDeviceNo` 命令) +3. 自动回复设备编号(`registerDeviceNo` 命令) + +> 注册成功即将设备标记为在线;设备取得 deviceNo 后仍必须定时上报电量,以持续刷新在线心跳。 --- @@ -317,11 +319,36 @@ long nextRetryAt; // 下次重试时间戳 } ``` -**服务端处理**: 更新设备电量 + 刷新在线状态。 +**服务端处理**: 更新设备电量,将数据库设备状态改为在线,并将 Redis 在线心跳刷新为 600 秒。 + +设备必须周期上报,即使电量没有变化也要发送。建议每 5 分钟上报一次,连续 10 分钟未收到有效电量消息且期间无 ACK 刷新时,服务端将设备判定为离线。 --- -### 5.3 按键浇水完成 — `/{identity}/publish/finish/key` +### 5.3 设备离线遗嘱 — `/{identity}/publish/status` + +```json +{ + "status": "offline" +} +``` + +**服务端处理**: 收到非 retained 的 `offline` 或 `0` 后立即将设备改为离线。`online` 或 `1` 不再用于上线,服务端会忽略,上线状态只由电量心跳维护。 + +设备应将该消息配置为 MQTT LWT,建议 `retain=false`。Topic 第一段优先使用 deviceNo;设备注册前无法取得 deviceNo 时可使用 MAC 地址。 + +兼容设备也可以发送以下格式,服务端会使用 `deviceMac` 解析设备,并将 `offline=true`(字符串或布尔值)按离线处理: + +```json +{ + "deviceMac": "AA:BB:CC:DD:EE:FF", + "offline": "true" +} +``` + +--- + +### 5.4 按键浇水完成 — `/{identity}/publish/finish/key` ```json { @@ -340,7 +367,7 @@ long nextRetryAt; // 下次重试时间戳 --- -### 5.4 排程浇水完成 — `/{identity}/publish/finish/schedule` +### 5.5 排程浇水完成 — `/{identity}/publish/finish/schedule` ```json { @@ -358,7 +385,7 @@ long nextRetryAt; // 下次重试时间戳 --- -### 5.5 设备异常告警 — `/{identity}/publish/error` +### 5.6 设备异常告警 — `/{identity}/publish/error` ```json { @@ -371,7 +398,7 @@ long nextRetryAt; // 下次重试时间戳 --- -### 5.6 命令 ACK — `/{identity}/publish/ack` +### 5.7 命令 ACK — `/{identity}/publish/ack` ```json { @@ -411,6 +438,7 @@ long nextRetryAt; // 下次重试时间戳 | `maxRetryCount` | 3 | 最大重试次数 | | `retryIntervalMs` | 5000 | 重试间隔(ms) | | `scanIntervalMs` | 5000 | 定时扫描间隔(ms) | +| `ackLockWaitMs` | 3000 | ACK 等待同一命令重试锁的最长时间(ms) | | `pendingTtlSeconds` | 86400 | pending 命令 TTL(秒) | | `ackTtlSeconds` | 86400 | ACK 结果缓存 TTL(秒) | @@ -495,8 +523,14 @@ mqtt: pending-set-key: "mqtt:command:pending:ids" retry-lock-key-prefix: "lock:mqtt:command:retry:" retry-lock-ttl-ms: 30000 + ack-lock-wait-ms: 3000 device-status-cache-prefix: "mqtt:device:status:" - device-status-cache-ttl-seconds: 300 + device-status-cache-ttl-seconds: 600 + + offline-check: + enabled: true + ttl-compat-enabled: true + interval-ms: 30000 ``` --- @@ -538,10 +572,13 @@ public class MyNewHandler implements MqttTopicHandler { ## 10. 设备在线状态检测 -设备在线状态通过 Redis 缓存管理: +设备在线状态由电量上报和离线遗嘱共同管理: -- **写入时机**: 设备注册、电量上报、ACK 确认时刷新 +- **上线时机**: 注册成功或收到包含 `powerLevel` 的有效电量上报时刷新 +- **ACK 兼容逻辑**: 保留现有 ACK 在线刷新逻辑,收到有效 ACK 也会延长在线缓存 - **缓存格式**: `{ "deviceNo": "01", "status": "1", "lastReportTime": "2026-06-25T14:30:00Z" }` -- **TTL**: 默认 300 秒(5 分钟) -- **离线判定**: 缓存过期 = 设备离线 +- **TTL**: 默认 600 秒(10 分钟) +- **超时离线**: 每 30 秒扫描一次非离线设备,缓存过期后更新数据库状态为离线 +- **遗嘱离线**: 收到非 retained 的 LWT `status=offline/0` 时立即离线 +- **忽略在线状态**: `status=online/1` 不会刷新在线状态 - **并发保护**: 使用分布式锁(`lock:mqtt:device:status:{deviceNo}`)防止并发写入 diff --git a/docs/project-architecture.md b/docs/project-architecture.md index 061e93c..2f67e28 100644 --- a/docs/project-architecture.md +++ b/docs/project-architecture.md @@ -133,7 +133,7 @@ sequenceDiagram ```mermaid flowchart TB REDIS[("Redis")] - STATUS["mqtt:device:status:{deviceNo}
设备最新在线状态
TTL: 300s"] + STATUS["mqtt:device:status:{deviceNo}
设备电量在线心跳
TTL: 600s"] PENDING["mqtt:command:pending:{commandId}
待ACK命令详情
TTL: 86400s"] PENDING_IDS["mqtt:command:pending:ids
待ACK commandId 集合"] ACK["mqtt:command:ack:{commandId}
设备ACK结果
TTL: 86400s"] @@ -176,7 +176,7 @@ flowchart TB B --> C["后端单/少量订阅客户端消费通配 topic"] C --> D["有界队列吸收突发流量"] D --> E["批量消费降低线程调度成本"] - E --> F["状态写 Redis,避免心跳打数据库"] + E --> F["电量心跳写 Redis 并同步设备状态"] F --> G["数据/告警后续建议批量落库"] ``` diff --git a/docs/superpowers/plans/2026-07-16-app-controller-concurrency-test.md b/docs/superpowers/plans/2026-07-16-app-controller-concurrency-test.md new file mode 100644 index 0000000..b210f53 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-app-controller-concurrency-test.md @@ -0,0 +1,61 @@ +# AppController Concurrency Test Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add repeatable 32-thread unit concurrency tests for the singleton `AppController` without live infrastructure. + +**Architecture:** Create one standalone JUnit 5 test class with a shared controller instance and mocked collaborators. A fixed thread pool, ready latch, start latch, and bounded `Future#get` synchronize each test; concurrent collections retain observations for deterministic assertions after workers finish. + +**Tech Stack:** Java 17, JUnit 5, Mockito, AssertJ, Spring `MockMultipartFile`, Maven Surefire. + +## Global Constraints + +- Do not modify production code unless a concurrency failure is reproduced. +- Use exactly 32 worker threads per test. +- Do not connect to MySQL, Redis, MQTT, or OSS. +- Every future must have a bounded timeout. +- Use one shared `AppController` instance within each test. + +--- + +### Task 1: Add AppController Concurrency Coverage + +**Files:** +- Create: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java` + +**Interfaces:** +- Consumes: `AppController.switchDevice(String)`, `uploadImage(MultipartFile)`, and `editScheduleStatus(AppScheduleBo)`. +- Produces: three repeatable JUnit concurrency tests and shared executor helpers. + +- [ ] **Step 1: Create the concurrency test fixture** + +Add Mockito mocks for all constructor dependencies, construct one controller per test, register an `ObjectMapper` in a `GenericApplicationContext`, and add a helper that submits 32 workers behind ready/start latches. + +- [ ] **Step 2: Add concurrent timestamp formatting test** + +Run 32 workers with 100 calls each. Capture every `startTime`, require 3,200 successful responses and invocations, and parse every captured value with strict `yyyy-MM-dd HH:mm:ss` formatting. + +- [ ] **Step 3: Add concurrent image validation test** + +Run 32 workers with 50 uploads each, cycling through JPEG, PNG, GIF, WebP, and BMP signatures. Require 1,600 successful responses and OSS calls. + +- [ ] **Step 4: Add concurrent schedule payload isolation test** + +Run 32 workers with 20 unique schedules each. Scope `LoginHelper.getUserId()` to each worker, generate a unique device and detail marker per schedule, capture MQTT payloads in a `ConcurrentHashMap`, and verify all 640 payloads contain their matching values. + +- [ ] **Step 5: Run the focused concurrency test** + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppControllerConcurrencyTest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +Expected: 3 tests pass with zero failures, errors, and timeouts. + +- [ ] **Step 6: Run the full water-app suite and diff checks** + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test +git diff --check +``` + +Expected: reactor `BUILD SUCCESS`; all tests pass; `git diff --check` exits 0. diff --git a/docs/superpowers/plans/2026-07-16-app-controller-review-remediation.md b/docs/superpowers/plans/2026-07-16-app-controller-review-remediation.md new file mode 100644 index 0000000..3f90e8d --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-app-controller-review-remediation.md @@ -0,0 +1,689 @@ +# AppController Review Remediation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the approved exception-safety, type-safety, N+1-query, and image-upload findings while preserving every existing `/app/v1/**` route. + +**Architecture:** Keep `AppController` in place for this phase, but move database batching behind existing service interfaces. Route all non-degraded exceptions through `GlobalExceptionHandler`, validate image bytes before OSS, and cover behavior through focused unit and standalone MVC tests. + +**Tech Stack:** Java 17, Spring Boot 3.5, Spring MVC, MyBatis Plus, Hutool, JUnit 5, Mockito, AssertJ, Maven. + +## Global Constraints + +- Do not split `AppController` in this phase. +- Preserve existing route paths, request field names, successful response payloads, MQTT topics, and database schema. +- Unexpected system exceptions must not expose their original messages to clients. +- Image uploads are limited to 20 MB and to JPEG, PNG, GIF, WebP, and BMP detected by file signature. +- Keep the four intentional MQTT notification degradation catches and include the exception object in each log call. +- Do not revert unrelated dirty-worktree changes. + +--- + +### Task 1: Make Global Exception Responses Safe And Observable + +**Files:** +- Modify: `water-common/water-common-web/pom.xml` +- Modify: `water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java:60-167` +- Create: `water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java` + +**Interfaces:** +- Consumes: `ServiceException`, `BaseException`, `R`, `HttpServletRequest`. +- Produces: unchanged handler method signatures; generic system failures return `R.fail()` instead of `e.getMessage()`. + +- [ ] **Step 1: Add the test dependency and failing exception-handler tests** + +Add this dependency to `water-common-web/pom.xml`: + +```xml + + org.springframework.boot + spring-boot-starter-test + test + +``` + +Create `GlobalExceptionHandlerTest` with these behaviors: + +```java +@ExtendWith(MockitoExtension.class) +class GlobalExceptionHandlerTest { + + @Mock + private HttpServletRequest request; + + private GlobalExceptionHandler handler; + + @BeforeEach + void setUp() { + handler = new GlobalExceptionHandler(); + when(request.getRequestURI()).thenReturn("/app/v1/device/D01"); + } + + @Test + void handleRuntimeException_hidesInternalMessage() { + R result = handler.handleRuntimeException( + new IllegalStateException("jdbc:mysql://internal-host/water"), request); + + assertThat(result.getCode()).isEqualTo(R.FAIL); + assertThat(result.getMsg()).doesNotContain("internal-host"); + } + + @Test + void handleBaseException_logsThrowableAndRequestUri() { + Logger logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + handler.handleBaseException(new BaseException("user", "业务校验失败"), request); + + ILoggingEvent event = appender.list.get(appender.list.size() - 1); + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("/app/v1/device/D01"); + assertThat(event.getThrowableProxy()).isNotNull(); + } finally { + logger.detachAppender(appender); + } + } +} +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```powershell +mvn -pl water-common/water-common-web -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dtest=GlobalExceptionHandlerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +Expected: failures because runtime messages are currently returned unchanged and `BaseException` logs have no throwable at `ERROR` level. + +- [ ] **Step 3: Implement the minimal handler changes** + +Apply these handler rules: + +```java +@ExceptionHandler(ServiceException.class) +public R handleServiceException(ServiceException e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + String message = resolveErrorMessage(e.getMessage()); + log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e); + Integer code = e.getCode(); + return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message); +} + +@ExceptionHandler(BaseException.class) +public R handleBaseException(BaseException e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + String message = e.getMessage(); + log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e); + return R.fail(message); +} + +@ExceptionHandler(RuntimeException.class) +public R handleRuntimeException(RuntimeException e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + log.error("请求地址'{}',发生未知异常.", requestURI, e); + return R.fail(); +} + +@ExceptionHandler(Exception.class) +public R handleException(Exception e, HttpServletRequest request) { + String requestURI = request.getRequestURI(); + log.error("请求地址'{}',发生系统异常.", requestURI, e); + return R.fail(); +} +``` + +- [ ] **Step 4: Run the focused tests and verify GREEN** + +Run the command from Step 2. Expected: `GlobalExceptionHandlerTest` passes with zero failures. + +- [ ] **Step 5: Record the Task 1 diff checkpoint** + +```powershell +git diff -- water-common/water-common-web/pom.xml water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java +``` + +--- + +### Task 2: Validate Image Upload Content And Size + +**Files:** +- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java:131-169` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:593-612` + +**Interfaces:** +- Consumes: `MultipartFile` and existing `ISysOssService.upload(MultipartFile)`. +- Produces: unchanged `POST /app/v1/uploadImage`; invalid files throw `ServiceException` before OSS is called. + +- [ ] **Step 1: Write failing upload validation tests** + +Replace the fake valid PNG bytes with a real signature helper and add forged, oversized, and SVG cases: + +```java +private byte[] pngBytes() { + return new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 + }; +} + +@Test +void uploadImage_rejectsForgedImageContent() { + AppController controller = newController(); + MockMultipartFile file = new MockMultipartFile( + "file", "plant.png", "image/png", "not-an-image".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("图片文件内容不合法"); + verify(ossService, never()).upload(any(MultipartFile.class)); +} + +@Test +void uploadImage_rejectsFileLargerThanTwentyMegabytes() { + AppController controller = newController(); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("图片大小不能超过20MB"); + verify(ossService, never()).upload(any(MultipartFile.class)); +} + +@Test +void uploadImage_rejectsSvg() { + AppController controller = newController(); + MockMultipartFile file = new MockMultipartFile( + "file", "plant.svg", "image/svg+xml", "".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片"); + verify(ossService, never()).upload(any(MultipartFile.class)); +} +``` + +- [ ] **Step 2: Run upload tests and verify RED** + +Run: + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppControllerTest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +Expected: the forged and oversized tests fail because current code trusts `Content-Type` and has no endpoint-specific limit. + +- [ ] **Step 3: Implement image validation** + +Add constants and mappings: + +```java +private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024 * 1024; +private static final Set ALLOWED_IMAGE_EXTENSIONS = + Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp"); +private static final Map IMAGE_CONTENT_TYPES = Map.of( + "jpg", "image/jpeg", + "png", "image/png", + "gif", "image/gif", + "webp", "image/webp", + "bmp", "image/bmp" +); +``` + +Call `validateImageFile(file)` before OSS. Implement it with `FileUtil.extName` and `FileTypeUtil.getType(file.getInputStream())`, normalize `jpeg` to `jpg`, and enforce extension/MIME/signature compatibility: + +```java +private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ServiceException("上传图片不能为空"); + } + if (file.getSize() > MAX_IMAGE_SIZE_BYTES) { + throw new ServiceException("图片大小不能超过20MB"); + } + String extension = StringUtils.lowerCase(FileUtil.extName(file.getOriginalFilename())); + if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) { + throw new ServiceException("仅支持jpg、jpeg、png、gif、webp、bmp图片"); + } + String contentType = StringUtils.lowerCase(file.getContentType()); + String normalizedExtension = "jpeg".equals(extension) ? "jpg" : extension; + if (!IMAGE_CONTENT_TYPES.get(normalizedExtension).equals(contentType)) { + throw new ServiceException("图片文件类型不匹配"); + } + try (InputStream inputStream = file.getInputStream()) { + String detectedType = StringUtils.lowerCase(FileTypeUtil.getType(inputStream)); + String normalizedDetectedType = "jpeg".equals(detectedType) ? "jpg" : detectedType; + if (!normalizedExtension.equals(normalizedDetectedType)) { + throw new ServiceException("图片文件内容不合法"); + } + } catch (IOException e) { + throw new ServiceException("读取图片文件失败"); + } +} +``` + +- [ ] **Step 4: Run AppController tests and verify GREEN** + +Run the command from Step 2. Expected: all existing and new upload tests pass. + +- [ ] **Step 5: Record the Task 2 diff checkpoint** + +```powershell +git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +``` + +--- + +### Task 3: Add Bounded Batch Query Service APIs + +**Files:** +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java` +- Modify: `water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java` +- Create: `water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java` + +**Interfaces:** +- Produces: `List queryByDeviceNos(Collection deviceNos)`. +- Produces: `Set findBoundDeviceNos(Collection deviceNos)`. + +- [ ] **Step 1: Write failing service tests against the desired APIs** + +Add to `AppDeviceServiceImplTest`: + +```java +@Test +void queryByDeviceNos_delegatesToSingleBatchQuery() { + AppDeviceServiceImpl service = new AppDeviceServiceImpl( + appDeviceMapper, schedulingDeviceMapper, wateringLogMapper, wateringLogService); + List deviceNos = List.of("D01", "D02"); + List expected = List.of(new AppDeviceVo(), new AppDeviceVo()); + when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected); + + assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected); + verify(appDeviceMapper).selectVoByIds(deviceNos); +} +``` + +Create `AppSchedulingDeviceServiceImplTest`: + +```java +@ExtendWith(MockitoExtension.class) +class AppSchedulingDeviceServiceImplTest { + + @Mock + private AppSchedulingDeviceMapper mapper; + + @Test + void findBoundDeviceNos_usesOneConstrainedQuery() { + AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper); + AppSchedulingDevice first = new AppSchedulingDevice(); + first.setDeviceNo("D01"); + AppSchedulingDevice duplicate = new AppSchedulingDevice(); + duplicate.setDeviceNo("D01"); + when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, duplicate)); + + Set result = service.findBoundDeviceNos(List.of("D01", "D02")); + + assertThat(result).containsExactly("D01"); + verify(mapper, times(1)).selectList(any(Wrapper.class)); + } +} +``` + +- [ ] **Step 2: Run service tests and verify RED** + +Run: + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" "-Dtest=AppDeviceServiceImplTest,AppSchedulingDeviceServiceImplTest" "-Dsurefire.failIfNoSpecifiedTests=false" test +``` + +Expected: test compilation fails because the two batch APIs do not exist yet. + +- [ ] **Step 3: Add the interfaces and minimal implementations** + +Add to `IAppDeviceService` and `AppDeviceServiceImpl`: + +```java +List queryByDeviceNos(Collection deviceNos); +``` + +```java +@Override +public List queryByDeviceNos(Collection deviceNos) { + if (deviceNos == null || deviceNos.isEmpty()) { + return List.of(); + } + return baseMapper.selectVoByIds(deviceNos); +} +``` + +Add to `IAppSchedulingDeviceService` and `AppSchedulingDeviceServiceImpl`: + +```java +Set findBoundDeviceNos(Collection deviceNos); +``` + +```java +@Override +public Set findBoundDeviceNos(Collection deviceNos) { + if (deviceNos == null || deviceNos.isEmpty()) { + return Set.of(); + } + return baseMapper.selectList( + Wrappers.lambdaQuery() + .select(AppSchedulingDevice::getDeviceNo) + .in(AppSchedulingDevice::getDeviceNo, deviceNos)) + .stream() + .map(AppSchedulingDevice::getDeviceNo) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toSet()); +} +``` + +- [ ] **Step 4: Run service tests and verify GREEN** + +Run the command from Step 2. Expected: both service test classes pass. + +- [ ] **Step 5: Record the Task 3 diff checkpoint** + +```powershell +git diff -- water-modules/water-app/src/main/java/org/dromara/app/service water-modules/water-app/src/test/java/org/dromara/app/service/impl +``` + +--- + +### Task 4: Replace Controller N+1 Queries + +**Files:** +- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:175-195` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:357-400` + +**Interfaces:** +- Consumes: `IAppDeviceService.queryByDeviceNos` from Task 3. +- Consumes: `IAppSchedulingDeviceService.findBoundDeviceNos` from Task 3. +- Preserves: `GET /app/v1/scheduleDeviceList/{scheduleId}` and `GET /app/v1/schedule/{id}` payloads. + +- [ ] **Step 1: Write failing Controller interaction tests** + +Add these tests: + +```java +@Test +void scheduleDeviceList_usesSingleBatchBindingLookup() { + AppController controller = newController(); + AppDeviceVo first = ownedDevice("D01", 99L); + AppDeviceVo second = ownedDevice("D02", 99L); + when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second)); + when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02"))) + .thenReturn(Set.of("D01")); + + R> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L)); + + assertThat((List) result.getData().get("deviceList")) + .extracting(AppDeviceVo::getDeviceNo) + .containsExactly("D02"); + verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString()); +} + +@Test +void getScheduleInfo_usesSingleBatchDeviceLookup() { + AppController controller = newController(); + AppScheduleVo schedule = ownedSchedule(10L, 99L); + AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo(); + firstBinding.setDeviceNo("D01"); + AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo(); + secondBinding.setDeviceNo("D02"); + List devices = List.of(ownedDevice("D01", 99L), ownedDevice("D02", 99L)); + when(appScheduleService.queryById(10L)).thenReturn(schedule); + when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of()); + when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))) + .thenReturn(List.of(firstBinding, secondBinding)); + when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(devices); + + R result = callWithLogin(99L, () -> controller.getScheduleInfo(10L)); + + assertThat(result.getData().getDeviceNos()).isEqualTo(devices); + verify(appDeviceService, never()).queryById("D01"); + verify(appDeviceService, never()).queryById("D02"); +} +``` + +- [ ] **Step 2: Run AppController tests and verify RED** + +Run the Task 2 test command. Expected: interaction assertions fail because current methods query once per device/binding. + +- [ ] **Step 3: Implement batch-backed Controller methods** + +Use one binding lookup in `scheduleDeviceList`: + +```java +List deviceNos = appDeviceVos.stream().map(AppDeviceVo::getDeviceNo).toList(); +Set boundDeviceNos = appSchedulingDeviceService.findBoundDeviceNos(deviceNos); +List availableDevices = appDeviceVos.stream() + .filter(device -> !boundDeviceNos.contains(device.getDeviceNo())) + .toList(); +Map result = new HashMap<>(); +result.put("deviceList", availableDevices); +return R.ok(result); +``` + +Use one batch device lookup in `getScheduleInfo`: + +```java +List deviceNos = bindings.stream() + .map(AppSchedulingDeviceVo::getDeviceNo) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); +List devices = appDeviceService.queryByDeviceNos(deviceNos); +``` + +- [ ] **Step 4: Run AppController tests and verify GREEN** + +Run the Task 2 command. Expected: all `AppControllerTest` tests pass. + +- [ ] **Step 5: Record the Task 4 diff checkpoint** + +```powershell +git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +``` + +--- + +### Task 5: Reuse Schedule Details Across MQTT Notifications + +**Files:** +- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:200-287` +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java:763-794` + +**Interfaces:** +- Preserves all schedule MQTT command payload fields. +- Changes internal helper to consume prebuilt `List> details`. + +- [ ] **Step 1: Write a failing query-count test** + +Add an open-schedule test with two bindings: + +```java +@Test +void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() { + AppController controller = newController(); + AppScheduleBo bo = new AppScheduleBo(); + bo.setId(10L); + bo.setStatus("1"); + AppSchedulingDeviceVo first = new AppSchedulingDeviceVo(); + first.setDeviceNo("D01"); + AppSchedulingDeviceVo second = new AppSchedulingDeviceVo(); + second.setDeviceNo("D02"); + when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L)); + when(appScheduleService.updateStatusByBo(bo)).thenReturn(true); + when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))) + .thenReturn(List.of(first, second)); + when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of()); + + callWithLogin(99L, () -> controller.editScheduleStatus(bo)); + + verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L); + verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any()); +} +``` + +- [ ] **Step 2: Run the test and verify RED** + +Run the Task 2 command. Expected: detail service is called twice. + +- [ ] **Step 3: Build details once and reuse them** + +Change payload construction to: + +```java +private Map buildScheduleBindPayload( + String deviceNo, List> details) { + Map payload = new HashMap<>(); + payload.put("deviceNo", deviceNo); + payload.put("details", details); + return payload; +} + +private List> loadScheduleDetailPayload(Long scheduleId) { + return toScheduleDetailPayload(appScheduleDetailService.queryByScheduleIdByStatus(scheduleId)); +} +``` + +Call `loadScheduleDetailPayload(scheduleId)` once before each device loop in add/update/open-status notification paths, then pass the result into `buildScheduleBindPayload(deviceNo, details)`. + +- [ ] **Step 4: Run AppController tests and verify GREEN** + +Run the Task 2 command. Expected: all tests pass and the detail service is called once for two devices. + +- [ ] **Step 5: Record the Task 5 diff checkpoint** + +```powershell +git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +``` + +--- + +### Task 6: Complete Type-Safety And Local Cleanup + +**Files:** +- Modify: `water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java` +- Modify: `water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java` + +**Interfaces:** +- Removes the unused `AppScheduleServiceImpl` constructor dependency. +- Preserves all endpoint return JSON shapes. + +- [ ] **Step 1: Capture the current static-analysis failure** + +Run: + +```powershell +rg -n '^import .*\*;|\b(Map|List|Set)\s+[a-zA-Z][a-zA-Z0-9_]*\s*=|R' water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java +``` + +Expected: matches for wildcard imports, raw collections, and raw response maps. + +- [ ] **Step 2: Replace raw types and wildcard imports** + +Use these concrete types: + +```java +R> +List +List +List> +List +Map +Map +``` + +Replace `org.dromara.app.domain.vo.*`, `org.dromara.app.service.*`, `org.springframework.web.bind.annotation.*`, and `java.util.*` with explicit imports. Remove the unused `AppScheduleServiceImpl appScheduleServiceimpl` field and its test mock/constructor argument. + +Replace Controller time formatting with: + +```java +private static final DateTimeFormatter SECOND_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + +String startTime = LocalDateTime.now().format(SECOND_TIME_FORMATTER); + +private String formatSecondTime(Date time) { + return time == null ? null : time.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .format(SECOND_TIME_FORMATTER); +} +``` + +Remove commented-out annotations and dead commented code only in sections touched by this plan. + +- [ ] **Step 3: Re-run the static query and verify GREEN** + +Run the Step 1 command. Expected: no output and exit code 1 from `rg` because there are no matches. + +- [ ] **Step 4: Run all water-app tests** + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test +``` + +Expected: reactor `BUILD SUCCESS`, zero test failures. + +- [ ] **Step 5: Record the Task 6 diff checkpoint** + +```powershell +git diff -- water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +``` + +--- + +### Task 7: Final Verification Against The Review Standard + +**Files:** +- Verify only; no planned production edits. + +**Interfaces:** +- Verifies the deliverables from Tasks 1-6 together. + +- [ ] **Step 1: Run common-web tests** + +```powershell +mvn -pl water-common/water-common-web -am "-DskipTests=false" "-Dmaven.test.skip=false" test +``` + +Expected: `BUILD SUCCESS` and `GlobalExceptionHandlerTest` passes. + +- [ ] **Step 2: Run water-app tests** + +```powershell +mvn -pl water-modules/water-app -am "-DskipTests=false" "-Dmaven.test.skip=false" "-Dprofiles.active=dev" test +``` + +Expected: `BUILD SUCCESS` with zero failures and errors. + +- [ ] **Step 3: Run structural and diff checks** + +```powershell +rg -n 'return fail\(|private R fail|toI18nErrorMessage|^import .*\*;|R|\b(Map|List|Set)\s+[a-zA-Z][a-zA-Z0-9_]*\s*=' water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java +git diff --check +git status --short +``` + +Expected: no forbidden Controller matches, `git diff --check` exits 0, and status contains only intentional task files plus pre-existing unrelated changes. + +- [ ] **Step 4: Review the final diff by task scope** + +```powershell +git diff -- water-common/water-common-web water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java water-modules/water-app/src/main/java/org/dromara/app/service water-modules/water-app/src/test +``` + +Expected: no route changes, no MQTT topic changes, no schema changes, and no unrelated file modifications. diff --git a/overview.md b/overview.md new file mode 100644 index 0000000..1b61ea3 --- /dev/null +++ b/overview.md @@ -0,0 +1,41 @@ +# 代码审查标准与流程 - 工作概述 + +## 完成事项 + +为 water 项目制定了系统化的代码审查机制,包含完整的审查标准、流程规范和落地工具方案。 + +## 交付物 + +| 文件 | 内容 | +|------|------| +| `docs/code-review-standards.md` | 代码审查标准与流程主文档(6 大章节) | + +## 关键发现 + +通过对项目代码的全面审查,识别出以下系统性问题: + +1. **零静态分析工具** — 项目未配置 Checkstyle/SpotBugs/SonarQube +2. **上帝类** — `AppController` 940 行,职责过多 +3. **异常吞噬** — 几乎每个方法用 `try-catch(Exception)` 包裹 +4. **Raw Type 泛滥** — `Map`、`List`、`HashMap` 大量无泛型 +5. **N+1 查询** — 循环内逐个查询数据库/Redis +6. **线程安全** — `SimpleDateFormat` 在多线程环境使用 +7. **魔法值** — 状态字符串 `"0"`/`"1"` 硬编码 + +同时发现优秀实践:MQTT 架构设计(策略模式+有界队列+ACK重试)、分布式锁使用规范。 + +## 标准文档结构 + +1. **代码审查标准** — 10 个维度(架构/命名/安全/性能/异常/并发/测试/日志等),三级问题分级(Blocker/Suggestion/Nit) +2. **代码审查流程** — 角色定义、PR 提交前自检、审查步骤、合并标准、冲突升级、紧急修复通道 +3. **检查清单** — 8 个维度 40+ 检查项,可直接作为 PR 审查模板 +4. **静态分析工具集成方案** — Checkstyle + SpotBugs + SonarQube 的 Maven 配置和 4 周实施路线 +5. **典型问题案例** — 5 个 water 项目真实代码案例(含正反对比) +6. **推广落地建议** — 分阶段实施计划 + 度量指标 + +## 后续建议 + +- 第 1 周:团队宣贯,学习审查标准 +- 第 2 周:集成 Checkstyle + SpotBugs,修复 P0 问题 +- 第 3-4 周:PR 审查试运行 +- 第 5 周起:正式执行 Blocker 零容忍 diff --git a/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png b/tmp/hatch-pet/omen-cat/reference/omen-cat-source.png new file mode 100644 index 0000000000000000000000000000000000000000..396f3b271f0eda5a1990c07769daf9abe356ba02 GIT binary patch literal 68823 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 literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/jumping.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/jumping.png new file mode 100644 index 0000000000000000000000000000000000000000..606d80550dff7049cbb53ebe41f4acbb90f2cbfd GIT binary patch literal 1740 zcmeAS@N?(olHy`uVBq!ia0y~yU_Jn3U*KQ@lI~sePcbmCP4;wg45^s&_O4-GNVr7X z!`B^MdADx5tCXilt~q$xN;c@5q4$D55f!c_cV3I%e>mgg=MWJ$&USzMe7CTQT`_vn z3=AKv>i2$n&A`x*d)uzIzV-IoXOhd7VlBN!N0t)euI2iVf{BwY)-W^ zc&_uF^V{LfJMwSZL+{M5#pMlj-;NrOCn-=Za2h~K?)L{yv;<~na7=@6tnr_*JYC)N zw?OJU`|m{2@9FbV(=RAtPJdqi@AtnMkNfV-ruG;(W$Hu>ZC44;z5V1z2Y9 zum?sgxYTZ1e){RA+}m%^vFMxjQ*{;GzYo?$RM*!qkzJ;P3qzY&z3HF6 k0&9_lKS5Q*w(Gye)hqcnL^9UT1J+Uup00i_>zopr0Be9#;Q#;t literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/look-cardinals.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/look-cardinals.png new file mode 100644 index 0000000000000000000000000000000000000000..89c008d9f1965f2c2e872af7a06faa617dca815a GIT binary patch literal 1522 zcmeAS@N?(olHy`uVBq!ia0y~yUD} zSOgd*C^s-P$S|@nJmFAaP;h5pWUyqyrsP%bhx%7P-zH~QRR8<>>)&^=5C49B&iX&? zuZ`UAoh;AKt6J2{o&UXEZodsP(5#xzk_%qfpWlA!<(D7w&sM!}m-{E2Z7-f`-+%CD zv8CMpV_;P{Y`k#f^y$;3`-E&%ejltW_;*JQi|d4!Uw&y7&nY+QZ-Tu=ecohZd^4+H zwt4jp{%?o1&doQ(W@g^!G=89!5l4R-s}U0_YIl~MmiyNHcSG0bbRW!+yU=pGy-yOcwZQro}P85B9eg=_2sE0oYFQ;M+!sjom^S^!j^ZNdL{d4u7 zFFi<~@cq-Q_&aZY|9pG>&)=(ew*CL})b-~1x9bnpF-#!EVP1fbL;%m8|B{||GBw69hi;hz|zj1U$zg=$xy90WMGh; f$H>I6ZB{*lmekU7bIKon1{LX^u6{1-oD!M<_zE6l literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/look-row-10.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/look-row-10.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfc18141e2902ee45d20413f9007e5e86c5906f GIT binary patch literal 2355 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 literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/running-left.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/running-left.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfc18141e2902ee45d20413f9007e5e86c5906f GIT binary patch literal 2355 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 literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/waiting.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/waiting.png new file mode 100644 index 0000000000000000000000000000000000000000..c8e68ee6b96e8ac8958b05aa346572d06a23282d GIT binary patch literal 1942 zcmeAS@N?(olHy`uVBq!ia0y~yU}*rdFK{pc$q(GUFBurvV?A9QLn`LHy=#~!VK{=aJ;{E0mHbF=*O z^IYfVi_WqC{qx_d{r1)DJPZsElJ_%T`#<|~^~~b`_Rp&SR-gH%zviz_s(p3Azcr8l zMIHaSnf>{Bt8???Kl2fE^UD4}bFSP~A1b1YnQ}GO{r+2oS+kKPGW@a)S4_=D!dAJnXs?f8)Fr z6VN{jN(X?ZC?PZ79GSDZQYhzS-3IwN_AwKGbN`NXCn>1sN+g^3H|gHw|JMAuUAw40 z(f&22hrr(4AY6Rzndb&oVEh*RD{y_M|0Z3QSPy|cUzXH!(S~zdiv11#bMos>{B8Za zMj69H5O4aOv7D`baM~O8-v@sl7Tvjj!~9Z`lF`tOEO1(%|L@oR@9gvcKiL;w!~Zd~ z?kTtYx9>k+#sB<%*{1KEyWj2e$KzZ7)=UWh-MV_sp*p)0tN$Jf4NJ6_e{!|%q1NgR z^Z7qz?Jt-bdgjUd^YMAxZx_n+AAh{v?(NH;#|veES*P$#-npHB`J3zSUfy&6y}kXm z`SnTm-#&|dO9qnJ@79;ju|FsOJF@)W4>1>v#J%xvJwvi4G?xQY`1#{1xMK0o@A)5i zza9QR{WJUThkGsRM@}^2O|7GOj-&!|;`;obPnJKIKmKmse7mFapJQv<#IMy|+bI9$ z_|MOAbL?;Qe}1mJhZ$71{w&&kx9@RVFlWY5_|z sN~-85U}YE@&kd|%g27eCfr~%%S=`H;PqnlQ0?RrEPgg&ebxsLQ03=C$^8f$< literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/layout-guides/waving.png b/tmp/hatch-pet/omen-cat/run/references/layout-guides/waving.png new file mode 100644 index 0000000000000000000000000000000000000000..89c008d9f1965f2c2e872af7a06faa617dca815a GIT binary patch literal 1522 zcmeAS@N?(olHy`uVBq!ia0y~yUD} zSOgd*C^s-P$S|@nJmFAaP;h5pWUyqyrsP%bhx%7P-zH~QRR8<>>)&^=5C49B&iX&? zuZ`UAoh;AKt6J2{o&UXEZodsP(5#xzk_%qfpWlA!<(D7w&sM!}m-{E2Z7-f`-+%CD zv8CMpV_;P{Y`k#f^y$;3`-E&%ejltW_;*JQi|d4!Uw&y7&nY+QZ-Tu=ecohZd^4+H zwt4jp{%?o1&doQ(W@g^!G=89!5l4R-s}U0_YIl~MmiyNHcSG0bbRW!+yU=pGy-yOcwZQro}P85B9eg=_2sE0oYFQ;M+!sjom^S^!j^ZNdL{d4u7 zFFi<~@cq-Q_&aZY|9pG>&)=(ew*CL})b-~1x9bnpF-#!EVP1fbL;%m8|B{||GBw69hi;hz|zj1U$zg=$xy90WMGh; f$H>I6ZB{*lmekU7bIKon1{LX^u6{1-oD!M<_zE6l literal 0 HcmV?d00001 diff --git a/tmp/hatch-pet/omen-cat/run/references/reference-01.png b/tmp/hatch-pet/omen-cat/run/references/reference-01.png new file mode 100644 index 0000000000000000000000000000000000000000..396f3b271f0eda5a1990c07769daf9abe356ba02 GIT binary patch literal 68823 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 { - deletePending(commandId); - return true; - }); - } - @Override public void handleAck(String deviceNo, String payload) { if (StringUtils.isBlank(payload)) { @@ -111,7 +96,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { return; } } - boolean ackSaved = withCommandLock(ack.getCommandId(), () -> { + long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs(); + CommandLockResult ackResult = withCommandLock(ack.getCommandId(), lockWaitMs, () -> { DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(ack.getCommandId())); if (pending == null) { log.warn("[MQTT] ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, ack.getCommandId()); @@ -126,14 +112,36 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { RedisUtils.setCacheObject(ackKey(ack.getCommandId()), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); return true; }); - if (!ackSaved) { - log.warn("[MQTT] ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, ack.getCommandId()); + if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) { + log.warn("[MQTT] ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}", + deviceNo, ack.getCommandId(), lockWaitMs); + return; + } + if (ackResult == CommandLockResult.INTERRUPTED) { + return; + } + if (ackResult == CommandLockResult.ACTION_FAILED) { return; } refreshDeviceOnline(deviceNo); log.info("[MQTT] 收到命令确认 设备编号={} 命令编号={} message={}", deviceNo, ack.getCommandId(), ack.getMessage()); } + public void savePending(DeviceCommand command) { + RedisUtils.setCacheObject(pendingKey(command.getCommandId()), command, Duration.ofSeconds(mqttProperties.getCommandAck().getPendingTtlSeconds())); + pendingIds().add(command.getCommandId()); + } + + public void removePending(String commandId) { + if (StringUtils.isBlank(commandId)) { + return; + } + withCommandLock(commandId, () -> { + deletePending(commandId); + return true; + }); + } + private void handlePlainAck(String deviceNo, String payload) { List pendingCommands = findPendingCommandsByDeviceNo(deviceNo); refreshDeviceOnline(deviceNo); @@ -150,7 +158,8 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { ack.setStatus("1"); ack.setMessage(payload); - boolean ackSaved = withCommandLock(commandId, () -> { + long lockWaitMs = mqttProperties.getCommandAck().getAckLockWaitMs(); + CommandLockResult ackResult = withCommandLock(commandId, lockWaitMs, () -> { DeviceCommand pending = RedisUtils.getCacheObject(pendingKey(commandId)); if (pending == null) { log.warn("[MQTT] 非 JSON ACK 对应命令不存在或已过期 设备编号={} 命令编号={}", deviceNo, commandId); @@ -165,13 +174,24 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { RedisUtils.setCacheObject(ackKey(commandId), ack, Duration.ofSeconds(mqttProperties.getCommandAck().getAckTtlSeconds())); return true; }); - if (!ackSaved) { - log.warn("[MQTT] 非 JSON ACK 处理未获得命令锁,本次跳过 设备编号={} 命令编号={}", deviceNo, commandId); + if (ackResult == CommandLockResult.LOCK_UNAVAILABLE) { + log.warn("[MQTT] 非 JSON ACK 等待命令锁超时,本次跳过 设备编号={} 命令编号={} 等待毫秒={}", + deviceNo, commandId, lockWaitMs); + return; + } + if (ackResult == CommandLockResult.INTERRUPTED) { + return; + } + if (ackResult == CommandLockResult.ACTION_FAILED) { return; } log.info("[MQTT] 收到非 JSON 命令确认 设备编号={} 命令编号={} 消息体={}", deviceNo, commandId, payload); } + private CommandLockResult withCommandLock(String commandId, BooleanSupplier action) { + return withCommandLock(commandId, 0L, action); + } + private List findPendingCommandsByDeviceNo(String deviceNo) { List commands = new ArrayList<>(); for (String commandId : pendingIds().readAll()) { @@ -284,22 +304,22 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { pendingIds().remove(commandId); } - private boolean withCommandLock(String commandId, BooleanSupplier action) { + private CommandLockResult withCommandLock(String commandId, long waitTimeMs, BooleanSupplier action) { RLock lock = RedisUtils.getClient().getLock(mqttProperties.getCommandAck().getRetryLockKeyPrefix() + commandId); boolean locked = false; try { - locked = lock.tryLock(0, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS); + locked = lock.tryLock(waitTimeMs, mqttProperties.getCommandAck().getRetryLockTtlMs(), TimeUnit.MILLISECONDS); if (!locked) { - return false; + return CommandLockResult.LOCK_UNAVAILABLE; } - return action.getAsBoolean(); + return action.getAsBoolean() ? CommandLockResult.SUCCESS : CommandLockResult.ACTION_FAILED; } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("[MQTT] 命令锁等待被中断 命令编号={}", commandId); - return false; + return CommandLockResult.INTERRUPTED; } catch (RuntimeException e) { log.error("[MQTT] 命令锁内处理失败 命令编号={}", commandId, e); - return false; + return CommandLockResult.ACTION_FAILED; } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); @@ -307,6 +327,13 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { } } + private enum CommandLockResult { + SUCCESS, + LOCK_UNAVAILABLE, + INTERRUPTED, + ACTION_FAILED + } + private String pendingKey(String commandId) { return mqttProperties.getCommandAck().getPendingKeyPrefix() + commandId; } diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java index 3f0569a..01b656d 100644 --- a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/config/properties/MqttProperties.java @@ -64,6 +64,7 @@ public class MqttProperties { private String pendingSetKey = "mqtt:command:pending:ids"; private String retryLockKeyPrefix = "lock:mqtt:command:retry:"; private long retryLockTtlMs = 30000; + private long ackLockWaitMs = 3000; private String deviceStatusCachePrefix = "mqtt:device:status:"; private long deviceStatusCacheTtlSeconds = 900; } diff --git a/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java b/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java index 147c0c8..efd19f0 100644 --- a/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java +++ b/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java @@ -2,6 +2,7 @@ package org.dromara.mqtt; import cn.hutool.extra.spring.SpringUtil; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.session.Configuration; import org.dromara.app.domain.AppDevice; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.redisson.api.RLock; +import org.redisson.api.RSet; import org.redisson.api.RedissonClient; import org.springframework.context.support.GenericApplicationContext; import org.springframework.test.util.ReflectionTestUtils; @@ -24,6 +26,7 @@ import java.time.Duration; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -42,6 +45,7 @@ class MqttCommandAckServiceTest { } applicationContext = new GenericApplicationContext(); applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class)); + applicationContext.registerBean(ObjectMapper.class, (Supplier) ObjectMapper::new); applicationContext.refresh(); new SpringUtil().setApplicationContext(applicationContext); } @@ -93,6 +97,44 @@ class MqttCommandAckServiceTest { assertThat(MqttCommandAckService.ackMatchesPendingCommand("D01", null)).isFalse(); } + @Test + void handleAckWaitsBrieflyForCommandLock() throws Exception { + MqttProperties properties = new MqttProperties(); + AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class); + RedissonClient redissonClient = mock(RedissonClient.class); + RLock commandLock = mock(RLock.class); + RLock statusLock = mock(RLock.class); + RSet pendingIds = mock(RSet.class); + + DeviceCommand pending = new DeviceCommand(); + pending.setCommandId("cmd-1"); + pending.setDeviceNo("D01"); + + when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock); + when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true); + when(commandLock.isHeldByCurrentThread()).thenReturn(true); + when(redissonClient.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 redis = mockStatic(RedisUtils.class)) { + redis.when(RedisUtils::getClient).thenReturn(redissonClient); + redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending); + + service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}"); + + verify(commandLock).tryLock(3000, 30000, TimeUnit.MILLISECONDS); + redis.verify(() -> RedisUtils.setCacheObject( + eq("mqtt:command:ack:cmd-1"), + any(DeviceCommandAck.class), + eq(Duration.ofSeconds(86400)) + )); + verify(pendingIds).remove("cmd-1"); + verify(commandLock).unlock(); + } + } + @Test void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception { MqttProperties properties = new MqttProperties(); diff --git a/water-common/water-common-web/pom.xml b/water-common/water-common-web/pom.xml index 1ea052d..0cf7f05 100644 --- a/water-common/water-common-web/pom.xml +++ b/water-common/water-common-web/pom.xml @@ -52,6 +52,12 @@ cn.hutool hutool-crypto + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java b/water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java index 7a5d82c..a207053 100644 --- a/water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java +++ b/water-common/water-common-web/src/main/java/org/dromara/common/web/handler/GlobalExceptionHandler.java @@ -12,7 +12,9 @@ import org.dromara.common.core.domain.R; import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.SseException; import org.dromara.common.core.exception.base.BaseException; +import org.dromara.common.core.utils.MessageUtils; import org.dromara.common.core.utils.StreamUtils; +import org.dromara.common.core.utils.StringUtils; import org.dromara.common.json.utils.JsonUtils; import org.springframework.context.MessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable; @@ -57,9 +59,11 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(ServiceException.class) public R handleServiceException(ServiceException e, HttpServletRequest request) { - log.error(e.getMessage()); + String requestURI = request.getRequestURI(); + String message = resolveErrorMessage(e.getMessage()); + log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e); Integer code = e.getCode(); - return ObjectUtil.isNotNull(code) ? R.fail(code, e.getMessage()) : R.fail(e.getMessage()); + return ObjectUtil.isNotNull(code) ? R.fail(code, message) : R.fail(message); } /** @@ -88,8 +92,10 @@ public class GlobalExceptionHandler { */ @ExceptionHandler(BaseException.class) public R handleBaseException(BaseException e, HttpServletRequest request) { - log.error(e.getMessage()); - return R.fail(e.getMessage()); + String requestURI = request.getRequestURI(); + String message = e.getMessage(); + log.warn("请求地址'{}',发生业务异常'{}'", requestURI, message, e); + return R.fail(message); } /** @@ -150,7 +156,7 @@ public class GlobalExceptionHandler { public R handleRuntimeException(RuntimeException e, HttpServletRequest request) { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生未知异常.", requestURI, e); - return R.fail(e.getMessage()); + return R.fail(); } /** @@ -160,7 +166,7 @@ public class GlobalExceptionHandler { public R handleException(Exception e, HttpServletRequest request) { String requestURI = request.getRequestURI(); log.error("请求地址'{}',发生系统异常.", requestURI, e); - return R.fail(e.getMessage()); + return R.fail(); } /** @@ -232,4 +238,12 @@ public class GlobalExceptionHandler { return R.fail(HttpStatus.HTTP_INTERNAL_ERROR, "SpEL解析失败:" + e.getMessage()); } + private String resolveErrorMessage(String message) { + if (StringUtils.isBlank(message)) { + return MessageUtils.message("operation.fail"); + } + String i18nMessage = MessageUtils.message(message); + return message.equals(i18nMessage) ? message : i18nMessage; + } + } diff --git a/water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java b/water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..014e64d --- /dev/null +++ b/water-common/water-common-web/src/test/java/org/dromara/common/web/handler/GlobalExceptionHandlerTest.java @@ -0,0 +1,89 @@ +package org.dromara.common.web.handler; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import cn.hutool.extra.spring.SpringUtil; +import jakarta.servlet.http.HttpServletRequest; +import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.base.BaseException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.context.support.StaticMessageSource; + +import java.util.Locale; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@Tag("dev") +class GlobalExceptionHandlerTest { + + @Mock + private HttpServletRequest request; + + private GlobalExceptionHandler handler; + private GenericApplicationContext applicationContext; + + @BeforeEach + void setUp() { + StaticMessageSource messageSource = new StaticMessageSource(); + messageSource.addMessage("operation.fail", Locale.getDefault(), "操作失败"); + applicationContext = new GenericApplicationContext(); + applicationContext.registerBean("messageSource", StaticMessageSource.class, () -> messageSource); + applicationContext.refresh(); + new SpringUtil().setApplicationContext(applicationContext); + handler = new GlobalExceptionHandler(); + when(request.getRequestURI()).thenReturn("/app/v1/device/D01"); + } + + @AfterEach + void tearDown() { + applicationContext.close(); + } + + @Test + void handleRuntimeException_hidesInternalMessage() { + R result = handler.handleRuntimeException( + new IllegalStateException("jdbc:mysql://internal-host/water"), request); + + assertThat(result.getCode()).isEqualTo(R.FAIL); + assertThat(result.getMsg()).doesNotContain("internal-host"); + } + + @Test + void handleException_hidesInternalMessage() { + R result = handler.handleException( + new Exception("oss accessKeySecret=internal-secret"), request); + + assertThat(result.getCode()).isEqualTo(R.FAIL); + assertThat(result.getMsg()).doesNotContain("internal-secret"); + } + + @Test + void handleBaseException_logsThrowableAndRequestUri() { + Logger logger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + handler.handleBaseException(new BaseException("user", "业务校验失败"), request); + + ILoggingEvent event = appender.list.get(appender.list.size() - 1); + assertThat(event.getLevel()).isEqualTo(Level.WARN); + assertThat(event.getFormattedMessage()).contains("/app/v1/device/D01"); + assertThat(event.getThrowableProxy()).isNotNull(); + } finally { + logger.detachAppender(appender); + } + } +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java index 6d9f935..4ba90cc 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java @@ -1,11 +1,14 @@ package org.dromara.app.controller; +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.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; @@ -16,7 +19,6 @@ import org.dromara.app.domain.bo.AppSchedulingDeviceBo; import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.vo.*; import org.dromara.app.service.*; -import org.dromara.app.service.impl.AppScheduleServiceImpl; import org.dromara.common.core.domain.R; import org.dromara.common.core.domain.model.LoginUser; import org.dromara.common.core.exception.ServiceException; @@ -47,21 +49,36 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.text.SimpleDateFormat; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.*; @Slf4j @RequiredArgsConstructor @RestController -//@SaCheckLogin @RequestMapping("/app/v1") public class AppController extends BaseController { + private static final long MAX_IMAGE_SIZE_BYTES = 20L * 1024 * 1024; + private static final DateTimeFormatter SECOND_TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set ALLOWED_IMAGE_EXTENSIONS = + Set.of("jpg", "jpeg", "png", "gif", "webp", "bmp"); + private static final Map> IMAGE_CONTENT_TYPES = Map.of( + "jpg", Set.of("image/jpeg", "image/jpg"), + "png", Set.of("image/png"), + "gif", Set.of("image/gif"), + "webp", Set.of("image/webp"), + "bmp", Set.of("image/bmp", "image/x-bmp", "image/x-ms-bmp") + ); + private final IAppDeviceService appDeviceService; private final IAppScheduleService appScheduleService; private final IAppScheduleDetailService appScheduleDetailService; - private final AppScheduleServiceImpl appScheduleServiceimpl; private final IAppSchedulingDeviceService appSchedulingDeviceService; private final ISysUserService userService; private final IAppWateringLogService appWateringLogService; @@ -89,15 +106,11 @@ public class AppController extends BaseController { */ @ApiEncrypt @PostMapping("/bindDeviceStatus") - public R bindDeviceStatus(@RequestBody String body) { - try { - AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); - appDevice.setUserId(LoginHelper.getUserId()); - Map map = appDeviceService.bindDeviceStatus(appDevice); - return R.ok(map); - } catch (Exception e) { - return fail(e); - } + public R> bindDeviceStatus(@RequestBody String body) { + AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); + appDevice.setUserId(LoginHelper.getUserId()); + Map map = appDeviceService.bindDeviceStatus(appDevice); + return R.ok(map); } /** @@ -106,13 +119,9 @@ public class AppController extends BaseController { @ApiEncrypt @PostMapping("/addDevice") public R bindDevice(@RequestBody String body) { - try { - AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); - appDevice.setUserId(LoginHelper.getUserId()); - return R.ok(appDeviceService.bindRegisteredDevice(appDevice)); - } catch (Exception e) { - return fail(e); - } + AppDeviceBo appDevice = JsonUtils.parseObject(body, AppDeviceBo.class); + appDevice.setUserId(LoginHelper.getUserId()); + return R.ok(appDeviceService.bindRegisteredDevice(appDevice)); } /** @@ -124,11 +133,7 @@ public class AppController extends BaseController { @GetMapping("/device/{deviceNo}") public R getDeviceInfo(@NotNull(message = "主键不能为空") @PathVariable String deviceNo) { - try { - return R.ok(getOwnedDevice(deviceNo)); - } catch (Exception e) { - return fail(e); - } + return R.ok(getOwnedDevice(deviceNo)); } @@ -136,18 +141,12 @@ public class AppController extends BaseController { * 修改设备信息 * */ - // @SaCheckRole("appadmin") @RepeatSubmit() @PutMapping("/updateDeviceInfo") public R updateDeviceInfo(@Validated @RequestBody AppDeviceBo bo) { - try { - assertDeviceOwned(bo.getDeviceNo()); - bo.setUserId(LoginHelper.getUserId()); - return toAjax(appDeviceService.updateByBo(bo)); - } catch (Exception e) { - return fail(e); - } - + assertDeviceOwned(bo.getDeviceNo()); + bo.setUserId(LoginHelper.getUserId()); + return toAjax(appDeviceService.updateByBo(bo)); } /** @@ -157,14 +156,10 @@ public class AppController extends BaseController { @DeleteMapping("/deleteDevice/{deviceNos}") public R removeDevice(@NotEmpty(message = "主键不能为空") @PathVariable String[] deviceNos) { - try { - for (String deviceNo : deviceNos) { - assertDeviceOwned(deviceNo); - } - return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId())); - } catch (Exception e) { - return fail(e); + for (String deviceNo : deviceNos) { + assertDeviceOwned(deviceNo); } + return toAjax(appDeviceService.unbindDevices(List.of(deviceNos), LoginHelper.getUserId())); } @@ -172,25 +167,19 @@ public class AppController extends BaseController { * 手动开启关闭设备 * */ - // @SaCheckRole("appadmin") @RepeatSubmit() @PutMapping("/switchDevice") public R switchDevice(@RequestBody String body) { - try { - Map map = JsonUtils.parseObject(body, Map.class); - String deviceNo = map.get("deviceNo"); - String status = map.get("workStatus"); - String startTime = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); - String durationMinValue = map.get("durationMin"); - Integer durationMin = StringUtils.isBlank(durationMinValue) ? null : Integer.valueOf(durationMinValue); - //更新设备工作状态 - Boolean flag = appDeviceService.switchDevice(deviceNo, status, startTime, durationMin); - // 记录信息到历史表 - return toAjax(flag); - } catch (Exception e) { - return fail(e); - } - + Map map = JsonUtils.parseObject(body, new TypeReference<>() { }); + String deviceNo = map.get("deviceNo"); + String status = map.get("workStatus"); + String startTime = LocalDateTime.now().format(SECOND_TIME_FORMATTER); + String durationMinValue = map.get("durationMin"); + Integer durationMin = StringUtils.isBlank(durationMinValue) ? null : Integer.valueOf(durationMinValue); + //更新设备工作状态 + Boolean flag = appDeviceService.switchDevice(deviceNo, status, startTime, durationMin); + // 记录信息到历史表 + return toAjax(flag); } /** @@ -199,33 +188,22 @@ public class AppController extends BaseController { * @return */ @GetMapping("/scheduleDeviceList/{scheduleId}") - public R> scheduleDeviceList( @PathVariable Long scheduleId) { - try { - - Map retMap = new HashMap(); - //所有设备列表 - AppDeviceBo appDeviceBo = new AppDeviceBo(); - appDeviceBo.setUserId(LoginHelper.getUserId()); - List appDeviceVos = appDeviceService.queryList(appDeviceBo); - - List appDeviceVoList = new ArrayList(); - AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); - schedulingDeviceBo.setScheduleId(scheduleId); - - - for (AppDeviceVo appDeviceVo:appDeviceVos){ - List appSchedulingDeviceVos = appSchedulingDeviceService.findByDeviceNo(appDeviceVo.getDeviceNo()); - if(appSchedulingDeviceVos.size() == 0){ - appDeviceVoList.add(appDeviceVo); - } - } - retMap.put("deviceList", appDeviceVoList); - - - return R.ok(retMap); - } catch (Exception e) { - return fail(e); - } + public R> scheduleDeviceList(@PathVariable Long scheduleId) { + Map retMap = new HashMap<>(); + //所有设备列表 + AppDeviceBo appDeviceBo = new AppDeviceBo(); + appDeviceBo.setUserId(LoginHelper.getUserId()); + List appDeviceVos = appDeviceService.queryList(appDeviceBo); + List deviceNos = appDeviceVos.stream() + .map(AppDeviceVo::getDeviceNo) + .filter(StringUtils::isNotBlank) + .toList(); + Set boundDeviceNos = appSchedulingDeviceService.findBoundDeviceNos(deviceNos); + List appDeviceVoList = appDeviceVos.stream() + .filter(device -> !boundDeviceNos.contains(device.getDeviceNo())) + .toList(); + retMap.put("deviceList", appDeviceVoList); + return R.ok(retMap); } /** *绑定排程跟设备 @@ -233,37 +211,35 @@ public class AppController extends BaseController { @RepeatSubmit() @PostMapping("/addScheduleDevice") public R addScheduleDevice( @RequestBody String body) { - try { - Map map = JsonUtils.parseObject(body, Map.class); - if (map == null || map.get("scheduleId") == null) { - throw new ServiceException("app.schedule.id.not.blank.request"); - } - Long scheduleId = Long.valueOf(map.get("scheduleId").toString()); - assertScheduleOwned(scheduleId); - List deviceNos = parseDeviceNos(map); - if (deviceNos.isEmpty()) { - throw new ServiceException("app.schedule.devices.not.blank"); - } - - for (String deviceNo:deviceNos){ - if (StringUtils.isBlank(deviceNo)) { - continue; - } - assertDeviceOwned(deviceNo); - if (appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(scheduleId, deviceNo) != null) { - continue; - } - AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); - schedulingDeviceBo.setDeviceNo(deviceNo); - schedulingDeviceBo.setScheduleId(scheduleId); - if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo)) { - deviceCommandService.sendScheduleBindCommand(deviceNo, buildScheduleBindPayload(scheduleId, deviceNo)); - } - } - return R.ok(); - } catch (Exception e) { - return fail(e); + Map map = JsonUtils.parseObject(body, new TypeReference<>() { }); + if (map == null || map.get("scheduleId") == null) { + throw new ServiceException("app.schedule.id.not.blank.request"); } + Long scheduleId = Long.valueOf(map.get("scheduleId").toString()); + assertScheduleOwned(scheduleId); + List deviceNos = parseDeviceNos(map); + if (deviceNos.isEmpty()) { + throw new ServiceException("app.schedule.devices.not.blank"); + } + List> scheduleDetails = loadScheduleDetailPayload(scheduleId); + + for (String deviceNo:deviceNos){ + if (StringUtils.isBlank(deviceNo)) { + continue; + } + assertDeviceOwned(deviceNo); + if (appSchedulingDeviceService.queryByScheduleIdAndDeviceNo(scheduleId, deviceNo) != null) { + continue; + } + AppSchedulingDeviceBo schedulingDeviceBo = new AppSchedulingDeviceBo(); + schedulingDeviceBo.setDeviceNo(deviceNo); + schedulingDeviceBo.setScheduleId(scheduleId); + if (appSchedulingDeviceService.insertByBo(schedulingDeviceBo)) { + deviceCommandService.sendScheduleBindCommand(deviceNo, + buildScheduleBindPayload(deviceNo, scheduleDetails)); + } + } + return R.ok(); } private List parseDeviceNos(Map map) { @@ -289,15 +265,17 @@ public class AppController extends BaseController { .toList(); } - private Map buildScheduleBindPayload(Long scheduleId, String deviceNo) { - - List details = appScheduleDetailService.queryByScheduleIdByStatus(scheduleId); + private Map buildScheduleBindPayload(String deviceNo, List> details) { Map payload = new HashMap<>(); payload.put("deviceNo", deviceNo); - payload.put("details", toScheduleDetailPayload(details)); + payload.put("details", details); return payload; } + private List> loadScheduleDetailPayload(Long scheduleId) { + return toScheduleDetailPayload(appScheduleDetailService.queryByScheduleIdByStatus(scheduleId)); + } + private List> toScheduleDetailPayload(List details) { List> detailPayload = new ArrayList<>(); if (details == null) { @@ -334,30 +312,26 @@ public class AppController extends BaseController { @DeleteMapping("/deleteScheduleDevice") public R deleteScheduleDevice(@RequestParam(required = false) String scheduleId, @RequestParam(required = false) String deviceNo) { - try { - if (StringUtils.isBlank(scheduleId)) { - throw new ServiceException("app.schedule.id.not.blank.request"); - } - if (StringUtils.isBlank(deviceNo)) { - throw new ServiceException("app.device.no.not.blank"); - } - Long scheduleIdValue = Long.valueOf(scheduleId); - assertScheduleOwned(scheduleIdValue); - assertDeviceOwned(deviceNo); - Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(scheduleIdValue, deviceNo); - if (deleted) { - // 通知设备解绑排程 - try { - deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue); - } catch (Exception mqttEx) { - log.warn("[排程通知] 删除排程设备绑定后下发解绑命令失败 排程ID={} 设备编号={} 原因={}", - scheduleIdValue, deviceNo, mqttEx.getMessage()); - } - } - return toAjax(deleted); - } catch (Exception e) { - return fail(e); + if (StringUtils.isBlank(scheduleId)) { + throw new ServiceException("app.schedule.id.not.blank.request"); } + if (StringUtils.isBlank(deviceNo)) { + throw new ServiceException("app.device.no.not.blank"); + } + Long scheduleIdValue = Long.valueOf(scheduleId); + assertScheduleOwned(scheduleIdValue); + assertDeviceOwned(deviceNo); + Boolean deleted = appSchedulingDeviceService.deleteWithValidByScheduleIdAndDeviceNo(scheduleIdValue, deviceNo); + if (deleted) { + // 通知设备解绑排程 + try { + deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleIdValue); + } catch (Exception mqttEx) { + log.warn("[排程通知] 删除排程设备绑定后下发解绑命令失败 排程ID={} 设备编号={} 原因={}", + scheduleIdValue, deviceNo, mqttEx.getMessage(), mqttEx); + } + } + return toAjax(deleted); } @@ -381,17 +355,12 @@ public class AppController extends BaseController { @RepeatSubmit() @PostMapping("/addschedule") public R addSchedule(@Validated(AddGroup.class) @RequestBody AppScheduleBo bo) { - try { - bo.setCreateTime(new Date()); - AppScheduleVo appScheduleVo = appScheduleService.insertByBo(bo); - if (ObjectUtil.isNotNull(appScheduleVo)){ - return R.ok(appScheduleVo); - } - return R.fail(); - // return toAjax(appScheduleService.insertByBo(bo)); - } catch (Exception e) { - return fail(e); + bo.setCreateTime(new Date()); + AppScheduleVo appScheduleVo = appScheduleService.insertByBo(bo); + if (ObjectUtil.isNotNull(appScheduleVo)){ + return R.ok(appScheduleVo); } + return R.fail(); } /** @@ -403,52 +372,55 @@ public class AppController extends BaseController { @GetMapping("/schedule/{id}") public R getScheduleInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) { - try { - AppScheduleVo appScheduleVo = getOwnedSchedule(id); - if (ObjectUtil.isNotNull(appScheduleVo)){ - List list = appScheduleDetailService.queryByScheduleIdByStatus(appScheduleVo.getId()); - - AppSchedulingDeviceBo appSchedulingDeviceBo = new AppSchedulingDeviceBo(); - appSchedulingDeviceBo.setScheduleId(appScheduleVo.getId()); - List appSchedulingDeviceVos = appSchedulingDeviceService.queryList(appSchedulingDeviceBo); - List deviceList = new ArrayList(); - for (AppSchedulingDeviceVo appSchedulingDeviceVo:appSchedulingDeviceVos){ - AppDeviceVo appDeviceVo = appDeviceService.queryById(appSchedulingDeviceVo.getDeviceNo()); - if (ObjectUtil.isNotNull(appDeviceVo)){ - deviceList.add(appDeviceVo); - } + AppScheduleVo appScheduleVo = getOwnedSchedule(id); + if (ObjectUtil.isNotNull(appScheduleVo)){ + List list = appScheduleDetailService.queryByScheduleIdByStatus(appScheduleVo.getId()); + AppSchedulingDeviceBo appSchedulingDeviceBo = new AppSchedulingDeviceBo(); + appSchedulingDeviceBo.setScheduleId(appScheduleVo.getId()); + List appSchedulingDeviceVos = appSchedulingDeviceService.queryList(appSchedulingDeviceBo); + List deviceNos = appSchedulingDeviceVos.stream() + .map(AppSchedulingDeviceVo::getDeviceNo) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + List queriedDevices = appDeviceService.queryByDeviceNos(deviceNos); + Map devicesByNo = new HashMap<>(); + for (AppDeviceVo device : queriedDevices) { + if (device != null && StringUtils.isNotBlank(device.getDeviceNo())) { + devicesByNo.putIfAbsent(device.getDeviceNo(), device); } - List detailList = new ArrayList(); + } + List deviceList = deviceNos.stream() + .map(devicesByNo::get) + .filter(Objects::nonNull) + .toList(); + List> detailList = new ArrayList<>(); - for (AppScheduleDetailVo detail:list){ - List timeSlots = new ArrayList(); - JSONArray mapArray = JSONUtil.parseArray(detail.getTimeData()); - for (Object timeSlot:mapArray){ - timeSlots.add(timeSlot); - } - - Map map = new HashMap(); - map.put("id",detail.getId() ); - map.put("weekday",detail.getWeekday() ); - map.put("timeData",timeSlots ); - map.put("triggerType",detail.getTriggerType() ); - map.put("status",detail.getStatus() ); - detailList.add(map); + for (AppScheduleDetailVo detail:list){ + List timeSlots = new ArrayList<>(); + JSONArray mapArray = JSONUtil.parseArray(detail.getTimeData()); + for (Object timeSlot:mapArray){ + timeSlots.add(timeSlot); } - - - appScheduleVo.setDetails(detailList); - appScheduleVo.setDeviceNos(deviceList); - return R.ok(appScheduleVo); + Map map = new HashMap<>(); + map.put("id",detail.getId() ); + map.put("weekday",detail.getWeekday() ); + map.put("timeData",timeSlots ); + map.put("triggerType",detail.getTriggerType() ); + map.put("status",detail.getStatus() ); + detailList.add(map); } - return R.fail(appScheduleVo); - } catch (Exception e) { - return fail(e); + + appScheduleVo.setDetails(detailList); + appScheduleVo.setDeviceNos(deviceList); + return R.ok(appScheduleVo); } + + return R.fail(appScheduleVo); } @@ -460,18 +432,14 @@ public class AppController extends BaseController { @PutMapping("/editScheduleStatus") public R editScheduleStatus(@NotNull(message = "主键不能为空") @RequestBody AppScheduleBo bo) { - try { - assertScheduleOwned(bo.getId()); - bo.setUserId(LoginHelper.getUserId()); - Boolean updated = appScheduleService.updateStatusByBo(bo); - if (updated) { - // 向所有绑定设备下发排程状态变更 - notifyBoundDevicesScheduleStatusChange(bo.getId(), bo.getStatus()); - } - return toAjax(updated); - } catch (Exception e) { - return fail(e); + assertScheduleOwned(bo.getId()); + bo.setUserId(LoginHelper.getUserId()); + Boolean updated = appScheduleService.updateStatusByBo(bo); + if (updated) { + // 向所有绑定设备下发排程状态变更 + notifyBoundDevicesScheduleStatusChange(bo.getId(), bo.getStatus()); } + return toAjax(updated); } /** @@ -481,21 +449,16 @@ public class AppController extends BaseController { @RepeatSubmit() @PutMapping("/updataschedule") public R editSchedule(@Validated(EditGroup.class) @RequestBody AppScheduleBo bo) { - try { - assertScheduleOwned(bo.getId()); - bo.setUserId(LoginHelper.getUserId()); - Map map = appScheduleService.updateByBo(bo); - if (Boolean.valueOf(map.get("flag").toString())){ - // 向所有绑定设备下发排程更新 - notifyBoundDevicesScheduleUpdate(bo.getId()); - return R.ok(map.get("appSchedule")); - }else { - return R.fail(); - } - } catch (Exception e) { - return fail(e); + assertScheduleOwned(bo.getId()); + bo.setUserId(LoginHelper.getUserId()); + Map map = appScheduleService.updateByBo(bo); + if (Boolean.valueOf(map.get("flag").toString())){ + // 向所有绑定设备下发排程更新 + notifyBoundDevicesScheduleUpdate(bo.getId()); + return R.ok(map.get("appSchedule")); + }else { + return R.fail(); } - } @@ -508,24 +471,20 @@ public class AppController extends BaseController { @DeleteMapping("/deleteschedule/{ids}") public R removeSchedule(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) { - try { - for (Long id : ids) { - assertScheduleOwned(id); - } - Map> boundDeviceNosBySchedule = new HashMap<>(); - for (Long id : ids) { - boundDeviceNosBySchedule.put(id, findBoundDeviceNos(id)); - } - Boolean deleted = appScheduleService.deleteWithValidByIds(List.of(ids), true); - if (deleted) { - for (Map.Entry> entry : boundDeviceNosBySchedule.entrySet()) { - notifyScheduleUnbind(entry.getKey(), entry.getValue()); - } - } - return toAjax(deleted); - } catch (Exception e) { - return fail(e); + for (Long id : ids) { + assertScheduleOwned(id); } + Map> boundDeviceNosBySchedule = new HashMap<>(); + for (Long id : ids) { + boundDeviceNosBySchedule.put(id, findBoundDeviceNos(id)); + } + Boolean deleted = appScheduleService.deleteWithValidByIds(List.of(ids), true); + if (deleted) { + for (Map.Entry> entry : boundDeviceNosBySchedule.entrySet()) { + notifyScheduleUnbind(entry.getKey(), entry.getValue()); + } + } + return toAjax(deleted); } /** @@ -541,7 +500,7 @@ public class AppController extends BaseController { AppDeviceBo appDeviceBo = new AppDeviceBo(); appDeviceBo.setUserId(LoginHelper.getUserId()); List appDeviceVos = appDeviceService.queryList(appDeviceBo); - List l = new ArrayList<>(); + List wateringLogs = new ArrayList<>(); for (AppWateringLogVo vo : appWateringLogVoTableDataInfo.getRows()){ for(AppDeviceVo appDeviceVo:appDeviceVos){ if (appDeviceVo.getDeviceNo().equals(vo.getDeviceNo())){ @@ -549,10 +508,9 @@ public class AppController extends BaseController { vo.setDeviceNo(appDeviceVo.getDeviceNo()); } } - // vo.setTriggerType(vo.getTriggerType().equals("0")?"排程":"手动"); - l.add(vo); + wateringLogs.add(vo); } - appWateringLogVoTableDataInfo.setRows(l); + appWateringLogVoTableDataInfo.setRows(wateringLogs); return appWateringLogVoTableDataInfo; } @@ -561,32 +519,28 @@ public class AppController extends BaseController { */ @GetMapping("/latestWaterLog") public R> latestWaterLog(@RequestParam String deviceNo) { - try { - assertDeviceOwned(deviceNo); + assertDeviceOwned(deviceNo); - AppWateringLogBo bo = new AppWateringLogBo(); - bo.setUserId(LoginHelper.getUserId()); - bo.setDeviceNo(deviceNo); - bo.setStatus("1"); - List logs = appWateringLogService.queryList(bo); - if (logs == null || logs.isEmpty()) { - return R.ok(null); - } - - AppWateringLogVo latest = logs.stream() - .max(Comparator.comparing(AppWateringLogVo::getId, Comparator.nullsFirst(Long::compareTo))) - .orElse(null); - if (latest == null) { - return R.ok(null); - } - - Map result = new HashMap<>(); - result.put("startTime", formatSecondTime(latest.getStartTime())); - result.put("endTime", formatSecondTime(latest.getEndTime())); - return R.ok(result); - } catch (Exception e) { - return fail(e); + AppWateringLogBo bo = new AppWateringLogBo(); + bo.setUserId(LoginHelper.getUserId()); + bo.setDeviceNo(deviceNo); + bo.setStatus("1"); + List logs = appWateringLogService.queryList(bo); + if (logs == null || logs.isEmpty()) { + return R.ok(null); } + + AppWateringLogVo latest = logs.stream() + .max(Comparator.comparing(AppWateringLogVo::getId, Comparator.nullsFirst(Long::compareTo))) + .orElse(null); + if (latest == null) { + return R.ok(null); + } + + Map result = new HashMap<>(); + result.put("startTime", formatSecondTime(latest.getStartTime())); + result.put("endTime", formatSecondTime(latest.getEndTime())); + return R.ok(result); } @@ -599,14 +553,10 @@ public class AppController extends BaseController { @DeleteMapping("/waterLog/{ids}") public R remove(@NotEmpty(message = "主键不能为空") @PathVariable Long[] ids) { - try { - for (Long id : ids) { - assertWateringLogOwned(id); - } - return toAjax(appWateringLogService.deleteWithValidByIds(List.of(ids), true)); - } catch (Exception e) { - return fail(e); + for (Long id : ids) { + assertWateringLogOwned(id); } + return toAjax(appWateringLogService.deleteWithValidByIds(List.of(ids), true)); } @@ -614,36 +564,32 @@ public class AppController extends BaseController { * 数据统计 */ @GetMapping("/dataStatistics") - public R dataStatistics() { - Map map = new HashMap(); - try { - Long userId = LoginHelper.getUserId(); - //总浇水次数 - Long count = appWateringLogService.queryCount(userId); - //本月浇水次数 - Long monCount = appWateringLogService.queryByMonCount(userId); - //本周浇水次数 - Long weekCount = appWateringLogService.queryByWeekCount(userId); - //排程浇水次数 - Long schedule = appWateringLogService.queryByTriggerTypeCount(userId, "0"); - //手动浇水次数 - Long manualNum = appWateringLogService.queryByTriggerTypeCount(userId, "1"); - //总浇水时间 - Long allTime = Optional.ofNullable(appWateringLogService.queryByTotalWateringTime(userId)).orElse(0L); - //平均浇水时间 - Long averageTime = count == null || count == 0 ? 0L : allTime / count; + public R> dataStatistics() { + Map map = new HashMap<>(); + Long userId = LoginHelper.getUserId(); + //总浇水次数 + Long count = appWateringLogService.queryCount(userId); + //本月浇水次数 + Long monCount = appWateringLogService.queryByMonCount(userId); + //本周浇水次数 + Long weekCount = appWateringLogService.queryByWeekCount(userId); + //排程浇水次数 + Long schedule = appWateringLogService.queryByTriggerTypeCount(userId, "0"); + //手动浇水次数 + Long manualNum = appWateringLogService.queryByTriggerTypeCount(userId, "1"); + //总浇水时间 + Long allTime = Optional.ofNullable(appWateringLogService.queryByTotalWateringTime(userId)).orElse(0L); + //平均浇水时间 + Long averageTime = count == null || count == 0 ? 0L : allTime / count; - map.put("count",count); - map.put("monCount",monCount); - map.put("weekCount",weekCount); - map.put("schedule",schedule); - map.put("manualNum",manualNum); - map.put("allTime",allTime); - map.put("averageTime",averageTime); - return R.ok(map); - } catch (Exception e) { - return fail(e); - } + map.put("count",count); + map.put("monCount",monCount); + map.put("weekCount",weekCount); + map.put("schedule",schedule); + map.put("manualNum",manualNum); + map.put("allTime",allTime); + map.put("averageTime",averageTime); + return R.ok(map); } @@ -654,20 +600,16 @@ public class AppController extends BaseController { */ @GetMapping("/getUserInfo") public R getUserInfo() { - try { - UserInfoVo userInfoVo = new UserInfoVo(); - LoginUser loginUser = LoginHelper.getLoginUser(); - SysUserVo user = DataPermissionHelper.ignore(() -> userService.selectUserById(loginUser.getUserId())); - if (ObjectUtil.isNull(user)) { - return R.fail(MessageUtils.message("app.user.data.denied")); - } - userInfoVo.setUser(user); - userInfoVo.setPermissions(loginUser.getMenuPermission()); - userInfoVo.setRoles(loginUser.getRolePermission()); - return R.ok(userInfoVo); - } catch (Exception e) { - return fail(e); + UserInfoVo userInfoVo = new UserInfoVo(); + LoginUser loginUser = LoginHelper.getLoginUser(); + SysUserVo user = DataPermissionHelper.ignore(() -> userService.selectUserById(loginUser.getUserId())); + if (ObjectUtil.isNull(user)) { + return R.fail(MessageUtils.message("app.user.data.denied")); } + userInfoVo.setUser(user); + userInfoVo.setPermissions(loginUser.getMenuPermission()); + userInfoVo.setRoles(loginUser.getRolePermission()); + return R.ok(userInfoVo); } /** @@ -675,24 +617,52 @@ public class AppController extends BaseController { */ @PostMapping(value = "/uploadImage", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R uploadImage(@RequestPart("file") MultipartFile file) { - try { - if (file == null || file.isEmpty()) { - throw new ServiceException("上传图片不能为空"); - } - String contentType = file.getContentType(); - if (StringUtils.isBlank(contentType) || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) { - throw new ServiceException("只能上传图片文件"); - } + validateImageFile(file); - SysOssVo oss = ossService.upload(file); - SysOssUploadVo uploadVo = new SysOssUploadVo(); - uploadVo.setUrl(oss.getUrl()); - uploadVo.setFileName(oss.getOriginalName()); - uploadVo.setOssId(oss.getOssId() == null ? null : oss.getOssId().toString()); - return R.ok(uploadVo); - } catch (Exception e) { - return fail(e); + SysOssVo oss = ossService.upload(file); + SysOssUploadVo uploadVo = new SysOssUploadVo(); + uploadVo.setUrl(oss.getUrl()); + uploadVo.setFileName(oss.getOriginalName()); + uploadVo.setOssId(oss.getOssId() == null ? null : oss.getOssId().toString()); + return R.ok(uploadVo); + } + + private void validateImageFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ServiceException("上传图片不能为空"); } + if (file.getSize() > MAX_IMAGE_SIZE_BYTES) { + throw new ServiceException("图片大小不能超过20MB"); + } + + String contentType = StringUtils.lowerCase(file.getContentType()); + if (StringUtils.isBlank(contentType) || !contentType.startsWith("image/")) { + throw new ServiceException("只能上传图片文件"); + } + + String extension = StringUtils.lowerCase(FileUtil.extName(file.getOriginalFilename())); + if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) { + throw new ServiceException("仅支持jpg、jpeg、png、gif、webp、bmp图片"); + } + + String normalizedExtension = normalizeImageType(extension); + if (!IMAGE_CONTENT_TYPES.get(normalizedExtension).contains(contentType)) { + throw new ServiceException("图片文件类型不匹配"); + } + + try (InputStream inputStream = file.getInputStream()) { + String detectedType = normalizeImageType(FileTypeUtil.getType(inputStream)); + if (!normalizedExtension.equals(detectedType)) { + throw new ServiceException("图片文件内容不合法"); + } + } catch (IOException e) { + throw new ServiceException("读取图片文件失败"); + } + } + + private String normalizeImageType(String imageType) { + String normalizedType = StringUtils.lowerCase(imageType); + return "jpeg".equals(normalizedType) ? "jpg" : normalizedType; } /** @@ -701,54 +671,45 @@ public class AppController extends BaseController { @GetMapping("/checkVersion") public R checkVersion(@RequestParam(required = false, defaultValue = "android") String platform, @RequestParam(required = false) String currentVersion) { - try { - if (StringUtils.isBlank(currentVersion)) { - throw new ServiceException("当前版本不能为空"); - } - String normalizedPlatform = StringUtils.lowerCase(StringUtils.trim(platform)); - if (!StringUtils.equalsAny(normalizedPlatform, "android", "ios")) { - throw new ServiceException("平台类型仅支持 android 或 ios"); - } - - AppVersionVo version = appVersionService.queryLatestByPlatform(normalizedPlatform); - if (version == null || StringUtils.isBlank(version.getLatestVersion())) { - throw new ServiceException("版本配置不存在"); - } - - AppVersionCheckVo result = new AppVersionCheckVo(); - result.setPlatform(normalizedPlatform); - result.setCurrentVersion(currentVersion); - result.setLatestVersion(version.getLatestVersion()); - result.setVersionCode(version.getVersionCode()); - result.setUpdateAvailable(!StringUtils.equals(currentVersion, version.getLatestVersion())); - result.setForceUpdate(StringUtils.equals(version.getForceUpdate(), "1")); - result.setDownloadUrl(version.getDownloadUrl()); - result.setReleaseNotes(version.getReleaseNotes()); - return R.ok(result); - } catch (Exception e) { - return fail(e); + if (StringUtils.isBlank(currentVersion)) { + throw new ServiceException("当前版本不能为空"); } + String normalizedPlatform = StringUtils.lowerCase(StringUtils.trim(platform)); + if (!StringUtils.equalsAny(normalizedPlatform, "android", "ios")) { + throw new ServiceException("平台类型仅支持 android 或 ios"); + } + + AppVersionVo version = appVersionService.queryLatestByPlatform(normalizedPlatform); + if (version == null || StringUtils.isBlank(version.getLatestVersion())) { + throw new ServiceException("版本配置不存在"); + } + + AppVersionCheckVo result = new AppVersionCheckVo(); + result.setPlatform(normalizedPlatform); + result.setCurrentVersion(currentVersion); + result.setLatestVersion(version.getLatestVersion()); + result.setVersionCode(version.getVersionCode()); + result.setUpdateAvailable(!StringUtils.equals(currentVersion, version.getLatestVersion())); + result.setForceUpdate(StringUtils.equals(version.getForceUpdate(), "1")); + result.setDownloadUrl(version.getDownloadUrl()); + result.setReleaseNotes(version.getReleaseNotes()); + return R.ok(result); } /** * 修改用户 */ - // @SaCheckPermission("system:user:edit") @Log(title = "用户管理", businessType = BusinessType.UPDATE) @RepeatSubmit() @PutMapping("/updataUser") public R editUser( @RequestBody SysUserBo user) { - try { - userService.checkUserAllowed(user.getUserId()); - userService.checkUserDataScope(user.getUserId()); - if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user)) { - return R.fail(MessageUtils.message("app.user.phone.exists", user.getUserName())); - } - - return toAjax(userService.updateAppUser(user)); - } catch (Exception e) { - return fail(e); + userService.checkUserAllowed(user.getUserId()); + userService.checkUserDataScope(user.getUserId()); + if (StringUtils.isNotEmpty(user.getPhonenumber()) && !userService.checkPhoneUnique(user)) { + return R.fail(MessageUtils.message("app.user.phone.exists", user.getUserName())); } + + return toAjax(userService.updateAppUser(user)); } /** @@ -757,19 +718,15 @@ public class AppController extends BaseController { @RepeatSubmit() @PutMapping("/updataPassword") public R updataPassword(@RequestBody SysUserVo vo) { - try { - Long userId = LoginHelper.getUserId(); - SysUserVo sysUserVo = userService.selectUserById(userId); - if (!BCrypt.checkpw(vo.getOldPassword(), sysUserVo.getPassword())){ - throw new UserException("user.password.not.match"); - } - SysUserBo userBo = new SysUserBo(); - userBo.setUserId(userId); - userBo.setPassword(vo.getPassword()); - return toAjax(userService.updateUserPas(userBo)); - } catch (Exception e) { - return fail(e); + Long userId = LoginHelper.getUserId(); + SysUserVo sysUserVo = userService.selectUserById(userId); + if (!BCrypt.checkpw(vo.getOldPassword(), sysUserVo.getPassword())){ + throw new UserException("user.password.not.match"); } + SysUserBo userBo = new SysUserBo(); + userBo.setUserId(userId); + userBo.setPassword(vo.getPassword()); + return toAjax(userService.updateUserPas(userBo)); } /** @@ -778,57 +735,29 @@ public class AppController extends BaseController { @ApiEncrypt @PostMapping("/retrievePassword") public R forgot(@RequestBody String body) { - try { - SysUserBo bo = JsonUtils.parseObject(body, SysUserBo.class); - bo.setUserId(LoginHelper.getUserId()); + 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"); - } + 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)); - } catch (Exception e) { - return fail(e); } - } - - - @GetMapping("/test") - public R test (){ - try { - String bindDevice = deviceCommandService.sendBindDeviceCommand("01"); - - // String s = deviceCommandService.sendSwitchCommand("01", "1", "2026-06-10 14:35:31", 20); - return R.ok(bindDevice); - } catch (Exception e) { - return fail(e); - } - } - - private R fail(Exception e) { - return R.fail(toI18nErrorMessage(e)); - } - - private String toI18nErrorMessage(Exception e) { - String message = e.getMessage(); - if (StringUtils.isBlank(message)) { - return MessageUtils.message("operation.fail"); - } - String i18nMessage = MessageUtils.message(message); - return message.equals(i18nMessage) ? message : i18nMessage; + return toAjax(userService.updateUserPas(bo)); } private String formatSecondTime(Date time) { - return time == null ? null : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(time); + return time == null ? null : time.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() + .format(SECOND_TIME_FORMATTER); } private AppDeviceVo getOwnedDevice(String deviceNo) { @@ -892,11 +821,14 @@ public class AppController extends BaseController { */ private void notifyBoundDevicesScheduleUpdate(Long scheduleId) { List deviceNos = findBoundDeviceNos(scheduleId); + List> scheduleDetails = loadScheduleDetailPayload(scheduleId); for (String deviceNo : deviceNos) { try { - deviceCommandService.sendScheduleBindCommand(deviceNo, buildScheduleBindPayload(scheduleId, deviceNo)); + deviceCommandService.sendScheduleBindCommand(deviceNo, + buildScheduleBindPayload(deviceNo, scheduleDetails)); } catch (Exception e) { - log.warn("[排程通知] 下发排程更新失败 排程ID={} 设备编号={} 原因={}", scheduleId, deviceNo, e.getMessage()); + log.warn("[排程通知] 下发排程更新失败 排程ID={} 设备编号={} 原因={}", + scheduleId, deviceNo, e.getMessage(), e); } } } @@ -906,16 +838,20 @@ public class AppController extends BaseController { */ private void notifyBoundDevicesScheduleStatusChange(Long scheduleId, String status) { List deviceNos = findBoundDeviceNos(scheduleId); + List> scheduleDetails = "0".equals(status) + ? Collections.emptyList() + : loadScheduleDetailPayload(scheduleId); for (String deviceNo : deviceNos) { try { if ("0".equals(status)) { deviceCommandService.sendScheduleCanceledCommand(deviceNo, scheduleId); } else { - deviceCommandService.sendScheduleBindCommand(deviceNo, buildScheduleBindPayload(scheduleId, deviceNo)); + deviceCommandService.sendScheduleBindCommand(deviceNo, + buildScheduleBindPayload(deviceNo, scheduleDetails)); } } catch (Exception e) { log.warn("[排程通知] 下发排程状态变更失败 排程ID={} 状态={} 设备编号={} 原因={}", - scheduleId, status, deviceNo, e.getMessage()); + scheduleId, status, deviceNo, e.getMessage(), e); } } } @@ -932,7 +868,8 @@ public class AppController extends BaseController { try { deviceCommandService.sendScheduleUnbindCommand(deviceNo, scheduleId); } catch (Exception e) { - log.warn("[排程通知] 下发排程解绑失败 排程ID={} 设备编号={} 原因={}", scheduleId, deviceNo, e.getMessage()); + log.warn("[排程通知] 下发排程解绑失败 排程ID={} 设备编号={} 原因={}", + scheduleId, deviceNo, e.getMessage(), e); } } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java index 1e7dc5e..efb249f 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/vo/AppScheduleVo.java @@ -1,15 +1,17 @@ package org.dromara.app.domain.vo; -import jakarta.validation.constraints.NotEmpty; -import org.dromara.app.domain.AppSchedule; import cn.idev.excel.annotation.ExcelIgnoreUnannotated; import cn.idev.excel.annotation.ExcelProperty; import io.github.linpeilie.annotations.AutoMapper; +import jakarta.validation.constraints.NotEmpty; import lombok.Data; +import org.dromara.app.domain.AppSchedule; + import java.io.Serial; import java.io.Serializable; import java.util.List; +import java.util.Map; /** @@ -47,7 +49,7 @@ public class AppScheduleVo implements Serializable { /** * 设备ids */ - private List deviceNos; + private List deviceNos; /** * 状态 0-关闭 1 开启 */ @@ -55,7 +57,7 @@ public class AppScheduleVo implements Serializable { private String status; @NotEmpty(message = "至少需要配置一天") - private List details; + private List> details; } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java index 6f81e0c..463f4e6 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceDataHandler.java @@ -13,7 +13,7 @@ import java.util.Map; import java.util.regex.Pattern; /** - * 设备数据上报处理器 — 匹配 /{deviceNo}/publish/power + * 设备电量及在线心跳处理器,匹配 /{deviceNo}/publish/power。 */ @Slf4j @Component @@ -23,6 +23,7 @@ public class DeviceDataHandler implements MqttTopicHandler { private static final Pattern PATTERN = Pattern.compile("^/([^/]+)/publish/power$"); private final IAppDeviceService appDeviceService; private final DeviceIdentityResolver deviceIdentityResolver; + private final MqttDeviceStatusService deviceStatusService; @Override public Pattern topicPattern() { @@ -44,13 +45,15 @@ public class DeviceDataHandler implements MqttTopicHandler { log.warn("[MQTT] 设备电量上报缺少电量字段 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); return; } - // 更新设备电量 + 同步在线状态到数据库 + // 有效电量上报同时作为设备在线心跳。 AppDeviceBo appDeviceBo = new AppDeviceBo(); appDeviceBo.setDeviceNo(deviceNo); appDeviceBo.setPowerLevel(dto.get("powerLevel").toString()); appDeviceBo.setPowerLevelUpdatatime(new Date()); appDeviceService.updateByBo(appDeviceBo); - log.info("[MQTT] 设备电量更新 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); + deviceStatusService.markOnline(deviceNo); + log.info("[MQTT] 设备电量更新并刷新在线心跳 时间={} 设备编号={} 消息体={}", + HandlerLogTime.now(), deviceNo, payload); } catch (Exception e) { log.error("[MQTT] 设备电量更新失败 时间={} 设备标识={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceIdentity, deviceNo, payload, e); diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java index dd64f82..9e52b68 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java @@ -33,6 +33,7 @@ DeviceRegisterHandler implements MqttTopicHandler { private final AppDeviceMapper appDeviceMapper; private final ObjectProvider commandPublisherProvider; private final DeviceIdentityResolver deviceIdentityResolver; + private final MqttDeviceStatusService deviceStatusService; @Override public Pattern topicPattern() { @@ -60,8 +61,9 @@ DeviceRegisterHandler implements MqttTopicHandler { } AppDeviceBo device = buildRegisterDevice(deviceNo, normalizedDeviceMac, dto); appDeviceService.registerByMqtt(device); + deviceStatusService.markOnline(deviceNo); sendDeviceNoToDevice(deviceNo, normalizedDeviceMac); - log.info("[MQTT] 设备注册 时间={} MAC={} 设备编号={} 消息体={}", + log.info("[MQTT] 设备注册并上线 时间={} MAC={} 设备编号={} 消息体={}", HandlerLogTime.now(), normalizedDeviceMac, deviceNo, payload); } catch (Exception e) { log.error("[MQTT] 设备注册失败 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload, e); diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java index 67cf924..5975e13 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceStatusHandler.java @@ -13,10 +13,10 @@ import java.util.Map; import java.util.regex.Pattern; /** - * 设备在线/离线状态处理器,匹配 /{deviceIdentity}/publish/status。 + * 设备遗嘱离线状态处理器,匹配 /{deviceIdentity}/publish/status。 *

* deviceIdentity 支持设备编号;设备遗嘱消息允许使用 MAC 地址,处理前统一解析为设备编号。 - * 设备通过 status=online 标记上线,通过 LWT status=offline 标记异常离线。 + * 在线状态由电量上报刷新;本处理器仅通过 LWT status=offline 标记异常离线。 */ @Slf4j @Component @@ -42,17 +42,18 @@ public class DeviceStatusHandler implements MqttTopicHandler { @Override public void handle(String deviceIdentity, String payload, boolean retained) { - String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity); + Map body = parsePayload(payload); + String deviceNo = resolveDeviceNo(deviceIdentity, body); if (deviceNo == null) { log.warn("[MQTT] 设备状态上报未找到设备 时间={} 设备标识={} 消息体={}", HandlerLogTime.now(), deviceIdentity, payload); return; } - String status = parseStatus(payload); + String status = parseStatus(payload, body); if ("online".equals(status) || "1".equals(status)) { - deviceStatusService.markOnline(deviceNo); - log.info("[MQTT] 设备状态在线 时间={} 设备编号={} 消息体={}", HandlerLogTime.now(), deviceNo, payload); + log.debug("[MQTT] 忽略设备主动在线状态,在线状态由电量心跳维护 时间={} 设备编号={} 消息体={}", + HandlerLogTime.now(), deviceNo, payload); return; } if ("offline".equals(status) || "0".equals(status)) { @@ -69,22 +70,52 @@ public class DeviceStatusHandler implements MqttTopicHandler { HandlerLogTime.now(), deviceNo, payload); } - private String parseStatus(String payload) { + private String resolveDeviceNo(String deviceIdentity, Map body) { + String deviceNo = deviceIdentityResolver.resolveDeviceNo(deviceIdentity); + if (deviceNo != null || body == null) { + return deviceNo; + } + Object deviceMac = body.get("deviceMac"); + if (deviceMac == null || StringUtils.isBlank(String.valueOf(deviceMac))) { + return null; + } + return deviceIdentityResolver.resolveDeviceNo(String.valueOf(deviceMac)); + } + + private Map parsePayload(String payload) { if (StringUtils.isBlank(payload)) { return null; } String trimmed = payload.trim(); if (!trimmed.startsWith("{")) { - return trimmed.toLowerCase(Locale.ROOT); + return null; } try { - Map body = objectMapper.readValue(trimmed, new TypeReference>() { + return objectMapper.readValue(trimmed, new TypeReference>() { }); - Object status = body.get("status"); - return status == null ? null : String.valueOf(status).trim().toLowerCase(Locale.ROOT); } catch (Exception e) { log.warn("[MQTT] 设备状态上报 JSON 格式错误 时间={} 消息体={}", HandlerLogTime.now(), payload, e); return null; } } + + private String parseStatus(String payload, Map body) { + if (body == null) { + if (StringUtils.isBlank(payload)) { + return null; + } + String trimmed = payload.trim(); + return trimmed.startsWith("{") ? null : trimmed.toLowerCase(Locale.ROOT); + } + + Object status = body.get("status"); + if (status != null) { + return String.valueOf(status).trim().toLowerCase(Locale.ROOT); + } + Object offline = body.get("offline"); + if (offline != null && "true".equalsIgnoreCase(String.valueOf(offline).trim())) { + return "offline"; + } + return null; + } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/MqttDeviceStatusService.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/MqttDeviceStatusService.java index 4c5b6e0..4b4eb4d 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/MqttDeviceStatusService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/MqttDeviceStatusService.java @@ -32,7 +32,7 @@ public class MqttDeviceStatusService { @Value("${mqtt.command-ack.device-status-cache-prefix}") private String deviceStatusCachePrefix; - @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}") + @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}") private long deviceStatusCacheTtlSeconds; public void markOnline(String deviceNo) { diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java index 5a87735..606539f 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppDeviceService.java @@ -29,6 +29,14 @@ public interface IAppDeviceService { */ AppDeviceVo queryById(String deviceNo); + /** + * 按设备编号批量查询设备。 + * + * @param deviceNos 设备编号集合 + * @return 设备列表 + */ + List queryByDeviceNos(Collection deviceNos); + /** * 分页查询设备信息 列表 diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java index 279537c..e7786e2 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/IAppSchedulingDeviceService.java @@ -8,6 +8,7 @@ import org.dromara.common.mybatis.core.page.TableDataInfo; import java.util.Collection; import java.util.List; +import java.util.Set; public interface IAppSchedulingDeviceService { @@ -25,6 +26,14 @@ public interface IAppSchedulingDeviceService { List findByDeviceNo(String deviceNo); + /** + * 批量查询已绑定排程的设备编号。 + * + * @param deviceNos 待检查的设备编号 + * @return 已绑定排程的设备编号 + */ + Set findBoundDeviceNos(Collection deviceNos); + Boolean deleteWithValidByScheduleIdAndDeviceNo(@NotEmpty(message = "主键不能为空") Long scheduleId, String deviceNo); AppSchedulingDeviceVo queryByScheduleIdAndDeviceNo(Long scheduleId, String deviceNo); diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java index 49aadce..0ac4694 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppDeviceServiceImpl.java @@ -73,6 +73,14 @@ public class AppDeviceServiceImpl implements IAppDeviceService { return baseMapper.selectVoById(deviceNo); } + @Override + public List queryByDeviceNos(Collection deviceNos) { + if (deviceNos == null || deviceNos.isEmpty()) { + return List.of(); + } + return baseMapper.selectVoByIds(deviceNos); + } + @Override public TableDataInfo queryPageList(AppDeviceBo bo, PageQuery pageQuery) { Page page = pageQuery.build(); @@ -307,7 +315,7 @@ public class AppDeviceServiceImpl implements IAppDeviceService { return params; } params.put("bindDevice", exists); - if (exists.getUserId() == null && exists.getWorkStatus().equals("2") && exists.getStatus().equals("0") ) { + if (exists.getUserId() == null && "2".equals(exists.getWorkStatus()) && "0".equals(exists.getStatus())) { params.put("bindDeviceStatus", 305); params.put("bindDeviceStatusName", "设备未配网,请先去配置网络"); return params; diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java index d6ab07e..487e266 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppScheduleServiceImpl.java @@ -165,10 +165,10 @@ public class AppScheduleServiceImpl implements IAppScheduleService { return conflicts; } - private List saveDetails(Long scheduleId, List details) { - List list = new ArrayList<>(); + private List> saveDetails(Long scheduleId, List details) { + List> detailList = new ArrayList<>(); if (details == null) { - return list; + return detailList; } for (AppScheduleBo.AppScheduleDetail item : details) { AppScheduleDetail detail = new AppScheduleDetail(); @@ -181,13 +181,13 @@ public class AppScheduleServiceImpl implements IAppScheduleService { detail.setTriggerType(item.getTriggerType()); detail.setStatus(item.getStatus()); scheduleDetailMapper.insert(detail); - list.add(toDetailMap(detail, item.getTimeData())); + detailList.add(toDetailMap(detail, item.getTimeData())); } - return list; + return detailList; } - private List updateDetails(List details) { - List detailList = new ArrayList<>(); + private List> updateDetails(List details) { + List> detailList = new ArrayList<>(); if (details == null) { return detailList; } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java index 71c3361..0cc5727 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImpl.java @@ -21,6 +21,8 @@ import org.springframework.stereotype.Service; import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor @@ -106,4 +108,19 @@ public class AppSchedulingDeviceServiceImpl implements IAppSchedulingDeviceServi public List findByDeviceNo(String deviceNo) { return baseMapper.selectVoList(new QueryWrapper().eq("device_no", deviceNo)); } + + @Override + public Set findBoundDeviceNos(Collection deviceNos) { + if (deviceNos == null || deviceNos.isEmpty()) { + return Set.of(); + } + return baseMapper.selectList( + new QueryWrapper() + .select("device_no") + .in("device_no", deviceNos)) + .stream() + .map(AppSchedulingDevice::getDeviceNo) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toSet()); + } } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/task/DeviceOfflineCheckTask.java b/water-modules/water-app/src/main/java/org/dromara/app/task/DeviceOfflineCheckTask.java index 607a488..142d41b 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/task/DeviceOfflineCheckTask.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/task/DeviceOfflineCheckTask.java @@ -18,8 +18,8 @@ import java.util.List; /** * 设备离线检测定时任务 *

- * 逻辑:默认通过设备 LWT 离线消息更新状态。 - * 兼容旧设备时,可打开 ttl-compat-enabled,继续使用 Redis 在线 Key 过期兜底离线。 + * 逻辑:电量上报刷新 Redis 在线 Key,Key 超时后判定设备离线。 + * 设备 LWT 离线消息仍可立即更新离线状态,本任务用于处理未收到遗嘱的异常断线。 * 本任务定期扫描数据库中非离线状态的设备,如果对应 Redis Key 已不存在, * 则将数据库 status 更新为 0(离线)。 *

@@ -37,19 +37,19 @@ public class DeviceOfflineCheckTask { @Value("${mqtt.command-ack.device-status-cache-prefix}") private String deviceStatusCachePrefix; - @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:900}") + @Value("${mqtt.command-ack.device-status-cache-ttl-seconds:600}") private long deviceStatusCacheTtlSeconds; @Value("${mqtt.offline-check.enabled:true}") private boolean enabled; - @Value("${mqtt.offline-check.ttl-compat-enabled:false}") + @Value("${mqtt.offline-check.ttl-compat-enabled:true}") private boolean ttlCompatEnabled; /** - * 每 90 秒检查一次。默认不开启 TTL 兼容离线检测,避免低功耗长连接设备被误判离线。 + * 每 30 秒检查一次电量心跳是否超时。 * 可通过 mqtt.offline-check.interval-ms 覆盖(单位 ms) */ - @Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:90000}") + @Scheduled(fixedDelayString = "${mqtt.offline-check.interval-ms:30000}") public void checkOfflineDevices() { if (!enabled || !ttlCompatEnabled) { return; @@ -96,7 +96,7 @@ public class DeviceOfflineCheckTask { List updatedDeviceNos = new ArrayList<>(); for (String deviceNo : offlineDeviceNos) { - if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备掉线")) { + if (deviceStatusService.markOfflineIfCacheMissing(deviceNo, "设备电量心跳超时")) { updatedDeviceNos.add(deviceNo); } } diff --git a/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java new file mode 100644 index 0000000..cb5c914 --- /dev/null +++ b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerConcurrencyTest.java @@ -0,0 +1,306 @@ +package org.dromara.app.controller; + +import cn.hutool.extra.spring.SpringUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.app.domain.bo.AppScheduleBo; +import org.dromara.app.domain.bo.AppSchedulingDeviceBo; +import org.dromara.app.domain.vo.AppScheduleDetailVo; +import org.dromara.app.domain.vo.AppScheduleVo; +import org.dromara.app.domain.vo.AppSchedulingDeviceVo; +import org.dromara.app.service.*; +import org.dromara.common.core.domain.R; +import org.dromara.common.satoken.utils.LoginHelper; +import org.dromara.system.domain.vo.SysOssUploadVo; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; +import org.dromara.system.service.ISysUserService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.ResolverStyle; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@Tag("dev") +class AppControllerConcurrencyTest { + + private static final int WORKER_COUNT = 32; + private static final int GROUP_TIMEOUT_SECONDS = 30; + private static final Long USER_ID = 99L; + private static final DateTimeFormatter STRICT_SECOND_FORMATTER = + DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss").withResolverStyle(ResolverStyle.STRICT); + + @Mock private IAppDeviceService appDeviceService; + @Mock private IAppScheduleService appScheduleService; + @Mock private IAppScheduleDetailService appScheduleDetailService; + @Mock private IAppSchedulingDeviceService appSchedulingDeviceService; + @Mock private ISysUserService userService; + @Mock private IAppWateringLogService appWateringLogService; + @Mock private IDeviceCommandService deviceCommandService; + @Mock private ISysOssService ossService; + @Mock private IAppVersionService appVersionService; + + private GenericApplicationContext applicationContext; + + @BeforeEach + void setUp() { + applicationContext = new GenericApplicationContext(); + Supplier objectMapperSupplier = ObjectMapper::new; + applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier); + applicationContext.refresh(); + new SpringUtil().setApplicationContext(applicationContext); + } + + @AfterEach + void tearDown() { + applicationContext.close(); + } + + @Test + void switchDevice_formatsAllStartTimesCorrectlyUnderConcurrency() throws Exception { + AppController controller = newController(); + int iterationsPerWorker = 100; + int expectedCalls = WORKER_COUNT * iterationsPerWorker; + ConcurrentLinkedQueue startTimes = new ConcurrentLinkedQueue<>(); + AtomicInteger successfulResponses = new AtomicInteger(); + when(appDeviceService.switchDevice(eq("D01"), eq("1"), anyString(), eq(10))) + .thenAnswer(invocation -> { + startTimes.add(invocation.getArgument(2, String.class)); + return true; + }); + + runConcurrently(iterationsPerWorker, (worker, iteration) -> { + R result = controller.switchDevice( + "{\"deviceNo\":\"D01\",\"workStatus\":\"1\",\"durationMin\":\"10\"}"); + assertThat(result.getCode()).isEqualTo(R.SUCCESS); + successfulResponses.incrementAndGet(); + }); + + assertThat(successfulResponses.get()).isEqualTo(expectedCalls); + assertThat(startTimes).hasSize(expectedCalls).allSatisfy(startTime -> + assertThatCode(() -> LocalDateTime.parse(startTime, STRICT_SECOND_FORMATTER)) + .doesNotThrowAnyException()); + verify(appDeviceService, times(expectedCalls)) + .switchDevice(eq("D01"), eq("1"), anyString(), eq(10)); + } + + @Test + void uploadImage_validatesAllSupportedFormatsUnderConcurrency() throws Exception { + AppController controller = newController(); + int iterationsPerWorker = 50; + int expectedCalls = WORKER_COUNT * iterationsPerWorker; + List fixtures = supportedImages(); + SysOssVo oss = new SysOssVo(); + oss.setOssId(123L); + oss.setOriginalName("plant-image"); + oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image"); + when(ossService.upload(any(MultipartFile.class))).thenReturn(oss); + + runConcurrently(iterationsPerWorker, (worker, iteration) -> { + ImageFixture fixture = fixtures.get((worker + iteration) % fixtures.size()); + MockMultipartFile file = new MockMultipartFile( + "file", fixture.fileName(), fixture.contentType(), fixture.content()); + + R result = controller.uploadImage(file); + + assertThat(result.getCode()).isEqualTo(R.SUCCESS); + assertThat(result.getData().getOssId()).isEqualTo("123"); + assertThat(result.getData().getUrl()).isEqualTo(oss.getUrl()); + }); + + verify(ossService, times(expectedCalls)).upload(any(MultipartFile.class)); + } + + @Test + void editScheduleStatus_keepsMqttPayloadsIsolatedUnderConcurrency() throws Exception { + AppController controller = newController(); + int iterationsPerWorker = 20; + int expectedCalls = WORKER_COUNT * iterationsPerWorker; + ConcurrentHashMap> payloadsByDevice = new ConcurrentHashMap<>(); + when(appScheduleService.queryById(anyLong())).thenAnswer(invocation -> { + Long scheduleId = invocation.getArgument(0, Long.class); + AppScheduleVo schedule = new AppScheduleVo(); + schedule.setId(scheduleId); + schedule.setUserId(USER_ID); + schedule.setStatus("1"); + return schedule; + }); + when(appScheduleService.updateStatusByBo(any(AppScheduleBo.class))).thenReturn(true); + when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))).thenAnswer(invocation -> { + AppSchedulingDeviceBo bo = invocation.getArgument(0, AppSchedulingDeviceBo.class); + AppSchedulingDeviceVo binding = new AppSchedulingDeviceVo(); + binding.setScheduleId(bo.getScheduleId()); + binding.setDeviceNo(deviceNo(bo.getScheduleId())); + return List.of(binding); + }); + when(appScheduleDetailService.queryByScheduleIdByStatus(anyLong())).thenAnswer(invocation -> { + Long scheduleId = invocation.getArgument(0, Long.class); + AppScheduleDetailVo detail = new AppScheduleDetailVo(); + detail.setScheduleId(scheduleId); + detail.setWeekday((int) ((scheduleId - 1) % 7) + 1); + detail.setTimeData("[{\"startTime\":\"08:00\",\"durationMin\":15}]"); + detail.setTriggerType(detailMarker(scheduleId)); + detail.setStatus("1"); + return List.of(detail); + }); + when(deviceCommandService.sendScheduleBindCommand(anyString(), any())).thenAnswer(invocation -> { + String deviceNo = invocation.getArgument(0, String.class); + Map payload = invocation.getArgument(1); + assertThat(payloadsByDevice.putIfAbsent(deviceNo, payload)).isNull(); + return "cmd-" + deviceNo; + }); + + runConcurrently(iterationsPerWorker, (worker, iteration) -> { + try (MockedStatic loginHelper = mockStatic(LoginHelper.class)) { + loginHelper.when(LoginHelper::getUserId).thenReturn(USER_ID); + Long scheduleId = (long) worker * iterationsPerWorker + iteration + 1; + AppScheduleBo bo = new AppScheduleBo(); + bo.setId(scheduleId); + bo.setStatus("1"); + + R result = controller.editScheduleStatus(bo); + + assertThat(result.getCode()).isEqualTo(R.SUCCESS); + } + }); + + assertThat(payloadsByDevice).hasSize(expectedCalls); + for (long scheduleId = 1; scheduleId <= expectedCalls; scheduleId++) { + String deviceNo = deviceNo(scheduleId); + Map payload = payloadsByDevice.get(deviceNo); + assertThat(payload).as(deviceNo).isNotNull(); + assertThat(payload.get("deviceNo")).isEqualTo(deviceNo); + List details = asList(payload.get("details")); + assertThat(details).hasSize(1); + Map detail = asMap(details.get(0)); + assertThat(detail.get("triggerType")).isEqualTo(detailMarker(scheduleId)); + assertThat(detail.get("weekday")).isEqualTo((int) ((scheduleId - 1) % 7) + 1); + } + verify(deviceCommandService, times(expectedCalls)).sendScheduleBindCommand(anyString(), any()); + verify(appScheduleDetailService, times(expectedCalls)).queryByScheduleIdByStatus(anyLong()); + } + + private void runConcurrently(int iterationsPerWorker, ConcurrentOperation operation) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(WORKER_COUNT); + CountDownLatch ready = new CountDownLatch(WORKER_COUNT); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(WORKER_COUNT); + try { + for (int worker = 0; worker < WORKER_COUNT; worker++) { + int workerIndex = worker; + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + throw new TimeoutException("并发工作线程等待启动超时"); + } + for (int iteration = 0; iteration < iterationsPerWorker; iteration++) { + operation.execute(workerIndex, iteration); + } + return null; + })); + } + assertThat(ready.await(10, TimeUnit.SECONDS)).as("32个工作线程应全部就绪").isTrue(); + start.countDown(); + awaitAll(futures); + } finally { + start.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(10, TimeUnit.SECONDS)) + .as("并发测试线程池应按时结束") + .isTrue(); + } + } + + private void awaitAll(List> futures) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(GROUP_TIMEOUT_SECONDS); + for (Future future : futures) { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + throw new TimeoutException("并发测试组执行超过" + GROUP_TIMEOUT_SECONDS + "秒"); + } + future.get(remainingNanos, TimeUnit.NANOSECONDS); + } + } + + private AppController newController() { + return new AppController( + appDeviceService, + appScheduleService, + appScheduleDetailService, + appSchedulingDeviceService, + userService, + appWateringLogService, + deviceCommandService, + ossService, + appVersionService + ); + } + + private List supportedImages() { + return List.of( + new ImageFixture("plant.jpg", "image/jpeg", new byte[]{ + (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, + 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00 + }), + new ImageFixture("plant.png", "image/png", new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 + }), + new ImageFixture("plant.gif", "image/gif", + new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}), + new ImageFixture("plant.webp", "image/webp", new byte[]{ + 0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20 + }), + new ImageFixture("plant.bmp", "image/bmp", + new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00}) + ); + } + + private String deviceNo(Long scheduleId) { + return "D" + scheduleId; + } + + private String detailMarker(Long scheduleId) { + return "schedule-" + scheduleId; + } + + private List asList(Object value) { + assertThat(value).isInstanceOf(List.class); + return (List) value; + } + + private Map asMap(Object value) { + assertThat(value).isInstanceOf(Map.class); + return (Map) value; + } + + @FunctionalInterface + private interface ConcurrentOperation { + void execute(int worker, int iteration) throws Exception; + } + + private record ImageFixture(String fileName, String contentType, byte[] content) { + } +} diff --git a/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java index 1244602..764a084 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java @@ -3,12 +3,14 @@ package org.dromara.app.controller; import cn.hutool.extra.spring.SpringUtil; import cn.hutool.json.JSONObject; import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.bo.AppScheduleBo; import org.dromara.app.domain.bo.AppSchedulingDeviceBo; import org.dromara.app.domain.bo.AppWateringLogBo; import org.dromara.app.domain.vo.*; import org.dromara.app.service.*; import org.dromara.common.core.domain.R; +import org.dromara.common.core.exception.ServiceException; import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.system.domain.vo.SysOssUploadVo; import org.dromara.system.domain.vo.SysOssVo; @@ -17,22 +19,23 @@ import org.dromara.system.service.ISysUserService; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.MockedStatic; +import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.support.GenericApplicationContext; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -42,13 +45,13 @@ public class AppControllerTest { @Mock private IAppDeviceService appDeviceService; @Mock private IAppScheduleService appScheduleService; @Mock private IAppScheduleDetailService appScheduleDetailService; - @Mock private org.dromara.app.service.impl.AppScheduleServiceImpl appScheduleServiceimpl; @Mock private IAppSchedulingDeviceService appSchedulingDeviceService; @Mock private ISysUserService userService; @Mock private IAppWateringLogService appWateringLogService; @Mock private IDeviceCommandService deviceCommandService; @Mock private ISysOssService ossService; @Mock private IAppVersionService appVersionService; + @Captor private ArgumentCaptor> schedulePayloadCaptor; @Test public void checkVersion_returnsUpdateAvailableWhenConfiguredVersionDiffers() { @@ -100,10 +103,9 @@ public class AppControllerTest { public void checkVersion_rejectsMissingCurrentVersion() { AppController controller = newController(); - R result = callWithLogin(1L, () -> controller.checkVersion("android", " ")); - - assertThat(result.getCode()).isEqualTo(500); - assertThat(result.getMsg()).isEqualTo("当前版本不能为空"); + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", " "))) + .isInstanceOf(ServiceException.class) + .hasMessage("当前版本不能为空"); verify(appVersionService, never()).queryLatestByPlatform(any()); } @@ -111,10 +113,9 @@ public class AppControllerTest { public void checkVersion_rejectsUnsupportedPlatform() { AppController controller = newController(); - R result = callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0")); - - assertThat(result.getCode()).isEqualTo(500); - assertThat(result.getMsg()).isEqualTo("平台类型仅支持 android 或 ios"); + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("harmony", "1.0.0"))) + .isInstanceOf(ServiceException.class) + .hasMessage("平台类型仅支持 android 或 ios"); verify(appVersionService, never()).queryLatestByPlatform(any()); } @@ -123,10 +124,9 @@ public class AppControllerTest { AppController controller = newController(); when(appVersionService.queryLatestByPlatform("android")).thenReturn(null); - R result = callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0")); - - assertThat(result.getCode()).isEqualTo(500); - assertThat(result.getMsg()).isEqualTo("版本配置不存在"); + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.checkVersion("android", "1.0.0"))) + .isInstanceOf(ServiceException.class) + .hasMessage("版本配置不存在"); } @Test @@ -136,7 +136,7 @@ public class AppControllerTest { "file", "plant.png", "image/png", - new byte[]{1, 2, 3} + pngBytes() ); SysOssVo oss = new SysOssVo(); oss.setOssId(123L); @@ -153,6 +153,29 @@ public class AppControllerTest { verify(ossService).upload(file); } + @Test + public void uploadImage_acceptsAllSupportedBinaryImageFormats() { + AppController controller = newController(); + SysOssVo oss = new SysOssVo(); + oss.setOssId(123L); + oss.setOriginalName("plant-image"); + oss.setUrl("https://bucket.oss-cn-hangzhou.aliyuncs.com/plant-image"); + when(ossService.upload(any(MultipartFile.class))).thenReturn(oss); + List files = List.of( + new MockMultipartFile("file", "plant.jpg", "image/jpeg", jpegBytes()), + new MockMultipartFile("file", "plant.gif", "image/gif", gifBytes()), + new MockMultipartFile("file", "plant.webp", "image/webp", webpBytes()), + new MockMultipartFile("file", "plant.bmp", "image/bmp", bmpBytes()) + ); + + for (MockMultipartFile file : files) { + R result = callWithLogin(1L, () -> controller.uploadImage(file)); + assertThat(result.getCode()).as(file.getOriginalFilename()).isEqualTo(200); + } + + verify(ossService, times(files.size())).upload(any(MultipartFile.class)); + } + @Test public void uploadImage_rejectsNonImageFile() { AppController controller = newController(); @@ -163,13 +186,67 @@ public class AppControllerTest { new byte[]{1, 2, 3} ); - R result = callWithLogin(1L, () -> controller.uploadImage(file)); - - assertThat(result.getCode()).isEqualTo(500); - assertThat(result.getMsg()).isEqualTo("只能上传图片文件"); + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("只能上传图片文件"); verify(ossService, never()).upload(any(org.springframework.web.multipart.MultipartFile.class)); } + @Test + public void uploadImage_rejectsForgedImageContent() { + AppController controller = newController(); + MockMultipartFile file = new MockMultipartFile( + "file", + "plant.png", + "image/png", + "not-an-image".getBytes(StandardCharsets.UTF_8) + ); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("图片文件内容不合法"); + verify(ossService, never()).upload(any(MultipartFile.class)); + } + + @Test + public void uploadImage_rejectsFileLargerThanTwentyMegabytes() { + AppController controller = newController(); + MultipartFile file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(20L * 1024 * 1024 + 1); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("图片大小不能超过20MB"); + verify(ossService, never()).upload(any(MultipartFile.class)); + } + + @Test + public void uploadImage_rejectsSvg() { + AppController controller = newController(); + MockMultipartFile file = new MockMultipartFile( + "file", + "plant.svg", + "image/svg+xml", + "".getBytes(StandardCharsets.UTF_8) + ); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.uploadImage(file))) + .isInstanceOf(ServiceException.class) + .hasMessage("仅支持jpg、jpeg、png、gif、webp、bmp图片"); + verify(ossService, never()).upload(any(MultipartFile.class)); + } + + @Test + public void getDeviceInfo_propagatesUnexpectedException() { + AppController controller = newController(); + IllegalStateException failure = new IllegalStateException("database unavailable"); + when(appDeviceService.queryById("D01")).thenThrow(failure); + + assertThatThrownBy(() -> callWithLogin(1L, () -> controller.getDeviceInfo("D01"))) + .isSameAs(failure); + } + @Test public void switchDevice_usesCurrentTimeWithSecondsAsStartTime() throws Exception { AppController controller = newController(); @@ -186,6 +263,47 @@ public class AppControllerTest { assertThat(startTimeCaptor.getValue()).doesNotEndWith(":00"); } + @Test + public void scheduleDeviceList_usesSingleBatchBindingLookup() { + AppController controller = newController(); + AppDeviceVo first = ownedDevice("D01", 99L); + AppDeviceVo second = ownedDevice("D02", 99L); + when(appDeviceService.queryList(any(AppDeviceBo.class))).thenReturn(List.of(first, second)); + when(appSchedulingDeviceService.findBoundDeviceNos(List.of("D01", "D02"))) + .thenReturn(Set.of("D01")); + + R> result = callWithLogin(99L, () -> controller.scheduleDeviceList(10L)); + + assertThat(result.getData().get("deviceList")).isEqualTo(List.of(second)); + verify(appSchedulingDeviceService, never()).findByDeviceNo(anyString()); + } + + @Test + public void getScheduleInfo_usesSingleBatchDeviceLookup() { + AppController controller = newController(); + AppScheduleVo schedule = ownedSchedule(10L, 99L); + AppSchedulingDeviceVo firstBinding = new AppSchedulingDeviceVo(); + firstBinding.setDeviceNo("D01"); + AppSchedulingDeviceVo secondBinding = new AppSchedulingDeviceVo(); + secondBinding.setDeviceNo("D02"); + AppDeviceVo firstDevice = ownedDevice("D01", 99L); + AppDeviceVo secondDevice = ownedDevice("D02", 99L); + List queriedDevices = List.of(secondDevice, firstDevice); + when(appScheduleService.queryById(10L)).thenReturn(schedule); + when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of()); + when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))) + .thenReturn(List.of(firstBinding, secondBinding)); + when(appDeviceService.queryByDeviceNos(List.of("D01", "D02"))).thenReturn(queriedDevices); + + R result = callWithLogin(99L, () -> controller.getScheduleInfo(10L)); + + assertThat(result.getData().getDeviceNos()) + .extracting(AppDeviceVo::getDeviceNo) + .containsExactly("D01", "D02"); + verify(appDeviceService, never()).queryById("D01"); + verify(appDeviceService, never()).queryById("D02"); + } + @Test public void addScheduleDevice_dispatchesSchedulePayloadAfterBinding() { AppController controller = newController(); @@ -217,23 +335,21 @@ public class AppControllerTest { assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); - ArgumentCaptor> payloadCaptor = ArgumentCaptor.forClass(Map.class); - verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), payloadCaptor.capture()); - Map payload = payloadCaptor.getValue(); + verify(deviceCommandService).sendScheduleBindCommand(eq("D01"), schedulePayloadCaptor.capture()); + Map payload = schedulePayloadCaptor.getValue(); assertThat(payload.get("deviceNo")).isEqualTo("D01"); assertThat(payload).containsKey("details").doesNotContainKeys("cmd", "schedule"); - List> details = (List>) payload.get("details"); + List details = asList(payload.get("details")); assertThat(details).hasSize(1); - Map detailPayload = details.get(0); - assertThat(detailPayload) - .containsEntry("weekday", 1) - .containsEntry("triggerType", "0") - .containsEntry("status", "1") - .doesNotContainKey("id") - .doesNotContainKey("zones"); + Map detailPayload = asMap(details.get(0)); + assertThat(detailPayload.get("weekday")).isEqualTo(1); + assertThat(detailPayload.get("triggerType")).isEqualTo("0"); + assertThat(detailPayload.get("status")).isEqualTo("1"); + assertThat(detailPayload.containsKey("id")).isFalse(); + assertThat(detailPayload.containsKey("zones")).isFalse(); - List timeData = (List) detailPayload.get("timeData"); + List timeData = asList(detailPayload.get("timeData")); assertThat(timeData).hasSize(1); JSONObject timeSlot = (JSONObject) timeData.get(0); assertThat(timeSlot.getStr("startTime")).isEqualTo("08:00"); @@ -275,7 +391,7 @@ public class AppControllerTest { R result = callWithLogin(userId, () -> controller.removeSchedule(new Long[]{10L})); assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200); - var ordered = inOrder(appScheduleService, deviceCommandService); + InOrder ordered = inOrder(appScheduleService, deviceCommandService); ordered.verify(appScheduleService).deleteWithValidByIds(List.of(10L), true); ordered.verify(deviceCommandService).sendScheduleUnbindCommand("D01", 10L); } @@ -348,6 +464,29 @@ public class AppControllerTest { verify(deviceCommandService, never()).sendScheduleCanceledCommand("D01", 10L); } + @Test + public void editScheduleStatus_queriesScheduleDetailsOnceForMultipleDevices() { + AppController controller = newController(); + AppScheduleBo bo = new AppScheduleBo(); + bo.setId(10L); + bo.setStatus("1"); + AppSchedulingDeviceVo first = new AppSchedulingDeviceVo(); + first.setDeviceNo("D01"); + AppSchedulingDeviceVo second = new AppSchedulingDeviceVo(); + second.setDeviceNo("D02"); + + when(appScheduleService.queryById(10L)).thenReturn(ownedSchedule(10L, 99L)); + when(appScheduleService.updateStatusByBo(bo)).thenReturn(true); + when(appSchedulingDeviceService.queryList(any(AppSchedulingDeviceBo.class))) + .thenReturn(List.of(first, second)); + when(appScheduleDetailService.queryByScheduleIdByStatus(10L)).thenReturn(List.of()); + + callWithLogin(99L, () -> controller.editScheduleStatus(bo)); + + verify(appScheduleDetailService, times(1)).queryByScheduleIdByStatus(10L); + verify(deviceCommandService, times(2)).sendScheduleBindCommand(anyString(), any()); + } + @Test public void latestWaterLog_returnsLatestRunningLogById() throws Exception { AppController controller = newController(); @@ -387,7 +526,6 @@ public class AppControllerTest { appDeviceService, appScheduleService, appScheduleDetailService, - appScheduleServiceimpl, appSchedulingDeviceService, userService, appWateringLogService, @@ -400,7 +538,8 @@ public class AppControllerTest { private R callWithLogin(Long userId, Supplier> action) { try (GenericApplicationContext applicationContext = new GenericApplicationContext(); MockedStatic loginHelper = mockStatic(LoginHelper.class)) { - applicationContext.registerBean(ObjectMapper.class, (Supplier) ObjectMapper::new); + Supplier objectMapperSupplier = ObjectMapper::new; + applicationContext.registerBean(ObjectMapper.class, objectMapperSupplier); applicationContext.refresh(); new SpringUtil().setApplicationContext(applicationContext); loginHelper.when(LoginHelper::getUserId).thenReturn(userId); @@ -408,6 +547,16 @@ public class AppControllerTest { } } + private List asList(Object value) { + assertThat(value).isInstanceOf(List.class); + return (List) value; + } + + private Map asMap(Object value) { + assertThat(value).isInstanceOf(Map.class); + return (Map) value; + } + private AppScheduleVo ownedSchedule(Long scheduleId, Long userId) { AppScheduleVo schedule = new AppScheduleVo(); schedule.setId(scheduleId); @@ -422,6 +571,35 @@ public class AppControllerTest { return device; } + private byte[] pngBytes() { + return new byte[]{ + (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52 + }; + } + + private byte[] jpegBytes() { + return new byte[]{ + (byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, + 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00 + }; + } + + private byte[] gifBytes() { + return new byte[]{0x47, 0x49, 0x46, 0x38, 0x39, 0x61}; + } + + private byte[] webpBytes() { + return new byte[]{ + 0x52, 0x49, 0x46, 0x46, 0x18, 0x00, 0x00, 0x00, + 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38, 0x20 + }; + } + + private byte[] bmpBytes() { + return new byte[]{0x42, 0x4D, 0x00, 0x00, 0x00, 0x00}; + } + private void waitUntilCurrentSecondIsSafelyNonZero() throws InterruptedException { while (true) { int second = Calendar.getInstance().get(Calendar.SECOND); diff --git a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceDataHandlerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceDataHandlerTest.java new file mode 100644 index 0000000..c0d1961 --- /dev/null +++ b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceDataHandlerTest.java @@ -0,0 +1,64 @@ +package org.dromara.app.handler; + +import cn.hutool.extra.spring.SpringUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.app.domain.bo.AppDeviceBo; +import org.dromara.app.service.IAppDeviceService; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.support.GenericApplicationContext; + +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@Tag("dev") +class DeviceDataHandlerTest { + + @Mock + private IAppDeviceService appDeviceService; + + @Mock + private DeviceIdentityResolver deviceIdentityResolver; + + @Mock + private MqttDeviceStatusService deviceStatusService; + + @InjectMocks + private DeviceDataHandler handler; + + @Test + void handleMarksDeviceOnlineAndUpdatesPowerLevel() { + when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); + + withJsonContext(() -> { + handler.handle("D01", "{\"powerLevel\":\"86\"}"); + return null; + }); + + ArgumentCaptor deviceCaptor = ArgumentCaptor.forClass(AppDeviceBo.class); + verify(appDeviceService).updateByBo(deviceCaptor.capture()); + AppDeviceBo updatedDevice = deviceCaptor.getValue(); + assertThat(updatedDevice.getDeviceNo()).isEqualTo("D01"); + assertThat(updatedDevice.getPowerLevel()).isEqualTo("86"); + assertThat(updatedDevice.getPowerLevelUpdatatime()).isNotNull(); + verify(deviceStatusService).markOnline("D01"); + } + + private T withJsonContext(Supplier action) { + try (GenericApplicationContext applicationContext = new GenericApplicationContext()) { + applicationContext.registerBean(ObjectMapper.class, (Supplier) ObjectMapper::new); + applicationContext.refresh(); + new SpringUtil().setApplicationContext(applicationContext); + return action.get(); + } + } +} diff --git a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java index f5c72ac..26f4701 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java @@ -1,5 +1,7 @@ package org.dromara.app.handler; +import cn.hutool.extra.spring.SpringUtil; +import com.fasterxml.jackson.databind.ObjectMapper; import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.bo.AppDeviceBo; import org.dromara.app.domain.mqtt.DeviceCommand; @@ -13,8 +15,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.context.support.GenericApplicationContext; import java.lang.reflect.Method; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +34,7 @@ class DeviceRegisterHandlerTest { @Mock private ObjectProvider commandPublisherProvider; @Mock private IDeviceCommandPublisher commandPublisher; @Mock private DeviceIdentityResolver deviceIdentityResolver; + @Mock private MqttDeviceStatusService deviceStatusService; @Test void firstRegistrationReplyStillUsesMacTopic() throws Exception { @@ -37,7 +42,8 @@ class DeviceRegisterHandlerTest { appDeviceService, appDeviceMapper, commandPublisherProvider, - deviceIdentityResolver + deviceIdentityResolver, + deviceStatusService ); when(commandPublisherProvider.getIfAvailable()).thenReturn(commandPublisher); when(commandPublisher.send(any(DeviceCommand.class))).thenReturn("cmd-1"); @@ -61,7 +67,8 @@ class DeviceRegisterHandlerTest { appDeviceService, appDeviceMapper, commandPublisherProvider, - deviceIdentityResolver + deviceIdentityResolver, + deviceStatusService ); AppDevice existing = new AppDevice(); existing.setDeviceNo("2075423638947475457"); @@ -69,11 +76,24 @@ class DeviceRegisterHandlerTest { when(deviceIdentityResolver.resolve("2075423638947475457")).thenReturn(existing); when(commandPublisherProvider.getIfAvailable()).thenReturn(null); - handler.handle("2075423638947475457", "{}"); + withJsonContext(() -> { + handler.handle("2075423638947475457", "{}"); + return null; + }); org.mockito.ArgumentCaptor captor = org.mockito.ArgumentCaptor.forClass(AppDeviceBo.class); verify(appDeviceService).registerByMqtt(captor.capture()); assertThat(captor.getValue().getDeviceNo()).isEqualTo("2075423638947475457"); assertThat(captor.getValue().getMacAddress()).isEqualTo("aa:bb:cc"); + verify(deviceStatusService).markOnline("2075423638947475457"); + } + + private T withJsonContext(Supplier action) { + try (GenericApplicationContext applicationContext = new GenericApplicationContext()) { + applicationContext.registerBean(ObjectMapper.class, (Supplier) ObjectMapper::new); + applicationContext.refresh(); + new SpringUtil().setApplicationContext(applicationContext); + return action.get(); + } } } diff --git a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java index 90b28c0..56cccb3 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceStatusHandlerTest.java @@ -35,13 +35,13 @@ class DeviceStatusHandlerTest { } @Test - void handleMarksDeviceOnlineFromPlainPayload() { + void handleIgnoresOnlineStatusBecausePowerReportMarksDeviceOnline() { DeviceStatusHandler handler = newHandler(); when(deviceIdentityResolver.resolveDeviceNo("D01")).thenReturn("D01"); handler.handle("D01", "online"); - verify(deviceStatusService).markOnline("D01"); + verify(deviceStatusService, never()).markOnline(anyString()); verify(deviceStatusService, never()).markOffline(anyString(), anyString()); } @@ -56,6 +56,19 @@ class DeviceStatusHandlerTest { verify(deviceStatusService, never()).markOnline(anyString()); } + @Test + void handleMarksDeviceOfflineFromOfflineFlagAndPayloadMac() { + DeviceStatusHandler handler = newHandler(); + when(deviceIdentityResolver.resolveDeviceNo("unknown-topic-identity")).thenReturn(null); + when(deviceIdentityResolver.resolveDeviceNo("DC:DA:0C:FA:29:5E")).thenReturn("D01"); + + handler.handle("unknown-topic-identity", "{\"deviceMac\":\"DC:DA:0C:FA:29:5E\",\"offline\":\"true\"}"); + + verify(deviceIdentityResolver).resolveDeviceNo("DC:DA:0C:FA:29:5E"); + verify(deviceStatusService).markOffline("D01", "设备 MQTT 状态离线"); + verify(deviceStatusService, never()).markOnline(anyString()); + } + @Test void handleResolvesLastWillTopicMacToDeviceNo() { String macAddress = "DC:DA:0C:FA:29:5E"; diff --git a/water-modules/water-app/src/test/java/org/dromara/app/handler/MqttDeviceStatusServiceTest.java b/water-modules/water-app/src/test/java/org/dromara/app/handler/MqttDeviceStatusServiceTest.java index 8e9dfc6..7fe239c 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/handler/MqttDeviceStatusServiceTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/handler/MqttDeviceStatusServiceTest.java @@ -76,7 +76,7 @@ class MqttDeviceStatusServiceTest { redis.verify(() -> RedisUtils.setCacheObject( eq("mqtt:device:status:D01"), any(Map.class), - eq(Duration.ofSeconds(900)) + eq(Duration.ofSeconds(600)) )); verify(appDeviceMapper).update(eq(null), any(LambdaUpdateWrapper.class)); verify(lock).unlock(); @@ -128,7 +128,7 @@ class MqttDeviceStatusServiceTest { private MqttDeviceStatusService newService() { MqttDeviceStatusService service = new MqttDeviceStatusService(appDeviceMapper, wateringLogService); ReflectionTestUtils.setField(service, "deviceStatusCachePrefix", "mqtt:device:status:"); - ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 900L); + ReflectionTestUtils.setField(service, "deviceStatusCacheTtlSeconds", 600L); return service; } } diff --git a/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java b/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java index 71ed5f1..d441610 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppDeviceServiceImplTest.java @@ -3,6 +3,7 @@ package org.dromara.app.service.impl; import com.baomidou.mybatisplus.core.conditions.Wrapper; import org.dromara.app.domain.AppDevice; import org.dromara.app.domain.bo.AppDeviceBo; +import org.dromara.app.domain.vo.AppDeviceVo; import org.dromara.app.mapper.AppDeviceMapper; import org.dromara.app.mapper.AppSchedulingDeviceMapper; import org.dromara.app.mapper.AppWateringLogMapper; @@ -174,6 +175,22 @@ class AppDeviceServiceImplTest { } } + @Test + void queryByDeviceNos_delegatesToSingleBatchQuery() { + AppDeviceServiceImpl service = new AppDeviceServiceImpl( + appDeviceMapper, + schedulingDeviceMapper, + wateringLogMapper, + wateringLogService + ); + List deviceNos = List.of("D01", "D02"); + List expected = List.of(new AppDeviceVo(), new AppDeviceVo()); + when(appDeviceMapper.selectVoByIds(deviceNos)).thenReturn(expected); + + assertThat(service.queryByDeviceNos(deviceNos)).isSameAs(expected); + verify(appDeviceMapper).selectVoByIds(deviceNos); + } + private int countNonWhitePixels(BufferedImage image, int startY, int endY) { int count = 0; for (int y = startY; y < endY; y++) { diff --git a/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java b/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java new file mode 100644 index 0000000..ae1a8bd --- /dev/null +++ b/water-modules/water-app/src/test/java/org/dromara/app/service/impl/AppSchedulingDeviceServiceImplTest.java @@ -0,0 +1,40 @@ +package org.dromara.app.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.dromara.app.domain.AppSchedulingDevice; +import org.dromara.app.mapper.AppSchedulingDeviceMapper; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@Tag("dev") +class AppSchedulingDeviceServiceImplTest { + + @Mock + private AppSchedulingDeviceMapper mapper; + + @Test + void findBoundDeviceNos_usesOneConstrainedQuery() { + AppSchedulingDeviceServiceImpl service = new AppSchedulingDeviceServiceImpl(mapper); + AppSchedulingDevice first = new AppSchedulingDevice(); + first.setDeviceNo("D01"); + AppSchedulingDevice duplicate = new AppSchedulingDevice(); + duplicate.setDeviceNo("D01"); + when(mapper.selectList(any(Wrapper.class))).thenReturn(List.of(first, duplicate)); + + Set result = service.findBoundDeviceNos(List.of("D01", "D02")); + + assertThat(result).containsExactly("D01"); + verify(mapper, times(1)).selectList(any(Wrapper.class)); + } +} diff --git a/water-modules/water-app/src/test/java/org/dromara/app/task/DeviceOfflineCheckTaskTest.java b/water-modules/water-app/src/test/java/org/dromara/app/task/DeviceOfflineCheckTaskTest.java index 45e13df..44a1e62 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/task/DeviceOfflineCheckTaskTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/task/DeviceOfflineCheckTaskTest.java @@ -67,15 +67,31 @@ class DeviceOfflineCheckTaskTest { task.checkOfflineDevices(); - redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(900))); + redis.verify(() -> RedisUtils.expire("mqtt:device:status:D01", Duration.ofSeconds(600))); verify(deviceStatusService, never()).markOfflineIfCacheMissing(any(), any()); } } + @Test + void checkOfflineDevicesMarksDeviceOfflineWhenPowerHeartbeatExpired() { + DeviceOfflineCheckTask task = newTask(); + AppDevice device = new AppDevice(); + device.setDeviceNo("D01"); + when(appDeviceMapper.selectList(any(Wrapper.class))).thenReturn(List.of(device)); + + try (MockedStatic redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.hasKey("mqtt:device:status:D01")).thenReturn(false); + + task.checkOfflineDevices(); + + verify(deviceStatusService).markOfflineIfCacheMissing("D01", "设备电量心跳超时"); + } + } + private DeviceOfflineCheckTask newTask() { DeviceOfflineCheckTask task = new DeviceOfflineCheckTask(appDeviceMapper, deviceStatusService); ReflectionTestUtils.setField(task, "deviceStatusCachePrefix", "mqtt:device:status:"); - ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 900L); + ReflectionTestUtils.setField(task, "deviceStatusCacheTtlSeconds", 600L); ReflectionTestUtils.setField(task, "enabled", true); ReflectionTestUtils.setField(task, "ttlCompatEnabled", true); return task;