切换支付国家汇率同步数据源

This commit is contained in:
hy001 2026-06-25 17:21:30 +08:00
parent 9957dc2a27
commit 986f7507e7

View File

@ -1,16 +1,17 @@
package com.red.circle.order.inner.service.impl;
package com.red.circle.order.inner.service.impl;
import com.alibaba.nacos.shaded.com.google.common.collect.Lists;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.api.client.util.Maps;
import com.red.circle.framework.core.response.CommonErrorCode;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.framework.dto.PageResult;
import com.red.circle.framework.mybatis.constant.PageConstant;
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService;
import com.red.circle.order.infra.database.rds.entity.pay.PayCountry;
import com.red.circle.order.infra.database.rds.entity.pay.PayCountryChannel;
import com.red.circle.order.infra.database.rds.entity.pay.PayCountryChannelDetails;
import com.red.circle.order.infra.database.rds.service.pay.PayCountryChannelDetailsService;
import com.red.circle.order.infra.database.rds.service.pay.PayCountryChannelDetailsService;
import com.red.circle.order.infra.database.rds.service.pay.PayCountryChannelService;
import com.red.circle.order.infra.database.rds.service.pay.PayCountryService;
import com.red.circle.order.inner.convertor.WebPayInnerConvertor;
@ -31,8 +32,15 @@ import com.red.circle.tool.core.date.TimestampUtils;
import com.red.circle.tool.core.text.StringUtils;
import com.red.circle.other.inner.enums.user.RegionRelationGroupEnum;
import com.red.circle.other.inner.endpoint.user.region.RegionRelationClient;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Currency;
import java.util.List;
@ -43,22 +51,37 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 开通支付国家.
*
* @author pengliang on 2023/11/3
*/
@Service
@Service
@RequiredArgsConstructor
@Slf4j
public class PayCountryClientServiceImpl implements PayCountryClientService {
private static final String BASE_CURRENCY = "USD";
private static final String SOURCE_NAME = "ISO4217/PayerMax";
private static final String SOURCE_NAME = "ISO4217/PublicExchangeRate";
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final int RATE_SCALE = 8;
private static final Duration EXCHANGE_RATE_TIMEOUT = Duration.ofSeconds(8);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final List<ExchangeRateSource> EXCHANGE_RATE_SOURCES = List.of(
new ExchangeRateSource("open.er-api.com", "https://open.er-api.com/v6/latest/USD",
ExchangeRateSourceType.OPEN_EXCHANGE_RATE),
new ExchangeRateSource("currency-api",
"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.json",
ExchangeRateSourceType.FAWAZ_CURRENCY_API),
new ExchangeRateSource("floatrates.com", "https://www.floatrates.com/daily/usd.json",
ExchangeRateSourceType.FLOAT_RATES),
new ExchangeRateSource("frankfurter.dev",
"https://api.frankfurter.dev/v1/latest?base=USD",
ExchangeRateSourceType.FRANKFURTER));
private final PayCountryService payCountryService;
private final SysCountryCodeClient countryCodeClient;
@ -67,7 +90,9 @@ public class PayCountryClientServiceImpl implements PayCountryClientService {
private final SysCountryCodeClient sysCountryCodeClient;
private final PayCountryChannelService payCountryChannelService;
private final PayCountryChannelDetailsService payCountryChannelDetailsService;
private final PayPlaceAnOrderService payPlaceAnOrderService;
private final HttpClient exchangeRateHttpClient = HttpClient.newBuilder()
.connectTimeout(EXCHANGE_RATE_TIMEOUT)
.build();
@Override
@ -316,23 +341,24 @@ public class PayCountryClientServiceImpl implements PayCountryClientService {
.filter(currency -> !Objects.equals(BASE_CURRENCY, currency))
.collect(Collectors.toSet());
Map<String, BigDecimal> rateMap = Maps.newHashMap();
rateMap.put(BASE_CURRENCY, BigDecimal.ONE);
rateMap.putAll(payPlaceAnOrderService.getRealTimeExchangeRates(BASE_CURRENCY, targetCurrencies));
ExchangeRateFetchResult exchangeRateFetchResult = fetchLatestUsdRates(targetCurrencies);
Map<String, BigDecimal> rateMap = exchangeRateFetchResult.getRates();
List<PayCountry> updates = payCountries.stream()
.map(payCountry -> buildSyncUpdate(payCountry, countryMap.get(payCountry.getCountryId()),
currencyMap.get(payCountry.getId()), rateMap, markupMultiplier, skippedCountries))
.filter(Objects::nonNull)
.collect(Collectors.toList());
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
payCountryService.updateCurrencyRates(updates));
if (!CollectionUtils.isEmpty(updates)) {
ResponseAssert.isTrue(CommonErrorCode.OPERATING_FAILURE,
payCountryService.updateCurrencyRates(updates));
}
return new PayCountryCurrencyRateSyncDTO()
.setTotalCount(payCountries.size())
.setUpdatedCount(updates.size())
.setSkippedCount(skippedCountries.size())
.setSourceName(SOURCE_NAME)
.setSourceName(exchangeRateFetchResult.getSourceName())
.setSkippedCountries(skippedCountries);
}
@ -348,12 +374,132 @@ public class PayCountryClientServiceImpl implements PayCountryClientService {
return null;
}
payCountry.setCurrency(currency);
payCountry.setUsdExchangeRate(rate.multiply(markupMultiplier).setScale(RATE_SCALE,
RoundingMode.HALF_UP));
payCountry.setUsdExchangeRate(roundUpRate(rate.multiply(markupMultiplier)));
payCountry.setUpdateTime(TimestampUtils.now());
return payCountry;
}
private ExchangeRateFetchResult fetchLatestUsdRates(Set<String> targetCurrencies) {
Map<String, BigDecimal> rateMap = Maps.newHashMap();
rateMap.put(BASE_CURRENCY, BigDecimal.ONE);
if (CollectionUtils.isEmpty(targetCurrencies)) {
return new ExchangeRateFetchResult(rateMap, SOURCE_NAME);
}
Set<String> missingCurrencies = targetCurrencies.stream()
.filter(StringUtils::isNotBlank)
.map(currency -> currency.trim().toUpperCase(Locale.ROOT))
.filter(currency -> !Objects.equals(BASE_CURRENCY, currency))
.collect(Collectors.toSet());
if (CollectionUtils.isEmpty(missingCurrencies)) {
return new ExchangeRateFetchResult(rateMap, SOURCE_NAME);
}
List<String> sourceNames = new ArrayList<>();
for (ExchangeRateSource source : EXCHANGE_RATE_SOURCES) {
if (CollectionUtils.isEmpty(missingCurrencies)) {
break;
}
try {
Map<String, BigDecimal> sourceRates = fetchRatesFromSource(source);
int beforeSize = missingCurrencies.size();
missingCurrencies.removeIf(currency -> {
BigDecimal rate = sourceRates.get(currency);
if (Objects.isNull(rate) || rate.compareTo(BigDecimal.ZERO) <= 0) {
return false;
}
rateMap.put(currency, rate);
return true;
});
if (missingCurrencies.size() < beforeSize) {
sourceNames.add(source.getName());
}
} catch (Exception e) {
log.warn("同步支付国家汇率源失败, source={}", source.getName(), e);
}
}
String sourceName = sourceNames.isEmpty()
? SOURCE_NAME
: SOURCE_NAME + "/" + String.join(",", sourceNames);
return new ExchangeRateFetchResult(rateMap, sourceName);
}
private Map<String, BigDecimal> fetchRatesFromSource(ExchangeRateSource source)
throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(source.getUrl()))
.timeout(EXCHANGE_RATE_TIMEOUT)
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = exchangeRateHttpClient.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new IOException("exchange rate source http status " + response.statusCode());
}
JsonNode root = OBJECT_MAPPER.readTree(response.body());
return switch (source.getType()) {
case OPEN_EXCHANGE_RATE -> parseStandardRates(root.path("rates"));
case FAWAZ_CURRENCY_API -> parseStandardRates(root.path("usd"));
case FLOAT_RATES -> parseFloatRates(root);
case FRANKFURTER -> parseStandardRates(root.path("rates"));
};
}
private Map<String, BigDecimal> parseStandardRates(JsonNode ratesNode) {
Map<String, BigDecimal> rateMap = Maps.newHashMap();
if (Objects.isNull(ratesNode) || !ratesNode.isObject()) {
return rateMap;
}
ratesNode.fields().forEachRemaining(entry -> {
BigDecimal rate = readPositiveRate(entry.getValue());
if (Objects.nonNull(rate)) {
rateMap.put(entry.getKey().trim().toUpperCase(Locale.ROOT), rate);
}
});
return rateMap;
}
private Map<String, BigDecimal> parseFloatRates(JsonNode root) {
Map<String, BigDecimal> rateMap = Maps.newHashMap();
if (Objects.isNull(root) || !root.isObject()) {
return rateMap;
}
root.fields().forEachRemaining(entry -> {
JsonNode item = entry.getValue();
String currency = Optional.ofNullable(item.path("alphaCode").asText(null))
.filter(StringUtils::isNotBlank)
.orElseGet(() -> Optional.ofNullable(item.path("code").asText(null))
.filter(StringUtils::isNotBlank)
.orElse(entry.getKey()));
BigDecimal rate = readPositiveRate(item.path("rate"));
if (StringUtils.isNotBlank(currency) && Objects.nonNull(rate)) {
rateMap.put(currency.trim().toUpperCase(Locale.ROOT), rate);
}
});
return rateMap;
}
private BigDecimal readPositiveRate(JsonNode node) {
if (Objects.isNull(node) || node.isMissingNode() || node.isNull()) {
return null;
}
try {
BigDecimal rate = new BigDecimal(node.asText());
return rate.compareTo(BigDecimal.ZERO) > 0 ? rate : null;
} catch (NumberFormatException e) {
return null;
}
}
private BigDecimal roundUpRate(BigDecimal rate) {
// 与参考项目保持一致先叠加上浮比例再向上保留 1 位小数避免后台显示低于实时成本
return rate.movePointRight(1)
.setScale(0, RoundingMode.CEILING)
.movePointLeft(1)
.setScale(1);
}
private String resolveCountryCurrency(SysCountryCodeDTO country) {
if (Objects.isNull(country) || StringUtils.isBlank(country.getAlphaTwo())) {
return "";
@ -381,6 +527,57 @@ public class PayCountryClientServiceImpl implements PayCountryClientService {
return countryName + "(" + payCountry.getId() + "): " + reason;
}
private enum ExchangeRateSourceType {
OPEN_EXCHANGE_RATE,
FAWAZ_CURRENCY_API,
FLOAT_RATES,
FRANKFURTER
}
private static final class ExchangeRateSource {
private final String name;
private final String url;
private final ExchangeRateSourceType type;
private ExchangeRateSource(String name, String url, ExchangeRateSourceType type) {
this.name = name;
this.url = url;
this.type = type;
}
private String getName() {
return name;
}
private String getUrl() {
return url;
}
private ExchangeRateSourceType getType() {
return type;
}
}
private static final class ExchangeRateFetchResult {
private final Map<String, BigDecimal> rates;
private final String sourceName;
private ExchangeRateFetchResult(Map<String, BigDecimal> rates, String sourceName) {
this.rates = rates;
this.sourceName = sourceName;
}
private Map<String, BigDecimal> getRates() {
return rates;
}
private String getSourceName() {
return sourceName;
}
}
@Override
public List<PayCountryDTO> listPayCountryByRegionId(String regionId) {