修复管家婆网点名称映射

This commit is contained in:
tanglong 2026-09-24 10:55:20 +08:00
parent b44c2805b9
commit 53b52995df
10 changed files with 260 additions and 11 deletions

View File

@ -1,23 +1,38 @@
package com.company.agentfoundation.integration.ngp;
import com.fasterxml.jackson.databind.JsonNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@Component
public class NgpStoreDirectory {
private static final Logger log = LoggerFactory.getLogger(NgpStoreDirectory.class);
private static final long CACHE_MILLIS = Duration.ofHours(1).toMillis();
private static final int MAX_PAGES = 1_000;
private final NgpApiClient apiClient;
private final NgpStoreDirectoryCacheRepository cacheRepository;
private final Object refreshLock = new Object();
private volatile Snapshot snapshot = new Snapshot(Map.of(), 0L);
public NgpStoreDirectory(NgpApiClient apiClient) {
@Autowired
public NgpStoreDirectory(
NgpApiClient apiClient,
NgpStoreDirectoryCacheRepository cacheRepository
) {
this.apiClient = apiClient;
this.cacheRepository = cacheRepository;
}
NgpStoreDirectory(NgpApiClient apiClient) {
this(apiClient, null);
}
public Map<String, String> namesById() {
@ -27,9 +42,43 @@ public class NgpStoreDirectory {
synchronized (refreshLock) {
current = snapshot;
if (current.fresh(now)) return current.namesById();
Map<String, String> loaded = loadAll();
snapshot = new Snapshot(loaded, now);
return loaded;
Optional<NgpStoreDirectoryCacheRepository.CachedDirectory> persisted = persistedDirectory();
if (persisted.isPresent() && persisted.get().fresh(now, CACHE_MILLIS)) {
snapshot = new Snapshot(persisted.get().namesById(), persisted.get().loadedAtMillis());
return snapshot.namesById();
}
try {
Map<String, String> loaded = loadAll();
snapshot = new Snapshot(loaded, now);
persist(loaded, now);
return loaded;
} catch (RuntimeException exception) {
if (persisted.isPresent() && !persisted.get().namesById().isEmpty()) {
log.warn("Serving stale NGP store directory after refresh failed: {}", exception.getMessage());
snapshot = new Snapshot(persisted.get().namesById(), now);
return snapshot.namesById();
}
throw exception;
}
}
}
private Optional<NgpStoreDirectoryCacheRepository.CachedDirectory> persistedDirectory() {
if (cacheRepository == null) return Optional.empty();
try {
return cacheRepository.findAll();
} catch (RuntimeException exception) {
log.warn("Unable to read persisted NGP store directory: {}", exception.getMessage());
return Optional.empty();
}
}
private void persist(Map<String, String> namesById, long loadedAtMillis) {
if (cacheRepository == null) return;
try {
cacheRepository.replace(namesById, loadedAtMillis);
} catch (RuntimeException exception) {
log.warn("Unable to persist NGP store directory: {}", exception.getMessage());
}
}

View File

@ -0,0 +1,82 @@
package com.company.agentfoundation.integration.ngp;
import jakarta.annotation.PostConstruct;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Repository
public class NgpStoreDirectoryCacheRepository {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private final JdbcTemplate jdbcTemplate;
public NgpStoreDirectoryCacheRepository(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@PostConstruct
public void initializeSchema() {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS ngp_store_directory_cache (
store_id VARCHAR(191) NOT NULL PRIMARY KEY,
store_name VARCHAR(255) NOT NULL,
refreshed_at DATETIME(3) NOT NULL,
KEY idx_ngp_store_directory_refreshed_at (refreshed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
public Optional<CachedDirectory> findAll() {
Map<String, String> namesById = new LinkedHashMap<>();
List<LocalDateTime> refreshTimes = jdbcTemplate.query("""
SELECT store_id, store_name, refreshed_at
FROM ngp_store_directory_cache
ORDER BY store_id
""",
(resultSet, rowNum) -> {
namesById.put(resultSet.getString("store_id"), resultSet.getString("store_name"));
return resultSet.getTimestamp("refreshed_at").toLocalDateTime();
});
if (namesById.isEmpty() || refreshTimes.isEmpty()) return Optional.empty();
LocalDateTime newest = refreshTimes.stream().max(LocalDateTime::compareTo).orElseThrow();
long loadedAtMillis = newest.atZone(BUSINESS_ZONE).toInstant().toEpochMilli();
return Optional.of(new CachedDirectory(Map.copyOf(namesById), loadedAtMillis));
}
@Transactional
public void replace(Map<String, String> namesById, long loadedAtMillis) {
jdbcTemplate.update("DELETE FROM ngp_store_directory_cache");
if (namesById.isEmpty()) return;
Timestamp refreshedAt = Timestamp.valueOf(LocalDateTime.ofInstant(
Instant.ofEpochMilli(loadedAtMillis), BUSINESS_ZONE));
List<Map.Entry<String, String>> rows = List.copyOf(namesById.entrySet());
jdbcTemplate.batchUpdate("""
INSERT INTO ngp_store_directory_cache (store_id, store_name, refreshed_at)
VALUES (?, ?, ?)
""",
rows,
rows.size(),
(PreparedStatement statement, Map.Entry<String, String> row) -> {
statement.setString(1, row.getKey());
statement.setString(2, row.getValue());
statement.setTimestamp(3, refreshedAt);
});
}
public record CachedDirectory(Map<String, String> namesById, long loadedAtMillis) {
boolean fresh(long now, long ttlMillis) {
return !namesById.isEmpty() && now - loadedAtMillis <= ttlMillis;
}
}
}

View File

@ -3,6 +3,7 @@ package com.company.agentfoundation.ngp.monthly;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.ngp.performance.NgpDailyStoreTotal;
import com.company.agentfoundation.ngp.performance.NgpPerformanceCacheRepository;
import com.company.agentfoundation.ngp.performance.NgpCachedStoreNameResolver;
import com.company.agentfoundation.ngp.performance.NgpPerformanceDayLoader;
import com.company.agentfoundation.ngp.performance.NgpPerformanceRefreshCoordinator;
import org.slf4j.Logger;
@ -40,6 +41,7 @@ public class NgpMonthlyWeeklyService {
private final NgpPerformanceCacheRepository repository;
private final NgpPerformanceRefreshCoordinator refreshCoordinator;
private final NgpCachedStoreNameResolver storeNameResolver;
private final FoundationProperties.Ngp config;
private final Clock clock;
@ -47,19 +49,22 @@ public class NgpMonthlyWeeklyService {
public NgpMonthlyWeeklyService(
NgpPerformanceCacheRepository repository,
NgpPerformanceRefreshCoordinator refreshCoordinator,
NgpCachedStoreNameResolver storeNameResolver,
FoundationProperties properties
) {
this(repository, refreshCoordinator, properties, Clock.system(BUSINESS_ZONE));
this(repository, refreshCoordinator, storeNameResolver, properties, Clock.system(BUSINESS_ZONE));
}
NgpMonthlyWeeklyService(
NgpPerformanceCacheRepository repository,
NgpPerformanceRefreshCoordinator refreshCoordinator,
NgpCachedStoreNameResolver storeNameResolver,
FoundationProperties properties,
Clock clock
) {
this.repository = repository;
this.refreshCoordinator = refreshCoordinator;
this.storeNameResolver = storeNameResolver;
this.config = properties.getNgp();
this.clock = clock;
}
@ -100,7 +105,8 @@ public class NgpMonthlyWeeklyService {
refreshStaleInBackground(staleDates);
List<NgpMonthlyWeek> weeks = buildWeeks(month, today);
List<NgpDailyStoreTotal> dailyTotals = repository.findDailyTotals(monthStart, dataEnd);
List<NgpDailyStoreTotal> dailyTotals = storeNameResolver.canonicalize(
repository.findDailyTotals(monthStart, dataEnd));
List<String> requestedStoreNames = normalizeRequestedStoreNames(request);
return buildResponse(month, today, weeks, dailyTotals, requestedStoreNames);
}

View File

@ -0,0 +1,60 @@
package com.company.agentfoundation.ngp.performance;
import com.company.agentfoundation.integration.ngp.NgpStoreDirectory;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class NgpCachedStoreNameResolver {
private final NgpStoreDirectory storeDirectory;
public NgpCachedStoreNameResolver(NgpStoreDirectory storeDirectory) {
this.storeDirectory = storeDirectory;
}
public Map<String, NgpStoreTotal> canonicalize(Map<String, NgpStoreTotal> totals) {
if (totals.values().stream().noneMatch(row -> unresolved(row.storeName()))) return totals;
Map<String, String> namesById = storeDirectory.namesById();
Map<String, NgpStoreTotal> resolved = new LinkedHashMap<>();
totals.forEach((storeKey, row) -> {
String storeName = resolve(namesById, storeKey, row.storeName());
String canonicalKey = NgpPerformanceDayLoader.normalize(storeName);
NgpStoreTotal current = resolved.get(canonicalKey);
long totalCents = row.totalCents() + (current == null ? 0L : current.totalCents());
resolved.put(canonicalKey, new NgpStoreTotal(storeName, totalCents));
});
return Map.copyOf(resolved);
}
public List<NgpDailyStoreTotal> canonicalize(List<NgpDailyStoreTotal> totals) {
if (totals.stream().noneMatch(row -> unresolved(row.storeName()))) return totals;
Map<String, String> namesById = storeDirectory.namesById();
List<NgpDailyStoreTotal> resolved = new ArrayList<>(totals.size());
for (NgpDailyStoreTotal row : totals) {
String storeName = resolve(namesById, row.storeKey(), row.storeName());
resolved.add(new NgpDailyStoreTotal(
row.businessDate(),
NgpPerformanceDayLoader.normalize(storeName),
storeName,
row.totalCents()));
}
return List.copyOf(resolved);
}
private String resolve(Map<String, String> namesById, String storeKey, String storeName) {
if (!unresolved(storeName)) return storeName;
String resolved = namesById.getOrDefault(storeName, "");
if (resolved.isBlank()) resolved = namesById.getOrDefault(storeKey, "");
return resolved.isBlank() ? storeName : resolved;
}
private boolean unresolved(String storeName) {
if (storeName == null || storeName.isBlank()) return false;
String clean = storeName.trim();
return clean.matches("\\d{6,}") || clean.matches("未命名网点\\s+\\d{6,}");
}
}

View File

@ -111,7 +111,11 @@ public class NgpPerformanceDayLoader {
String storeName = firstText(
bill, "eshopFullname", "eshopFullName", "eshopName", "ofullname", "oFullName",
"storeName", "shopName");
if (storeName.isBlank() && !storeId.isBlank()) storeName = storeNamesById.getOrDefault(storeId, "");
String directoryName = storeNamesById.getOrDefault(storeId, "");
if (directoryName.isBlank() && !storeName.isBlank()) {
directoryName = storeNamesById.getOrDefault(storeName, "");
}
if (!directoryName.isBlank()) storeName = directoryName;
if (storeName.isBlank()) {
storeName = storeId.isBlank() ? "未命名网点" : "未命名网点 " + storeId;
}

View File

@ -28,15 +28,18 @@ public class NgpPerformanceTotalService {
private final NgpPerformanceCacheRepository repository;
private final NgpPerformanceRefreshCoordinator refreshCoordinator;
private final NgpCachedStoreNameResolver storeNameResolver;
private final FoundationProperties.Ngp config;
public NgpPerformanceTotalService(
NgpPerformanceCacheRepository repository,
NgpPerformanceRefreshCoordinator refreshCoordinator,
NgpCachedStoreNameResolver storeNameResolver,
FoundationProperties properties
) {
this.repository = repository;
this.refreshCoordinator = refreshCoordinator;
this.storeNameResolver = storeNameResolver;
this.config = properties.getNgp();
}
@ -68,7 +71,8 @@ public class NgpPerformanceTotalService {
refreshStaleInBackground(staleDates);
String storeInput = request.storeName() == null ? "" : request.storeName().trim();
Map<String, NgpStoreTotal> totals = repository.aggregate(request.startDate(), request.endDate());
Map<String, NgpStoreTotal> totals = storeNameResolver.canonicalize(
repository.aggregate(request.startDate(), request.endDate()));
long totalCents = selectStores(totals, storeInput).stream().mapToLong(NgpStoreTotal::totalCents).sum();
BigDecimal totalAmount = BigDecimal.valueOf(totalCents, 2).setScale(2, RoundingMode.UNNECESSARY);
return new NgpPerformanceTotalResponse(

View File

@ -1,6 +1,8 @@
package com.company.agentfoundation.ngp.monthly;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.integration.ngp.NgpStoreDirectory;
import com.company.agentfoundation.ngp.performance.NgpCachedStoreNameResolver;
import com.company.agentfoundation.ngp.performance.NgpDailyStoreTotal;
import com.company.agentfoundation.ngp.performance.NgpPerformanceCacheRepository;
import com.company.agentfoundation.ngp.performance.NgpPerformanceRefreshCoordinator;
@ -36,7 +38,9 @@ class NgpMonthlyWeeklyServiceTest {
void setUp() {
repository = mock(NgpPerformanceCacheRepository.class);
coordinator = mock(NgpPerformanceRefreshCoordinator.class);
service = new NgpMonthlyWeeklyService(repository, coordinator, new FoundationProperties(), CLOCK);
NgpCachedStoreNameResolver resolver = new NgpCachedStoreNameResolver(mock(NgpStoreDirectory.class));
service = new NgpMonthlyWeeklyService(
repository, coordinator, resolver, new FoundationProperties(), CLOCK);
}
@Test
@ -143,6 +147,7 @@ class NgpMonthlyWeeklyServiceTest {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) {
context.registerBean(NgpPerformanceCacheRepository.class, () -> mock(NgpPerformanceCacheRepository.class));
context.registerBean(NgpPerformanceRefreshCoordinator.class, () -> mock(NgpPerformanceRefreshCoordinator.class));
context.registerBean(NgpCachedStoreNameResolver.class, () -> mock(NgpCachedStoreNameResolver.class));
context.registerBean(FoundationProperties.class, FoundationProperties::new);
context.register(NgpMonthlyWeeklyService.class);
context.refresh();

View File

@ -0,0 +1,37 @@
package com.company.agentfoundation.ngp.performance;
import com.company.agentfoundation.integration.ngp.NgpStoreDirectory;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class NgpCachedStoreNameResolverTest {
@Test
void resolvesNumericStoreNamesInExistingTotalsAndDailyRows() {
NgpStoreDirectory directory = mock(NgpStoreDirectory.class);
when(directory.namesById()).thenReturn(Map.of(
"1621085730283489767", "跃动内容电商(抖店)"));
NgpCachedStoreNameResolver resolver = new NgpCachedStoreNameResolver(directory);
Map<String, NgpStoreTotal> totals = resolver.canonicalize(Map.of(
"1621085730283489767", new NgpStoreTotal("1621085730283489767", 12345L)));
List<NgpDailyStoreTotal> daily = resolver.canonicalize(List.of(
new NgpDailyStoreTotal(
LocalDate.of(2026, 9, 1),
"1621085730283489767",
"1621085730283489767",
12345L)));
String expectedKey = NgpPerformanceDayLoader.normalize("跃动内容电商(抖店)");
assertThat(totals).containsEntry(
expectedKey, new NgpStoreTotal("跃动内容电商(抖店)", 12345L));
assertThat(daily).containsExactly(new NgpDailyStoreTotal(
LocalDate.of(2026, 9, 1), expectedKey, "跃动内容电商(抖店)", 12345L));
}
}

View File

@ -67,7 +67,7 @@ class NgpPerformanceDayLoaderTest {
{"taskId":"B","otypeId":"101","detailList":[
{"refundState":4,"disedTaxedTotal":"10.00"}
]},
{"taskId":"C","otypeId":"102","detailList":[
{"taskId":"C","otypeId":"102","eshopFullname":"102","detailList":[
{"refundState":4,"disedTaxedTotal":"-2.00"}
]}
]}}

View File

@ -1,6 +1,7 @@
package com.company.agentfoundation.ngp.performance;
import com.company.agentfoundation.config.FoundationProperties;
import com.company.agentfoundation.integration.ngp.NgpStoreDirectory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -25,7 +26,8 @@ class NgpPerformanceTotalServiceTest {
repository = mock(NgpPerformanceCacheRepository.class);
coordinator = mock(NgpPerformanceRefreshCoordinator.class);
FoundationProperties properties = new FoundationProperties();
service = new NgpPerformanceTotalService(repository, coordinator, properties);
NgpCachedStoreNameResolver resolver = new NgpCachedStoreNameResolver(mock(NgpStoreDirectory.class));
service = new NgpPerformanceTotalService(repository, coordinator, resolver, properties);
}
@Test