diff --git a/rc-service/rc-service-order/order-inner-endpoint/src/main/java/com/red/circle/order/inner/service/impl/PayCountryClientServiceImpl.java b/rc-service/rc-service-order/order-inner-endpoint/src/main/java/com/red/circle/order/inner/service/impl/PayCountryClientServiceImpl.java index d7202eb6..d0c23310 100644 --- a/rc-service/rc-service-order/order-inner-endpoint/src/main/java/com/red/circle/order/inner/service/impl/PayCountryClientServiceImpl.java +++ b/rc-service/rc-service-order/order-inner-endpoint/src/main/java/com/red/circle/order/inner/service/impl/PayCountryClientServiceImpl.java @@ -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 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 rateMap = Maps.newHashMap(); - rateMap.put(BASE_CURRENCY, BigDecimal.ONE); - rateMap.putAll(payPlaceAnOrderService.getRealTimeExchangeRates(BASE_CURRENCY, targetCurrencies)); + ExchangeRateFetchResult exchangeRateFetchResult = fetchLatestUsdRates(targetCurrencies); + Map rateMap = exchangeRateFetchResult.getRates(); List 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 targetCurrencies) { + Map rateMap = Maps.newHashMap(); + rateMap.put(BASE_CURRENCY, BigDecimal.ONE); + if (CollectionUtils.isEmpty(targetCurrencies)) { + return new ExchangeRateFetchResult(rateMap, SOURCE_NAME); + } + + Set 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 sourceNames = new ArrayList<>(); + for (ExchangeRateSource source : EXCHANGE_RATE_SOURCES) { + if (CollectionUtils.isEmpty(missingCurrencies)) { + break; + } + try { + Map 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 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 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 parseStandardRates(JsonNode ratesNode) { + Map 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 parseFloatRates(JsonNode root) { + Map 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 rates; + private final String sourceName; + + private ExchangeRateFetchResult(Map rates, String sourceName) { + this.rates = rates; + this.sourceName = sourceName; + } + + private Map getRates() { + return rates; + } + + private String getSourceName() { + return sourceName; + } + } + @Override public List listPayCountryByRegionId(String regionId) {