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