From 79bb91237b8a873af2a869e86334a97be8c42a7b Mon Sep 17 00:00:00 2001 From: hy001 Date: Fri, 10 Jul 2026 19:59:12 +0800 Subject: [PATCH] feat(order): add Yumi V5Pay recharge --- nacos_config/rc-service-order/application.yml | 12 ++ .../gateway/filter/ApiLoggingFilter.java | 38 +++- .../gateway/filter/ApiLoggingFilterTest.java | 53 ++++++ .../model/enums/MonthlyRechargeType.java | 9 +- .../app/PayServerNoticeController.java | 28 ++- .../pay/web/PayWebOrderStatusQueryExe.java | 42 ++++- .../pay/web/PayWebPlaceAnOrderCmdExe.java | 82 +++++++-- .../pay/web/V5PayOrderReconcileService.java | 66 +++++++ .../pay/web/V5PayOrderSettlementService.java | 77 +++++++++ .../pay/web/V5PayPurchaseCreditService.java | 12 ++ .../web/V5PayPurchaseCreditServiceImpl.java | 21 +++ ...V5PayServerNoticeReceivePaymentCmdExe.java | 119 +++++++++++++ .../web/strategy/PayPlaceAnOrderService.java | 3 +- .../V5PayWebPayPlaceAnOrderStrategy.java | 60 +++++++ .../order/app/common/V5PayOrderData.java | 101 +++++++++++ .../order/app/common/V5PayOrderNumber.java | 44 +++++ .../circle/order/app/common/V5PayPayload.java | 65 +++++++ .../order/app/common/V5PaySignature.java | 59 +++++++ .../app/common/WebPayProviderSupport.java | 57 ++++++ .../InAppPurchaseProductServiceImpl.java | 26 ++- .../order/app/service/V5PayProperties.java | 51 ++++++ .../order/app/service/V5PayService.java | 163 ++++++++++++++++++ .../web/V5PayOrderSettlementServiceTest.java | 132 ++++++++++++++ ...yServerNoticeReceivePaymentCmdExeTest.java | 132 ++++++++++++++ .../order/app/common/V5PayOrderDataTest.java | 55 ++++++ .../order/app/common/V5PaySignatureTest.java | 53 ++++++ .../order/app/dto/cmd/PayOrderStatusCmd.java | 20 +++ .../order/app/dto/cmd/PayPlaceAnOrderCmd.java | 30 +++- .../service/InAppPurchaseProductService.java | 16 +- .../order/OrderUserPurchasePayService.java | 10 ++ .../impl/OrderUserPurchasePayServiceImpl.java | 15 ++ .../pay/PayCountryChannelDetailsService.java | 11 +- .../PayCountryChannelDetailsServiceImpl.java | 26 ++- sql/20260710_yumi_v5pay_factory.sql | 30 ++++ 34 files changed, 1669 insertions(+), 49 deletions(-) create mode 100644 rc-gateway/src/test/java/com/red/circle/gateway/filter/ApiLoggingFilterTest.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderReconcileService.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementService.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditService.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditServiceImpl.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExe.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/V5PayWebPayPlaceAnOrderStrategy.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderData.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNumber.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPayload.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignature.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/WebPayProviderSupport.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayProperties.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayService.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementServiceTest.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExeTest.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PayOrderDataTest.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignatureTest.java create mode 100644 sql/20260710_yumi_v5pay_factory.sql diff --git a/nacos_config/rc-service-order/application.yml b/nacos_config/rc-service-order/application.yml index ccd08e6c..1a4f9e73 100644 --- a/nacos_config/rc-service-order/application.yml +++ b/nacos_config/rc-service-order/application.yml @@ -49,6 +49,18 @@ red-circle: factory-config: cache-ttl-seconds: ${LIKEI_PAY_FACTORY_CONFIG_CACHE_TTL_SECONDS:300} + # V5Pay 功能默认开启;商户账号、密钥与所有外部地址只能由运行环境注入,仓库不提供伪造可用值。 + v5-pay: + enabled: ${LIKEI_V5PAY_ENABLED:true} + merchant-no: ${LIKEI_V5PAY_MERCHANT_NO:} + app-key: ${LIKEI_V5PAY_APP_KEY:} + secret-key: ${LIKEI_V5PAY_SECRET_KEY:} + gateway-base-url: ${LIKEI_V5PAY_GATEWAY_BASE_URL:} + notify-url: ${LIKEI_V5PAY_NOTIFY_URL:} + return-url: ${LIKEI_V5PAY_RETURN_URL:} + # 共享商户必须隔离各系统 orderNo;仅在供应商侧约定变化时覆盖。 + order-prefix: ${LIKEI_V5PAY_ORDER_PREFIX:YUMI} + airwallex: client-id: ${LIKEI_AIRWALLEX_CLIENT_ID} api-key: ${LIKEI_AIRWALLEX_API_KEY} diff --git a/rc-gateway/src/main/java/com/red/circle/gateway/filter/ApiLoggingFilter.java b/rc-gateway/src/main/java/com/red/circle/gateway/filter/ApiLoggingFilter.java index 27bed08a..f81ad104 100644 --- a/rc-gateway/src/main/java/com/red/circle/gateway/filter/ApiLoggingFilter.java +++ b/rc-gateway/src/main/java/com/red/circle/gateway/filter/ApiLoggingFilter.java @@ -43,6 +43,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Optional; import java.util.zip.GZIPInputStream; @@ -58,6 +59,7 @@ import java.util.zip.GZIPInputStream; public class ApiLoggingFilter implements GlobalFilter, Ordered { private static final int MAX_RESPONSE_BODY_LOG_LENGTH = 500; + private static final String REDACTED_V5PAY_BODY = "[REDACTED_V5PAY_CALLBACK]"; private final LoggingUploadService loggingUploadService; private final List> messageReaders = HandlerStrategies.withDefaults() @@ -72,9 +74,15 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered { return chain.filter(exchange); } - GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange); - - return checkBodyType(exchange.getRequest()) + GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange); + + if (isV5PayCallbackPath(request.getURI().getPath())) { + // V5Pay 的 MD5 覆盖原始字段文本;通用 bodyToMono -> JSON 重写会把 14400.00 变成 + // 14400.0 并破坏验签,同时还会把 sign/appKey 写入日志。该回调必须完全不消费、不重写 body。 + return writeSensitiveRawBodyLog(exchange, chain, logging); + } + + return checkBodyType(exchange.getRequest()) ? writeBodyLog(exchange, chain, logging) : writeBasicLog(exchange, chain, logging); } @@ -186,6 +194,30 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered { }; } + static boolean isV5PayCallbackPath(String path) { + if (StringUtils.isBlank(path)) { + return false; + } + String normalized = path.toLowerCase(Locale.ROOT); + return normalized.endsWith("/play-server-notice/v5pay/receive_payment") + || normalized.endsWith("/play-server-notice/v5_pay/receive_payment"); + } + + private Mono writeSensitiveRawBodyLog(ServerWebExchange exchange, + GatewayFilterChain chain, + GatewayLogging logging) { + // 明确写固定脱敏标记,避免后续日志上传误以为 body 尚未采集并再次尝试读取敏感回调体。 + logging.getRequest().setBody(REDACTED_V5PAY_BODY); + ServerHttpResponseDecorator decoratedResponse = responseLog(exchange, logging); + ServerHttpRequest request = exchange.getRequest().mutate().headers(this::setRequestTraceId) + .build(); + return chain.filter(exchange.mutate().request(request).response(decoratedResponse).build()) + .then(Mono.fromRunnable(() -> { + ApiLoggingUtils.resetGatewayLogging(exchange, logging); + logging.uploadLog(loggingUploadService); + })); + } + private boolean isSuccessResponse(ServerHttpResponse response) { HttpStatusCode statusCode = response.getStatusCode(); return statusCode == null || statusCode.is2xxSuccessful(); diff --git a/rc-gateway/src/test/java/com/red/circle/gateway/filter/ApiLoggingFilterTest.java b/rc-gateway/src/test/java/com/red/circle/gateway/filter/ApiLoggingFilterTest.java new file mode 100644 index 00000000..6b353b25 --- /dev/null +++ b/rc-gateway/src/test/java/com/red/circle/gateway/filter/ApiLoggingFilterTest.java @@ -0,0 +1,53 @@ +package com.red.circle.gateway.filter; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.red.circle.gateway.logging.LoggingUploadService; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; + +public class ApiLoggingFilterTest { + + @Test + public void recognizesBothV5PayCallbackRoutesOnly() { + assertTrue(ApiLoggingFilter.isV5PayCallbackPath( + "/play-server-notice/v5pay/receive_payment")); + assertTrue(ApiLoggingFilter.isV5PayCallbackPath( + "/order/play-server-notice/v5_pay/receive_payment")); + assertFalse(ApiLoggingFilter.isV5PayCallbackPath( + "/play-server-notice/mifa_pay/receive_payment")); + } + + @Test + public void v5PayJsonBodyPassesThroughWithoutNumericReserialization() { + byte[] raw = ("{\"orderNo\":\"YUMI-42\",\"amount\":14400.00," + + "\"appKey\":\"sensitive\",\"sign\":\"signature\"}") + .getBytes(StandardCharsets.UTF_8); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/play-server-notice/v5pay/receive_payment") + .contentType(MediaType.APPLICATION_JSON) + .body(new String(raw, StandardCharsets.UTF_8))); + AtomicReference forwarded = new AtomicReference<>(); + ApiLoggingFilter filter = new ApiLoggingFilter(new LoggingUploadService(null)); + + filter.filter(exchange, forwardedExchange -> DataBufferUtils + .join(forwardedExchange.getRequest().getBody()) + .doOnNext(buffer -> { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + forwarded.set(bytes); + }) + .then()) + .block(); + + assertArrayEquals(raw, forwarded.get()); + } +} diff --git a/rc-service/rc-inner-api/order-inner/order-inner-model/src/main/java/com/red/circle/order/inner/model/enums/MonthlyRechargeType.java b/rc-service/rc-inner-api/order-inner/order-inner-model/src/main/java/com/red/circle/order/inner/model/enums/MonthlyRechargeType.java index 17d2cfc5..c15f9629 100644 --- a/rc-service/rc-inner-api/order-inner/order-inner-model/src/main/java/com/red/circle/order/inner/model/enums/MonthlyRechargeType.java +++ b/rc-service/rc-inner-api/order-inner/order-inner-model/src/main/java/com/red/circle/order/inner/model/enums/MonthlyRechargeType.java @@ -22,7 +22,12 @@ public enum MonthlyRechargeType { /** * MifaPay */ - MIFA_PAY, + MIFA_PAY, + + /** + * V5Pay. + */ + V5_PAY, /** * Airwallex. @@ -93,7 +98,7 @@ public enum MonthlyRechargeType { * 个人充值. */ public static List getPersonalRecharge() { - return List.of(HUAWEI, GOOGLE, APPLE, PAYER_MAX, MIFA_PAY, AIRWALLEX, PAYNICORN, + return List.of(HUAWEI, GOOGLE, APPLE, PAYER_MAX, MIFA_PAY, V5_PAY, AIRWALLEX, PAYNICORN, STRIPE, PAY_PAL, CLIPSPAY, SHIPPING_AGENT, SALARY_EXCHANGE); } diff --git a/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/PayServerNoticeController.java b/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/PayServerNoticeController.java index d9d26afa..7be95fbf 100644 --- a/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/PayServerNoticeController.java +++ b/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/PayServerNoticeController.java @@ -86,9 +86,31 @@ public class PayServerNoticeController extends BaseController { */ @IgnoreResultResponse @PostMapping(value = "/mifa_pay/receive_payment", consumes = MediaType.APPLICATION_JSON_VALUE) - public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) { - return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body); - } + public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) { + return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body); + } + + /** + * V5Pay JSON 收款回调;供应商要求 text/plain 小写 success/fail. + */ + @IgnoreResultResponse + @PostMapping(value = {"/v5pay/receive_payment", "/v5_pay/receive_payment"}, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + public String v5PayReceivePaymentJson(@RequestBody(required = false) byte[] body) { + return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(body); + } + + /** + * V5Pay 部分通道会使用 form 回调,与 JSON 入口共用同一验签和幂等入账流程. + */ + @IgnoreResultResponse + @PostMapping(value = {"/v5pay/receive_payment", "/v5_pay/receive_payment"}, + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + public String v5PayReceivePaymentForm(@RequestParam Map body) { + return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(body); + } /** * payerMax 退款回调地址-自定义. diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebOrderStatusQueryExe.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebOrderStatusQueryExe.java index 5c8e25e6..1bd58aa0 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebOrderStatusQueryExe.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebOrderStatusQueryExe.java @@ -2,6 +2,7 @@ package com.red.circle.order.app.command.pay.web; import com.red.circle.framework.core.asserts.ResponseAssert; import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.WebPayProviderSupport; import com.red.circle.order.app.dto.clientobject.pay.PayOrderStatusCO; import com.red.circle.order.app.dto.cmd.PayOrderStatusCmd; import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; @@ -10,6 +11,7 @@ import com.red.circle.order.inner.asserts.OrderErrorCode; import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus; import java.util.Objects; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; /** @@ -19,9 +21,11 @@ import org.springframework.stereotype.Component; */ @Component @RequiredArgsConstructor +@Slf4j public class PayWebOrderStatusQueryExe { private final OrderUserPurchasePayService orderUserPurchasePayService; + private final V5PayOrderReconcileService v5PayOrderReconcileService; public PayOrderStatusCO execute(PayOrderStatusCmd cmd) { OrderUserPurchasePay mysqlOrder = getMysqlOrder(cmd.getOrderId()); @@ -30,6 +34,20 @@ public class PayWebOrderStatusQueryExe { // 登录态请求继续使用网关注入的 reqUserId;公开 H5 轮询没有登录态,只允许用下单用户ID校验同一笔订单. ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, Objects.nonNull(expectedUserId) && Objects.equals(mysqlOrder.getUserId(), expectedUserId)); + validateProviderSnapshot(cmd, mysqlOrder); + + if (WebPayProviderSupport.isV5PayFactory(mysqlOrder.getFactoryCode()) + && Objects.equals(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT, + mysqlOrder.getPayStatus())) { + try { + // H5 返回页主动查单用于补偿回调延迟;上游临时失败时保留本地 pending,避免轮询接口变成 5xx. + v5PayOrderReconcileService.refreshIfPending(mysqlOrder); + } catch (Exception e) { + log.warn("V5Pay主动查单失败,保留本地订单状态: localOrderId={}, errorType={}", + mysqlOrder.getId(), e.getClass().getSimpleName()); + } + mysqlOrder = orderUserPurchasePayService.getById(mysqlOrder.getId()); + } InAppPurchaseStatus status = MiFaPayMysqlOrderSupport .toInAppPurchaseStatus(mysqlOrder.getPayStatus()); return new PayOrderStatusCO() @@ -40,7 +58,7 @@ public class PayWebOrderStatusQueryExe { .setFinished(MiFaPayMysqlOrderSupport.isFinished(mysqlOrder.getPayStatus())) .setReason(mysqlOrder.getReason()) .setFactoryCode(mysqlOrder.getFactoryCode()) - .setFactoryChannelCode(mysqlOrder.getPaymentChannel()) + .setFactoryChannelCode(mysqlOrder.getFactoryChannel()) .setCurrency(mysqlOrder.getPaymentUnit()) .setAmount(mysqlOrder.getPaymentAmount()) .setUpdateTime(mysqlOrder.getUpdateTime()); @@ -50,4 +68,26 @@ public class PayWebOrderStatusQueryExe { Long mysqlOrderId = MiFaPayMysqlOrderSupport.parseOrderId(orderId); return mysqlOrderId == null ? null : orderUserPurchasePayService.getById(mysqlOrderId); } + + private void validateProviderSnapshot(PayOrderStatusCmd cmd, OrderUserPurchasePay order) { + String requestedFactory = WebPayProviderSupport.toFactoryCode(cmd.getProviderCode()); + ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, requestedFactory != null); + if (!Objects.equals("", requestedFactory)) { + ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, + Objects.equals(requestedFactory, order.getFactoryCode())); + } + if (cmd.getPayType() != null && !cmd.getPayType().isBlank()) { + ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, + cmd.getPayType().trim().equalsIgnoreCase( + Objects.toString(order.getFactoryChannel(), "").trim())); + } + if (cmd.getPayWay() != null && !cmd.getPayWay().isBlank()) { + String paymentChannel = Objects.toString(order.getPaymentChannel(), ""); + String prefix = cmd.getPayWay().trim() + "_"; + ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, + paymentChannel.equalsIgnoreCase(cmd.getPayWay().trim()) + || paymentChannel.regionMatches(true, 0, prefix, 0, prefix.length())); + } + // returnUrl 只为兼容 H5 统一参数对象而接受;状态查询不重定向,绝不把它写回订单或发给上游. + } } diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebPlaceAnOrderCmdExe.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebPlaceAnOrderCmdExe.java index 234aa18f..b52f579e 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebPlaceAnOrderCmdExe.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebPlaceAnOrderCmdExe.java @@ -2,10 +2,12 @@ package com.red.circle.order.app.command.pay.web; import com.red.circle.common.business.core.amount.PayAmountArithmeticUtils; import com.red.circle.component.pay.PayProperties; -import com.red.circle.framework.core.asserts.ResponseAssert; -import com.red.circle.framework.core.response.CommonErrorCode; -import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2; -import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService; +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.framework.core.response.CommonErrorCode; +import com.red.circle.framework.core.response.ResponseErrorCode; +import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2; +import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService; +import com.red.circle.order.app.common.WebPayProviderSupport; import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO; import com.red.circle.order.app.dto.cmd.PayPlaceAnOrderCmd; import com.red.circle.order.domain.gateway.UserFreightRechargeRecordGateway; @@ -27,6 +29,7 @@ import com.red.circle.tool.core.sequence.IdWorkerUtils; import com.red.circle.tool.core.text.StringUtils; import com.red.circle.order.app.common.PayerMaxProperties; import java.math.BigDecimal; +import java.net.URI; import java.util.List; import java.util.Objects; @@ -70,9 +73,7 @@ public class PayWebPlaceAnOrderCmdExe { ResponseAssert.notNull(PayErrorCode.REGION_NOT_SUPPORTED, payCountry); cmd.setPayCountryId(payCountry.getId()); - PayCountryChannelDetails channelDetails = payCountryChannelDetailsService - .getSuggestScoreMax(cmd.getPayCountryId(), cmd.getChannelCode(), - commodity.getAmountUsd()); + PayCountryChannelDetails channelDetails = resolveChannelDetails(cmd, commodity); // 没有合适的支付方式 ResponseAssert.notNull(PayErrorCode.NO_SUITABLE_PAYMENT_CHANNEL, channelDetails); SysCountryCodeDTO sysCountryCode = ResponseAssert.requiredSuccess( @@ -114,11 +115,12 @@ public class PayWebPlaceAnOrderCmdExe { details.setRechargeUrl(payProperties.getWebPayRechargePageUrl() + "/" + application.getId() + "?type=" + commodity.getType()); - details.setSuccessRedirectUrl(payProperties.getWebPayResultPageUrl() - + "?redirect=" + payProperties.getWebPayRechargePage() - + "&appId=" + application.getId() - + "&appStatus=0" - + "&appType=" + commodity.getType()); + String defaultSuccessRedirectUrl = payProperties.getWebPayResultPageUrl() + + "?redirect=" + payProperties.getWebPayRechargePage() + + "&appId=" + application.getId() + + "&appStatus=0" + + "&appType=" + commodity.getType(); + details.setSuccessRedirectUrl(resolveReturnUrl(cmd.getReturnUrl(), defaultSuccessRedirectUrl)); details.setCancelRedirectUrl(details.getRechargeUrl()); details.setNewVersion(cmd.getNewVersion()); details.setAppVersion(cmd.getAppVersion()); @@ -126,8 +128,60 @@ public class PayWebPlaceAnOrderCmdExe { // 记录充值金额 rechargeRecord(details); - return details; - } + return details; + } + + private PayCountryChannelDetails resolveChannelDetails(PayPlaceAnOrderCmd cmd, + PayApplicationCommodity commodity) { + String factoryCode = WebPayProviderSupport.toFactoryCode(cmd.getProviderCode()); + // providerCode 非空但无法映射时必须拒绝,不能回退到旧评分逻辑后让用户在未知厂商付款. + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, factoryCode != null); + if (StringUtils.isBlank(factoryCode)) { + return payCountryChannelDetailsService.getSuggestScoreMax( + cmd.getPayCountryId(), cmd.getChannelCode(), commodity.getAmountUsd()); + } + + PayCountryChannelDetails configured = payCountryChannelDetailsService.getSuggestScoreMax( + cmd.getPayCountryId(), cmd.getChannelCode(), commodity.getAmountUsd(), factoryCode, + cmd.getPayType()); + if (configured != null || !WebPayProviderSupport.isV5PayFactory(factoryCode)) { + return configured; + } + + // Yumi 的 app JSON 是旧 H5 支付方式控制面;V5Pay 尚无旧表 method_id 时,用请求中的 + // payWay/payType 生成只服务当前订单的瞬时渠道快照,金额、国家和币种仍完全取服务端商品配置. + ResponseAssert.notBlank(ResponseErrorCode.REQUEST_PARAMETER_ERROR, cmd.getPayWay()); + ResponseAssert.notBlank(ResponseErrorCode.REQUEST_PARAMETER_ERROR, cmd.getPayType()); + return new PayCountryChannelDetails() + .setId(0L) + .setPayCountryId(cmd.getPayCountryId()) + .setChannelCode(cmd.getChannelCode()) + .setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY) + .setFactoryChannel(cmd.getPayType().trim()) + .setFactoryCurrencyPoint(2) + .setSuggestScore(100) + .setShelf(Boolean.TRUE); + } + + private String resolveReturnUrl(String requestedReturnUrl, String defaultReturnUrl) { + if (StringUtils.isBlank(requestedReturnUrl)) { + return defaultReturnUrl; + } + try { + URI uri = URI.create(requestedReturnUrl.trim()); + boolean httpScheme = "http".equalsIgnoreCase(uri.getScheme()) + || "https".equalsIgnoreCase(uri.getScheme()); + // 返回地址会展示在三方收银台;只接受无账号信息的绝对 http(s) URL,拒绝 javascript/data + // 等可执行协议和 user@host 混淆地址. + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + uri.isAbsolute() && httpScheme && StringUtils.isNotBlank(uri.getHost()) + && StringUtils.isBlank(uri.getUserInfo())); + return uri.toString(); + } catch (IllegalArgumentException e) { + ResponseAssert.failure(ResponseErrorCode.REQUEST_PARAMETER_ERROR, "returnUrl invalid"); + return defaultReturnUrl; + } + } /** * 获取实时汇率,失败时返回配置的汇率 diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderReconcileService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderReconcileService.java new file mode 100644 index 00000000..34922238 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderReconcileService.java @@ -0,0 +1,66 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.command.pay.web.V5PayOrderSettlementService.SettlementResult; +import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.V5PayOrderData; +import com.red.circle.order.app.common.WebPayProviderSupport; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayNoticeService; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * V5Pay 主动查单补偿;用于 H5 返回后的状态轮询,避免异步回调延迟或丢失造成页面长期 pending. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class V5PayOrderReconcileService { + + private final V5PayService v5PayService; + private final V5PayOrderSettlementService settlementService; + private final OrderUserPurchasePayNoticeService noticeService; + + public void refreshIfPending(OrderUserPurchasePay order) { + if (order == null + || !WebPayProviderSupport.isV5PayFactory(order.getFactoryCode()) + || !Objects.equals(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT, + order.getPayStatus())) { + return; + } + + V5PayOrderData data = v5PayService.queryOrder(order); + safeAddNotice(order, data, "v5pay-query-" + Objects.toString(data.getStatus(), "pending")); + if (data.isSuccessful()) { + SettlementResult result = settlementService.settleSuccess(order.getId(), data); + if (result == SettlementResult.MISMATCH || result == SettlementResult.NOT_FOUND) { + throw new IllegalStateException("V5Pay query result does not match local order"); + } + return; + } + if (data.isFailed()) { + settlementService.markFailed(order.getId(), data); + return; + } + if (!data.isPending()) { + throw new IllegalStateException("V5Pay query returned an unknown status"); + } + } + + private void safeAddNotice(OrderUserPurchasePay order, V5PayOrderData data, String noticeType) { + try { + noticeService.addNotice( + order.getId(), + "v5pay:query:" + order.getId() + ":" + Objects.toString(data.getStatus(), "pending"), + noticeType, + data.getSanitizedPayload(), + order.getUpdateUser()); + } catch (Exception e) { + // 审计明细失败不能阻断主动查单入账;支付主订单仍由行锁和终态保证幂等. + log.error("V5Pay查单通知落库失败: localOrderId={}", order.getId(), e); + } + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementService.java new file mode 100644 index 00000000..82b01168 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementService.java @@ -0,0 +1,77 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.V5PayOrderData; +import com.red.circle.order.app.common.WebPayProviderSupport; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayService; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * V5Pay 订单终态与发币处理;回调和主动查单共用,确保两条路径的校验与幂等语义一致. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class V5PayOrderSettlementService { + + private final V5PayService v5PayService; + private final V5PayPurchaseCreditService purchaseCreditService; + private final OrderUserPurchasePayService orderUserPurchasePayService; + + @Transactional(rollbackFor = Exception.class) + public SettlementResult settleSuccess(Long localOrderId, V5PayOrderData data) { + OrderUserPurchasePay order = orderUserPurchasePayService.getByIdForUpdate(localOrderId); + if (order == null || !WebPayProviderSupport.isV5PayFactory(order.getFactoryCode())) { + return SettlementResult.NOT_FOUND; + } + // 行锁内再次检查终态;并发 callback/query 中只有第一个 PREPAYMENT 请求能执行发币副作用. + if (!Objects.equals(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT, order.getPayStatus())) { + return SettlementResult.ALREADY_FINISHED; + } + if (!data.matches(order, v5PayService.orderPrefix())) { + orderUserPurchasePayService.updateFail(order.getId(), + "V5Pay success data does not match local order snapshot"); + return SettlementResult.MISMATCH; + } + + InAppPurchaseDetails purchase = MiFaPayMysqlOrderSupport.toInAppPurchaseDetails(order); + if (!purchase.receiptTypeEqPayment()) { + orderUserPurchasePayService.updateFail(order.getId(), "V5Pay receipt type is invalid"); + return SettlementResult.MISMATCH; + } + purchaseCreditService.credit(purchase, + () -> orderUserPurchasePayService.updateSuccess(order.getId(), data.getTransactionId())); + log.info("V5Pay订单入账成功: localOrderId={}, transactionId={}", + localOrderId, data.getTransactionId()); + return SettlementResult.SUCCESS; + } + + @Transactional(rollbackFor = Exception.class) + public SettlementResult markFailed(Long localOrderId, V5PayOrderData data) { + OrderUserPurchasePay order = orderUserPurchasePayService.getByIdForUpdate(localOrderId); + if (order == null || !WebPayProviderSupport.isV5PayFactory(order.getFactoryCode())) { + return SettlementResult.NOT_FOUND; + } + if (!Objects.equals(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT, order.getPayStatus())) { + return SettlementResult.ALREADY_FINISHED; + } + orderUserPurchasePayService.updateFail(order.getId(), + "V5Pay payment failed: " + Objects.toString(data.getStatus(), "")); + return SettlementResult.FAILED; + } + + public enum SettlementResult { + SUCCESS, + FAILED, + ALREADY_FINISHED, + MISMATCH, + NOT_FOUND + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditService.java new file mode 100644 index 00000000..9088c695 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditService.java @@ -0,0 +1,12 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails; + +/** + * V5Pay 入账动作边界,便于终态协调器独立验证并发幂等语义. + */ +@FunctionalInterface +public interface V5PayPurchaseCreditService { + + void credit(InAppPurchaseDetails purchase, Runnable successHandler); +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditServiceImpl.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditServiceImpl.java new file mode 100644 index 00000000..169d1646 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayPurchaseCreditServiceImpl.java @@ -0,0 +1,21 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.common.InAppPurchaseCommon; +import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 复用旧 Web Pay 的金币、进货币、统计、佣金及首充处理链路. + */ +@Component +@RequiredArgsConstructor +public class V5PayPurchaseCreditServiceImpl implements V5PayPurchaseCreditService { + + private final InAppPurchaseCommon inAppPurchaseCommon; + + @Override + public void credit(InAppPurchaseDetails purchase, Runnable successHandler) { + inAppPurchaseCommon.inAppPurchasePayment(purchase, successHandler); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExe.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExe.java new file mode 100644 index 00000000..83bfd265 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExe.java @@ -0,0 +1,119 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.command.pay.web.V5PayOrderSettlementService.SettlementResult; +import com.red.circle.order.app.common.V5PayOrderData; +import com.red.circle.order.app.common.V5PayPayload; +import com.red.circle.order.app.common.V5PaySignature; +import com.red.circle.order.app.common.WebPayProviderSupport; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayNoticeService; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayService; +import com.red.circle.tool.core.text.StringUtils; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * V5Pay JSON/form 支付回调处理. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class V5PayServerNoticeReceivePaymentCmdExe { + + public static final String SUCCESS = "success"; + public static final String FAIL = "fail"; + + private final V5PayService v5PayService; + private final V5PayOrderSettlementService settlementService; + private final OrderUserPurchasePayService orderService; + private final OrderUserPurchasePayNoticeService noticeService; + + public String executeJson(byte[] body) { + if (body == null || body.length == 0) { + return FAIL; + } + try { + return execute(V5PayPayload.parseJson(new String(body, StandardCharsets.UTF_8))); + } catch (Exception e) { + log.error("V5Pay JSON回调解析失败: errorType={}", e.getClass().getSimpleName()); + return FAIL; + } + } + + public String executeForm(Map form) { + return execute(form == null ? null : new LinkedHashMap<>(form)); + } + + private String execute(Map fields) { + if (fields == null || fields.isEmpty()) { + return FAIL; + } + try { + Long localOrderId = v5PayService.localOrderId(fields.get("orderNo")); + if (localOrderId == null) { + // 共享商户回调必须拒绝非 YUMI 前缀,防止把 Lalu/Aslan 订单映射到本地同号订单. + log.warn("V5Pay回调订单前缀不属于Yumi"); + return FAIL; + } + OrderUserPurchasePay order = orderService.getById(localOrderId); + if (order == null || !WebPayProviderSupport.isV5PayFactory(order.getFactoryCode())) { + log.warn("V5Pay回调未找到Yumi订单: localOrderId={}", localOrderId); + return FAIL; + } + if (!V5PaySignature.verify(fields, v5PayService.secretKey())) { + // 不记录完整 sign,避免日志成为可复制的供应商凭证材料. + log.warn("V5Pay回调验签失败: localOrderId={}", localOrderId); + return FAIL; + } + + V5PayOrderData data = V5PayOrderData.fromFields(fields); + safeAddNotice(order, data); + if (data.isSuccessful()) { + SettlementResult result = settlementService.settleSuccess(localOrderId, data); + return result == SettlementResult.SUCCESS || result == SettlementResult.ALREADY_FINISHED + ? SUCCESS : FAIL; + } + if (data.isFailed()) { + settlementService.markFailed(localOrderId, data); + return SUCCESS; + } + if (data.isPending()) { + return SUCCESS; + } + log.warn("V5Pay回调状态未知: localOrderId={}, status={}", + localOrderId, data.getStatus()); + return FAIL; + } catch (Exception e) { + log.error("V5Pay回调处理失败: orderNo={}, errorType={}", + maskedOrderNo(fields.get("orderNo")), e.getClass().getSimpleName()); + return FAIL; + } + } + + private void safeAddNotice(OrderUserPurchasePay order, V5PayOrderData data) { + try { + noticeService.addNotice( + order.getId(), + "v5pay:notify:" + order.getId() + ":" + + Objects.toString(data.getTransactionId(), data.getStatus()), + "v5pay-notify-" + Objects.toString(data.getStatus(), "pending"), + data.getSanitizedPayload(), + order.getUpdateUser()); + } catch (Exception e) { + log.error("V5Pay回调通知落库失败: localOrderId={}", order.getId(), e); + } + } + + private String maskedOrderNo(String orderNo) { + if (StringUtils.isBlank(orderNo) || orderNo.length() <= 6) { + return "***"; + } + return orderNo.substring(0, 5) + "***"; + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderService.java index 465d6a4f..857da66e 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderService.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderService.java @@ -4,6 +4,7 @@ import com.red.circle.component.pay.paymax.PayMaxUtils; import com.red.circle.framework.core.request.RequestClientEnum; import com.red.circle.framework.web.props.EnvProperties; import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.WebPayProviderSupport; import com.red.circle.order.app.common.PayerMaxProperties; import com.red.circle.order.app.common.QuoteQueryParam; import com.red.circle.order.app.common.QuoteQueryResponse; @@ -53,7 +54,7 @@ public class PayPlaceAnOrderService { param.getStrategyCode()) .doOperation(param); - if (MiFaPayMysqlOrderSupport.isMiFaPay(param.getFactoryCode())) { + if (WebPayProviderSupport.isMysqlWebPayFactory(param.getFactoryCode())) { Long purchasePayId = MiFaPayMysqlOrderSupport.parseOrderId(param.getOrderId()); orderUserPurchasePayService.save( MiFaPayMysqlOrderSupport.buildPrepaymentOrder(param, response, env())); diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/V5PayWebPayPlaceAnOrderStrategy.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/V5PayWebPayPlaceAnOrderStrategy.java new file mode 100644 index 00000000..3f3b9c13 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/V5PayWebPayPlaceAnOrderStrategy.java @@ -0,0 +1,60 @@ +package com.red.circle.order.app.command.pay.web.strategy; + +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.app.service.V5PayService.CreateOrderResult; +import com.red.circle.order.inner.asserts.OrderErrorCode; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * V5Pay H5 收银台下单策略. + */ +@Slf4j +@Component("V5_PAY_PLACE_AN_ORDER") +@RequiredArgsConstructor +public class V5PayWebPayPlaceAnOrderStrategy implements PayWebPlaceAnOrderStrategyV2 { + + private final V5PayService v5PayService; + + @Override + public PlaceAnOrderResponseCO doOperation(PayPlaceAnOrderDetailsV2 details) { + CreateOrderResult result; + try { + result = v5PayService.createOrder(details); + } catch (Exception e) { + // 日志只记录本地订单和错误类型,禁止输出带 sign、appKey 或 secretKey 的完整请求体. + log.error("V5Pay订单创建失败: orderId={}, factoryChannel={}, errorType={}", + details.getOrderId(), details.getFactoryChannel(), e.getClass().getSimpleName()); + ResponseAssert.failure(OrderErrorCode.CREATE_ORDER_ERROR, "V5Pay order creation failed"); + return null; + } + ResponseAssert.notNull(OrderErrorCode.CREATE_ORDER_ERROR, result); + + return new PlaceAnOrderResponseCO() + .setTradeNo(result.getTransactionId()) + // H5 和本地状态查询始终使用无前缀本地单号,三方 YUMI- 前缀只存在于 V5Pay 协议边界. + .setOrderId(details.getOrderId()) + .setCurrency(details.getCurrency()) + .setCountryCode(details.getCountryCode()) + .setFactoryCode(details.getFactoryCode()) + .setFactoryChannelCode(details.getFactoryChannel()) + .setRequestUrl(result.getCheckoutUrl()) + .putRequestParam(safeRequestSummary(details)) + .putResponseParam(result.getSanitizedResponse()); + } + + private Map safeRequestSummary(PayPlaceAnOrderDetailsV2 details) { + Map summary = new LinkedHashMap<>(); + summary.put("localOrderId", details.getOrderId()); + summary.put("countryCode", details.getCountryCode()); + summary.put("currency", details.getCurrency()); + summary.put("amount", details.getAmount().toPlainString()); + summary.put("productType", details.getFactoryChannel()); + return summary; + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderData.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderData.java new file mode 100644 index 00000000..4ebed503 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderData.java @@ -0,0 +1,101 @@ +package com.red.circle.order.app.common; + +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.tool.core.text.StringUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Map; +import java.util.Objects; +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * V5Pay 回调或主动查单返回的统一订单字段. + */ +@Data +@Accessors(chain = true) +public class V5PayOrderData { + + private String orderNo; + private String transactionId; + private String status; + private String amount; + private String currency; + private String productType; + private Map sanitizedPayload; + + public static V5PayOrderData fromFields(Map fields) { + return new V5PayOrderData() + .setOrderNo(fields.get("orderNo")) + .setTransactionId(fields.get("transactionId")) + .setStatus(fields.get("status")) + .setAmount(fields.get("amount")) + .setCurrency(fields.get("currency")) + .setProductType(fields.get("productType")) + .setSanitizedPayload(V5PayPayload.sanitized(fields)); + } + + public boolean isSuccessful() { + return Objects.equals("2", normalizedStatus()); + } + + public boolean isFailed() { + String value = normalizedStatus(); + return Objects.equals("3", value) || Objects.equals("5", value) + || Objects.equals("6", value); + } + + public boolean isPending() { + String value = normalizedStatus(); + return StringUtils.isBlank(value) || Objects.equals("0", value) || Objects.equals("1", value); + } + + /** + * 成功入账前必须使用本地订单快照做四字段强校验;任一字段缺失也视为不匹配, + * 不能因为上游省略字段而放宽金额或产品校验. + */ + public boolean matches(OrderUserPurchasePay order, String orderPrefix) { + if (order == null + || StringUtils.isBlank(orderNo) + || StringUtils.isBlank(amount) + || StringUtils.isBlank(currency) + || StringUtils.isBlank(productType)) { + return false; + } + Long parsedOrderId = V5PayOrderNumber.localOrderId(orderPrefix, orderNo); + Long providerMinor = parseAmountMinor(amount); + Long localMinor = toMinor(order.getPaymentAmount()); + return Objects.equals(order.getId(), parsedOrderId) + && Objects.equals(localMinor, providerMinor) + && currency.trim().equalsIgnoreCase(Objects.toString(order.getPaymentUnit(), "").trim()) + && productType.trim().equalsIgnoreCase( + Objects.toString(order.getFactoryChannel(), "").trim()); + } + + public static Long parseAmountMinor(String value) { + if (StringUtils.isBlank(value)) { + return null; + } + try { + BigDecimal amount = new BigDecimal(value.trim()).setScale(2, RoundingMode.UNNECESSARY); + return amount.movePointRight(2).longValueExact(); + } catch (ArithmeticException | NumberFormatException ignored) { + return null; + } + } + + private static Long toMinor(BigDecimal value) { + if (value == null) { + return null; + } + try { + return value.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).longValueExact(); + } catch (ArithmeticException ignored) { + return null; + } + } + + private String normalizedStatus() { + return Objects.toString(status, "").trim(); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNumber.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNumber.java new file mode 100644 index 00000000..4b3b780a --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNumber.java @@ -0,0 +1,44 @@ +package com.red.circle.order.app.common; + +import com.red.circle.tool.core.text.StringUtils; +import java.util.Objects; + +/** + * V5Pay 共享商户下的订单号命名空间隔离. + */ +public final class V5PayOrderNumber { + + private V5PayOrderNumber() { + } + + public static String providerOrderNo(String prefix, Long localOrderId) { + if (localOrderId == null) { + throw new IllegalArgumentException("V5Pay local order id is missing"); + } + return normalizedPrefix(prefix) + "-" + localOrderId; + } + + /** + * 共享 V5Pay 商户可能同时收到 Lalu/Aslan/Yumi 回调,必须先校验命名空间前缀再解析本地 ID; + * 不能直接截取最后一段数字,否则其他系统订单可能被错误入账到 Yumi. + */ + public static Long localOrderId(String prefix, String providerOrderNo) { + String expectedPrefix = normalizedPrefix(prefix) + "-"; + if (StringUtils.isBlank(providerOrderNo) || !providerOrderNo.startsWith(expectedPrefix)) { + return null; + } + String local = providerOrderNo.substring(expectedPrefix.length()); + if (StringUtils.isBlank(local) || local.contains("-")) { + return null; + } + return MiFaPayMysqlOrderSupport.parseOrderId(local); + } + + private static String normalizedPrefix(String prefix) { + String normalized = Objects.toString(prefix, "").trim(); + if (StringUtils.isBlank(normalized) || normalized.contains("-")) { + throw new IllegalArgumentException("V5Pay order prefix is invalid"); + } + return normalized; + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPayload.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPayload.java new file mode 100644 index 00000000..6599aab1 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPayload.java @@ -0,0 +1,65 @@ +package com.red.circle.order.app.common; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * V5Pay 扁平 JSON / form 字段处理工具. + */ +public final class V5PayPayload { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private V5PayPayload() { + } + + /** + * 流式读取数字 token,保留供应商原始数字文本,避免 14400.00 被 Double 归一成 14400.0 + * 后导致签名串变化;V5Pay 当前协议是扁平对象,复杂值仅作为原始 JSON 文本保留. + */ + public static Map parseJson(String body) throws IOException { + Map fields = new LinkedHashMap<>(); + try (JsonParser parser = OBJECT_MAPPER.getFactory().createParser(body)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw new IOException("V5Pay payload must be a JSON object"); + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.currentToken() != JsonToken.FIELD_NAME) { + throw new IOException("V5Pay payload contains an invalid field"); + } + String name = parser.currentName(); + JsonToken valueToken = parser.nextToken(); + if (valueToken == JsonToken.VALUE_NULL) { + fields.put(name, ""); + } else if (valueToken.isScalarValue()) { + fields.put(name, parser.getText()); + } else { + fields.put(name, OBJECT_MAPPER.readTree(parser).toString()); + } + } + } + return fields; + } + + /** + * 通知与查单审计数据不能持久化完整 sign、appKey 或商户凭证,避免订单明细成为凭证副本. + */ + public static Map sanitized(Map fields) { + Map result = new LinkedHashMap<>(); + if (fields != null) { + fields.forEach((key, value) -> { + if (!"sign".equalsIgnoreCase(key) + && !"appKey".equalsIgnoreCase(key) + && !"merchantNo".equalsIgnoreCase(key) + && !"secretKey".equalsIgnoreCase(key)) { + result.put(key, value); + } + }); + } + return result; + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignature.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignature.java new file mode 100644 index 00000000..69674e7f --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignature.java @@ -0,0 +1,59 @@ +package com.red.circle.order.app.common; + +import com.red.circle.tool.core.text.StringUtils; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * V5Pay MD5 签名工具. + */ +public final class V5PaySignature { + + private V5PaySignature() { + } + + /** + * V5Pay 规定:排除 sign 和空值,按 key 升序拼成 key=value&...,末尾直接追加 secretKey, + * 然后计算小写 MD5;secretKey 前不能额外补 &,否则网关会判定验签失败. + */ + public static String sign(Map fields, String secretKey) { + if (fields == null || StringUtils.isBlank(secretKey)) { + throw new IllegalArgumentException("V5Pay signing fields or secret key is missing"); + } + String plainText = fields.entrySet().stream() + .filter(entry -> !Objects.equals("sign", entry.getKey())) + .filter(entry -> StringUtils.isNotBlank(entry.getValue())) + .sorted(Map.Entry.comparingByKey(Comparator.naturalOrder())) + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining("&")) + secretKey; + return md5Hex(plainText); + } + + public static boolean verify(Map fields, String secretKey) { + if (fields == null || StringUtils.isBlank(fields.get("sign"))) { + return false; + } + return MessageDigest.isEqual( + sign(fields, secretKey).getBytes(StandardCharsets.US_ASCII), + fields.get("sign").trim().toLowerCase().getBytes(StandardCharsets.US_ASCII) + ); + } + + private static String md5Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(bytes.length * 2); + for (byte item : bytes) { + hex.append(String.format("%02x", item & 0xff)); + } + return hex.toString(); + } catch (Exception e) { + throw new IllegalStateException("MD5 is unavailable", e); + } + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/WebPayProviderSupport.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/WebPayProviderSupport.java new file mode 100644 index 00000000..1fe7a033 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/WebPayProviderSupport.java @@ -0,0 +1,57 @@ +package com.red.circle.order.app.common; + +import com.red.circle.tool.core.text.StringUtils; +import java.util.Locale; +import java.util.Objects; + +/** + * H5 provider code 与旧订单 factory code 的唯一映射入口. + */ +public final class WebPayProviderSupport { + + public static final String PROVIDER_MIFAPAY = "mifapay"; + public static final String PROVIDER_V5PAY = "v5pay"; + public static final String FACTORY_MIFAPAY = MiFaPayMysqlOrderSupport.FACTORY_CODE; + public static final String FACTORY_V5PAY = "V5_PAY"; + + private WebPayProviderSupport() { + } + + /** + * providerCode 为空表示旧客户端,继续走原有的渠道评分选择;非空但未知时返回 null, + * 调用方必须拒绝请求,不能悄悄降级到另一个收款厂商. + */ + public static String toFactoryCode(String providerCode) { + String normalized = normalizeProviderCode(providerCode); + if (StringUtils.isBlank(normalized)) { + return ""; + } + if (Objects.equals(PROVIDER_MIFAPAY, normalized)) { + return FACTORY_MIFAPAY; + } + if (Objects.equals(PROVIDER_V5PAY, normalized)) { + return FACTORY_V5PAY; + } + return null; + } + + public static String normalizeProviderCode(String providerCode) { + return StringUtils.isBlank(providerCode) + ? "" : providerCode.trim().toLowerCase(Locale.ROOT); + } + + public static boolean isV5PayFactory(String factoryCode) { + return Objects.equals(FACTORY_V5PAY, normalizeFactoryCode(factoryCode)); + } + + public static boolean isMysqlWebPayFactory(String factoryCode) { + String normalized = normalizeFactoryCode(factoryCode); + return Objects.equals(FACTORY_MIFAPAY, normalized) + || Objects.equals(FACTORY_V5PAY, normalized); + } + + private static String normalizeFactoryCode(String factoryCode) { + return StringUtils.isBlank(factoryCode) + ? "" : factoryCode.trim().toUpperCase(Locale.ROOT); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductServiceImpl.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductServiceImpl.java index 48661f57..18d011b0 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductServiceImpl.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductServiceImpl.java @@ -29,7 +29,8 @@ import com.red.circle.order.app.command.pay.web.PayWebPlaceAnOrderCmdExe; import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeReceivePaymentCmdExe; import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe; import com.red.circle.order.app.command.pay.web.PaynicornServerNoticeCmdExe; -import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe; +import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe; +import com.red.circle.order.app.command.pay.web.V5PayServerNoticeReceivePaymentCmdExe; import com.red.circle.order.app.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe; import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO; import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO; @@ -74,7 +75,8 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ private final ReceiptPayWebPlaceAnOrderReceiptCmdExe receiptPayWebPlaceAnOrderReceiptCmdExe; private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe; private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe; - private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe; + private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe; + private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe; private final TaskMqMessage taskMqMessage; @Override @@ -189,8 +191,18 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ } @Override - public String miFaPayServerNoticeReceivePayment(byte[] body) { - return miFaPayServerNoticeReceivePaymentCmdExe.execute(body); - } - -} + public String miFaPayServerNoticeReceivePayment(byte[] body) { + return miFaPayServerNoticeReceivePaymentCmdExe.execute(body); + } + + @Override + public String v5PayServerNoticeReceivePayment(byte[] body) { + return v5PayServerNoticeReceivePaymentCmdExe.executeJson(body); + } + + @Override + public String v5PayServerNoticeReceivePayment(Map body) { + return v5PayServerNoticeReceivePaymentCmdExe.executeForm(body); + } + +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayProperties.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayProperties.java new file mode 100644 index 00000000..4e7f5edf --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayProperties.java @@ -0,0 +1,51 @@ +package com.red.circle.order.app.service; + +import com.red.circle.tool.core.text.StringUtils; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * V5Pay 环境配置;功能默认开启,但商户号、appKey、secretKey 与地址必须由运行环境注入. + */ +@Data +@Component +@ConfigurationProperties(prefix = "red-circle.pay.v5-pay") +public class V5PayProperties { + + private Boolean enabled = Boolean.TRUE; + private String merchantNo; + private String appKey; + private String secretKey; + private String gatewayBaseUrl; + private String notifyUrl; + private String returnUrl; + private String orderPrefix = "YUMI"; + + public void requireCreateReady() { + requireQueryReady(); + require("notifyUrl", notifyUrl); + require("returnUrl", returnUrl); + } + + public void requireQueryReady() { + requireSignatureReady(); + require("merchantNo", merchantNo); + require("appKey", appKey); + require("gatewayBaseUrl", gatewayBaseUrl); + } + + public void requireSignatureReady() { + if (!Boolean.TRUE.equals(enabled)) { + throw new IllegalStateException("V5Pay is disabled"); + } + require("secretKey", secretKey); + require("orderPrefix", orderPrefix); + } + + private void require(String fieldName, String value) { + if (StringUtils.isBlank(value)) { + throw new IllegalStateException("V5Pay config missing: " + fieldName); + } + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayService.java new file mode 100644 index 00000000..dabbc4fb --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayService.java @@ -0,0 +1,163 @@ +package com.red.circle.order.app.service; + +import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2; +import com.red.circle.order.app.common.V5PayOrderData; +import com.red.circle.order.app.common.V5PayOrderNumber; +import com.red.circle.order.app.common.V5PayPayload; +import com.red.circle.order.app.common.V5PaySignature; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.util.MemberInfoGenerator; +import com.red.circle.tool.core.http.RcHttpClient; +import com.red.circle.tool.core.json.JacksonUtils; +import com.red.circle.tool.core.text.StringUtils; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.springframework.stereotype.Service; + +/** + * V5Pay 下单与主动查单客户端. + */ +@Service +@RequiredArgsConstructor +public class V5PayService { + + private static final String CREATE_ORDER_PATH = "/cgi/cashier/v2/payin"; + private static final String QUERY_ORDER_PATH = "/cgi/payment/v1/payin/query"; + + private final V5PayProperties properties; + private final RcHttpClient rcHttpClient = RcHttpClient.builder().build(); + + public CreateOrderResult createOrder(PayPlaceAnOrderDetailsV2 details) { + properties.requireCreateReady(); + MemberInfoGenerator.MemberInfo member = MemberInfoGenerator.generate( + Objects.toString(details.getAcceptUserId())); + Map fields = baseFields(); + fields.put("sysCountryCode", upper(details.getCountryCode())); + fields.put("currency", upper(details.getCurrency())); + String providerOrderNo = V5PayOrderNumber.providerOrderNo( + properties.getOrderPrefix(), Long.valueOf(details.getOrderId())); + fields.put("orderNo", providerOrderNo); + fields.put("amount", details.getAmount().setScale(2, RoundingMode.UNNECESSARY).toPlainString()); + fields.put("productType", details.getFactoryChannel()); + fields.put("callbackUrl", properties.getNotifyUrl().trim()); + fields.put("redirectUrl", firstNotBlank(details.getSuccessRedirectUrl(), properties.getReturnUrl())); + fields.put("merchantParam", details.getOrderId()); + fields.put("language", "en-US"); + fields.put("tradeSummary", Objects.toString(details.getProductDescriptor(), "coin package")); + fields.put("email", member.getEmail()); + fields.put("merchantCustomerId", Objects.toString(details.getAcceptUserId())); + + Map response = postSigned(CREATE_ORDER_PATH, fields); + String checkoutUrl = response.get("checkoutUrl"); + if (StringUtils.isBlank(checkoutUrl)) { + throw new IllegalStateException("V5Pay order response is incomplete"); + } + return new CreateOrderResult() + .setOrderNo(firstNotBlank(response.get("orderNo"), providerOrderNo)) + .setTransactionId(firstNotBlank(response.get("transactionId"), providerOrderNo)) + .setCheckoutUrl(checkoutUrl) + .setSanitizedResponse(V5PayPayload.sanitized(response)); + } + + public V5PayOrderData queryOrder(OrderUserPurchasePay order) { + properties.requireQueryReady(); + Map fields = baseFields(); + fields.put("sysCountryCode", upper(order.getCountryCode())); + fields.put("currency", upper(order.getPaymentUnit())); + fields.put("orderNo", V5PayOrderNumber.providerOrderNo(properties.getOrderPrefix(), order.getId())); + return V5PayOrderData.fromFields(postSigned(QUERY_ORDER_PATH, fields)); + } + + public Long localOrderId(String providerOrderNo) { + return V5PayOrderNumber.localOrderId(properties.getOrderPrefix(), providerOrderNo); + } + + public String orderPrefix() { + return properties.getOrderPrefix(); + } + + public String secretKey() { + // 回调验签只依赖 enabled/secretKey/orderPrefix;不能因返回页等无关配置临时缺失而拒绝已付款订单. + properties.requireSignatureReady(); + return properties.getSecretKey(); + } + + private Map postSigned(String path, Map unsignedFields) { + Map request = new LinkedHashMap<>(unsignedFields); + request.put("sign", V5PaySignature.sign(request, properties.getSecretKey())); + String rawResponse = rcHttpClient.post() + .uri(joinUrl(properties.getGatewayBaseUrl(), path)) + .contentType("application/json") + .bodyValue(JacksonUtils.toJson(request)) + .retrieve() + .bodyToEntity(String.class) + .block(); + if (StringUtils.isBlank(rawResponse)) { + throw new IllegalStateException("V5Pay returned an empty response"); + } + try { + Map response = V5PayPayload.parseJson(rawResponse); + verifyResponse(response); + return response; + } catch (Exception e) { + throw new IllegalStateException("V5Pay upstream response is invalid", e); + } + } + + private void verifyResponse(Map response) { + String code = Objects.toString(response.get("code"), "").trim(); + if (StringUtils.isNotBlank(code) && !Objects.equals("1000", code)) { + throw new IllegalStateException("V5Pay request rejected: " + code + ": " + + Objects.toString(response.get("message"), "")); + } + if (!V5PaySignature.verify(response, properties.getSecretKey())) { + throw new IllegalStateException("V5Pay response signature verification failed"); + } + if (StringUtils.isNotBlank(response.get("merchantNo")) + && !Objects.equals(properties.getMerchantNo(), response.get("merchantNo"))) { + throw new IllegalStateException("V5Pay response merchantNo mismatch"); + } + if (StringUtils.isNotBlank(response.get("appKey")) + && !Objects.equals(properties.getAppKey(), response.get("appKey"))) { + throw new IllegalStateException("V5Pay response appKey mismatch"); + } + } + + private Map baseFields() { + Map fields = new LinkedHashMap<>(); + fields.put("merchantNo", properties.getMerchantNo().trim()); + fields.put("appKey", properties.getAppKey().trim()); + return fields; + } + + private static String joinUrl(String baseUrl, String path) { + String normalized = Objects.toString(baseUrl, "").trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized + path; + } + + private static String upper(String value) { + return Objects.toString(value, "").trim().toUpperCase(); + } + + private static String firstNotBlank(String first, String second) { + return StringUtils.isNotBlank(first) ? first.trim() : Objects.toString(second, "").trim(); + } + + @Data + @Accessors(chain = true) + public static class CreateOrderResult { + + private String orderNo; + private String transactionId; + private String checkoutUrl; + private Map sanitizedResponse; + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementServiceTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementServiceTest.java new file mode 100644 index 00000000..738f0b3f --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderSettlementServiceTest.java @@ -0,0 +1,132 @@ +package com.red.circle.order.app.command.pay.web; + +import static org.junit.Assert.assertTrue; + +import com.red.circle.order.app.command.pay.web.V5PayOrderSettlementService.SettlementResult; +import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.V5PayOrderData; +import com.red.circle.order.app.common.WebPayProviderSupport; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.app.service.V5PayProperties; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayService; +import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseReceiptType; +import java.math.BigDecimal; +import java.lang.reflect.Proxy; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class V5PayOrderSettlementServiceTest { + + @Test + public void concurrentCallbackAndQueryCreditOnlyOnce() throws Exception { + V5PayProperties properties = new V5PayProperties(); + properties.setOrderPrefix("YUMI"); + V5PayService v5PayService = new V5PayService(properties); + OrderUserPurchasePay sharedOrder = order(); + CountDownLatch credited = new CountDownLatch(1); + AtomicInteger lockCalls = new AtomicInteger(); + AtomicInteger creditCalls = new AtomicInteger(); + AtomicInteger successUpdates = new AtomicInteger(); + + V5PayPurchaseCreditService creditService = (purchase, successHandler) -> { + creditCalls.incrementAndGet(); + successHandler.run(); + }; + OrderUserPurchasePayService lockingOrderService = (OrderUserPurchasePayService) Proxy + .newProxyInstance( + OrderUserPurchasePayService.class.getClassLoader(), + new Class[]{OrderUserPurchasePayService.class}, + (proxy, method, args) -> { + if (method.getName().equals("getByIdForUpdate")) { + // 模拟 MySQL SELECT ... FOR UPDATE:第二个实例必须等第一个事务提交终态后才能读. + if (lockCalls.incrementAndGet() > 1) { + assertTrue(credited.await(2, TimeUnit.SECONDS)); + } + return sharedOrder; + } + if (method.getName().equals("updateSuccess") && args.length == 2) { + successUpdates.incrementAndGet(); + sharedOrder.setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_SUCCESSFUL); + credited.countDown(); + return null; + } + return defaultValue(method.getReturnType()); + }); + V5PayOrderSettlementService service = new V5PayOrderSettlementService( + v5PayService, creditService, lockingOrderService); + + ExecutorService pool = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + Future callback = pool.submit(() -> { + start.await(); + return service.settleSuccess(42L, data()); + }); + Future query = pool.submit(() -> { + start.await(); + return service.settleSuccess(42L, data()); + }); + start.countDown(); + + List results = List.of(callback.get(3, TimeUnit.SECONDS), + query.get(3, TimeUnit.SECONDS)); + pool.shutdownNow(); + assertTrue(results.contains(SettlementResult.SUCCESS)); + assertTrue(results.contains(SettlementResult.ALREADY_FINISHED)); + assertTrue(creditCalls.get() == 1); + assertTrue(successUpdates.get() == 1); + } + + private Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + return 0; + } + + private OrderUserPurchasePay order() { + return new OrderUserPurchasePay() + .setId(42L) + .setEvn("TEST") + .setSysOrigin("LIKEI") + .setUserId(100L) + .setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY) + .setFactoryOrderId("YUMI-42") + .setReferenceId("2048200000000000701") + .setReceiptType(InAppPurchaseReceiptType.PAYMENT.name()) + .setPaymentChannel("UPI_UPI") + .setFactoryChannel("UPI") + .setProductCode("GOLD") + .setProductContent("100") + .setProductDescriptor("10USD = 100GOLD") + .setPaymentUnit("USD") + .setPaymentAmount(new BigDecimal("10")) + .setComputeUsdAmount(new BigDecimal("10")) + .setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT) + .setReceiptDetailsId(1L); + } + + private V5PayOrderData data() { + return new V5PayOrderData() + .setOrderNo("YUMI-42") + .setTransactionId("tx-42") + .setStatus("2") + .setAmount("10.00") + .setCurrency("USD") + .setProductType("UPI") + .setSanitizedPayload(Map.of("orderNo", "YUMI-42", "status", "2")); + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExeTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExeTest.java new file mode 100644 index 00000000..dfeed761 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayServerNoticeReceivePaymentCmdExeTest.java @@ -0,0 +1,132 @@ +package com.red.circle.order.app.command.pay.web; + +import static org.junit.Assert.assertEquals; + +import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport; +import com.red.circle.order.app.common.V5PaySignature; +import com.red.circle.order.app.common.WebPayProviderSupport; +import com.red.circle.order.app.service.V5PayProperties; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayNoticeService; +import com.red.circle.order.infra.database.rds.service.order.OrderUserPurchasePayService; +import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseReceiptType; +import java.lang.reflect.Proxy; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +public class V5PayServerNoticeReceivePaymentCmdExeTest { + + @Test + public void acceptsSignedJsonWithExactDecimalAndReturnsLowercaseSuccess() { + Fixture fixture = fixture(); + Map fields = successFields(); + String json = "{\"orderNo\":\"YUMI-42\",\"transactionId\":\"tx-42\"," + + "\"status\":2,\"amount\":10.00,\"currency\":\"USD\"," + + "\"productType\":\"UPI\",\"sign\":\"" + fields.get("sign") + "\"}"; + + assertEquals("success", fixture.executor.executeJson(json.getBytes(StandardCharsets.UTF_8))); + assertEquals(MiFaPayMysqlOrderSupport.PAY_STATUS_SUCCESSFUL, + fixture.order.get().getPayStatus()); + } + + @Test + public void acceptsFormButRejectsAnotherSystemsOrderPrefix() { + Fixture fixture = fixture(); + assertEquals("success", fixture.executor.executeForm(successFields())); + + fixture.order.get().setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT); + Map wrongPrefix = successFields(); + wrongPrefix.put("orderNo", "LALU-42"); + wrongPrefix.put("sign", V5PaySignature.sign(wrongPrefix, "secret")); + assertEquals("fail", fixture.executor.executeForm(wrongPrefix)); + } + + private Fixture fixture() { + V5PayProperties properties = new V5PayProperties(); + properties.setSecretKey("secret"); + properties.setOrderPrefix("YUMI"); + V5PayService v5PayService = new V5PayService(properties); + AtomicReference order = new AtomicReference<>(order()); + OrderUserPurchasePayService orderService = (OrderUserPurchasePayService) Proxy.newProxyInstance( + OrderUserPurchasePayService.class.getClassLoader(), + new Class[]{OrderUserPurchasePayService.class}, + (proxy, method, args) -> { + if (method.getName().equals("getById") || method.getName().equals("getByIdForUpdate")) { + return order.get(); + } + if (method.getName().equals("updateSuccess") && args.length == 2) { + order.get().setFactoryOrderId((String) args[1]); + order.get().setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_SUCCESSFUL); + return null; + } + return defaultValue(method.getReturnType()); + }); + OrderUserPurchasePayNoticeService noticeService = + (OrderUserPurchasePayNoticeService) Proxy.newProxyInstance( + OrderUserPurchasePayNoticeService.class.getClassLoader(), + new Class[]{OrderUserPurchasePayNoticeService.class}, + (proxy, method, args) -> defaultValue(method.getReturnType())); + V5PayOrderSettlementService settlement = new V5PayOrderSettlementService( + v5PayService, (purchase, successHandler) -> successHandler.run(), orderService); + V5PayServerNoticeReceivePaymentCmdExe executor = + new V5PayServerNoticeReceivePaymentCmdExe( + v5PayService, settlement, orderService, noticeService); + return new Fixture(executor, order); + } + + private Map successFields() { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", "YUMI-42"); + fields.put("transactionId", "tx-42"); + fields.put("status", "2"); + fields.put("amount", "10.00"); + fields.put("currency", "USD"); + fields.put("productType", "UPI"); + fields.put("sign", V5PaySignature.sign(fields, "secret")); + return fields; + } + + private OrderUserPurchasePay order() { + return new OrderUserPurchasePay() + .setId(42L) + .setEvn("TEST") + .setSysOrigin("LIKEI") + .setUserId(100L) + .setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY) + .setFactoryOrderId("YUMI-42") + .setReferenceId("2048200000000000701") + .setReceiptType(InAppPurchaseReceiptType.PAYMENT.name()) + .setPaymentChannel("UPI") + .setFactoryChannel("UPI") + .setProductCode("GOLD") + .setProductContent("100") + .setProductDescriptor("10USD = 100GOLD") + .setPaymentUnit("USD") + .setPaymentAmount(new BigDecimal("10")) + .setComputeUsdAmount(new BigDecimal("10")) + .setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT) + .setReceiptDetailsId(1L); + } + + private static Object defaultValue(Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return false; + } + if (type == char.class) { + return '\0'; + } + return 0; + } + + private record Fixture(V5PayServerNoticeReceivePaymentCmdExe executor, + AtomicReference order) { + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PayOrderDataTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PayOrderDataTest.java new file mode 100644 index 00000000..4c9681c4 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PayOrderDataTest.java @@ -0,0 +1,55 @@ +package com.red.circle.order.app.common; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; + +public class V5PayOrderDataTest { + + @Test + public void successDataMustMatchPrefixedOrderAmountCurrencyAndProductType() { + OrderUserPurchasePay order = order(); + V5PayOrderData data = data("YUMI-42", "10.00", "usd", "UPI"); + + assertTrue(data.matches(order, "YUMI")); + assertFalse(data("LALU-42", "10.00", "USD", "UPI").matches(order, "YUMI")); + assertFalse(data("YUMI-42", "10.01", "USD", "UPI").matches(order, "YUMI")); + assertFalse(data("YUMI-42", "10.00", "INR", "UPI").matches(order, "YUMI")); + assertFalse(data("YUMI-42", "10.00", "USD", "BANK_ONLINE").matches(order, "YUMI")); + } + + @Test + public void classifiesOnlyDocumentedV5PayStatuses() { + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("2").isSuccessful()); + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("3").isFailed()); + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("5").isFailed()); + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("6").isFailed()); + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("0").isPending()); + assertTrue(data("YUMI-42", "10.00", "USD", "UPI").setStatus("1").isPending()); + } + + private OrderUserPurchasePay order() { + return new OrderUserPurchasePay() + .setId(42L) + .setPaymentAmount(new BigDecimal("10")) + .setPaymentUnit("USD") + .setFactoryChannel("UPI"); + } + + private V5PayOrderData data(String orderNo, String amount, String currency, + String productType) { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", orderNo); + fields.put("amount", amount); + fields.put("currency", currency); + fields.put("productType", productType); + fields.put("status", "2"); + fields.put("transactionId", "tx-1"); + return V5PayOrderData.fromFields(fields); + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignatureTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignatureTest.java new file mode 100644 index 00000000..52885f96 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignatureTest.java @@ -0,0 +1,53 @@ +package com.red.circle.order.app.common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Test; + +public class V5PaySignatureTest { + + @Test + public void signsSortedNonEmptyFieldsAndAppendsSecretWithoutSeparator() { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", "YUMI-123"); + fields.put("currency", "USD"); + fields.put("merchantNo", "merchant"); + fields.put("empty", ""); + fields.put("appKey", "app"); + fields.put("amount", "100.00"); + fields.put("sign", "must-be-excluded"); + + assertEquals("40e7e9f5fe267470a4448991b99e7bdd", + V5PaySignature.sign(fields, "secret")); + } + + @Test + public void verifiesOnlyWhenSignIsPresentAndMatches() { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", "YUMI-123"); + fields.put("status", "2"); + fields.put("sign", V5PaySignature.sign(fields, "secret")); + + assertTrue(V5PaySignature.verify(fields, "secret")); + assertFalse(V5PaySignature.verify(fields, "wrong-secret")); + fields.remove("sign"); + assertFalse(V5PaySignature.verify(fields, "secret")); + } + + @Test + public void jsonParserPreservesProviderAmountTextAndRedactsCredentials() throws Exception { + Map fields = V5PayPayload.parseJson( + "{\"amount\":14400.00,\"status\":2,\"sign\":\"abc\",\"appKey\":\"key\"," + + "\"merchantNo\":\"merchant\"}"); + + assertEquals("14400.00", fields.get("amount")); + assertEquals("2", fields.get("status")); + assertFalse(V5PayPayload.sanitized(fields).containsKey("sign")); + assertFalse(V5PayPayload.sanitized(fields).containsKey("appKey")); + assertFalse(V5PayPayload.sanitized(fields).containsKey("merchantNo")); + } +} diff --git a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayOrderStatusCmd.java b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayOrderStatusCmd.java index 0c63c1ec..68b6016b 100644 --- a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayOrderStatusCmd.java +++ b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayOrderStatusCmd.java @@ -28,4 +28,24 @@ public class PayOrderStatusCmd extends AppExtCommand { * H5公开支付页没有登录态,轮询状态时必须用下单用户ID继续做订单归属校验. */ private Long userId; + + /** + * H5 可回传提供商编码;非空时必须与订单快照一致,避免跨提供商猜单. + */ + private String providerCode; + + /** + * 与创建单共用的 H5 兼容字段;非空时用于核对订单渠道快照. + */ + private String payWay; + + /** + * 与创建单共用的 H5 兼容字段;非空时必须与订单 productType 快照一致. + */ + private String payType; + + /** + * 状态查询不会发生跳转,但保留该字段以兼容 H5 的统一订单参数结构. + */ + private String returnUrl; } diff --git a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayPlaceAnOrderCmd.java b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayPlaceAnOrderCmd.java index 8c8766b4..d41d6f37 100644 --- a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayPlaceAnOrderCmd.java +++ b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/dto/cmd/PayPlaceAnOrderCmd.java @@ -56,8 +56,28 @@ public class PayPlaceAnOrderCmd implements Serializable { * * @eo.required */ - @NotBlank(message = "channelCode required.") - private String channelCode; + @NotBlank(message = "channelCode required.") + private String channelCode; + + /** + * H5 三方支付提供商编码,例如 mifapay / v5pay;为空时保持旧版按渠道评分选厂商的行为. + */ + private String providerCode; + + /** + * H5 展示层支付大类,例如 BankTransfer / Ewallet / QR. + */ + private String payWay; + + /** + * 厂商支付产品类型;V5Pay 会把该值原样作为 productType 下单并写入订单快照. + */ + private String payType; + + /** + * 三方收银台完成后的 H5 返回地址;服务端只接受绝对 http(s) 地址. + */ + private String returnUrl; /** * 版本信息. @@ -71,6 +91,6 @@ public class PayPlaceAnOrderCmd implements Serializable { /** * 请求ip信息. */ - private String requestIp; - -} + private String requestIp; + +} diff --git a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductService.java b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductService.java index dfcf4f93..78fce353 100644 --- a/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductService.java +++ b/rc-service/rc-service-order/order-client/src/main/java/com/red/circle/order/app/service/InAppPurchaseProductService.java @@ -56,6 +56,16 @@ public interface InAppPurchaseProductService { /** * MiFaPay支付回调通知 */ - String miFaPayServerNoticeReceivePayment(byte[] body); - -} + String miFaPayServerNoticeReceivePayment(byte[] body); + + /** + * V5Pay JSON 回调通知. + */ + String v5PayServerNoticeReceivePayment(byte[] body); + + /** + * V5Pay form 回调通知. + */ + String v5PayServerNoticeReceivePayment(Map body); + +} diff --git a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/OrderUserPurchasePayService.java b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/OrderUserPurchasePayService.java index 54a0edb9..b84bf752 100644 --- a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/OrderUserPurchasePayService.java +++ b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/OrderUserPurchasePayService.java @@ -8,11 +8,21 @@ import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay */ public interface OrderUserPurchasePayService extends BaseService { + /** + * 支付终态处理必须先锁定订单行,防止多实例并发回调重复执行发币副作用. + */ + OrderUserPurchasePay getByIdForUpdate(Long id); + /** * 标记支付成功. */ void updateSuccess(Long id); + /** + * 标记成功并保存最终三方交易号. + */ + void updateSuccess(Long id, String factoryOrderId); + /** * 标记支付失败. */ diff --git a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/impl/OrderUserPurchasePayServiceImpl.java b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/impl/OrderUserPurchasePayServiceImpl.java index 1a328f6d..2f47b859 100644 --- a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/impl/OrderUserPurchasePayServiceImpl.java +++ b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/order/impl/OrderUserPurchasePayServiceImpl.java @@ -18,11 +18,26 @@ public class OrderUserPurchasePayServiceImpl private static final String PAY_STATUS_SUCCESSFUL = "SUCCESSFUL"; private static final String PAY_STATUS_FAIL = "FAIL"; + @Override + public OrderUserPurchasePay getByIdForUpdate(Long id) { + return query() + .eq(OrderUserPurchasePay::getId, id) + .last("FOR UPDATE") + .getOne(); + } + @Override public void updateSuccess(Long id) { + updateSuccess(id, null); + } + + @Override + public void updateSuccess(Long id, String factoryOrderId) { update() .set(OrderUserPurchasePay::getPayStatus, PAY_STATUS_SUCCESSFUL) .set(OrderUserPurchasePay::getReason, "") + .set(factoryOrderId != null && !factoryOrderId.isBlank(), + OrderUserPurchasePay::getFactoryOrderId, factoryOrderId) .set(OrderUserPurchasePay::getUpdateTime, TimestampUtils.now()) .eq(OrderUserPurchasePay::getId, id) .execute(); diff --git a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/pay/PayCountryChannelDetailsService.java b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/pay/PayCountryChannelDetailsService.java index fc93fad5..e5cbd40c 100644 --- a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/pay/PayCountryChannelDetailsService.java +++ b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/database/rds/service/pay/PayCountryChannelDetailsService.java @@ -30,8 +30,15 @@ public interface PayCountryChannelDetailsService extends BaseService