# Customer Map Selection Backend Implementation Plan > **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow this repository's AGENTS.md rule: do not enable subagents or superpowers execution modes unless the user explicitly requests them for the implementation session. **Goal:** Add backend customer-id-to-customer-internal-code query APIs for personal and corporate workflow creation, backed by local mock interfaces and profile configuration. **Architecture:** Keep workflow creation APIs unchanged. Add a small customer-map service that reads separate personal/corporate URLs from configuration, calls them with GET `cust_id`, and returns mapping records with underscore JSON field names through the existing RuoYi `AjaxResult` response convention. Add mock endpoints under the existing mock controller so all active profiles can point to local mock URLs first. **Tech Stack:** Spring Boot, RuoYi `AjaxResult`, Spring `RestTemplate`, Jackson `@JsonProperty`, JUnit 5, Mockito, Maven. --- ## File Structure - Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java` - Holds one customer mapping record. - Uses Java camelCase fields internally and `@JsonProperty` to serialize/deserialize underscore field names. - Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.java` - Owns config URL selection, GET forwarding, parameter validation, and response parsing. - Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java` - Adds authenticated business endpoints used by the frontend. - Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java` - Adds anonymous local mock endpoints. - Modify: `ruoyi-admin/src/main/resources/application-dev.yml` - Adds `customer-map` mock URL config. - Modify: `ruoyi-admin/src/main/resources/application-uat.yml` - Adds `customer-map` mock URL config. - Modify: `ruoyi-admin/src/main/resources/application-pro.yml` - Adds `customer-map` mock URL config for the production profile, currently pointing to local mock as requested. - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java` - Verifies JSON field names stay underscored. - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java` - Verifies personal/corporate URL routing, `cust_id` forwarding, response parsing, and missing customer-id errors. - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java` - Verifies mock endpoints return one or more underscore-field records. - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.java` - Verifies business controller methods delegate to the service and preserve the result records. ## Task 1: Add Customer Map Record VO **Files:** - Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java` - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java` - [ ] **Step 1: Write the failing serialization test** Create `CustomerMapRecordVOTest`: ```java package com.ruoyi.loanpricing.domain.vo; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; class CustomerMapRecordVOTest { @Test void shouldSerializeCustomerMapFieldsWithUnderscoreNames() throws Exception { CustomerMapRecordVO record = new CustomerMapRecordVO(); record.setCustId("101330419198206033217"); record.setCustIsn("81033011438"); record.setCustName("张三"); record.setFaithDay("20"); record.setBalanceAvg("300000"); record.setLoanCountHis("2"); record.setLastLoanDate("2025-12-01"); String json = new ObjectMapper().writeValueAsString(record); assertTrue(json.contains("\"cust_id\"")); assertTrue(json.contains("\"cust_isn\"")); assertTrue(json.contains("\"cust_name\"")); assertTrue(json.contains("\"faith_day\"")); assertTrue(json.contains("\"balance_avg\"")); assertTrue(json.contains("\"loan_count_his\"")); assertTrue(json.contains("\"last_loan_date\"")); } } ``` - [ ] **Step 2: Run the test to verify it fails** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL because `CustomerMapRecordVO` does not exist. - [ ] **Step 3: Implement the VO** Create `CustomerMapRecordVO.java`: ```java package com.ruoyi.loanpricing.domain.vo; import com.fasterxml.jackson.annotation.JsonProperty; import java.io.Serializable; import lombok.Data; @Data public class CustomerMapRecordVO implements Serializable { private static final long serialVersionUID = 1L; @JsonProperty("cust_id") private String custId; @JsonProperty("cust_isn") private String custIsn; @JsonProperty("cust_name") private String custName; @JsonProperty("faith_day") private String faithDay; @JsonProperty("balance_avg") private String balanceAvg; @JsonProperty("loan_count_his") private String loanCountHis; @JsonProperty("last_loan_date") private String lastLoanDate; } ``` - [ ] **Step 4: Run the test to verify it passes** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. ## Task 2: Add Customer Map Service **Files:** - Create: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.java` - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java` - [ ] **Step 1: Write service tests first** Create tests that use `MockRestServiceServer` and the package-private test constructor: ```java package com.ruoyi.loanpricing.service; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.web.client.RestTemplate; import org.springframework.test.web.client.MockRestServiceServer; class LoanPricingCustomerMapServiceTest { @Test void shouldQueryPersonalCustomerMapWithCustIdParam() { RestTemplate restTemplate = new RestTemplate(); MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate); LoanPricingCustomerMapService service = new LoanPricingCustomerMapService( restTemplate, "http://mock/personal", "http://mock/corporate"); server.expect(requestTo("http://mock/personal?cust_id=P001")) .andRespond(withSuccess("{\"code\":200,\"msg\":\"操作成功\",\"data\":[{\"cust_id\":\"P001\",\"cust_isn\":\"81033011438\",\"cust_name\":\"张三\"}]}", MediaType.APPLICATION_JSON)); List result = service.queryPersonal("P001"); assertEquals(1, result.size()); assertEquals("81033011438", result.get(0).getCustIsn()); assertEquals("张三", result.get(0).getCustName()); server.verify(); } @Test void shouldQueryCorporateCustomerMapWithCustIdParam() { RestTemplate restTemplate = new RestTemplate(); MockRestServiceServer server = MockRestServiceServer.createServer(restTemplate); LoanPricingCustomerMapService service = new LoanPricingCustomerMapService( restTemplate, "http://mock/personal", "http://mock/corporate"); server.expect(requestTo("http://mock/corporate?cust_id=C001")) .andRespond(withSuccess("{\"code\":200,\"data\":[{\"cust_id\":\"C001\",\"cust_isn\":\"82002469287\",\"cust_name\":\"测试企业\"}]}", MediaType.APPLICATION_JSON)); List result = service.queryCorporate("C001"); assertEquals("82002469287", result.get(0).getCustIsn()); assertEquals("测试企业", result.get(0).getCustName()); server.verify(); } @Test void shouldRejectBlankCustId() { LoanPricingCustomerMapService service = new LoanPricingCustomerMapService( new RestTemplate(), "http://mock/personal", "http://mock/corporate"); ServiceException ex = assertThrows(ServiceException.class, () -> service.queryPersonal(" ")); assertEquals("客户号不能为空", ex.getMessage()); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingCustomerMapServiceTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL because `LoanPricingCustomerMapService` does not exist. - [ ] **Step 3: Implement the service** Create the service with a default Spring constructor and a package-private test constructor: ```java package com.ruoyi.loanpricing.service; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO; import java.net.URI; import java.util.Collections; import java.util.List; import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; @Service public class LoanPricingCustomerMapService { private final RestTemplate restTemplate; @Value("${customer-map.personal-url}") private String personalUrl; @Value("${customer-map.corporate-url}") private String corporateUrl; public LoanPricingCustomerMapService() { this(new RestTemplate()); } LoanPricingCustomerMapService(RestTemplate restTemplate) { this.restTemplate = restTemplate; } LoanPricingCustomerMapService(RestTemplate restTemplate, String personalUrl, String corporateUrl) { this.restTemplate = restTemplate; this.personalUrl = personalUrl; this.corporateUrl = corporateUrl; } public List queryPersonal(String custId) { return query(personalUrl, custId); } public List queryCorporate(String custId) { return query(corporateUrl, custId); } private List query(String url, String custId) { if (!StringUtils.hasText(custId)) { throw new ServiceException("客户号不能为空"); } URI uri = UriComponentsBuilder.fromHttpUrl(url) .queryParam("cust_id", custId) .build() .toUri(); CustomerMapResponse response = restTemplate.getForObject(uri, CustomerMapResponse.class); if (response == null) { throw new ServiceException("客户号映射接口无返回"); } if (response.getCode() != null && response.getCode() != 200) { throw new ServiceException(StringUtils.hasText(response.getMsg()) ? response.getMsg() : "客户号映射查询失败"); } return response.getData() == null ? Collections.emptyList() : response.getData(); } @Data @JsonIgnoreProperties(ignoreUnknown = true) static class CustomerMapResponse { private Integer code; private String msg; private List data; } } ``` - [ ] **Step 4: Run service tests** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingCustomerMapServiceTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. ## Task 3: Add Mock Endpoints **Files:** - Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java` - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java` - [ ] **Step 1: Write controller tests for mock endpoints** ```java package com.ruoyi.loanpricing.controller; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.databind.ObjectMapper; import com.ruoyi.common.core.domain.AjaxResult; import java.util.List; import org.junit.jupiter.api.Test; class LoanRatePricingMockControllerCustomerMapTest { @Test void shouldReturnPersonalCustomerMapRecords() throws Exception { LoanRatePricingMockController controller = new LoanRatePricingMockController(); AjaxResult result = controller.queryPersonalCustomerMap("P001"); List rows = (List) result.get("data"); assertFalse(rows.isEmpty()); String json = new ObjectMapper().writeValueAsString(rows.get(0)); assertTrue(json.contains("\"cust_id\"")); assertTrue(json.contains("\"cust_isn\"")); assertTrue(json.contains("\"cust_name\"")); } @Test void shouldReturnCorporateCustomerMapRecords() throws Exception { LoanRatePricingMockController controller = new LoanRatePricingMockController(); AjaxResult result = controller.queryCorporateCustomerMap("C001"); List rows = (List) result.get("data"); assertFalse(rows.isEmpty()); String json = new ObjectMapper().writeValueAsString(rows.get(0)); assertTrue(json.contains("\"cust_id\"")); assertTrue(json.contains("\"loan_count_his\"")); assertTrue(json.contains("\"last_loan_date\"")); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRatePricingMockControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL because the mock methods do not exist. - [ ] **Step 3: Implement mock methods** Modify `LoanRatePricingMockController`: ```java @Anonymous @Operation(summary = "模拟个人客户号映射查询") @GetMapping("/customer-map/personal") public AjaxResult queryPersonalCustomerMap(@RequestParam("cust_id") String custId) { return success(randomCustomerMapRecords(custId, "个人客户")); } @Anonymous @Operation(summary = "模拟企业客户号映射查询") @GetMapping("/customer-map/corporate") public AjaxResult queryCorporateCustomerMap(@RequestParam("cust_id") String custId) { return success(randomCustomerMapRecords(custId, "企业客户")); } ``` Add a private helper in the same controller: ```java private List randomCustomerMapRecords(String custId, String namePrefix) { if (!StringUtils.hasText(custId)) { throw new ServiceException("客户号不能为空"); } int count = ThreadLocalRandom.current().nextInt(1, 4); List records = new ArrayList<>(); for (int i = 1; i <= count; i++) { CustomerMapRecordVO record = new CustomerMapRecordVO(); record.setCustId(custId); record.setCustIsn(String.valueOf(81000000000L + ThreadLocalRandom.current().nextInt(1000000))); record.setCustName(namePrefix + i); record.setFaithDay(String.valueOf(ThreadLocalRandom.current().nextInt(1, 365))); record.setBalanceAvg(String.valueOf(ThreadLocalRandom.current().nextInt(10000, 900000))); record.setLoanCountHis(String.valueOf(ThreadLocalRandom.current().nextInt(0, 10))); record.setLastLoanDate(LocalDate.now().minusDays(ThreadLocalRandom.current().nextInt(1, 800)).toString()); records.add(record); } return records; } ``` Required imports: ```java import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import java.util.concurrent.ThreadLocalRandom; ``` - [ ] **Step 4: Run mock controller tests** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanRatePricingMockControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. ## Task 4: Add Business Endpoints **Files:** - Modify: `ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java` - Create: `ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.java` - [ ] **Step 1: Write controller delegation tests** ```java package com.ruoyi.loanpricing.controller; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.when; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.loanpricing.domain.vo.CustomerMapRecordVO; import com.ruoyi.loanpricing.service.LoanPricingCustomerMapService; import java.lang.reflect.Field; import java.util.List; import org.junit.jupiter.api.Test; import org.mockito.Mockito; class LoanPricingWorkflowControllerCustomerMapTest { @Test void shouldReturnPersonalCustomerMapFromService() throws Exception { LoanPricingCustomerMapService service = Mockito.mock(LoanPricingCustomerMapService.class); CustomerMapRecordVO row = new CustomerMapRecordVO(); row.setCustIsn("81033011438"); when(service.queryPersonal("P001")).thenReturn(List.of(row)); LoanPricingWorkflowController controller = new LoanPricingWorkflowController(); setField(controller, "customerMapService", service); AjaxResult result = controller.queryPersonalCustomerMap("P001"); List rows = (List) result.get("data"); assertEquals(1, rows.size()); } @Test void shouldReturnCorporateCustomerMapFromService() throws Exception { LoanPricingCustomerMapService service = Mockito.mock(LoanPricingCustomerMapService.class); CustomerMapRecordVO row = new CustomerMapRecordVO(); row.setCustIsn("82002469287"); when(service.queryCorporate("C001")).thenReturn(List.of(row)); LoanPricingWorkflowController controller = new LoanPricingWorkflowController(); setField(controller, "customerMapService", service); AjaxResult result = controller.queryCorporateCustomerMap("C001"); List rows = (List) result.get("data"); assertEquals(1, rows.size()); } private static void setField(Object target, String fieldName, Object value) throws Exception { Field field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); field.set(target, value); } } ``` - [ ] **Step 2: Run the tests to verify they fail** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: FAIL because controller methods and field do not exist. - [ ] **Step 3: Implement controller methods** In `LoanPricingWorkflowController`, add: ```java @Autowired private LoanPricingCustomerMapService customerMapService; @Operation(summary = "查询个人客户号映射") @GetMapping("/customer-map/personal") public AjaxResult queryPersonalCustomerMap(@RequestParam("custId") String custId) { return success(customerMapService.queryPersonal(custId)); } @Operation(summary = "查询企业客户号映射") @GetMapping("/customer-map/corporate") public AjaxResult queryCorporateCustomerMap(@RequestParam("custId") String custId) { return success(customerMapService.queryCorporate(custId)); } ``` Required import: ```java import com.ruoyi.loanpricing.service.LoanPricingCustomerMapService; ``` - [ ] **Step 4: Run controller tests** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. ## Task 5: Add Profile Configuration **Files:** - Modify: `ruoyi-admin/src/main/resources/application-dev.yml` - Modify: `ruoyi-admin/src/main/resources/application-uat.yml` - Modify: `ruoyi-admin/src/main/resources/application-pro.yml` - [ ] **Step 1: Add `customer-map` to every active profile** Place this block next to the existing `model:` block in each profile file: ```yaml customer-map: personal-url: http://localhost:63310/rate/pricing/mock/customer-map/personal corporate-url: http://localhost:63310/rate/pricing/mock/customer-map/corporate ``` Keep the existing `model:` URLs unchanged. - [ ] **Step 2: Verify config keys exist** Run: ```bash rg -n "customer-map:|personal-url: http://localhost:63310/rate/pricing/mock/customer-map/personal|corporate-url: http://localhost:63310/rate/pricing/mock/customer-map/corporate" ruoyi-admin/src/main/resources/application-dev.yml ruoyi-admin/src/main/resources/application-uat.yml ruoyi-admin/src/main/resources/application-pro.yml ``` Expected: each profile contains one `customer-map` block with both mock URLs. ## Task 6: Backend Verification **Files:** - Verify only, no new files. - [ ] **Step 1: Run focused backend tests** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=CustomerMapRecordVOTest,LoanPricingCustomerMapServiceTest,LoanRatePricingMockControllerCustomerMapTest,LoanPricingWorkflowControllerCustomerMapTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. - [ ] **Step 2: Run affected existing model/workflow tests** Run: ```bash mvn -pl ruoyi-loan-pricing -am -Dtest=LoanPricingModelServiceTest,LoanPricingWorkflowServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test ``` Expected: PASS. - [ ] **Step 3: Manual API verification after backend restart** Restart backend so config and routes are active, then call: ```bash curl -sS 'http://localhost:63310/rate/pricing/mock/customer-map/personal?cust_id=P001' curl -sS 'http://localhost:63310/rate/pricing/mock/customer-map/corporate?cust_id=C001' TOKEN=$(curl -sS -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' 'http://localhost:63310/login/test' | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') curl -sS -H "Authorization: Bearer ${TOKEN}" 'http://localhost:63310/loanPricing/workflow/customer-map/personal?custId=P001' curl -sS -H "Authorization: Bearer ${TOKEN}" 'http://localhost:63310/loanPricing/workflow/customer-map/corporate?custId=C001' ``` Expected: each successful response uses the RuoYi response wrapper and `data` contains one or more records with underscore fields. If the local admin password differs, replace `admin/admin123` with a valid local test account before calling the authenticated workflow endpoints. - [ ] **Step 4: Commit backend work** Use a Chinese commit message and do not include unrelated dirty files: ```bash git status --short git add ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVO.java \ ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapService.java \ ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowController.java \ ruoyi-loan-pricing/src/main/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockController.java \ ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/domain/vo/CustomerMapRecordVOTest.java \ ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/service/LoanPricingCustomerMapServiceTest.java \ ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanRatePricingMockControllerCustomerMapTest.java \ ruoyi-loan-pricing/src/test/java/com/ruoyi/loanpricing/controller/LoanPricingWorkflowControllerCustomerMapTest.java \ ruoyi-admin/src/main/resources/application-dev.yml \ ruoyi-admin/src/main/resources/application-uat.yml \ ruoyi-admin/src/main/resources/application-pro.yml git commit -m "新增客户号映射后端接口" ``` Do not commit temporary curl output, screenshots, or generated test data.