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