Compare commits

..

11 Commits

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

1
.gitignore vendored
View File

@@ -46,6 +46,7 @@
/.trae/
/.windsurf/
/.workbuddy/
/.worktrees/
# IDE
.idea/

12
.idea/compiler.xml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ mqtt:
command-ack:
enabled: true
max-retry-count: 3
retry-interval-ms: 30000
retry-interval-ms: 10000
scan-interval-ms: 5000
pending-ttl-seconds: 86400
ack-ttl-seconds: 86400
@@ -172,6 +172,7 @@ security:
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
- /app/v1/retrievePassword
# 多租户配置
tenant:

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
private MqttClientManager mqttClientManager;
private final MqttProperties mqttProperties;
private final AppDeviceMapper appDeviceMapper;
private volatile boolean startupCleanupReady;
private static final String DEVICE_STATUS_LOCK_PREFIX = "lock:mqtt:device:status:";
static boolean ackMatchesPendingCommand(String topicDeviceNo, DeviceCommand pendingCommand) {
@@ -132,6 +133,34 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
pendingIds().add(command.getCommandId());
}
public int clearPendingCommands() {
RSet<String> pendingIds = pendingIds();
Set<String> commandIds = pendingIds.readAll();
pendingIds.removeAll(commandIds);
startupCleanupReady = true;
RuntimeException cleanupFailure = null;
for (String commandId : commandIds) {
try {
RedisUtils.deleteObject(pendingKey(commandId));
} catch (RuntimeException e) {
if (cleanupFailure == null) {
cleanupFailure = new RuntimeException("Failed to delete MQTT pending command object: " + commandId, e);
} else {
cleanupFailure.addSuppressed(e);
}
}
}
if (cleanupFailure != null) {
throw cleanupFailure;
}
return commandIds.size();
}
boolean isStartupCleanupReady() {
return startupCleanupReady;
}
public void removePending(String commandId) {
if (StringUtils.isBlank(commandId)) {
return;
@@ -232,6 +261,14 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
return;
}
if (Boolean.FALSE.equals(command.getRetryEnabled())) {
command.setRetryCount(command.getRetryCount() + 1);
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command);
log.debug("[MQTT] 命令已关闭自动重发,等待 ACK 或到期清理 设备编号={} 命令编号={} 等待次数={}",
command.getDeviceNo(), commandId, command.getRetryCount());
return;
}
if (!isDeviceOnline(command.getDeviceNo())) {
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
savePending(command);

View File

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

View File

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

View File

@@ -55,7 +55,7 @@ public class MqttProperties {
public static class CommandAck {
private boolean enabled = true;
private int maxRetryCount = 3;
private long retryIntervalMs = 5000;
private long retryIntervalMs = 10000;
private long scanIntervalMs = 5000;
private long pendingTtlSeconds = 86400;
private long ackTtlSeconds = 86400;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONArray;
@@ -13,10 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
import org.dromara.app.domain.bo.AppScheduleBo;
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
import org.dromara.app.domain.bo.AppWateringLogBo;
import org.dromara.app.domain.bo.*;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
@@ -732,25 +729,12 @@ public class AppController extends BaseController {
/**
* app用户手机号/邮箱找回忘记密码
*/
@SaIgnore
@ApiEncrypt
@PostMapping("/retrievePassword")
public R<Void> forgot(@RequestBody String body) {
SysUserBo bo = JsonUtils.parseObject(body, SysUserBo.class);
bo.setUserId(LoginHelper.getUserId());
if(Validator.isEmail(bo.getUserName())){
boolean checkPhoneFlag = userService.checkEmailUnique(bo);
if (!checkPhoneFlag){
throw new UserException("user.email.not.username");
}
}else if (Validator.isMobile(bo.getUserName())){
boolean checkPhoneFlag = userService.checkPhoneUnique(bo);
if (!checkPhoneFlag){
throw new UserException("user.mobile.phone.number.not.username");
}
}
return toAjax(userService.updateUserPas(bo));
public R<Void> forgot(@Validated @RequestBody AppForgotPasswordBo request) {
return toAjax(userService.resetPasswordByVerificationCode(
request.getUsername(), request.getCode(), request.getPassword()));
}
private String formatSecondTime(Date time) {

View File

@@ -0,0 +1,26 @@
package org.dromara.app.domain.bo;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
/**
* APP 忘记密码请求。
*/
@Data
public class AppForgotPasswordBo {
@JsonAlias("userName")
@NotBlank(message = "账号不能为空")
@Length(min = 2, max = 64, message = "账号长度必须在2到64个字符之间")
private String username;
@JsonAlias("smsCode")
@NotBlank(message = "验证码不能为空")
private String code;
@NotBlank(message = "新密码不能为空")
@Length(min = 5, max = 30, message = "密码长度必须在5到30个字符之间")
private String password;
}

View File

@@ -19,6 +19,7 @@ public class DeviceCommand implements Serializable {
private String commandType;
private String topic;
private Map<String, Object> payload = new HashMap<>();
private Boolean retryEnabled = Boolean.TRUE;
private int retryCount;
private long createdAt;
private long lastSentAt;

View File

@@ -123,6 +123,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
command.setDeviceMac(deviceMac);
command.setCommandType("registerDeviceNo");
command.setTopic("/" + deviceMac.trim().toLowerCase(Locale.ROOT) + "/subscriber/cmd");
command.setRetryEnabled(false);
command.getPayload().put("deviceNo", deviceNo);
command.getPayload().put("deviceMac", deviceMac);

View File

@@ -3,10 +3,7 @@ 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.bo.*;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
@@ -129,6 +126,41 @@ public class AppControllerTest {
.hasMessage("版本配置不存在");
}
@Test
public void retrievePassword_resetsPasswordWithoutReadingLoginState() {
AppController controller = newController();
AppForgotPasswordBo request = new AppForgotPasswordBo();
request.setUsername("alice@example.com");
request.setCode("123456");
request.setPassword("newPassword");
when(userService.resetPasswordByVerificationCode("alice@example.com", "123456", "newPassword"))
.thenReturn(true);
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
R<Void> result = controller.forgot(request);
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
loginHelper.verifyNoInteractions();
}
verify(userService).resetPasswordByVerificationCode(
"alice@example.com", "123456", "newPassword");
}
@Test
public void retrievePassword_acceptsLegacyRequestFieldAliases() throws Exception {
AppController controller = newController();
AppForgotPasswordBo request = new ObjectMapper().readValue(
"{\"userName\":\"13305376054\",\"smsCode\":\"654321\",\"password\":\"newPassword\"}",
AppForgotPasswordBo.class);
when(userService.resetPasswordByVerificationCode("13305376054", "654321", "newPassword"))
.thenReturn(true);
R<Void> result = controller.forgot(request);
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
verify(userService).resetPasswordByVerificationCode("13305376054", "654321", "newPassword");
}
@Test
public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
AppController controller = newController();

View File

@@ -59,6 +59,7 @@ class DeviceRegisterHandlerTest {
assertThat(captor.getValue().getTopic()).isEqualTo("/aa:bb:cc/subscriber/cmd");
assertThat(captor.getValue().getCommandType()).isEqualTo("registerDeviceNo");
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(captor.getValue().getRetryEnabled()).isFalse();
}
@Test

View File

@@ -100,6 +100,12 @@
<artifactId>water-common-sse</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -232,4 +232,14 @@ public interface ISysUserService {
int updateAppUser(SysUserBo user);
int updateUserPas(SysUserBo userBo);
/**
* 通过账号标识和验证码重置密码。
*
* @param username 手机号、邮箱或账号名
* @param code 验证码
* @param password 新密码明文
* @return 是否重置成功
*/
boolean resetPasswordByVerificationCode(String username, String code, String password);
}

View File

@@ -16,16 +16,17 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.CacheNames;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.dto.UserDTO;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.service.UserService;
import org.dromara.common.core.utils.*;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.mybatis.helper.DataPermissionHelper;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.system.domain.SysUser;
@@ -414,6 +415,54 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean resetPasswordByVerificationCode(String username, String code, String password) {
if (StringUtils.isBlank(username)) {
throw new ServiceException("账号不能为空");
}
if (StringUtils.isBlank(code)) {
throw new ServiceException("验证码不能为空");
}
if (StringUtils.isBlank(password)) {
throw new ServiceException("新密码不能为空");
}
LambdaQueryWrapper<SysUser> query = Wrappers.lambdaQuery();
query.eq(SysUser::getDelFlag, SystemConstants.NORMAL);
if (Validator.isEmail(username)) {
query.eq(SysUser::getEmail, username);
} else if (Validator.isMobile(username)) {
query.eq(SysUser::getPhonenumber, username);
} else {
query.eq(SysUser::getUserName, username);
}
SysUser user = baseMapper.selectOne(query);
if (user == null) {
throw new UserException("账号未注册");
}
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
String cachedCode = RedisUtils.getCacheObject(cacheKey);
if (StringUtils.isBlank(cachedCode)) {
throw new CaptchaExpireException();
}
if (!StringUtils.equals(cachedCode, code)) {
throw new UserException("验证码无效");
}
SysUser update = new SysUser();
update.setUserId(user.getUserId());
update.setPassword(BCrypt.hashpw(password));
int updatedRows = DataPermissionHelper.ignore(() -> baseMapper.updateById(update));
if (updatedRows < 1) {
throw new ServiceException("忘记密码修改失败");
}
RedisUtils.deleteObject(cacheKey);
return true;
}
/**
* 校验短信验证码
*/

View File

@@ -0,0 +1,205 @@
package org.dromara.system.service.impl;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.system.domain.SysUser;
import org.dromara.system.mapper.*;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RedissonClient;
import org.springframework.context.support.GenericApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@Tag("dev")
class SysUserServiceImplPasswordRecoveryTest {
private static GenericApplicationContext applicationContext;
@Mock private SysUserMapper userMapper;
@Mock private SysDeptMapper deptMapper;
@Mock private SysRoleMapper roleMapper;
@Mock private SysPostMapper postMapper;
@Mock private SysUserRoleMapper userRoleMapper;
@Mock private SysUserPostMapper userPostMapper;
@Captor private ArgumentCaptor<LambdaQueryWrapper<SysUser>> queryCaptor;
@Captor private ArgumentCaptor<SysUser> userCaptor;
@BeforeAll
static void initializeInfrastructure() {
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), SysUser.class);
}
applicationContext = new GenericApplicationContext();
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
applicationContext.refresh();
new SpringUtil().setApplicationContext(applicationContext);
}
@AfterAll
static void closeInfrastructure() {
applicationContext.close();
}
@ParameterizedTest
@CsvSource({
"alice@example.com,email",
"13305376054,phonenumber",
"alice,userName"
})
void resetPasswordByVerificationCode_supportsAllAccountIdentifiers(
String username, String expectedColumn) {
SysUserServiceImpl service = newService();
SysUser user = user(10L, username);
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user);
when(userMapper.updateById(any(SysUser.class))).thenReturn(1);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
boolean result = service.resetPasswordByVerificationCode(username, "123456", "newPassword");
assertThat(result).isTrue();
verify(userMapper).selectOne(queryCaptor.capture());
assertThat(queryCaptor.getValue().getSqlSegment()).contains(expectedColumn);
assertThat(queryCaptor.getValue().getParamNameValuePairs()).containsValue(username);
verify(userMapper).updateById(userCaptor.capture());
assertThat(userCaptor.getValue().getUserId()).isEqualTo(10L);
assertThat(BCrypt.checkpw("newPassword", userCaptor.getValue().getPassword())).isTrue();
redis.verify(() -> RedisUtils.deleteObject(cacheKey));
}
}
@Test
void resetPasswordByVerificationCode_rejectsWrongCodeWithoutUpdatingOrDeleting() {
SysUserServiceImpl service = newService();
String username = "13305376054";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "000000", "newPassword"))
.isInstanceOf(UserException.class);
verify(userMapper, never()).updateById(any(SysUser.class));
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_rejectsExpiredCodeWithoutUpdating() {
SysUserServiceImpl service = newService();
String username = "alice@example.com";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn(null);
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
.isInstanceOf(CaptchaExpireException.class);
verify(userMapper, never()).updateById(any(SysUser.class));
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_keepsCodeWhenDatabaseUpdateFails() {
SysUserServiceImpl service = newService();
String username = "alice";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
when(userMapper.updateById(any(SysUser.class))).thenReturn(0);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
.isInstanceOf(ServiceException.class);
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
}
}
@Test
void resetPasswordByVerificationCode_ignoresLoginBasedDataPermissionDuringUpdate() {
SysUserServiceImpl service = newService();
String username = "alice";
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
when(userMapper.updateById(any(SysUser.class))).thenAnswer(invocation -> {
assertThat(InterceptorIgnoreHelper.willIgnoreDataPermission("SysUserMapper.updateById"))
.isTrue();
return 1;
});
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
assertThat(service.resetPasswordByVerificationCode(
username, "123456", "newPassword")).isTrue();
}
}
@Test
void resetPasswordByVerificationCode_rejectsUnknownAccountBeforeReadingCode() {
SysUserServiceImpl service = newService();
when(userMapper.selectOne(any())).thenReturn(null);
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
assertThatThrownBy(() ->
service.resetPasswordByVerificationCode("missing", "123456", "newPassword"))
.isInstanceOf(UserException.class);
redis.verifyNoInteractions();
verify(userMapper, never()).updateById(any(SysUser.class));
}
}
private SysUserServiceImpl newService() {
return new SysUserServiceImpl(
userMapper, deptMapper, roleMapper, postMapper, userRoleMapper, userPostMapper);
}
private SysUser user(Long userId, String username) {
SysUser user = new SysUser();
user.setUserId(userId);
user.setUserName(username);
user.setEmail(username);
user.setPhonenumber(username);
return user;
}
}