feat(order): add Yumi V5Pay recharge

This commit is contained in:
hy001 2026-07-10 19:59:12 +08:00
parent 13a573083e
commit 79bb91237b
34 changed files with 1669 additions and 49 deletions

View File

@ -49,6 +49,18 @@ red-circle:
factory-config:
cache-ttl-seconds: ${LIKEI_PAY_FACTORY_CONFIG_CACHE_TTL_SECONDS:300}
# V5Pay 功能默认开启;商户账号、密钥与所有外部地址只能由运行环境注入,仓库不提供伪造可用值。
v5-pay:
enabled: ${LIKEI_V5PAY_ENABLED:true}
merchant-no: ${LIKEI_V5PAY_MERCHANT_NO:}
app-key: ${LIKEI_V5PAY_APP_KEY:}
secret-key: ${LIKEI_V5PAY_SECRET_KEY:}
gateway-base-url: ${LIKEI_V5PAY_GATEWAY_BASE_URL:}
notify-url: ${LIKEI_V5PAY_NOTIFY_URL:}
return-url: ${LIKEI_V5PAY_RETURN_URL:}
# 共享商户必须隔离各系统 orderNo仅在供应商侧约定变化时覆盖。
order-prefix: ${LIKEI_V5PAY_ORDER_PREFIX:YUMI}
airwallex:
client-id: ${LIKEI_AIRWALLEX_CLIENT_ID}
api-key: ${LIKEI_AIRWALLEX_API_KEY}

View File

@ -43,6 +43,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.zip.GZIPInputStream;
@ -58,6 +59,7 @@ import java.util.zip.GZIPInputStream;
public class ApiLoggingFilter implements GlobalFilter, Ordered {
private static final int MAX_RESPONSE_BODY_LOG_LENGTH = 500;
private static final String REDACTED_V5PAY_BODY = "[REDACTED_V5PAY_CALLBACK]";
private final LoggingUploadService loggingUploadService;
private final List<HttpMessageReader<?>> messageReaders = HandlerStrategies.withDefaults()
@ -72,9 +74,15 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
return chain.filter(exchange);
}
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
return checkBodyType(exchange.getRequest())
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
if (isV5PayCallbackPath(request.getURI().getPath())) {
// V5Pay MD5 覆盖原始字段文本通用 bodyToMono -> JSON 重写会把 14400.00 变成
// 14400.0 并破坏验签同时还会把 sign/appKey 写入日志该回调必须完全不消费不重写 body
return writeSensitiveRawBodyLog(exchange, chain, logging);
}
return checkBodyType(exchange.getRequest())
? writeBodyLog(exchange, chain, logging)
: writeBasicLog(exchange, chain, logging);
}
@ -186,6 +194,30 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
};
}
static boolean isV5PayCallbackPath(String path) {
if (StringUtils.isBlank(path)) {
return false;
}
String normalized = path.toLowerCase(Locale.ROOT);
return normalized.endsWith("/play-server-notice/v5pay/receive_payment")
|| normalized.endsWith("/play-server-notice/v5_pay/receive_payment");
}
private Mono<Void> writeSensitiveRawBodyLog(ServerWebExchange exchange,
GatewayFilterChain chain,
GatewayLogging logging) {
// 明确写固定脱敏标记避免后续日志上传误以为 body 尚未采集并再次尝试读取敏感回调体
logging.getRequest().setBody(REDACTED_V5PAY_BODY);
ServerHttpResponseDecorator decoratedResponse = responseLog(exchange, logging);
ServerHttpRequest request = exchange.getRequest().mutate().headers(this::setRequestTraceId)
.build();
return chain.filter(exchange.mutate().request(request).response(decoratedResponse).build())
.then(Mono.fromRunnable(() -> {
ApiLoggingUtils.resetGatewayLogging(exchange, logging);
logging.uploadLog(loggingUploadService);
}));
}
private boolean isSuccessResponse(ServerHttpResponse response) {
HttpStatusCode statusCode = response.getStatusCode();
return statusCode == null || statusCode.is2xxSuccessful();

View File

@ -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());
}
}

View File

