feat(order): add Yumi V5Pay recharge
This commit is contained in:
parent
13a573083e
commit
79bb91237b
@ -49,6 +49,18 @@ red-circle:
|
|||||||
factory-config:
|
factory-config:
|
||||||
cache-ttl-seconds: ${LIKEI_PAY_FACTORY_CONFIG_CACHE_TTL_SECONDS:300}
|
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:
|
airwallex:
|
||||||
client-id: ${LIKEI_AIRWALLEX_CLIENT_ID}
|
client-id: ${LIKEI_AIRWALLEX_CLIENT_ID}
|
||||||
api-key: ${LIKEI_AIRWALLEX_API_KEY}
|
api-key: ${LIKEI_AIRWALLEX_API_KEY}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ import java.io.ByteArrayOutputStream;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.zip.GZIPInputStream;
|
import java.util.zip.GZIPInputStream;
|
||||||
@ -58,6 +59,7 @@ import java.util.zip.GZIPInputStream;
|
|||||||
public class ApiLoggingFilter implements GlobalFilter, Ordered {
|
public class ApiLoggingFilter implements GlobalFilter, Ordered {
|
||||||
|
|
||||||
private static final int MAX_RESPONSE_BODY_LOG_LENGTH = 500;
|
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 LoggingUploadService loggingUploadService;
|
||||||
private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults()
|
private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults()
|
||||||
@ -72,9 +74,15 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
|
|||||||
return chain.filter(exchange);
|
return chain.filter(exchange);
|
||||||
}
|
}
|
||||||
|
|
||||||
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
|
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
|
||||||
|
|
||||||
return checkBodyType(exchange.getRequest())
|
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)
|
? writeBodyLog(exchange, chain, logging)
|
||||||
: writeBasicLog(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) {
|
private boolean isSuccessResponse(ServerHttpResponse response) {
|
||||||
HttpStatusCode statusCode = response.getStatusCode();
|
HttpStatusCode statusCode = response.getStatusCode();
|
||||||
return statusCode == null || statusCode.is2xxSuccessful();
|
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
|
* MifaPay
|
||||||
*/
|
*/
|
||||||
MIFA_PAY,
|
MIFA_PAY,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V5Pay.
|
||||||
|
*/
|
||||||
|
V5_PAY,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Airwallex.
|
* Airwallex.
|
||||||
@ -93,7 +98,7 @@ public enum MonthlyRechargeType {
|
|||||||
* 个人充值.
|
* 个人充值.
|
||||||
*/
|
*/
|
||||||
public static List<MonthlyRechargeType> getPersonalRecharge() {
|
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);
|
STRIPE, PAY_PAL, CLIPSPAY, SHIPPING_AGENT, SALARY_EXCHANGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -86,9 +86,31 @@ public class PayServerNoticeController extends BaseController {
|
|||||||
*/
|
*/
|
||||||
@IgnoreResultResponse
|
@IgnoreResultResponse
|
||||||
@PostMapping(value = "/mifa_pay/receive_payment", consumes = MediaType.APPLICATION_JSON_VALUE)
|
@PostMapping(value = "/mifa_pay/receive_payment", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) {
|
public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) {
|
||||||
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(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 退款回调地址-自定义.
|
* 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.framework.core.asserts.ResponseAssert;
|
||||||
import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport;
|
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.clientobject.pay.PayOrderStatusCO;
|
||||||
import com.red.circle.order.app.dto.cmd.PayOrderStatusCmd;
|
import com.red.circle.order.app.dto.cmd.PayOrderStatusCmd;
|
||||||
import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay;
|
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 com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,9 +21,11 @@ import org.springframework.stereotype.Component;
|
|||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
public class PayWebOrderStatusQueryExe {
|
public class PayWebOrderStatusQueryExe {
|
||||||
|
|
||||||
private final OrderUserPurchasePayService orderUserPurchasePayService;
|
private final OrderUserPurchasePayService orderUserPurchasePayService;
|
||||||
|
private final V5PayOrderReconcileService v5PayOrderReconcileService;
|
||||||
|
|
||||||
public PayOrderStatusCO execute(PayOrderStatusCmd cmd) {
|
public PayOrderStatusCO execute(PayOrderStatusCmd cmd) {
|
||||||
OrderUserPurchasePay mysqlOrder = getMysqlOrder(cmd.getOrderId());
|
OrderUserPurchasePay mysqlOrder = getMysqlOrder(cmd.getOrderId());
|
||||||
@ -30,6 +34,20 @@ public class PayWebOrderStatusQueryExe {
|
|||||||
// 登录态请求继续使用网关注入的 reqUserId;公开 H5 轮询没有登录态,只允许用下单用户ID校验同一笔订单.
|
// 登录态请求继续使用网关注入的 reqUserId;公开 H5 轮询没有登录态,只允许用下单用户ID校验同一笔订单.
|
||||||
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
|
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
|
||||||
Objects.nonNull(expectedUserId) && Objects.equals(mysqlOrder.getUserId(), expectedUserId));
|
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
|
InAppPurchaseStatus status = MiFaPayMysqlOrderSupport
|
||||||
.toInAppPurchaseStatus(mysqlOrder.getPayStatus());
|
.toInAppPurchaseStatus(mysqlOrder.getPayStatus());
|
||||||
return new PayOrderStatusCO()
|
return new PayOrderStatusCO()
|
||||||
@ -40,7 +58,7 @@ public class PayWebOrderStatusQueryExe {
|
|||||||
.setFinished(MiFaPayMysqlOrderSupport.isFinished(mysqlOrder.getPayStatus()))
|
.setFinished(MiFaPayMysqlOrderSupport.isFinished(mysqlOrder.getPayStatus()))
|
||||||
.setReason(mysqlOrder.getReason())
|
.setReason(mysqlOrder.getReason())
|
||||||
.setFactoryCode(mysqlOrder.getFactoryCode())
|
.setFactoryCode(mysqlOrder.getFactoryCode())
|
||||||
.setFactoryChannelCode(mysqlOrder.getPaymentChannel())
|
.setFactoryChannelCode(mysqlOrder.getFactoryChannel())
|
||||||
.setCurrency(mysqlOrder.getPaymentUnit())
|
.setCurrency(mysqlOrder.getPaymentUnit())
|
||||||
.setAmount(mysqlOrder.getPaymentAmount())
|
.setAmount(mysqlOrder.getPaymentAmount())
|
||||||
.setUpdateTime(mysqlOrder.getUpdateTime());
|
.setUpdateTime(mysqlOrder.getUpdateTime());
|
||||||
@ -50,4 +68,26 @@ public class PayWebOrderStatusQueryExe {
|
|||||||
Long mysqlOrderId = MiFaPayMysqlOrderSupport.parseOrderId(orderId);
|
Long mysqlOrderId = MiFaPayMysqlOrderSupport.parseOrderId(orderId);
|
||||||
return mysqlOrderId == null ? null : orderUserPurchasePayService.getById(mysqlOrderId);
|
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.common.business.core.amount.PayAmountArithmeticUtils;
|
||||||
import com.red.circle.component.pay.PayProperties;
|
import com.red.circle.component.pay.PayProperties;
|
||||||
import com.red.circle.framework.core.asserts.ResponseAssert;
|
import com.red.circle.framework.core.asserts.ResponseAssert;
|
||||||
import com.red.circle.framework.core.response.CommonErrorCode;
|
import com.red.circle.framework.core.response.CommonErrorCode;
|
||||||
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2;
|
import com.red.circle.framework.core.response.ResponseErrorCode;
|
||||||
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService;
|
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.clientobject.pay.PlaceAnOrderResponseCO;
|
||||||
import com.red.circle.order.app.dto.cmd.PayPlaceAnOrderCmd;
|
import com.red.circle.order.app.dto.cmd.PayPlaceAnOrderCmd;
|
||||||
import com.red.circle.order.domain.gateway.UserFreightRechargeRecordGateway;
|
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.tool.core.text.StringUtils;
|
||||||
import com.red.circle.order.app.common.PayerMaxProperties;
|
import com.red.circle.order.app.common.PayerMaxProperties;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
||||||
@ -70,9 +73,7 @@ public class PayWebPlaceAnOrderCmdExe {
|
|||||||
ResponseAssert.notNull(PayErrorCode.REGION_NOT_SUPPORTED, payCountry);
|
ResponseAssert.notNull(PayErrorCode.REGION_NOT_SUPPORTED, payCountry);
|
||||||
cmd.setPayCountryId(payCountry.getId());
|
cmd.setPayCountryId(payCountry.getId());
|
||||||
|
|
||||||
PayCountryChannelDetails channelDetails = payCountryChannelDetailsService
|
PayCountryChannelDetails channelDetails = resolveChannelDetails(cmd, commodity);
|
||||||
.getSuggestScoreMax(cmd.getPayCountryId(), cmd.getChannelCode(),
|
|
||||||
commodity.getAmountUsd());
|
|
||||||
// 没有合适的支付方式
|
// 没有合适的支付方式
|
||||||
ResponseAssert.notNull(PayErrorCode.NO_SUITABLE_PAYMENT_CHANNEL, channelDetails);
|
ResponseAssert.notNull(PayErrorCode.NO_SUITABLE_PAYMENT_CHANNEL, channelDetails);
|
||||||
SysCountryCodeDTO sysCountryCode = ResponseAssert.requiredSuccess(
|
SysCountryCodeDTO sysCountryCode = ResponseAssert.requiredSuccess(
|
||||||
@ -114,11 +115,12 @@ public class PayWebPlaceAnOrderCmdExe {
|
|||||||
details.setRechargeUrl(payProperties.getWebPayRechargePageUrl()
|
details.setRechargeUrl(payProperties.getWebPayRechargePageUrl()
|
||||||
+ "/" + application.getId()
|
+ "/" + application.getId()
|
||||||
+ "?type=" + commodity.getType());
|
+ "?type=" + commodity.getType());
|
||||||
details.setSuccessRedirectUrl(payProperties.getWebPayResultPageUrl()
|
String defaultSuccessRedirectUrl = payProperties.getWebPayResultPageUrl()
|
||||||
+ "?redirect=" + payProperties.getWebPayRechargePage()
|
+ "?redirect=" + payProperties.getWebPayRechargePage()
|
||||||
+ "&appId=" + application.getId()
|
+ "&appId=" + application.getId()
|
||||||
+ "&appStatus=0"
|
+ "&appStatus=0"
|
||||||
+ "&appType=" + commodity.getType());
|
+ "&appType=" + commodity.getType();
|
||||||
|
details.setSuccessRedirectUrl(resolveReturnUrl(cmd.getReturnUrl(), defaultSuccessRedirectUrl));
|
||||||
details.setCancelRedirectUrl(details.getRechargeUrl());
|
details.setCancelRedirectUrl(details.getRechargeUrl());
|
||||||
details.setNewVersion(cmd.getNewVersion());
|
details.setNewVersion(cmd.getNewVersion());
|
||||||
details.setAppVersion(cmd.getAppVersion());
|
details.setAppVersion(cmd.getAppVersion());
|
||||||
@ -126,8 +128,60 @@ public class PayWebPlaceAnOrderCmdExe {
|
|||||||
|
|
||||||
// 记录充值金额
|
// 记录充值金额
|
||||||
rechargeRecord(details);
|
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.core.request.RequestClientEnum;
|
||||||
import com.red.circle.framework.web.props.EnvProperties;
|
import com.red.circle.framework.web.props.EnvProperties;
|
||||||
import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport;
|
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.PayerMaxProperties;
|
||||||
import com.red.circle.order.app.common.QuoteQueryParam;
|
import com.red.circle.order.app.common.QuoteQueryParam;
|
||||||
import com.red.circle.order.app.common.QuoteQueryResponse;
|
import com.red.circle.order.app.common.QuoteQueryResponse;
|
||||||
@ -53,7 +54,7 @@ public class PayPlaceAnOrderService {
|
|||||||
param.getStrategyCode())
|
param.getStrategyCode())
|
||||||
.doOperation(param);
|
.doOperation(param);
|
||||||
|
|
||||||
if (MiFaPayMysqlOrderSupport.isMiFaPay(param.getFactoryCode())) {
|
if (WebPayProviderSupport.isMysqlWebPayFactory(param.getFactoryCode())) {
|
||||||
Long purchasePayId = MiFaPayMysqlOrderSupport.parseOrderId(param.getOrderId());
|
Long purchasePayId = MiFaPayMysqlOrderSupport.parseOrderId(param.getOrderId());
|
||||||
orderUserPurchasePayService.save(
|
orderUserPurchasePayService.save(
|
||||||
MiFaPayMysqlOrderSupport.buildPrepaymentOrder(param, response, env()));
|
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.PayerMaxServerNoticeReceivePaymentCmdExe;
|
||||||
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe;
|
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.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.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe;
|
||||||
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
|
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
|
||||||
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
|
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
|
||||||
@ -74,7 +75,8 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
|
|||||||
private final ReceiptPayWebPlaceAnOrderReceiptCmdExe receiptPayWebPlaceAnOrderReceiptCmdExe;
|
private final ReceiptPayWebPlaceAnOrderReceiptCmdExe receiptPayWebPlaceAnOrderReceiptCmdExe;
|
||||||
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
|
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
|
||||||
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
|
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
|
||||||
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
|
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
|
||||||
|
private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe;
|
||||||
private final TaskMqMessage taskMqMessage;
|
private final TaskMqMessage taskMqMessage;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -189,8 +191,18 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String miFaPayServerNoticeReceivePayment(byte[] body) {
|
public String miFaPayServerNoticeReceivePayment(byte[] body) {
|
||||||
return miFaPayServerNoticeReceivePaymentCmdExe.execute(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继续做订单归属校验.
|
* H5公开支付页没有登录态,轮询状态时必须用下单用户ID继续做订单归属校验.
|
||||||
*/
|
*/
|
||||||
private Long userId;
|
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
|
* @eo.required
|
||||||
*/
|
*/
|
||||||
@NotBlank(message = "channelCode required.")
|
@NotBlank(message = "channelCode required.")
|
||||||
private String channelCode;
|
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信息.
|
* 请求ip信息.
|
||||||
*/
|
*/
|
||||||
private String requestIp;
|
private String requestIp;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,6 +56,16 @@ public interface InAppPurchaseProductService {
|
|||||||
/**
|
/**
|
||||||
* MiFaPay支付回调通知
|
* 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> {
|
public interface OrderUserPurchasePayService extends BaseService<OrderUserPurchasePay> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付终态处理必须先锁定订单行,防止多实例并发回调重复执行发币副作用.
|
||||||
|
*/
|
||||||
|
OrderUserPurchasePay getByIdForUpdate(Long id);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记支付成功.
|
* 标记支付成功.
|
||||||
*/
|
*/
|
||||||
void updateSuccess(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_SUCCESSFUL = "SUCCESSFUL";
|
||||||
private static final String PAY_STATUS_FAIL = "FAIL";
|
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
|
@Override
|
||||||
public void updateSuccess(Long id) {
|
public void updateSuccess(Long id) {
|
||||||
|
updateSuccess(id, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateSuccess(Long id, String factoryOrderId) {
|
||||||
update()
|
update()
|
||||||
.set(OrderUserPurchasePay::getPayStatus, PAY_STATUS_SUCCESSFUL)
|
.set(OrderUserPurchasePay::getPayStatus, PAY_STATUS_SUCCESSFUL)
|
||||||
.set(OrderUserPurchasePay::getReason, "")
|
.set(OrderUserPurchasePay::getReason, "")
|
||||||
|
.set(factoryOrderId != null && !factoryOrderId.isBlank(),
|
||||||
|
OrderUserPurchasePay::getFactoryOrderId, factoryOrderId)
|
||||||
.set(OrderUserPurchasePay::getUpdateTime, TimestampUtils.now())
|
.set(OrderUserPurchasePay::getUpdateTime, TimestampUtils.now())
|
||||||
.eq(OrderUserPurchasePay::getId, id)
|
.eq(OrderUserPurchasePay::getId, id)
|
||||||
.execute();
|
.execute();
|
||||||
|
|||||||
@ -30,8 +30,15 @@ public interface PayCountryChannelDetailsService extends BaseService<PayCountryC
|
|||||||
/**
|
/**
|
||||||
* 获取建议支付的渠道厂商信息.
|
* 获取建议支付的渠道厂商信息.
|
||||||
*/
|
*/
|
||||||
PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
|
PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
|
||||||
BigDecimal amountUsd);
|
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
|
@Override
|
||||||
public PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
|
public PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
|
||||||
BigDecimal amountUsd) {
|
BigDecimal amountUsd) {
|
||||||
return Optional
|
return getSuggestScoreMax(payCountryId, channelCode, amountUsd, null, null);
|
||||||
.ofNullable(query()
|
}
|
||||||
.eq(PayCountryChannelDetails::getChannelCode, channelCode)
|
|
||||||
.eq(PayCountryChannelDetails::getPayCountryId, payCountryId)
|
@Override
|
||||||
.le(PayCountryChannelDetails::getFactoryMinLimit, amountUsd)
|
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::getFactoryMaxLimit, amountUsd)
|
||||||
.ge(PayCountryChannelDetails::getFactoryDailyLimit, amountUsd)
|
.ge(PayCountryChannelDetails::getFactoryDailyLimit, amountUsd)
|
||||||
.eq(PayCountryChannelDetails::getShelf, Boolean.TRUE)
|
.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