diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java index 62cc469..ef3062e 100644 --- a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java @@ -28,6 +28,7 @@ import java.util.concurrent.atomic.AtomicLong; public class NgpApiClient { static final String SALES_POST_BILL_LIST = "ngp.bill.salepostbill.list"; static final String ESHOP_DELIVERY_BILL_LIST = "ngp.eshopsaleorder.bills"; + static final String STORE_LIST = "ngp.otype.list"; static final int DELIVERY_TIME = 3; static final int DELIVERED = 100; @@ -68,6 +69,10 @@ public class NgpApiClient { eshopDeliveryBillRequestBody(beginTime, endTime, pageIndex)); } + public JsonNode storePage(int pageIndex) { + return call(STORE_LIST, storeRequestBody(pageIndex)); + } + Map salesPostBillRequestBody(String beginTime, String endTime, int pageIndex) { Map body = new LinkedHashMap<>(); body.put("pageSize", Math.min(Math.max(config.getPageSize(), 1), 200)); @@ -90,6 +95,15 @@ public class NgpApiClient { return body; } + Map storeRequestBody(int pageIndex) { + Map body = new LinkedHashMap<>(); + body.put("pageSize", Math.min(Math.max(config.getPageSize(), 1), 200)); + body.put("pageIndex", pageIndex); + body.put("showAll", true); + body.put("showAllEshopType", true); + return body; + } + private JsonNode call(String method, Object requestBody) { int attempts = Math.max(config.getMaxRetries(), 0) + 1; boolean tokenRefreshed = false; diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectory.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectory.java new file mode 100644 index 0000000..d172640 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectory.java @@ -0,0 +1,85 @@ +package com.company.agentfoundation.integration.ngp; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class NgpStoreDirectory { + private static final long CACHE_MILLIS = Duration.ofHours(1).toMillis(); + private static final int MAX_PAGES = 1_000; + + private final NgpApiClient apiClient; + private final Object refreshLock = new Object(); + private volatile Snapshot snapshot = new Snapshot(Map.of(), 0L); + + public NgpStoreDirectory(NgpApiClient apiClient) { + this.apiClient = apiClient; + } + + public Map namesById() { + long now = System.currentTimeMillis(); + Snapshot current = snapshot; + if (current.fresh(now)) return current.namesById(); + synchronized (refreshLock) { + current = snapshot; + if (current.fresh(now)) return current.namesById(); + Map loaded = loadAll(); + snapshot = new Snapshot(loaded, now); + return loaded; + } + } + + private Map loadAll() { + Map result = new LinkedHashMap<>(); + JsonNode firstPage = apiClient.storePage(1); + collect(firstPage, result); + long total = longValue(firstPage.path("data").path("total")); + int pageSize = Math.max(firstPage.path("data").path("pageSize").asInt(200), 1); + int totalPages = Math.max(1, (int) ((total + pageSize - 1L) / pageSize)); + if (totalPages > MAX_PAGES) { + throw new NgpApiException("NGP 店铺目录分页数量异常:" + totalPages); + } + for (int pageIndex = 2; pageIndex <= totalPages; pageIndex++) { + collect(apiClient.storePage(pageIndex), result); + } + if (result.isEmpty()) throw new NgpApiException("NGP 店铺目录为空,无法生成按网点业绩缓存"); + return Map.copyOf(result); + } + + private void collect(JsonNode payload, Map result) { + JsonNode rows = payload == null ? null : payload.path("data").path("list"); + if (rows == null || !rows.isArray()) return; + for (JsonNode store : rows) { + String id = firstText(store, "otypeId", "oTypeId", "id"); + String name = firstText(store, "fullName", "fullname", "name", "ofullname"); + if (!id.isBlank() && !name.isBlank()) result.put(id, name); + } + } + + private long longValue(JsonNode value) { + if (value == null || value.isMissingNode() || value.isNull()) return 0; + try { + return Long.parseLong(value.asText("0")); + } catch (NumberFormatException exception) { + throw new NgpApiException("NGP 店铺目录返回了无效的总数:" + value.asText(), exception); + } + } + + private String firstText(JsonNode node, String... fields) { + for (String field : fields) { + String value = node.path(field).asText("").trim(); + if (!value.isBlank()) return value; + } + return ""; + } + + private record Snapshot(Map namesById, long loadedAtMillis) { + private boolean fresh(long now) { + return !namesById.isEmpty() && now - loadedAtMillis <= CACHE_MILLIS; + } + } +} diff --git a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java index b434bd6..afb532d 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceCacheRepository.java @@ -17,7 +17,7 @@ import java.util.Optional; @Repository public class NgpPerformanceCacheRepository { - static final String FILTER_SIGNATURE = "eshop-delivered-refund-state-4-v2"; + static final String FILTER_SIGNATURE = "eshop-delivered-refund-state-4-store-directory-v3"; private final JdbcTemplate jdbcTemplate; diff --git a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java index f420cf2..55000c1 100644 --- a/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java +++ b/src/main/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoader.java @@ -2,6 +2,7 @@ package com.company.agentfoundation.ngp.performance; import com.company.agentfoundation.config.FoundationProperties; import com.company.agentfoundation.integration.ngp.NgpApiClient; +import com.company.agentfoundation.integration.ngp.NgpStoreDirectory; import com.fasterxml.jackson.databind.JsonNode; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; @@ -30,15 +31,18 @@ public class NgpPerformanceDayLoader { private static final int NO_REFUND_STATE = 4; private final NgpApiClient apiClient; + private final NgpStoreDirectory storeDirectory; private final FoundationProperties.Ngp config; private final Executor queryExecutor; public NgpPerformanceDayLoader( NgpApiClient apiClient, + NgpStoreDirectory storeDirectory, FoundationProperties properties, @Qualifier("ngpQueryExecutor") Executor queryExecutor ) { this.apiClient = apiClient; + this.storeDirectory = storeDirectory; this.config = properties.getNgp(); this.queryExecutor = queryExecutor; } @@ -50,9 +54,10 @@ public class NgpPerformanceDayLoader { int pageSize = Math.min(Math.max(config.getPageSize(), 1), 200); int concurrency = Math.min(Math.max(config.getConcurrency(), 1), 100); Aggregate aggregate = new Aggregate(); + Map storeNamesById = storeDirectory.namesById(); JsonNode firstPage = apiClient.eshopDeliveryBillPage(beginTime, endTime, 1); - collect(firstPage, 1, aggregate); + collect(firstPage, 1, aggregate, storeNamesById); long observedTotal = extractTotal(firstPage); int totalPages = Math.max(1, pages(observedTotal, pageSize)); @@ -67,7 +72,7 @@ public class NgpPerformanceDayLoader { for (PageFuture future : futures) { try { JsonNode payload = future.payload().join(); - collect(payload, future.pageIndex(), aggregate); + collect(payload, future.pageIndex(), aggregate, storeNamesById); observedTotal = Math.max(observedTotal, extractTotal(payload)); } catch (CompletionException exception) { Throwable cause = exception.getCause() == null ? exception : exception.getCause(); @@ -86,7 +91,12 @@ public class NgpPerformanceDayLoader { return (int) pages; } - private void collect(JsonNode payload, int pageIndex, Aggregate aggregate) { + private void collect( + JsonNode payload, + int pageIndex, + Aggregate aggregate, + Map storeNamesById + ) { JsonNode rows = payload == null ? null : payload.path("data").path("list"); if (rows == null || !rows.isArray()) return; int rowIndex = 0; @@ -97,8 +107,14 @@ public class NgpPerformanceDayLoader { if (!aggregate.seenBills.add(billKey)) continue; long billCents = billCents(bill); if (billCents == 0) continue; - String storeName = firstText(bill, "eshopFullname", "ofullname"); - if (storeName.isBlank()) storeName = "未命名网点"; + String storeId = firstText(bill, "otypeId", "oTypeId", "eshopId", "shopId", "storeId"); + String storeName = firstText( + bill, "eshopFullname", "eshopFullName", "eshopName", "ofullname", "oFullName", + "storeName", "shopName"); + if (storeName.isBlank() && !storeId.isBlank()) storeName = storeNamesById.getOrDefault(storeId, ""); + if (storeName.isBlank()) { + storeName = storeId.isBlank() ? "未命名网点" : "未命名网点 " + storeId; + } String storeKey = normalize(storeName); NgpStoreTotal current = aggregate.storeTotals.get(storeKey); long nextTotal = (current == null ? 0L : current.totalCents()) + billCents; diff --git a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java index c0aa306..036af61 100644 --- a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java +++ b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java @@ -54,6 +54,23 @@ class NgpApiClientTest { .doesNotContainKeys("postStateList", "refundType", "otypeIds"); } + @Test + void storeRequestIncludesEveryShopType() { + FoundationProperties properties = new FoundationProperties(); + NgpApiClient client = new NgpApiClient( + properties, + org.mockito.Mockito.mock(NgpTokenProvider.class), + new ObjectMapper(), + WebClient.builder() + ); + + assertThat(client.storeRequestBody(2)) + .containsEntry("pageSize", 200) + .containsEntry("pageIndex", 2) + .containsEntry("showAll", true) + .containsEntry("showAllEshopType", true); + } + @Test void recognizesNgpErrorTokenResponseAsExpiredToken() throws Exception { JsonNode response = objectMapper.readTree("{\"code\":-1,\"message\":\"error token\"}"); diff --git a/src/test/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectoryTest.java b/src/test/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectoryTest.java new file mode 100644 index 0000000..71023e8 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/integration/ngp/NgpStoreDirectoryTest.java @@ -0,0 +1,43 @@ +package com.company.agentfoundation.integration.ngp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class NgpStoreDirectoryTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void loadsEveryPageAndCachesStoreNamesById() throws Exception { + NgpApiClient apiClient = mock(NgpApiClient.class); + when(apiClient.storePage(1)).thenReturn(objectMapper.readTree(""" + {"code":0,"data":{"pageSize":2,"total":3,"list":[ + {"otypeId":"101","fullName":"兰教练(抖店)"}, + {"otypeId":"102","fullName":"另一个网点"} + ]}} + """)); + when(apiClient.storePage(2)).thenReturn(objectMapper.readTree(""" + {"code":0,"data":{"pageSize":2,"total":3,"list":[ + {"otypeId":"103","fullName":"第三个网点"} + ]}} + """)); + NgpStoreDirectory directory = new NgpStoreDirectory(apiClient); + + Map first = directory.namesById(); + Map second = directory.namesById(); + + assertThat(first).containsEntry("101", "兰教练(抖店)") + .containsEntry("102", "另一个网点") + .containsEntry("103", "第三个网点"); + assertThat(second).isEqualTo(first); + verify(apiClient, times(1)).storePage(1); + verify(apiClient, times(1)).storePage(2); + } +} diff --git a/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java index af96728..6057778 100644 --- a/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java +++ b/src/test/java/com/company/agentfoundation/ngp/performance/NgpPerformanceDayLoaderTest.java @@ -2,6 +2,7 @@ package com.company.agentfoundation.ngp.performance; import com.company.agentfoundation.config.FoundationProperties; import com.company.agentfoundation.integration.ngp.NgpApiClient; +import com.company.agentfoundation.integration.ngp.NgpStoreDirectory; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; @@ -25,10 +26,15 @@ class NgpPerformanceDayLoaderTest { @BeforeEach void setUp() { apiClient = mock(NgpApiClient.class); + NgpStoreDirectory storeDirectory = mock(NgpStoreDirectory.class); + when(storeDirectory.namesById()).thenReturn(Map.of( + "101", "兰教练(抖店)", + "102", "另一个网点" + )); FoundationProperties properties = new FoundationProperties(); properties.getNgp().setPageSize(2); properties.getNgp().setConcurrency(2); - loader = new NgpPerformanceDayLoader(apiClient, properties, Runnable::run); + loader = new NgpPerformanceDayLoader(apiClient, storeDirectory, properties, Runnable::run); } @Test @@ -47,21 +53,21 @@ class NgpPerformanceDayLoaderTest { return Map.of( 1, objectMapper.readTree(""" {"code":0,"data":{"total":5,"list":[ - {"taskId":"A","eshopFullname":"兰教练(抖店)","detailList":[ + {"taskId":"A","otypeId":"101","detailList":[ {"refundState":4,"disedTaxedTotal":"11.95"}, {"refundState":2,"disedTaxedTotal":"99.00"} ]}, - {"taskId":"B","eshopFullname":"兰教练(抖店)","detailList":[ + {"taskId":"B","otypeId":"101","detailList":[ {"refundState":4,"disedTaxedTotal":"10.00"} ]} ]}} """), 2, objectMapper.readTree(""" {"code":0,"data":{"total":5,"list":[ - {"taskId":"B","eshopFullname":"兰教练(抖店)","detailList":[ + {"taskId":"B","otypeId":"101","detailList":[ {"refundState":4,"disedTaxedTotal":"10.00"} ]}, - {"taskId":"C","eshopFullname":"另一个网点","detailList":[ + {"taskId":"C","otypeId":"102","detailList":[ {"refundState":4,"disedTaxedTotal":"-2.00"} ]} ]}}