@ -22,7 +22,12 @@ public enum MonthlyRechargeType {
/**
* MifaPay
*/
MIFA_PAY,
MIFA_PAY,
/**
* V5Pay.
*/
V5_PAY,
/**
* Airwallex.
@ -93,7 +98,7 @@ public enum MonthlyRechargeType {
* 个人充值.
*/
public static List<MonthlyRechargeType> getPersonalRecharge() {
return List.of(HUAWEI, GOOGLE, APPLE, PAYER_MAX, MIFA_PAY, AIRWALLEX, PAYNICORN,
return List.of(HUAWEI, GOOGLE, APPLE, PAYER_MAX, MIFA_PAY, V5_PAY, AIRWALLEX, PAYNICORN,
STRIPE, PAY_PAL, CLIPSPAY, SHIPPING_AGENT, SALARY_EXCHANGE);
}

View File

@ -86,9 +86,31 @@ public class PayServerNoticeController extends BaseController {
*/
@IgnoreResultResponse
@PostMapping(value = "/mifa_pay/receive_payment", consumes = MediaType.APPLICATION_JSON_VALUE)
public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) {
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body);
}
public String miFaPayReceivePayment(@RequestBody(required = false) byte[] body) {
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body);
}
/**
* V5Pay JSON 收款回调供应商要求 text/plain 小写 success/fail.
*/
@IgnoreResultResponse
@PostMapping(value = {"/v5pay/receive_payment", "/v5_pay/receive_payment"},
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String v5PayReceivePaymentJson(@RequestBody(required = false) byte[] body) {
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(body);
}
/**
* V5Pay 部分通道会使用 form 回调 JSON 入口共用同一验签和幂等入账流程.
*/
@IgnoreResultResponse
@PostMapping(value = {"/v5pay/receive_payment", "/v5_pay/receive_payment"},
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String v5PayReceivePaymentForm(@RequestParam Map<String, String> body) {
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(body);
}
/**
* payerMax 退款回调地址-自定义.

View File

@ -2,6 +2,7 @@ package com.red.circle.order.app.command.pay.web;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport;
import com.red.circle.order.app.common.WebPayProviderSupport;
import com.red.circle.order.app.dto.clientobject.pay.PayOrderStatusCO;
import com.red.circle.order.app.dto.cmd.PayOrderStatusCmd;
import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay;
@ -10,6 +11,7 @@ import com.red.circle.order.inner.asserts.OrderErrorCode;
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
@ -19,9 +21,11 @@ import org.springframework.stereotype.Component;
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class PayWebOrderStatusQueryExe {
private final OrderUserPurchasePayService orderUserPurchasePayService;
private final V5PayOrderReconcileService v5PayOrderReconcileService;
public PayOrderStatusCO execute(PayOrderStatusCmd cmd) {
OrderUserPurchasePay mysqlOrder = getMysqlOrder(cmd.getOrderId());
@ -30,6 +34,20 @@ public class PayWebOrderStatusQueryExe {
// 登录态请求继续使用网关注入的 reqUserId公开 H5 轮询没有登录态只允许用下单用户ID校验同一笔订单.
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
Objects.nonNull(expectedUserId) && Objects.equals(mysqlOrder.getUserId(), expectedUserId));
validateProviderSnapshot(cmd, mysqlOrder);
if (WebPayProviderSupport.isV5PayFactory(mysqlOrder.getFactoryCode())
&& Objects.equals(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT,
mysqlOrder.getPayStatus())) {
try {
// H5 返回页主动查单用于补偿回调延迟上游临时失败时保留本地 pending避免轮询接口变成 5xx.
v5PayOrderReconcileService.refreshIfPending(mysqlOrder);
} catch (Exception e) {
log.warn("V5Pay主动查单失败保留本地订单状态: localOrderId={}, errorType={}",
mysqlOrder.getId(), e.getClass().getSimpleName());
}
mysqlOrder = orderUserPurchasePayService.getById(mysqlOrder.getId());
}
InAppPurchaseStatus status = MiFaPayMysqlOrderSupport
.toInAppPurchaseStatus(mysqlOrder.getPayStatus());
return new PayOrderStatusCO()
@ -40,7 +58,7 @@ public class PayWebOrderStatusQueryExe {
.setFinished(MiFaPayMysqlOrderSupport.isFinished(mysqlOrder.getPayStatus()))
.setReason(mysqlOrder.getReason())
.setFactoryCode(mysqlOrder.getFactoryCode())
.setFactoryChannelCode(mysqlOrder.getPaymentChannel())
.setFactoryChannelCode(mysqlOrder.getFactoryChannel())
.setCurrency(mysqlOrder.getPaymentUnit())
.setAmount(mysqlOrder.getPaymentAmount())
.setUpdateTime(mysqlOrder.getUpdateTime());
@ -50,4 +68,26 @@ public class PayWebOrderStatusQueryExe {
Long mysqlOrderId = MiFaPayMysqlOrderSupport.parseOrderId(orderId);
return mysqlOrderId == null ? null : orderUserPurchasePayService.getById(mysqlOrderId);
}
private void validateProviderSnapshot(PayOrderStatusCmd cmd, OrderUserPurchasePay order) {
String requestedFactory = WebPayProviderSupport.toFactoryCode(cmd.getProviderCode());
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND, requestedFactory != null);
if (!Objects.equals("", requestedFactory)) {
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
Objects.equals(requestedFactory, order.getFactoryCode()));
}
if (cmd.getPayType() != null && !cmd.getPayType().isBlank()) {
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
cmd.getPayType().trim().equalsIgnoreCase(
Objects.toString(order.getFactoryChannel(), "").trim()));
}
if (cmd.getPayWay() != null && !cmd.getPayWay().isBlank()) {
String paymentChannel = Objects.toString(order.getPaymentChannel(), "");
String prefix = cmd.getPayWay().trim() + "_";
ResponseAssert.isTrue(OrderErrorCode.NO_PURCHASE_RECORD_FOUND,
paymentChannel.equalsIgnoreCase(cmd.getPayWay().trim())
|| paymentChannel.regionMatches(true, 0, prefix, 0, prefix.length()));
}
// returnUrl 只为兼容 H5 统一参数对象而接受状态查询不重定向绝不把它写回订单或发给上游.
}
}

View File

@ -2,10 +2,12 @@ package com.red.circle.order.app.command.pay.web;
import com.red.circle.common.business.core.amount.PayAmountArithmeticUtils;
import com.red.circle.component.pay.PayProperties;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.framework.core.response.CommonErrorCode;
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2;
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.framework.core.response.CommonErrorCode;
import com.red.circle.framework.core.response.ResponseErrorCode;
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderDetailsV2;
import com.red.circle.order.app.command.pay.web.strategy.PayPlaceAnOrderService;
import com.red.circle.order.app.common.WebPayProviderSupport;
import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO;
import com.red.circle.order.app.dto.cmd.PayPlaceAnOrderCmd;
import com.red.circle.order.domain.gateway.UserFreightRechargeRecordGateway;
@ -27,6 +29,7 @@ import com.red.circle.tool.core.sequence.IdWorkerUtils;
import com.red.circle.tool.core.text.StringUtils;
import com.red.circle.order.app.common.PayerMaxProperties;
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import java.util.Objects;
@ -70,9 +73,7 @@ public class PayWebPlaceAnOrderCmdExe {
ResponseAssert.notNull(PayErrorCode.REGION_NOT_SUPPORTED, payCountry);
cmd.setPayCountryId(payCountry.getId());
PayCountryChannelDetails channelDetails = payCountryChannelDetailsService
.getSuggestScoreMax(cmd.getPayCountryId(), cmd.getChannelCode(),
commodity.getAmountUsd());
PayCountryChannelDetails channelDetails = resolveChannelDetails(cmd, commodity);
// 没有合适的支付方式
ResponseAssert.notNull(PayErrorCode.NO_SUITABLE_PAYMENT_CHANNEL, channelDetails);
SysCountryCodeDTO sysCountryCode = ResponseAssert.requiredSuccess(
@ -114,11 +115,12 @@ public class PayWebPlaceAnOrderCmdExe {
details.setRechargeUrl(payProperties.getWebPayRechargePageUrl()
+ "/" + application.getId()
+ "?type=" + commodity.getType());
details.setSuccessRedirectUrl(payProperties.getWebPayResultPageUrl()
+ "?redirect=" + payProperties.getWebPayRechargePage()
+ "&appId=" + application.getId()
+ "&appStatus=0"
+ "&appType=" + commodity.getType());
String defaultSuccessRedirectUrl = payProperties.getWebPayResultPageUrl()
+ "?redirect=" + payProperties.getWebPayRechargePage()
+ "&appId=" + application.getId()
+ "&appStatus=0"
+ "&appType=" + commodity.getType();
details.setSuccessRedirectUrl(resolveReturnUrl(cmd.getReturnUrl(), defaultSuccessRedirectUrl));
details.setCancelRedirectUrl(details.getRechargeUrl());
details.setNewVersion(cmd.getNewVersion());
details.setAppVersion(cmd.getAppVersion());
@ -126,8 +128,60 @@ public class PayWebPlaceAnOrderCmdExe {
// 记录充值金额
rechargeRecord(details);
return details;
}
return details;
}
private PayCountryChannelDetails resolveChannelDetails(PayPlaceAnOrderCmd cmd,
PayApplicationCommodity commodity) {
String factoryCode = WebPayProviderSupport.toFactoryCode(cmd.getProviderCode());
// providerCode 非空但无法映射时必须拒绝不能回退到旧评分逻辑后让用户在未知厂商付款.
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR, factoryCode != null);
if (StringUtils.isBlank(factoryCode)) {
return payCountryChannelDetailsService.getSuggestScoreMax(
cmd.getPayCountryId(), cmd.getChannelCode(), commodity.getAmountUsd());
}
PayCountryChannelDetails configured = payCountryChannelDetailsService.getSuggestScoreMax(
cmd.getPayCountryId(), cmd.getChannelCode(), commodity.getAmountUsd(), factoryCode,
cmd.getPayType());
if (configured != null || !WebPayProviderSupport.isV5PayFactory(factoryCode)) {
return configured;
}
// Yumi app JSON 是旧 H5 支付方式控制面V5Pay 尚无旧表 method_id 用请求中的
// payWay/payType 生成只服务当前订单的瞬时渠道快照金额国家和币种仍完全取服务端商品配置.
ResponseAssert.notBlank(ResponseErrorCode.REQUEST_PARAMETER_ERROR, cmd.getPayWay());
ResponseAssert.notBlank(ResponseErrorCode.REQUEST_PARAMETER_ERROR, cmd.getPayType());
return new PayCountryChannelDetails()
.setId(0L)
.setPayCountryId(cmd.getPayCountryId())
.setChannelCode(cmd.getChannelCode())
.setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY)
.setFactoryChannel(cmd.getPayType().trim())
.setFactoryCurrencyPoint(2)
.setSuggestScore(100)
.setShelf(Boolean.TRUE);
}
private String resolveReturnUrl(String requestedReturnUrl, String defaultReturnUrl) {
if (StringUtils.isBlank(requestedReturnUrl)) {
return defaultReturnUrl;
}
try {
URI uri = URI.create(requestedReturnUrl.trim());
boolean httpScheme = "http".equalsIgnoreCase(uri.getScheme())
|| "https".equalsIgnoreCase(uri.getScheme());
// 返回地址会展示在三方收银台只接受无账号信息的绝对 http(s) URL拒绝 javascript/data
// 等可执行协议和 user@host 混淆地址.
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
uri.isAbsolute() && httpScheme && StringUtils.isNotBlank(uri.getHost())
&& StringUtils.isBlank(uri.getUserInfo()));
return uri.toString();
} catch (IllegalArgumentException e) {
ResponseAssert.failure(ResponseErrorCode.REQUEST_PARAMETER_ERROR, "returnUrl invalid");
return defaultReturnUrl;
}
}
/**
* 获取实时汇率失败时返回配置的汇率

View File

@ -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);
}
}
}

View File

@ -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
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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) + "***";
}
}

View File

@ -4,6 +4,7 @@ import com.red.circle.component.pay.paymax.PayMaxUtils;
import com.red.circle.framework.core.request.RequestClientEnum;
import com.red.circle.framework.web.props.EnvProperties;
import com.red.circle.order.app.common.MiFaPayMysqlOrderSupport;
import com.red.circle.order.app.common.WebPayProviderSupport;
import com.red.circle.order.app.common.PayerMaxProperties;
import com.red.circle.order.app.common.QuoteQueryParam;
import com.red.circle.order.app.common.QuoteQueryResponse;
@ -53,7 +54,7 @@ public class PayPlaceAnOrderService {
param.getStrategyCode())
.doOperation(param);
if (MiFaPayMysqlOrderSupport.isMiFaPay(param.getFactoryCode())) {
if (WebPayProviderSupport.isMysqlWebPayFactory(param.getFactoryCode())) {
Long purchasePayId = MiFaPayMysqlOrderSupport.parseOrderId(param.getOrderId());
orderUserPurchasePayService.save(
MiFaPayMysqlOrderSupport.buildPrepaymentOrder(param, response, env()));

View File

@ -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) {
// 日志只记录本地订单和错误类型禁止输出带 signappKey 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;
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
/**
* 通知与查单审计数据不能持久化完整 signappKey 或商户凭证避免订单明细成为凭证副本.
*/
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;
}
}

