feat(order): add Aslan V5Pay recharge
This commit is contained in:
parent
c2e12ffb06
commit
c70cb20de5
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain,
|
||||
GatewayLogging logging) {
|
||||
|
||||
@ -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<byte[]> 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()));
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,9 @@ public enum MonthlyRechargeType {
|
||||
*/
|
||||
MIFA_PAY,
|
||||
|
||||
/** V5Pay web 充值。 */
|
||||
V5_PAY,
|
||||
|
||||
/**
|
||||
* Airwallex.
|
||||
*/
|
||||
|
||||
@ -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<String, String> fields) {
|
||||
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(fields);
|
||||
}
|
||||
|
||||
static Map<String, String> parseV5PayJsonFields(byte[] body) throws IOException {
|
||||
Map<String, String> 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 退款回调地址-自定义.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<String, String> 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"));
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,12 @@
|
||||
<artifactId>order-infrastructure</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.mockito</groupId>
|
||||
<artifactId>mockito-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<parent>
|
||||
|
||||
@ -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 下单和查单。
|
||||
*
|
||||
* <p>前端只提交商品 id 和支付方式标识;金额、金币数、商品类型、币种和汇率全部从后台表重新生成。</p>
|
||||
*
|
||||
@ -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;
|
||||
|
||||
@ -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 查询/回调共用的订单状态处理器。
|
||||
*
|
||||
* <p>成功状态只在签名已验证且订单号、金额、币种、productType 与本地快照全部一致时入账。</p>
|
||||
*/
|
||||
@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<String, String> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
@ -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<String, String> fields) {
|
||||
return orderStateService.handleNotification(fields);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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 响应的全部顶层字段。
|
||||
*
|
||||
* <p>响应签名覆盖全部非空字段,不能只反序列化当前业务使用的几个属性,否则新增字段会导致误判验签失败。</p>
|
||||
*/
|
||||
@Getter
|
||||
public class V5PayGatewayResponse {
|
||||
|
||||
private final Map<String, Object> 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<String, Object> sanitizedFields() {
|
||||
Map<String, Object> sanitized = new LinkedHashMap<>(fields);
|
||||
sanitized.remove("sign");
|
||||
sanitized.remove("appKey");
|
||||
sanitized.remove("merchantNo");
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 签名工具。
|
||||
*
|
||||
* <p>协议要求过滤空值和 sign 字段,按 key 排序后拼成 key=value&...,最后直接追加 secretKey。</p>
|
||||
*/
|
||||
public final class V5PaySignUtils {
|
||||
|
||||
private V5PaySignUtils() {
|
||||
}
|
||||
|
||||
public static String sign(Map<String, ?> fields, String secretKey) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (Map.Entry<String, ?> 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<String, ?> 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, "");
|
||||
}
|
||||
}
|
||||
@ -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<String, String> fields;
|
||||
}
|
||||
@ -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<String, String> fields) {
|
||||
return v5PayServerNoticeReceivePaymentCmdExe.execute(fields);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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<String, Object> 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<String, Object> 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<String, String> 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<String, Object> 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<String, String> 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<String, String> toStringFields(Map<String, Object> fields) {
|
||||
Map<String, String> values = new LinkedHashMap<>();
|
||||
fields.forEach((key, value) -> values.put(key, V5PaySignUtils.value(value)));
|
||||
return values;
|
||||
}
|
||||
|
||||
private String first(Map<String, String> 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";
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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<InAppPurchaseStatus> 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<Boolean> first = executor.submit(() -> context.service.apply(order(), data(), "callback"));
|
||||
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
|
||||
Future<Boolean> 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<InAppPurchaseStatus> 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<Boolean> owner = executor.submit(
|
||||
() -> context.service.apply(order(), data(), "callback"));
|
||||
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
|
||||
Future<Boolean> 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<InAppPurchaseStatus> 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<InAppPurchaseStatus> 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<InAppPurchaseStatus> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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<String, Object> 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-"));
|
||||
}
|
||||
}
|
||||
@ -51,4 +51,7 @@ public interface InAppPurchaseProductService {
|
||||
*/
|
||||
String miFaPayServerNoticeReceivePayment(byte[] body);
|
||||
|
||||
/** V5Pay 支付回调通知,返回小写纯文本 success/fail。 */
|
||||
String v5PayServerNoticeReceivePayment(Map<String, String> fields);
|
||||
|
||||
}
|
||||
|
||||
@ -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 商户和网关配置。
|
||||
*
|
||||
* <p>功能默认开启,但商户号、应用 Key、签名密钥和地址必须由环境或 Nacos 注入;代码仓库不提供可用凭证。</p>
|
||||
*/
|
||||
@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";
|
||||
}
|
||||
@ -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}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user