# eMind Station Sync Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add the first eMind integration slice: one API utility, Redis-cached access tokens refreshed one hour early, and an idempotent local copy of all eMind stations. **Architecture:** `EmindCloudApiUtil` owns every eMind HTTP detail and returns typed DTOs. `EmindStationSyncService` owns persistence and schedules a full remote-to-local reconciliation; the local table uses the remote `station_id` as a unique business key and soft-deletes rows missing from a successful full pull. Credentials are external configuration and the integration is disabled by default. **Tech Stack:** Java 8, Spring Boot, RestTemplate, Fastjson2, RedisService, MyBatis-Plus, MySQL, JUnit 5, Mockito. ## Global Constraints - Keep all eMind HTTP calls in one utility class. - Cache the token in Redis for exactly `max(1, expires_in - 60)` minutes. - Do not store platform credentials in source control. - Do not modify the user's existing changes in `SiteQuery.java`, `SiteMapper.xml`, or `TChargingOrderMapper.xml`. - Preserve the vendor field spelling `commossioningTime` in the transport DTO while using `commissioning_time` locally. --- ### Task 1: Typed eMind API client and token cache **Files:** - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/config/EmindCloudProperties.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/dto/EmindApiResponse.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/dto/EmindTokenData.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/dto/EmindPageData.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/dto/EmindStationDto.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/exception/EmindApiException.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/util/EmindCloudApiUtil.java` - Modify: `ruoyi-service/ruoyi-other/src/main/resources/application.yml` - Test: `ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/emind/util/EmindCloudApiUtilTest.java` **Interfaces:** - Produces: `String EmindCloudApiUtil.getAccessToken()` and `List EmindCloudApiUtil.getStations()`. - Consumes: `RedisService`, `RestTemplate`, and `EmindCloudProperties`. - [ ] **Step 1: Write failing token-cache tests** ```java @Test void returnsCachedTokenWithoutCallingRemote() { when(redisService.getCacheObject(EmindCloudApiUtil.ACCESS_TOKEN_CACHE_KEY)).thenReturn("cached"); assertEquals("cached", api.getAccessToken()); verifyNoInteractions(restTemplate); } @Test void cachesRemoteTokenOneHourShorterThanVendorLifetime() { when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(String.class))) .thenReturn(ResponseEntity.ok("{\"code\":200,\"data\":{\"access_token\":\"fresh\",\"expires_in\":720}}")); assertEquals("fresh", api.getAccessToken()); verify(redisService).setCacheObject(EmindCloudApiUtil.ACCESS_TOKEN_CACHE_KEY, "fresh", 660L, TimeUnit.MINUTES); } ``` - [ ] **Step 2: Run tests and confirm they fail because the client does not exist** Run: `mvn -pl ruoyi-service/ruoyi-other -am -Dtest=EmindCloudApiUtilTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: test compilation failure for missing eMind classes. - [ ] **Step 3: Implement configuration, response DTOs, validation, and cached token retrieval** ```java public synchronized String getAccessToken() { String cached = redisService.getCacheObject(ACCESS_TOKEN_CACHE_KEY); if (StringUtils.hasText(cached)) { return cached; } EmindTokenData data = executeGet(TOKEN_PATH, tokenQuery(), null, EmindTokenData.class); long cacheMinutes = Math.max(1L, data.getExpiresIn() - properties.getTokenRefreshAheadMinutes()); redisService.setCacheObject(ACCESS_TOKEN_CACHE_KEY, data.getAccessToken(), cacheMinutes, TimeUnit.MINUTES); return data.getAccessToken(); } ``` - [ ] **Step 4: Add paginated station retrieval** ```java public List getStations() { List all = new ArrayList<>(); int pageNum = 1; do { EmindPageData page = getStationPage(pageNum, properties.getStationPageSize()); all.addAll(page.getRows()); pageNum++; } while (all.size() < page.getTotal()); return all; } ``` - [ ] **Step 5: Run the API utility tests** Run: `mvn -pl ruoyi-service/ruoyi-other -am -Dtest=EmindCloudApiUtilTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: all `EmindCloudApiUtilTest` tests pass. ### Task 2: Local station redundancy and reconciliation **Files:** - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/domain/EmindStation.java` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/mapper/EmindStationMapper.java` - Create: `ruoyi-service/ruoyi-other/src/main/resources/mapper/other/EmindStationMapper.xml` - Create: `ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/emind/service/EmindStationSyncService.java` - Create: `sql/20260724_emind_station.sql` - Test: `ruoyi-service/ruoyi-other/src/test/java/com/ruoyi/other/emind/service/EmindStationSyncServiceTest.java` **Interfaces:** - Consumes: `List EmindCloudApiUtil.getStations()`. - Produces: `int EmindStationSyncService.syncStations()` returning the number of active remote stations persisted locally. - [ ] **Step 1: Write a failing reconciliation test** ```java @Test void fullSyncSoftDeletesOldRowsAndUpsertsRemoteRows() { when(api.getStations()).thenReturn(Collections.singletonList(remoteStation(219L, "环岛中心"))); assertEquals(1, service.syncStations()); InOrder order = inOrder(mapper); order.verify(mapper).markAllDeleted(any(LocalDateTime.class)); order.verify(mapper).upsertBatch(argThat(rows -> rows.size() == 1 && rows.get(0).getStationId().equals(219L))); } ``` - [ ] **Step 2: Run the service test and confirm it fails because persistence classes do not exist** Run: `mvn -pl ruoyi-service/ruoyi-other -am -Dtest=EmindStationSyncServiceTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: test compilation failure for missing persistence classes. - [ ] **Step 3: Add the idempotent database migration** ```sql CREATE TABLE IF NOT EXISTS `t_emind_station` ( `id` bigint NOT NULL AUTO_INCREMENT, `station_id` bigint NOT NULL, `name` varchar(255) NOT NULL, `address` varchar(500) DEFAULT NULL, `province` varchar(100) DEFAULT NULL, `city` varchar(100) DEFAULT NULL, `longitude` varchar(50) DEFAULT NULL, `latitude` varchar(50) DEFAULT NULL, `max_power` varchar(50) DEFAULT NULL, `capacity` varchar(50) DEFAULT NULL, `commissioning_time` date DEFAULT NULL, `del_flag` tinyint NOT NULL DEFAULT 0, `synced_at` datetime NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_emind_station_id` (`station_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='eMind储能电站本地冗余'; ``` - [ ] **Step 4: Implement mapper reconciliation and transactional sync** ```java @Transactional(rollbackFor = Exception.class) public int syncStations() { List remote = api.getStations(); LocalDateTime syncedAt = LocalDateTime.now(clock); mapper.markAllDeleted(syncedAt); if (!remote.isEmpty()) { mapper.upsertBatch(remote.stream().map(dto -> toEntity(dto, syncedAt)).collect(Collectors.toList())); } return remote.size(); } ``` - [ ] **Step 5: Add a configurable scheduled full sync** ```java @Scheduled(cron = "${emind.station-sync-cron:0 0/30 * * * ?}") public void scheduledSync() { syncStations(); } ``` - [ ] **Step 6: Run focused tests and compile the full module** Run: `mvn -pl ruoyi-service/ruoyi-other -am -Dtest=EmindCloudApiUtilTest,EmindStationSyncServiceTest -Dsurefire.failIfNoSpecifiedTests=false test` Expected: focused tests pass. Run: `mvn -pl ruoyi-service/ruoyi-other -am -DskipTests compile` Expected: reactor ends with `BUILD SUCCESS`.