View File

@ -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
* 然后计算小写 MD5secretKey 前不能额外补 &否则网关会判定验签失败.
*/
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);
}
}
}

View File

@ -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);
}
}

View File

@ -29,7 +29,8 @@ import com.red.circle.order.app.command.pay.web.PayWebPlaceAnOrderCmdExe;
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe;
import com.red.circle.order.app.command.pay.web.PaynicornServerNoticeCmdExe;
import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.V5PayServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe;
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
@ -74,7 +75,8 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
private final ReceiptPayWebPlaceAnOrderReceiptCmdExe receiptPayWebPlaceAnOrderReceiptCmdExe;
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe;
private final TaskMqMessage taskMqMessage;
@Override
@ -189,8 +191,18 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
}
@Override
public String miFaPayServerNoticeReceivePayment(byte[] body) {
return miFaPayServerNoticeReceivePaymentCmdExe.execute(body);
}
}
public String miFaPayServerNoticeReceivePayment(byte[] body) {
return miFaPayServerNoticeReceivePaymentCmdExe.execute(body);
}
@Override
public String v5PayServerNoticeReceivePayment(byte[] body) {
return v5PayServerNoticeReceivePaymentCmdExe.executeJson(body);
}
@Override
public String v5PayServerNoticeReceivePayment(Map<String, String> body) {
return v5PayServerNoticeReceivePaymentCmdExe.executeForm(body);
}
}

