From c70cb20de5de8bfb380de5ffcba42772b4194167 Mon Sep 17 00:00:00 2001 From: zhx Date: Fri, 10 Jul 2026 19:59:12 +0800 Subject: [PATCH] feat(order): add Aslan V5Pay recharge --- .deploy/prod-deploy/deploy-likei-services.sh | 13 +- .deploy/test-deploy/deploy-likei-services.sh | 1 + .../gateway/filter/ApiLoggingFilter.java | 14 ++ .../gateway/filter/ApiLoggingFilterTest.java | 51 ++++ .../model/enums/MonthlyRechargeType.java | 3 + .../app/PayServerNoticeController.java | 52 ++++ .../adapter/app/WebPayRestController.java | 4 +- .../app/PayServerNoticeControllerTest.java | 20 ++ .../order-application/pom.xml | 6 + .../pay/web/PayWebH5PlaceOrderCmdExe.java | 53 ++++- .../pay/web/V5PayOrderStateService.java | 216 +++++++++++++++++ ...V5PayServerNoticeReceivePaymentCmdExe.java | 17 ++ .../strategy/PayPlaceAnOrderDetailsV2.java | 3 + .../web/strategy/PayPlaceAnOrderService.java | 9 +- .../V5PayWebPayPlaceAnOrderStrategy.java | 54 +++++ .../app/common/V5PayGatewayResponse.java | 34 +++ .../order/app/common/V5PayOrderNoUtils.java | 30 +++ .../app/common/V5PayPlaceAnOrderParam.java | 22 ++ .../order/app/common/V5PaySignUtils.java | 63 +++++ .../app/common/V5PayTransactionData.java | 20 ++ .../InAppPurchaseProductServiceImpl.java | 7 + .../order/app/service/V5PayService.java | 197 ++++++++++++++++ .../pay/web/V5PayOrderStateServiceTest.java | 223 ++++++++++++++++++ .../strategy/PayPlaceAnOrderServiceTest.java | 29 +++ .../order/app/common/V5PaySignUtilsTest.java | 43 ++++ .../service/InAppPurchaseProductService.java | 3 + .../order/infra/config/V5PayProperties.java | 39 +++ .../src/main/resources/application.yml | 11 + 28 files changed, 1220 insertions(+), 17 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-adapter/src/test/java/com/red/circle/order/adapter/app/PayServerNoticeControllerTest.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateService.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/V5PayGatewayResponse.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNoUtils.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPlaceAnOrderParam.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignUtils.java create mode 100644 rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayTransactionData.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/V5PayOrderStateServiceTest.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderServiceTest.java create mode 100644 rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignUtilsTest.java create mode 100644 rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/config/V5PayProperties.java diff --git a/.deploy/prod-deploy/deploy-likei-services.sh b/.deploy/prod-deploy/deploy-likei-services.sh index bb7c0abf..79386dad 100755 --- a/.deploy/prod-deploy/deploy-likei-services.sh +++ b/.deploy/prod-deploy/deploy-likei-services.sh @@ -23,11 +23,13 @@ FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}" usage() { cat <<'EOF' Usage: - deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [wallet|other|external|console ...] + deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [gateway|wallet|order|other|external|console ...] Examples: /opt/aslan-deploy/deploy-likei-services.sh status /opt/aslan-deploy/deploy-likei-services.sh --mode preload wallet + /opt/aslan-deploy/deploy-likei-services.sh --mode preload order + /opt/aslan-deploy/deploy-likei-services.sh --mode preload gateway order /opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console /opt/aslan-deploy/deploy-likei-services.sh --fast wallet USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other @@ -104,7 +106,9 @@ update_source_from_git() { service_module() { case "$1" in + gateway) echo "rc-gateway" ;; wallet) echo "rc-service/rc-service-wallet/wallet-start" ;; + order) echo "rc-service/rc-service-order/order-start" ;; other) echo "rc-service/rc-service-other/other-start" ;; external) echo "rc-service/rc-service-external/external-start" ;; console) echo "rc-service/rc-service-console/console-start" ;; @@ -114,7 +118,9 @@ service_module() { service_dir() { case "$1" in + gateway) echo "rc-gateway" ;; wallet) echo "rc-service/rc-service-wallet" ;; + order) echo "rc-service/rc-service-order" ;; other) echo "rc-service/rc-service-other" ;; external) echo "rc-service/rc-service-external" ;; console) echo "rc-service/rc-service-console" ;; @@ -124,7 +130,9 @@ service_dir() { image_repo() { case "$1" in + gateway) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/gateway" ;; wallet) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/wallet" ;; + order) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/order" ;; other) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/other" ;; external) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/external" ;; console) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/console" ;; @@ -295,7 +303,7 @@ rollout() { } status() { - kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy wallet other external console -o wide + kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy gateway wallet order other external console -o wide kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide } @@ -344,6 +352,7 @@ main() { local services=("$@") if [[ "${#services[@]}" -eq 0 ]]; then + # 保留既有无参数发布范围;gateway/order 只在显式指定时发布,避免老运维命令意外扩大生产变更面。 services=(other external console) fi diff --git a/.deploy/test-deploy/deploy-likei-services.sh b/.deploy/test-deploy/deploy-likei-services.sh index ac8631b3..bbd0628d 100755 --- a/.deploy/test-deploy/deploy-likei-services.sh +++ b/.deploy/test-deploy/deploy-likei-services.sh @@ -26,6 +26,7 @@ Usage: Examples: /opt/aslan-test/deploy-likei-services.sh status /opt/aslan-test/deploy-likei-services.sh other + /opt/aslan-test/deploy-likei-services.sh order /opt/aslan-test/deploy-likei-services.sh --fast other external console GIT_REF=aslan_test /opt/aslan-test/deploy-likei-services.sh auth gateway external wallet order live other console 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 56e8c876..c1de4622 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 @@ -70,6 +70,13 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered { return chain.filter(exchange); } + if (isV5PayCallbackPath(request)) { + // V5Pay 的签名覆盖请求原始字段文本:读取为 Object 再序列化会把 14400.00 改成 14400.0, + // 同时常规网关日志会泄露 sign/appKey。该单一路径只补 trace header,body Flux 原样转发且完全不上传 body 日志。 + ServerHttpRequest forwarded = request.mutate().headers(this::setRequestTraceId).build(); + return chain.filter(exchange.mutate().request(forwarded).build()); + } + GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange); return checkBodyType(exchange.getRequest()) @@ -83,6 +90,13 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered { || MediaType.APPLICATION_JSON.isCompatibleWith(mediaType); } + static boolean isV5PayCallbackPath(ServerHttpRequest request) { + String path = request.getURI().getRawPath(); + // ApiLoggingFilter 可能看到 ForwardRoute 处理前的 /order 前缀,也可能在测试/后续过滤器顺序中看到已剥离路径。 + return "/order/play-server-notice/v5pay/receive_payment".equals(path) + || "/play-server-notice/v5pay/receive_payment".equals(path); + } + private Mono writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain, GatewayLogging logging) { 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..2afe04e8 --- /dev/null +++ b/rc-gateway/src/test/java/com/red/circle/gateway/filter/ApiLoggingFilterTest.java @@ -0,0 +1,51 @@ +package com.red.circle.gateway.filter; + +import com.red.circle.gateway.logging.LoggingUploadService; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +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 v5PayCallbackForwardsOriginalJsonBytesWithoutLogging() { + LoggingUploadService loggingUploadService = Mockito.mock(LoggingUploadService.class); + ApiLoggingFilter filter = new ApiLoggingFilter(loggingUploadService); + String rawBody = "{\"amount\":14400.00,\"appKey\":\"secret-app\",\"sign\":\"abc\"}"; + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.post("/order/play-server-notice/v5pay/receive_payment") + .contentType(MediaType.APPLICATION_JSON) + .body(rawBody)); + AtomicReference forwarded = new AtomicReference<>(); + GatewayFilterChain chain = current -> DataBufferUtils.join(current.getRequest().getBody()) + .doOnNext(buffer -> { + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + forwarded.set(bytes); + }) + .then(); + + filter.filter(exchange, chain).block(); + + Assertions.assertArrayEquals(rawBody.getBytes(StandardCharsets.UTF_8), forwarded.get()); + Mockito.verifyNoInteractions(loggingUploadService); + } + + @Test + public void v5PayCallbackMatchesBothExternalAndStrippedGatewayPaths() { + Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest + .post("/order/play-server-notice/v5pay/receive_payment").build())); + Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest + .post("/play-server-notice/v5pay/receive_payment").build())); + Assertions.assertFalse(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest + .post("/order/play-server-notice/mifa_pay/receive_payment").build())); + } +} 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 cf96023c..6eba45f3 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 @@ -24,6 +24,9 @@ public enum MonthlyRechargeType { */ MIFA_PAY, + /** V5Pay web 充值。 */ + V5_PAY, + /** * Airwallex. */ 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 50a03275..04fd525d 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 @@ -1,5 +1,8 @@ package com.red.circle.order.adapter.app; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; import com.red.circle.common.business.core.enums.SysOriginPlatformEnum; import com.red.circle.component.pay.paymax.PayMaxNoticeResponse; import com.red.circle.component.pay.paymax.service.PayMaxResponse; @@ -12,7 +15,9 @@ import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO; import com.red.circle.order.app.scheduler.GoogleReimburseCheckTask; import com.red.circle.order.app.service.InAppPurchaseProductService; import com.red.circle.tool.core.json.JacksonUtils; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; @@ -90,6 +95,53 @@ public class PayServerNoticeController extends BaseController { return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body); } + /** V5Pay JSON 回调;必须返回小写 text/plain,不能套统一 JSON 响应结构。 */ + @IgnoreResultResponse + @PostMapping(value = "/v5pay/receive_payment", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + public String v5PayReceivePaymentJson(@RequestBody(required = false) byte[] body) { + try { + return inAppPurchaseProductService.v5PayServerNoticeReceivePayment( + parseV5PayJsonFields(body)); + } catch (IOException exception) { + // V5Pay 需要纯文本失败响应;JSON 解析错误不能再经过全局异常处理包装成业务 JSON。 + return "fail"; + } + } + + /** V5Pay form 回调与 JSON 走同一验签/订单核验链路。 */ + @IgnoreResultResponse + @PostMapping(value = "/v5pay/receive_payment", + consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE, + produces = MediaType.TEXT_PLAIN_VALUE) + public String v5PayReceivePaymentForm(@RequestParam Map fields) { + return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(fields); + } + + static Map parseV5PayJsonFields(byte[] body) throws IOException { + Map fields = new LinkedHashMap<>(); + if (Objects.isNull(body) || body.length == 0) { + return fields; + } + try (JsonParser parser = new JsonFactory().createParser(body)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + return fields; + } + while (parser.nextToken() != JsonToken.END_OBJECT) { + String key = parser.currentName(); + JsonToken valueToken = parser.nextToken(); + if (valueToken != null && valueToken.isScalarValue()) { + // getText 保留 14400.00 这类数字的原始小数位,避免反序列化成 Double 后改变签名原文。 + fields.put(key, valueToken == JsonToken.VALUE_NULL ? "" : parser.getText()); + } else { + parser.skipChildren(); + } + } + } + return fields; + } + /** * payerMax 退款回调地址-自定义. */ diff --git a/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/WebPayRestController.java b/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/WebPayRestController.java index 6c085852..8b0a5539 100644 --- a/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/WebPayRestController.java +++ b/rc-service/rc-service-order/order-adapter/src/main/java/com/red/circle/order/adapter/app/WebPayRestController.java @@ -123,9 +123,9 @@ public class WebPayRestController { } /** - * H5 MiFaPay 下单. + * H5 三方支付下单(MiFaPay/V5Pay). * - * @eo.name H5 MiFaPay 下单. + * @eo.name H5 三方支付下单. * @eo.url /h5/orders * @eo.method post * @eo.request-type json diff --git a/rc-service/rc-service-order/order-adapter/src/test/java/com/red/circle/order/adapter/app/PayServerNoticeControllerTest.java b/rc-service/rc-service-order/order-adapter/src/test/java/com/red/circle/order/adapter/app/PayServerNoticeControllerTest.java new file mode 100644 index 00000000..eefd4406 --- /dev/null +++ b/rc-service/rc-service-order/order-adapter/src/test/java/com/red/circle/order/adapter/app/PayServerNoticeControllerTest.java @@ -0,0 +1,20 @@ +package com.red.circle.order.adapter.app; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +public class PayServerNoticeControllerTest { + + @Test + public void v5PayJsonParserPreservesSignedNumberText() throws Exception { + Map fields = PayServerNoticeController.parseV5PayJsonFields( + "{\"amount\":14400.00,\"status\":2,\"orderNo\":\"ASLAN-123\"}" + .getBytes(StandardCharsets.UTF_8)); + + Assert.assertEquals("14400.00", fields.get("amount")); + Assert.assertEquals("2", fields.get("status")); + Assert.assertEquals("ASLAN-123", fields.get("orderNo")); + } +} diff --git a/rc-service/rc-service-order/order-application/pom.xml b/rc-service/rc-service-order/order-application/pom.xml index 7afed8e2..ba8530ef 100644 --- a/rc-service/rc-service-order/order-application/pom.xml +++ b/rc-service/rc-service-order/order-application/pom.xml @@ -18,6 +18,12 @@ order-infrastructure + + org.mockito + mockito-core + test + + diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebH5PlaceOrderCmdExe.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebH5PlaceOrderCmdExe.java index 374c2f80..191371d6 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebH5PlaceOrderCmdExe.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/PayWebH5PlaceOrderCmdExe.java @@ -31,12 +31,13 @@ import com.red.circle.tool.core.text.StringUtils; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.List; +import java.util.Locale; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * Aslan H5 MiFaPay 下单和查单。 + * Aslan H5 MiFaPay/V5Pay 下单和查单。 * *

前端只提交商品 id 和支付方式标识;金额、金币数、商品类型、币种和汇率全部从后台表重新生成。

* @@ -47,7 +48,9 @@ import org.springframework.stereotype.Component; public class PayWebH5PlaceOrderCmdExe { private static final String PROVIDER_MIFAPAY = "mifapay"; + private static final String PROVIDER_V5PAY = "v5pay"; private static final String FACTORY_MIFAPAY = "MIFA_PAY"; + private static final String FACTORY_V5PAY = "V5_PAY"; private final PayCountryService payCountryService; private final PayApplicationService payApplicationService; @@ -55,23 +58,30 @@ public class PayWebH5PlaceOrderCmdExe { private final PayPlaceAnOrderService payPlaceAnOrderService; private final InAppPurchaseDetailsService inAppPurchaseDetailsService; private final PayApplicationCommodityService payApplicationCommodityService; + private final V5PayOrderStateService v5PayOrderStateService; public PayWebH5OrderCO execute(PayWebH5PlaceOrderCmd cmd) { + String providerCode = Objects.toString(cmd.getProviderCode(), "").trim() + .toLowerCase(Locale.ROOT); ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, - PROVIDER_MIFAPAY.equalsIgnoreCase(Objects.toString(cmd.getProviderCode(), ""))); + PROVIDER_MIFAPAY.equals(providerCode) || PROVIDER_V5PAY.equals(providerCode)); ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, Objects.nonNull(cmd.getApplicationId()) && Objects.nonNull(cmd.getGoodsId()) && Objects.nonNull(cmd.getPayCountryId()) && Objects.nonNull(cmd.getUserId()) - && StringUtils.isNotBlank(cmd.getPayWay()) && StringUtils.isNotBlank(cmd.getPayType())); + // MiFaPay 的 payWay 是独立必填字段;V5Pay 只把 payType 作为 productType,仍保留 payWay/channelCode 供 H5 配置透传。 + if (PROVIDER_MIFAPAY.equals(providerCode)) { + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + StringUtils.isNotBlank(cmd.getPayWay())); + } - PayPlaceAnOrderDetailsV2 details = buildDetails(cmd); + PayPlaceAnOrderDetailsV2 details = buildDetails(cmd, providerCode); PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details); return new PayWebH5OrderCO() .setOrderId(details.getOrderId()) - .setProviderCode(PROVIDER_MIFAPAY) + .setProviderCode(providerCode) .setPayUrl(response.getRequestUrl()) .setStatus("redirected") .setProviderAmountMinor(minor(details.getAmount(), 100L)) @@ -83,9 +93,14 @@ public class PayWebH5PlaceOrderCmdExe { public PayWebH5OrderCO getOrder(String orderId) { InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(orderId); ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, order); + // 只有 V5Pay 待处理订单主动查三方;MiFaPay 保持原有本地状态查询,避免改变存量渠道行为。 + if (Objects.nonNull(order.getFactory()) + && FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode())) { + order = v5PayOrderStateService.refresh(order); + } return new PayWebH5OrderCO() .setOrderId(order.getId()) - .setProviderCode(PROVIDER_MIFAPAY) + .setProviderCode(providerCode(order)) .setStatus(toH5Status(order.getStatus())) .setProviderAmountMinor(minor(order.getAmount(), 100L)) .setUsdMinorAmount(minor(order.getAmountUsd(), 100L)) @@ -93,7 +108,8 @@ public class PayWebH5PlaceOrderCmdExe { .setFailureReason(order.getReason()); } - private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd) { + private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd, + String providerCode) { PayApplication application = payApplicationService.getById(cmd.getApplicationId()); ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, application); @@ -143,7 +159,7 @@ public class PayWebH5PlaceOrderCmdExe { .setAmountUsd(commodity.getAmountUsd()) .setQuantity(1))); details.setProductDescriptor(productDescriptor(commodity)); - details.setChannelDetails(mifaPayChannel(cmd, payCountry)); + details.setChannelDetails(paymentChannel(cmd, payCountry, providerCode)); details.setCountry(sysCountryCode); details.setPayCountry(payCountry); details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), "")); @@ -151,19 +167,25 @@ public class PayWebH5PlaceOrderCmdExe { details.setCancelRedirectUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), "")); details.setNewVersion(Boolean.TRUE); details.setRequestIp(IpUtils.getIpAddr()); + details.setLanguage(cmd.getLanguage()); return details; } - private PayCountryChannelDetails mifaPayChannel(PayWebH5PlaceOrderCmd cmd, PayCountry payCountry) { + private PayCountryChannelDetails paymentChannel(PayWebH5PlaceOrderCmd cmd, + PayCountry payCountry, String providerCode) { + boolean v5Pay = PROVIDER_V5PAY.equals(providerCode); + String fallbackChannel = v5Pay + ? cmd.getPayType() + : cmd.getPayWay() + "_" + cmd.getPayType(); String channelCode = StringUtils.isNotBlank(cmd.getChannelCode()) ? cmd.getChannelCode() - : cmd.getPayWay() + "_" + cmd.getPayType(); - // 这里刻意不查后台渠道明细表:H5 支付国家和方式来自 app JSON,本对象只为复用现有 MiFaPay 策略和订单快照。 + : fallbackChannel; + // H5 支付方式来自 app JSON,本对象用于复用统一策略工厂并把 productType 固化到订单快照。 return new PayCountryChannelDetails() .setId(0L) .setPayCountryId(payCountry.getId()) .setChannelCode(channelCode) - .setFactoryCode(FACTORY_MIFAPAY) + .setFactoryCode(v5Pay ? FACTORY_V5PAY : FACTORY_MIFAPAY) .setFactoryChannel(cmd.getPayType()) .setFactoryCurrencyPoint(2) .setShelf(Boolean.TRUE); @@ -190,6 +212,13 @@ public class PayWebH5PlaceOrderCmdExe { return "pending"; } + private String providerCode(InAppPurchaseDetails order) { + return Objects.nonNull(order.getFactory()) + && FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode()) + ? PROVIDER_V5PAY + : PROVIDER_MIFAPAY; + } + private Long minor(BigDecimal amount, Long scale) { if (Objects.isNull(amount)) { return 0L; diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateService.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateService.java new file mode 100644 index 00000000..92b0cf54 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateService.java @@ -0,0 +1,216 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.common.InAppPurchaseCommon; +import com.red.circle.order.app.common.V5PayTransactionData; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService; +import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService; +import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails; +import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseEventNotice; +import com.red.circle.order.infra.database.rds.service.order.OrderCacheService; +import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus; +import com.red.circle.tool.core.date.TimestampUtils; +import com.red.circle.tool.core.text.StringUtils; +import java.math.BigDecimal; +import java.math.RoundingMode; +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 查询/回调共用的订单状态处理器。 + * + *

成功状态只在签名已验证且订单号、金额、币种、productType 与本地快照全部一致时入账。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class V5PayOrderStateService { + + static final String FACTORY_V5PAY = "V5_PAY"; + + private final V5PayService v5PayService; + private final InAppPurchaseCommon inAppPurchaseCommon; + private final InAppPurchaseDetailsService inAppPurchaseDetailsService; + private final InAppPurchaseCollectionReceiptService inAppPurchaseCollectionReceiptService; + private final OrderCacheService orderCacheService; + + public String handleNotification(Map fields) { + try { + V5PayTransactionData data = v5PayService.parseNotification(fields); + InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(data.getOrderNo()); + if (Objects.isNull(order)) { + log.error("V5Pay callback order not found: orderNo={}", data.getOrderNo()); + return "fail"; + } + return apply(order, data, "callback") ? "success" : "fail"; + } catch (Exception exception) { + // 回调日志不打印 fields、sign 或异常对象的序列化内容,防止签名材料进入日志系统。 + log.error("V5Pay callback rejected: reason={}", exception.getMessage()); + return "fail"; + } + } + + /** H5 查单时主动向 V5Pay 对账,补偿异步通知延迟或丢失。 */ + public InAppPurchaseDetails refresh(InAppPurchaseDetails order) { + if (!isV5PayOrder(order) || isTerminal(order.getStatus())) { + return order; + } + try { + V5PayTransactionData data = v5PayService.queryOrder( + order.getId(), order.getCountryCode(), order.getCurrency()); + apply(order, data, "query"); + InAppPurchaseDetails refreshed = inAppPurchaseDetailsService.getById(order.getId()); + return Objects.requireNonNullElse(refreshed, order); + } catch (Exception exception) { + // 三方暂时不可用时保留本地待支付状态;不能把查单网络异常误写成用户支付失败。 + log.warn("V5Pay query failed: orderNo={}, reason={}", order.getId(), + exception.getMessage()); + return order; + } + } + + boolean apply(InAppPurchaseDetails order, V5PayTransactionData data, String source) { + if (!isV5PayOrder(order)) { + log.error("V5Pay {} factory mismatch: orderNo={}", source, order.getId()); + return false; + } + recordNotice(order, data, source); + String status = Objects.toString(data.getStatus(), "").trim(); + if ("2".equals(status)) { + if (Objects.equals(order.getStatus(), InAppPurchaseStatus.SUCCESS)) { + // V5Pay 会重试通知;本地成功订单直接确认,不能再次触发余额和统计副作用。 + return true; + } + if (!Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) { + return false; + } + if (!matchesOrderSnapshot(order, data)) { + inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL, + "V5Pay " + source + " order snapshot mismatch"); + return false; + } + String claimKey = "V5PAY:CREDIT:" + order.getId(); + if (!orderCacheService.lock(claimKey)) { + // Redis SET NX 是跨实例原子 claim;竞争失败不能直接 ACK callback,否则持锁者稍后失败时三方会停止重试并造成丢单。 + InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId()); + if (Objects.nonNull(latest) && isTerminal(latest.getStatus())) { + return true; + } + // 主动 query 只需保留 pending;callback 必须返回 fail,让 V5Pay 在首个处理者失败时继续重试。 + return !"callback".equals(source); + } + try { + InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId()); + if (Objects.isNull(latest)) { + return false; + } + if (Objects.equals(latest.getStatus(), InAppPurchaseStatus.SUCCESS)) { + return true; + } + if (!Objects.equals(latest.getStatus(), InAppPurchaseStatus.CREATE) + || !matchesOrderSnapshot(latest, data)) { + return false; + } + if (latest.receiptTypeEqReceipt()) { + inAppPurchaseCommon.inAppPurchaseReceipt(latest); + return true; + } + if (latest.receiptTypeEqPayment()) { + inAppPurchaseCommon.inAppPurchasePayment(latest); + return true; + } + log.error("V5Pay order receipt type invalid: orderNo={}", latest.getId()); + return false; + } catch (Exception exception) { + // 失败后 finally 释放 claim 且订单仍为 CREATE,后续三方重试或主动查单可恢复;成功状态会在重试前被拦截。 + log.error("V5Pay credit failed: orderNo={}, reason={}", order.getId(), + exception.getMessage()); + return false; + } finally { + orderCacheService.unLock(claimKey); + } + } + if ("3".equals(status) || "5".equals(status) || "6".equals(status)) { + if (Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) { + if (order.receiptTypeEqReceipt()) { + inAppPurchaseCollectionReceiptService.updateStatusSuccessFail(order.getId()); + } + inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL, + "V5Pay payment failed: status=" + status); + } + return true; + } + if (status.isEmpty() || "0".equals(status) || "1".equals(status)) { + // 协议明确的待处理状态可以 ACK,但不改变本地订单。 + return true; + } + // 未知状态不能在 callback 上直接 ACK,否则未来新增成功码会被永久吞掉;主动 query 仍按 pending 展示。 + return !"callback".equals(source); + } + + static boolean matchesOrderSnapshot(InAppPurchaseDetails order, V5PayTransactionData data) { + if (Objects.isNull(order) || Objects.isNull(data) + || StringUtils.isBlank(data.getOrderNo()) + || StringUtils.isBlank(data.getAmount()) + || StringUtils.isBlank(data.getCurrency()) + || StringUtils.isBlank(data.getProductType()) + || !Objects.equals(order.getId(), data.getOrderNo()) + || !Objects.toString(order.getCurrency(), "").equalsIgnoreCase(data.getCurrency()) + || Objects.isNull(order.getFactory()) + || !Objects.toString(order.getFactory().getFactoryChannelCode(), "") + .equalsIgnoreCase(data.getProductType())) { + return false; + } + try { + return toMinor(order.getAmount()) == toMinor(data.getAmount()); + } catch (ArithmeticException | NumberFormatException exception) { + return false; + } + } + + static long toMinor(String amount) { + return toMinor(new BigDecimal(amount.trim())); + } + + static long toMinor(BigDecimal amount) { + // V5Pay 金额协议固定两位小数;超过两位时拒绝而不是截断,避免不同金额被误判为同一订单。 + return amount.setScale(2, RoundingMode.UNNECESSARY) + .movePointRight(2) + .longValueExact(); + } + + private void recordNotice(InAppPurchaseDetails order, V5PayTransactionData data, String source) { + Map sanitized = new LinkedHashMap<>(data.getFields()); + sanitized.remove("sign"); + sanitized.remove("appKey"); + sanitized.remove("merchantNo"); + String eventId = "v5pay-" + source + "-" + + StringUtils.isBlankOrElse(data.getTransactionId(), data.getOrderNo()) + "-" + + Objects.toString(data.getStatus(), "pending"); + // 重复通知不重复扩张 payNotices;订单状态门禁负责业务幂等,这里负责审计记录幂等。 + if (!inAppPurchaseDetailsService.existsPayNoticeByEventId(order.getId(), eventId)) { + inAppPurchaseDetailsService.addPayNotices(order.getId(), new InAppPurchaseEventNotice() + .setEventId(eventId) + .setNoticeType("v5pay-" + source) + .setNoticeData(sanitized) + .setCreateTime(TimestampUtils.now())); + } + } + + private boolean isV5PayOrder(InAppPurchaseDetails order) { + return Objects.nonNull(order) + && Objects.nonNull(order.getFactory()) + && FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode()); + } + + private boolean isTerminal(InAppPurchaseStatus status) { + return Objects.equals(status, InAppPurchaseStatus.SUCCESS) + || Objects.equals(status, InAppPurchaseStatus.FAIL) + || Objects.equals(status, InAppPurchaseStatus.CANCEL) + || Objects.equals(status, InAppPurchaseStatus.REFUND); + } +} 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..55385e51 --- /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,17 @@ +package com.red.circle.order.app.command.pay.web; + +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** V5Pay 回调命令入口。 */ +@Component +@RequiredArgsConstructor +public class V5PayServerNoticeReceivePaymentCmdExe { + + private final V5PayOrderStateService orderStateService; + + public String execute(Map fields) { + return orderStateService.handleNotification(fields); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderDetailsV2.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderDetailsV2.java index b55e2a0b..d157e655 100644 --- a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderDetailsV2.java +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderDetailsV2.java @@ -120,6 +120,9 @@ public class PayPlaceAnOrderDetailsV2 implements Serializable { */ private String requestIp; + /** H5 语言,用于让 V5Pay 收银台返回对应语言;非法或空值由客户端降级为 en-US。 */ + private String language; + public String getFactoryChannel() { return channelDetails.getFactoryChannel(); } 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 adf45e2e..a5eb3feb 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 @@ -59,7 +59,7 @@ public class PayPlaceAnOrderService { new InAppPurchaseFactory() .setPlatform(RequestClientEnum.H5.getClientName()) .setFactoryCode(param.getFactoryCode()) - .setFactoryChannelCode(param.getChannelCode()) + .setFactoryChannelCode(factoryChannelCode(param)) ) .setProducts(param.getProducts()) .setProductDescriptor(param.getProductDescriptor()) @@ -93,6 +93,13 @@ public class PayPlaceAnOrderService { return response; } + static String factoryChannelCode(PayPlaceAnOrderDetailsV2 param) { + // 只有 V5Pay 回调必须用 productType 做严格快照校验;存量 MiFa/PayMax 继续保存 channelCode,避免改变历史查询语义。 + return "V5_PAY".equalsIgnoreCase(param.getFactoryCode()) + ? param.getFactoryChannel() + : param.getChannelCode(); + } + private String env() { return envProperties.isProd() ? PayEnv.PROD.name() : PayEnv.TEST.name(); } 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..dc6e46af --- /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,54 @@ +package com.red.circle.order.app.command.pay.web.strategy; + +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.order.app.common.V5PayGatewayResponse; +import com.red.circle.order.app.common.V5PayPlaceAnOrderParam; +import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.util.MemberInfoGenerator; +import com.red.circle.order.inner.asserts.OrderErrorCode; +import com.red.circle.tool.core.text.StringUtils; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** V5Pay web 支付策略,复用统一的预支付订单持久化流程。 */ +@Component("V5_PAY_PLACE_AN_ORDER") +@RequiredArgsConstructor +public class V5PayWebPayPlaceAnOrderStrategy implements PayWebPlaceAnOrderStrategyV2 { + + private final V5PayService v5PayService; + + @Override + public PlaceAnOrderResponseCO doOperation(PayPlaceAnOrderDetailsV2 details) { + MemberInfoGenerator.MemberInfo member = MemberInfoGenerator.generate( + Objects.toString(details.getAcceptUserId())); + V5PayPlaceAnOrderParam param = V5PayPlaceAnOrderParam.builder() + .orderNo(details.getOrderId()) + .countryCode(details.getCountryCode()) + .currency(details.getCurrency()) + .amount(details.getAmount()) + .productType(details.getFactoryChannel()) + .returnUrl(details.getSuccessRedirectUrl()) + .language(details.getLanguage()) + .tradeSummary(details.getProductDescriptor()) + .userId(details.getAcceptUserId()) + .email(member.getEmail()) + .build(); + + V5PayGatewayResponse response = v5PayService.createOrder(param); + ResponseAssert.notNull(OrderErrorCode.CREATE_ORDER_ERROR, response); + return new PlaceAnOrderResponseCO() + .setTradeNo(StringUtils.isBlankOrElse(response.text("transactionId"), + response.text("orderNo"))) + .setOrderId(details.getOrderId()) + .setCurrency(details.getCurrency()) + .setCountryCode(details.getCountryCode()) + .setFactoryCode(details.getFactoryCode()) + .setFactoryChannelCode(details.getFactoryChannel()) + .setRequestUrl(response.text("checkoutUrl")) + // 订单快照只保存非敏感业务参数;商户号、appKey、secretKey 不进入订单扩展数据。 + .putRequestParam(param) + .putResponseParam(response.sanitizedFields()); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayGatewayResponse.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayGatewayResponse.java new file mode 100644 index 00000000..ff6eb83c --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayGatewayResponse.java @@ -0,0 +1,34 @@ +package com.red.circle.order.app.common; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.Getter; + +/** + * 保留 V5Pay 响应的全部顶层字段。 + * + *

响应签名覆盖全部非空字段,不能只反序列化当前业务使用的几个属性,否则新增字段会导致误判验签失败。

+ */ +@Getter +public class V5PayGatewayResponse { + + private final Map fields = new LinkedHashMap<>(); + + @JsonAnySetter + public void put(String key, Object value) { + fields.put(key, value); + } + + public String text(String key) { + return V5PaySignUtils.value(fields.get(key)); + } + + public Map sanitizedFields() { + Map sanitized = new LinkedHashMap<>(fields); + sanitized.remove("sign"); + sanitized.remove("appKey"); + sanitized.remove("merchantNo"); + return sanitized; + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNoUtils.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNoUtils.java new file mode 100644 index 00000000..7dbd918a --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayOrderNoUtils.java @@ -0,0 +1,30 @@ +package com.red.circle.order.app.common; + +import java.util.Locale; +import java.util.Objects; + +/** 共享 V5Pay 商户时隔离 Aslan 与其他系统订单号。 */ +public final class V5PayOrderNoUtils { + + private V5PayOrderNoUtils() { + } + + public static String providerOrderNo(String prefix, String localOrderNo) { + return normalizedPrefix(prefix) + "-" + Objects.toString(localOrderNo, "").trim(); + } + + public static String localOrderNo(String prefix, String providerOrderNo) { + String expected = normalizedPrefix(prefix) + "-"; + String value = Objects.toString(providerOrderNo, "").trim(); + if (!value.regionMatches(true, 0, expected, 0, expected.length()) + || value.length() == expected.length()) { + return ""; + } + return value.substring(expected.length()); + } + + private static String normalizedPrefix(String prefix) { + String value = Objects.toString(prefix, "ASLAN").trim(); + return (value.isBlank() ? "ASLAN" : value).toUpperCase(Locale.ROOT); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPlaceAnOrderParam.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPlaceAnOrderParam.java new file mode 100644 index 00000000..cbb8e1f7 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayPlaceAnOrderParam.java @@ -0,0 +1,22 @@ +package com.red.circle.order.app.common; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Data; + +/** V5Pay 收银台下单所需的非敏感业务参数。 */ +@Data +@Builder +public class V5PayPlaceAnOrderParam { + + private String orderNo; + private String countryCode; + private String currency; + private BigDecimal amount; + private String productType; + private String returnUrl; + private String language; + private String tradeSummary; + private Long userId; + private String email; +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignUtils.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignUtils.java new file mode 100644 index 00000000..9fde0146 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PaySignUtils.java @@ -0,0 +1,63 @@ +package com.red.circle.order.app.common; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * V5Pay MD5 签名工具。 + * + *

协议要求过滤空值和 sign 字段,按 key 排序后拼成 key=value&...,最后直接追加 secretKey。

+ */ +public final class V5PaySignUtils { + + private V5PaySignUtils() { + } + + public static String sign(Map fields, String secretKey) { + List keys = new ArrayList<>(); + for (Map.Entry entry : fields.entrySet()) { + String value = value(entry.getValue()); + if (!"sign".equals(entry.getKey()) && !value.isBlank()) { + keys.add(entry.getKey()); + } + } + keys.sort(Comparator.naturalOrder()); + + StringBuilder source = new StringBuilder(); + for (String key : keys) { + if (!source.isEmpty()) { + source.append('&'); + } + source.append(key).append('=').append(value(fields.get(key))); + } + // V5Pay 不是 key=secret 的形式,排序字段串后必须直接追加密钥,否则网关会报签名错误。 + source.append(Objects.toString(secretKey, "")); + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + return HexFormat.of().formatHex(digest.digest(source.toString() + .getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("MD5 algorithm is unavailable", exception); + } + } + + public static boolean verify(Map fields, String secretKey) { + String actual = value(fields.get("sign")).trim(); + return !actual.isBlank() && MessageDigest.isEqual( + actual.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII), + sign(fields, secretKey).getBytes(StandardCharsets.US_ASCII) + ); + } + + public static String value(Object value) { + return Objects.toString(value, ""); + } +} diff --git a/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayTransactionData.java b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayTransactionData.java new file mode 100644 index 00000000..21beabd6 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/common/V5PayTransactionData.java @@ -0,0 +1,20 @@ +package com.red.circle.order.app.common; + +import java.util.Map; +import lombok.Data; +import lombok.experimental.Accessors; + +/** V5Pay 查询或回调中用于本地订单核验的统一字段。 */ +@Data +@Accessors(chain = true) +public class V5PayTransactionData { + + private String orderNo; + private String providerOrderNo; + private String transactionId; + private String status; + private String amount; + private String currency; + private String productType; + private Map fields; +} 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 dfb96168..cb34af52 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,6 +29,7 @@ import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeReceivePayme 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.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; @@ -71,6 +72,7 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe; private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe; private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe; + private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe; private final TaskMqMessage taskMqMessage; @Override @@ -184,4 +186,9 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ return miFaPayServerNoticeReceivePaymentCmdExe.execute(body); } + @Override + public String v5PayServerNoticeReceivePayment(Map fields) { + return v5PayServerNoticeReceivePaymentCmdExe.execute(fields); + } + } 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..754da609 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/main/java/com/red/circle/order/app/service/V5PayService.java @@ -0,0 +1,197 @@ +package com.red.circle.order.app.service; + +import com.red.circle.framework.core.asserts.ResponseAssert; +import com.red.circle.framework.core.response.ResponseErrorCode; +import com.red.circle.order.app.common.V5PayGatewayResponse; +import com.red.circle.order.app.common.V5PayOrderNoUtils; +import com.red.circle.order.app.common.V5PayPlaceAnOrderParam; +import com.red.circle.order.app.common.V5PaySignUtils; +import com.red.circle.order.app.common.V5PayTransactionData; +import com.red.circle.order.infra.config.V5PayProperties; +import com.red.circle.tool.core.http.RcHttpClient; +import com.red.circle.tool.core.text.StringUtils; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** V5Pay 下单、查单和通知验签客户端。 */ +@Slf4j +@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 static final String SUCCESS_CODE = "1000"; + + private final V5PayProperties properties; + private final RcHttpClient httpClient = RcHttpClient.builder().build(); + + public V5PayGatewayResponse createOrder(V5PayPlaceAnOrderParam param) { + requireCreateConfigured(); + Map request = new LinkedHashMap<>(); + request.put("merchantNo", properties.getMerchantNo()); + request.put("appKey", properties.getAppKey()); + request.put("sysCountryCode", upper(param.getCountryCode())); + request.put("currency", upper(param.getCurrency())); + request.put("orderNo", V5PayOrderNoUtils.providerOrderNo( + properties.getOrderPrefix(), param.getOrderNo())); + request.put("amount", param.getAmount().setScale(2, RoundingMode.UNNECESSARY).toPlainString()); + request.put("productType", param.getProductType()); + request.put("callbackUrl", properties.getNotifyUrl()); + request.put("redirectUrl", param.getReturnUrl()); + request.put("merchantParam", param.getOrderNo()); + request.put("language", normalizeLanguage(param.getLanguage())); + request.put("tradeSummary", param.getTradeSummary()); + request.put("email", StringUtils.isBlankOrElse(param.getEmail(), + "v5pay-payer-" + param.getUserId() + "@haiyihy.com")); + request.put("merchantCustomerId", Objects.toString(param.getUserId(), "")); + request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey())); + + V5PayGatewayResponse response = post(CREATE_ORDER_PATH, request); + validateResponse(response, "create", param.getOrderNo()); + ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, + StringUtils.isNotBlank(response.text("checkoutUrl"))); + return response; + } + + public V5PayTransactionData queryOrder(String orderNo, String countryCode, String currency) { + requireGatewayConfigured(); + Map request = new LinkedHashMap<>(); + request.put("merchantNo", properties.getMerchantNo()); + request.put("appKey", properties.getAppKey()); + request.put("sysCountryCode", upper(countryCode)); + request.put("currency", upper(currency)); + request.put("orderNo", V5PayOrderNoUtils.providerOrderNo(properties.getOrderPrefix(), orderNo)); + request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey())); + + V5PayGatewayResponse response = post(QUERY_ORDER_PATH, request); + validateResponse(response, "query", orderNo); + return transactionData(toStringFields(response.getFields())); + } + + /** + * 回调验签必须先于订单查询和入账;调用方只能拿到通过签名验证后的业务字段。 + */ + public V5PayTransactionData parseNotification(Map fields) { + requireSigningConfigured(); + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + Objects.nonNull(fields) && !fields.isEmpty() + && V5PaySignUtils.verify(fields, properties.getSecretKey())); + V5PayTransactionData data = transactionData(fields); + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + StringUtils.isNotBlank(data.getOrderNo())); + return data; + } + + public boolean isConfigured() { + return isGatewayConfigured() + && StringUtils.isNotBlank(properties.getNotifyUrl()); + } + + private boolean isGatewayConfigured() { + return isSigningConfigured() + && StringUtils.isNotBlank(properties.getMerchantNo()) + && StringUtils.isNotBlank(properties.getAppKey()) + && StringUtils.isNotBlank(properties.getGatewayBaseUrl()); + } + + private boolean isSigningConfigured() { + return properties.isEnabled() + && StringUtils.isNotBlank(properties.getSecretKey()); + } + + private V5PayGatewayResponse post(String path, Map request) { + String baseUrl = properties.getGatewayBaseUrl().replaceAll("/+$", ""); + return httpClient.post() + .uri(baseUrl + path) + .contentType("application/json") + .bodyValue(request) + .retrieve() + .bodyToEntity(V5PayGatewayResponse.class) + .block(); + } + + private void validateResponse(V5PayGatewayResponse response, String operation, String orderNo) { + ResponseAssert.notNull(ResponseErrorCode.INTERNAL_SERVER_ERROR, response); + String code = response.text("code"); + if (StringUtils.isNotBlank(code) && !SUCCESS_CODE.equals(code)) { + // 只记录订单号、操作和错误码,不输出响应签名或任何商户密钥。 + log.error("V5Pay {} rejected: orderNo={}, code={}", operation, orderNo, code); + ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR, + "V5Pay request rejected: " + code); + } + if (!V5PaySignUtils.verify(response.getFields(), properties.getSecretKey())) { + log.error("V5Pay {} response signature invalid: orderNo={}", operation, orderNo); + ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR, + "V5Pay response signature invalid"); + } + } + + private void requireCreateConfigured() { + // enabled 默认开启;缺少真实环境配置时明确拒绝调用,避免使用伪造默认凭证发起请求。 + ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isConfigured()); + } + + private void requireGatewayConfigured() { + ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isGatewayConfigured()); + } + + private void requireSigningConfigured() { + ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isSigningConfigured()); + } + + private V5PayTransactionData transactionData(Map fields) { + String providerOrderNo = first(fields, "orderNo", "order_no"); + String localOrderNo = V5PayOrderNoUtils.localOrderNo( + properties.getOrderPrefix(), providerOrderNo); + // 共享商户收到其他系统前缀的回调时必须拒绝,不能用 provider orderNo 直接查询 Aslan 本地订单。 + ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, + StringUtils.isNotBlank(localOrderNo)); + return new V5PayTransactionData() + .setOrderNo(localOrderNo) + .setProviderOrderNo(providerOrderNo) + .setTransactionId(first(fields, "transactionId", "transaction_id")) + .setStatus(first(fields, "status", "orderStatus", "order_status")) + .setAmount(first(fields, "amount")) + .setCurrency(first(fields, "currency")) + .setProductType(first(fields, "productType", "product_type")) + .setFields(fields); + } + + private Map toStringFields(Map fields) { + Map values = new LinkedHashMap<>(); + fields.forEach((key, value) -> values.put(key, V5PaySignUtils.value(value))); + return values; + } + + private String first(Map fields, String... keys) { + for (String key : keys) { + if (StringUtils.isNotBlank(fields.get(key))) { + return fields.get(key).trim(); + } + } + return ""; + } + + private String upper(String value) { + return Objects.toString(value, "").trim().toUpperCase(Locale.ROOT); + } + + private String normalizeLanguage(String value) { + return switch (Objects.toString(value, "").trim().toLowerCase(Locale.ROOT)) { + case "zh", "zh-cn" -> "zh-CN"; + case "zh-tw", "zh-hk", "zh-tc" -> "zh-TC"; + case "es", "es-es" -> "es-ES"; + case "pt", "pt-pt" -> "pt-PT"; + case "ru", "ru-ru" -> "ru-RU"; + case "ja", "jp", "ja-jp", "jp-jp" -> "jp-JP"; + default -> "en-US"; + }; + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateServiceTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateServiceTest.java new file mode 100644 index 00000000..caa4b7c9 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/V5PayOrderStateServiceTest.java @@ -0,0 +1,223 @@ +package com.red.circle.order.app.command.pay.web; + +import com.red.circle.order.app.common.V5PayTransactionData; +import com.red.circle.order.app.common.InAppPurchaseCommon; +import com.red.circle.order.app.service.V5PayService; +import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService; +import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService; +import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails; +import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseFactory; +import com.red.circle.order.infra.database.rds.service.order.OrderCacheService; +import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseReceiptType; +import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus; +import java.math.BigDecimal; +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.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +public class V5PayOrderStateServiceTest { + + @Test + public void successfulProviderDataMustMatchEveryLocalSnapshotField() { + InAppPurchaseDetails order = order(); + V5PayTransactionData data = data(); + Assert.assertTrue(V5PayOrderStateService.matchesOrderSnapshot(order, data)); + + data.setOrderNo("other"); + Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data)); + data = data().setAmount("14400.01"); + Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data)); + data = data().setCurrency("USD"); + Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data)); + data = data().setProductType("other-product"); + Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data)); + } + + @Test + public void amountConversionRejectsPrecisionLoss() { + Assert.assertEquals(1_440_000L, V5PayOrderStateService.toMinor("14400.00")); + Assert.assertEquals(100L, V5PayOrderStateService.toMinor("1")); + try { + V5PayOrderStateService.toMinor("1.001"); + Assert.fail("more than two decimals must be rejected"); + } catch (ArithmeticException expected) { + // 精度超出协议时抛错,调用方会拒绝入账。 + } + } + + @Test + public void missingProviderSnapshotFieldNeverCredits() { + V5PayTransactionData data = data().setProductType(""); + Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order(), data)); + } + + @Test + public void concurrentCallbackDoesNotAckBeforeClaimOwnerFinishes() throws Exception { + AtomicBoolean claimed = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>(InAppPurchaseStatus.CREATE); + CountDownLatch creditStarted = new CountDownLatch(1); + CountDownLatch allowCreditFinish = new CountDownLatch(1); + TestContext context = context(claimed, status); + Mockito.doAnswer(invocation -> { + creditStarted.countDown(); + Assert.assertTrue(allowCreditFinish.await(5, TimeUnit.SECONDS)); + status.set(InAppPurchaseStatus.SUCCESS); + return null; + }).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> context.service.apply(order(), data(), "callback")); + Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS)); + Future duplicate = executor.submit( + () -> context.service.apply(order(), data(), "callback")); + Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS)); + allowCreditFinish.countDown(); + Assert.assertTrue(first.get(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + Mockito.verify(context.purchaseCommon, Mockito.times(1)) + .inAppPurchasePayment(Mockito.any()); + Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get()); + } + + @Test + public void concurrentCallbackStaysUnacknowledgedWhenClaimOwnerLaterFails() throws Exception { + AtomicBoolean claimed = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>(InAppPurchaseStatus.CREATE); + CountDownLatch creditStarted = new CountDownLatch(1); + CountDownLatch allowFailure = new CountDownLatch(1); + TestContext context = context(claimed, status); + Mockito.doAnswer(invocation -> { + creditStarted.countDown(); + Assert.assertTrue(allowFailure.await(5, TimeUnit.SECONDS)); + throw new IllegalStateException("owner failed"); + }).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any()); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future owner = executor.submit( + () -> context.service.apply(order(), data(), "callback")); + Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS)); + Future duplicate = executor.submit( + () -> context.service.apply(order(), data(), "callback")); + Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS)); + allowFailure.countDown(); + Assert.assertFalse(owner.get(5, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + Assert.assertEquals(InAppPurchaseStatus.CREATE, status.get()); + Assert.assertFalse(claimed.get()); + } + + @Test + public void failedCreditReleasesClaimAndNextNotificationCanRetry() { + AtomicBoolean claimed = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>(InAppPurchaseStatus.CREATE); + AtomicInteger attempts = new AtomicInteger(); + TestContext context = context(claimed, status); + Mockito.doAnswer(invocation -> { + if (attempts.incrementAndGet() == 1) { + throw new IllegalStateException("temporary downstream failure"); + } + status.set(InAppPurchaseStatus.SUCCESS); + return null; + }).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any()); + + Assert.assertFalse(context.service.apply(order(), data(), "callback")); + Assert.assertFalse(claimed.get()); + Assert.assertTrue(context.service.apply(order(), data(), "callback")); + Assert.assertEquals(2, attempts.get()); + Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get()); + } + + @Test + public void unknownProviderStatusIsNotAcknowledgedByCallback() { + AtomicBoolean claimed = new AtomicBoolean(); + AtomicReference status = new AtomicReference<>(InAppPurchaseStatus.CREATE); + TestContext context = context(claimed, status); + V5PayTransactionData unknown = data().setStatus("7"); + + Assert.assertFalse(context.service.apply(order(), unknown, "callback")); + Assert.assertTrue(context.service.apply(order(), unknown, "query")); + Mockito.verifyNoInteractions(context.purchaseCommon); + } + + private InAppPurchaseDetails order() { + return new InAppPurchaseDetails() + .setId("123") + .setCurrency("TRY") + .setAmount(new BigDecimal("14400")) + .setReceiptType(InAppPurchaseReceiptType.PAYMENT) + .setStatus(InAppPurchaseStatus.CREATE) + .setFactory(new InAppPurchaseFactory() + .setFactoryCode("V5_PAY") + .setFactoryChannelCode("1263")); + } + + private V5PayTransactionData data() { + return new V5PayTransactionData() + .setOrderNo("123") + .setAmount("14400.00") + .setCurrency("try") + .setProductType("1263") + .setStatus("2") + .setFields(Map.of( + "orderNo", "ASLAN-123", + "amount", "14400.00", + "currency", "TRY", + "productType", "1263", + "status", "2")); + } + + private TestContext context(AtomicBoolean claimed, + AtomicReference status) { + V5PayService v5PayService = Mockito.mock(V5PayService.class); + InAppPurchaseCommon purchaseCommon = Mockito.mock(InAppPurchaseCommon.class); + InAppPurchaseDetailsService detailsService = Mockito.mock(InAppPurchaseDetailsService.class); + InAppPurchaseCollectionReceiptService receiptService = Mockito.mock( + InAppPurchaseCollectionReceiptService.class); + OrderCacheService cacheService = Mockito.mock(OrderCacheService.class); + Mockito.when(detailsService.getById("123")).thenAnswer(invocation -> { + InAppPurchaseDetails latest = order(); + latest.setStatus(status.get()); + return latest; + }); + Mockito.when(detailsService.existsPayNoticeByEventId(Mockito.anyString(), Mockito.anyString())) + .thenReturn(true); + Mockito.when(cacheService.lock(Mockito.anyString())) + .thenAnswer(invocation -> claimed.compareAndSet(false, true)); + Mockito.doAnswer(invocation -> { + claimed.set(false); + return null; + }).when(cacheService).unLock(Mockito.anyString()); + return new TestContext( + new V5PayOrderStateService(v5PayService, purchaseCommon, detailsService, + receiptService, cacheService), + purchaseCommon + ); + } + + private static final class TestContext { + + private final V5PayOrderStateService service; + private final InAppPurchaseCommon purchaseCommon; + + private TestContext(V5PayOrderStateService service, + InAppPurchaseCommon purchaseCommon) { + this.service = service; + this.purchaseCommon = purchaseCommon; + } + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderServiceTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderServiceTest.java new file mode 100644 index 00000000..b1d9b919 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/command/pay/web/strategy/PayPlaceAnOrderServiceTest.java @@ -0,0 +1,29 @@ +package com.red.circle.order.app.command.pay.web.strategy; + +import com.red.circle.order.infra.database.rds.entity.pay.PayCountryChannelDetails; +import org.junit.Assert; +import org.junit.Test; + +public class PayPlaceAnOrderServiceTest { + + @Test + public void onlyV5PayPersistsFactoryProductTypeAsSnapshot() { + PayPlaceAnOrderDetailsV2 details = details("V5_PAY"); + Assert.assertEquals("1263", PayPlaceAnOrderService.factoryChannelCode(details)); + } + + @Test + public void existingFactoriesKeepHistoricalChannelCodeSnapshot() { + PayPlaceAnOrderDetailsV2 details = details("MIFA_PAY"); + Assert.assertEquals("card_visa", PayPlaceAnOrderService.factoryChannelCode(details)); + } + + private PayPlaceAnOrderDetailsV2 details(String factoryCode) { + PayPlaceAnOrderDetailsV2 details = new PayPlaceAnOrderDetailsV2(); + details.setChannelDetails(new PayCountryChannelDetails() + .setFactoryCode(factoryCode) + .setChannelCode("card_visa") + .setFactoryChannel("1263")); + return details; + } +} diff --git a/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignUtilsTest.java b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignUtilsTest.java new file mode 100644 index 00000000..216af0f1 --- /dev/null +++ b/rc-service/rc-service-order/order-application/src/test/java/com/red/circle/order/app/common/V5PaySignUtilsTest.java @@ -0,0 +1,43 @@ +package com.red.circle.order.app.common; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; + +public class V5PaySignUtilsTest { + + @Test + public void signSortsFieldsSkipsEmptyAndAppendsSecretDirectly() { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", "ASLAN-123"); + fields.put("sign", "must-be-ignored"); + fields.put("merchantNo", "M"); + fields.put("empty", ""); + fields.put("appKey", "A"); + fields.put("amount", "10.00"); + + Assert.assertEquals("a092fa6ad4f5ba4829c0a8ff1986d75c", + V5PaySignUtils.sign(fields, "secret")); + } + + @Test + public void verifyAcceptsUppercaseMd5ButRejectsChangedBusinessField() { + Map fields = new LinkedHashMap<>(); + fields.put("orderNo", "ASLAN-123"); + fields.put("amount", "10.00"); + fields.put("sign", V5PaySignUtils.sign(fields, "secret").toUpperCase()); + Assert.assertTrue(V5PaySignUtils.verify(fields, "secret")); + + fields.put("amount", "10.01"); + Assert.assertFalse(V5PaySignUtils.verify(fields, "secret")); + } + + @Test + public void orderPrefixOnlyAcceptsAslanNamespace() { + Assert.assertEquals("ASLAN-123", V5PayOrderNoUtils.providerOrderNo("aslan", "123")); + Assert.assertEquals("123", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN-123")); + Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "LALU-123")); + Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN-")); + } +} 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 b4287661..c29bfe07 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 @@ -51,4 +51,7 @@ public interface InAppPurchaseProductService { */ String miFaPayServerNoticeReceivePayment(byte[] body); + /** V5Pay 支付回调通知,返回小写纯文本 success/fail。 */ + String v5PayServerNoticeReceivePayment(Map fields); + } diff --git a/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/config/V5PayProperties.java b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/config/V5PayProperties.java new file mode 100644 index 00000000..e3b04278 --- /dev/null +++ b/rc-service/rc-service-order/order-infrastructure/src/main/java/com/red/circle/order/infra/config/V5PayProperties.java @@ -0,0 +1,39 @@ +package com.red.circle.order.infra.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Component; + +/** + * V5Pay 商户和网关配置。 + * + *

功能默认开启,但商户号、应用 Key、签名密钥和地址必须由环境或 Nacos 注入;代码仓库不提供可用凭证。

+ */ +@Data +@Component +@RefreshScope +@ConfigurationProperties(prefix = "red-circle.pay.v5-pay") +public class V5PayProperties { + + /** 是否允许创建和查询 V5Pay 订单。 */ + private boolean enabled = true; + + /** V5Pay 分配的商户号。 */ + private String merchantNo; + + /** V5Pay 分配的应用 Key。 */ + private String appKey; + + /** MD5 签名密钥,只能通过部署环境注入。 */ + private String secretKey; + + /** V5Pay API 根地址。 */ + private String gatewayBaseUrl; + + /** V5Pay 可从公网访问的异步回调地址。 */ + private String notifyUrl; + + /** 共享商户下的订单隔离前缀;默认 ASLAN,可由部署环境覆盖。 */ + private String orderPrefix = "ASLAN"; +} diff --git a/rc-service/rc-service-order/order-start/src/main/resources/application.yml b/rc-service/rc-service-order/order-start/src/main/resources/application.yml index bc38bfdb..ada94b81 100644 --- a/rc-service/rc-service-order/order-start/src/main/resources/application.yml +++ b/rc-service/rc-service-order/order-start/src/main/resources/application.yml @@ -30,4 +30,15 @@ management: health: show-details: always +# V5Pay 功能默认打开;商户凭证和地址全部由环境/Nacos 注入,仓库内不放置任何可用密钥。 +red-circle: + pay: + v5-pay: + enabled: ${V5PAY_ENABLED:true} + merchant-no: ${V5PAY_MERCHANT_NO:} + app-key: ${V5PAY_APP_KEY:} + secret-key: ${V5PAY_SECRET_KEY:} + gateway-base-url: ${V5PAY_GATEWAY_BASE_URL:} + notify-url: ${V5PAY_NOTIFY_URL:} + order-prefix: ${V5PAY_ORDER_PREFIX:ASLAN}