View File

@ -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 环境配置功能默认开启但商户号appKeysecretKey 与地址必须由运行环境注入.
*/
@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);
}
}
}

View File

@ -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;
}
}

View File

@ -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"));
}
}

View File

@ -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) {
}
}

View File

@ -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);
}
}

View File

@ -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"));
}
}

View File

@ -28,4 +28,24 @@ public class PayOrderStatusCmd extends AppExtCommand {
* H5公开支付页没有登录态轮询状态时必须用下单用户ID继续做订单归属校验.
*/
private Long userId;
/**
* H5 可回传提供商编码非空时必须与订单快照一致避免跨提供商猜单.
*/
private String providerCode;
/**
* 与创建单共用的 H5 兼容字段非空时用于核对订单渠道快照.
*/
private String payWay;
/**
* 与创建单共用的 H5 兼容字段非空时必须与订单 productType 快照一致.
*/
private String payType;
/**
* 状态查询不会发生跳转但保留该字段以兼容 H5 的统一订单参数结构.
*/
private String returnUrl;
}

View File

@ -56,8 +56,28 @@ public class PayPlaceAnOrderCmd implements Serializable {
*
* @eo.required
*/
@NotBlank(message = "channelCode required.")
private String channelCode;
@NotBlank(message = "channelCode required.")
private String channelCode;
/**
* H5 三方支付提供商编码例如 mifapay / v5pay为空时保持旧版按渠道评分选厂商的行为.
*/
private String providerCode;
/**
* H5 展示层支付大类例如 BankTransfer / Ewallet / QR.
*/
private String payWay;
/**
* 厂商支付产品类型V5Pay 会把该值原样作为 productType 下单并写入订单快照.
*/
private String payType;
/**
* 三方收银台完成后的 H5 返回地址服务端只接受绝对 http(s) 地址.
*/
private String returnUrl;
/**
* 版本信息.
@ -71,6 +91,6 @@ public class PayPlaceAnOrderCmd implements Serializable {
/**
* 请求ip信息.
*/
private String requestIp;
}
private String requestIp;
}

View File

@ -56,6 +56,16 @@ public interface InAppPurchaseProductService {
/**
* MiFaPay支付回调通知
*/
String miFaPayServerNoticeReceivePayment(byte[] body);
}
String miFaPayServerNoticeReceivePayment(byte[] body);
/**
* V5Pay JSON 回调通知.
*/
String v5PayServerNoticeReceivePayment(byte[] body);
/**
* V5Pay form 回调通知.
*/
String v5PayServerNoticeReceivePayment(Map<String, String> body);
}

View File

@ -8,11 +8,21 @@ import com.red.circle.order.infra.database.rds.entity.order.OrderUserPurchasePay
*/
public interface OrderUserPurchasePayService extends BaseService<OrderUserPurchasePay> {
/**
* 支付终态处理必须先锁定订单行防止多实例并发回调重复执行发币副作用.
*/
OrderUserPurchasePay getByIdForUpdate(Long id);
/**
* 标记支付成功.
*/
void updateSuccess(Long id);
/**
* 标记成功并保存最终三方交易号.
*/
void updateSuccess(Long id, String factoryOrderId);
/**
* 标记支付失败.
*/

View File

@ -18,11 +18,26 @@ public class OrderUserPurchasePayServiceImpl
private static final String PAY_STATUS_SUCCESSFUL = "SUCCESSFUL";
private static final String PAY_STATUS_FAIL = "FAIL";
@Override
public OrderUserPurchasePay getByIdForUpdate(Long id) {
return query()
.eq(OrderUserPurchasePay::getId, id)
.last("FOR UPDATE")
.getOne();
}
@Override
public void updateSuccess(Long id) {
updateSuccess(id, null);
}
@Override
public void updateSuccess(Long id, String factoryOrderId) {
update()
.set(OrderUserPurchasePay::getPayStatus, PAY_STATUS_SUCCESSFUL)
.set(OrderUserPurchasePay::getReason, "")
.set(factoryOrderId != null && !factoryOrderId.isBlank(),
OrderUserPurchasePay::getFactoryOrderId, factoryOrderId)
.set(OrderUserPurchasePay::getUpdateTime, TimestampUtils.now())
.eq(OrderUserPurchasePay::getId, id)
.execute();

View File

@ -30,8 +30,15 @@ public interface PayCountryChannelDetailsService extends BaseService<PayCountryC
/**
* 获取建议支付的渠道厂商信息.
*/
PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd);
PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd);
/**
* H5 显式指定 provider/payType 时按厂商和厂商产品精确选路禁止被同一 channelCode
* 下分值更高的另一个厂商截流.
*/
PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd, String factoryCode, String factoryChannel);
/**
* 国家支持渠道.

View File

@ -63,14 +63,24 @@ public class PayCountryChannelDetailsServiceImpl extends
}
@Override
public PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd) {
return Optional
.ofNullable(query()
.eq(PayCountryChannelDetails::getChannelCode, channelCode)
.eq(PayCountryChannelDetails::getPayCountryId, payCountryId)
.le(PayCountryChannelDetails::getFactoryMinLimit, amountUsd)
@Override
public PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd) {
return getSuggestScoreMax(payCountryId, channelCode, amountUsd, null, null);
}
@Override
public PayCountryChannelDetails getSuggestScoreMax(Long payCountryId, String channelCode,
BigDecimal amountUsd, String factoryCode, String factoryChannel) {
return Optional
.ofNullable(query()
.eq(PayCountryChannelDetails::getChannelCode, channelCode)
.eq(PayCountryChannelDetails::getPayCountryId, payCountryId)
.eq(StringUtils.isNotBlank(factoryCode), PayCountryChannelDetails::getFactoryCode,
factoryCode)
.eq(StringUtils.isNotBlank(factoryChannel),
PayCountryChannelDetails::getFactoryChannel, factoryChannel)
.le(PayCountryChannelDetails::getFactoryMinLimit, amountUsd)
.ge(PayCountryChannelDetails::getFactoryMaxLimit, amountUsd)
.ge(PayCountryChannelDetails::getFactoryDailyLimit, amountUsd)
.eq(PayCountryChannelDetails::getShelf, Boolean.TRUE)

View 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;