feat(order): add Aslan V5Pay recharge

This commit is contained in:
zhx 2026-07-10 19:59:12 +08:00
parent c2e12ffb06
commit c70cb20de5
28 changed files with 1220 additions and 17 deletions

View File

@ -23,11 +23,13 @@ FALLBACK_BASE_IMAGE="${FALLBACK_BASE_IMAGE:-eclipse-temurin:17-jdk-jammy}"
usage() {
cat <<'EOF'
Usage:
deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [wallet|other|external|console ...]
deploy-likei-services.sh [--mode preload|push|build-only|status] [--skip-build] [--fast] [gateway|wallet|order|other|external|console ...]
Examples:
/opt/aslan-deploy/deploy-likei-services.sh status
/opt/aslan-deploy/deploy-likei-services.sh --mode preload wallet
/opt/aslan-deploy/deploy-likei-services.sh --mode preload order
/opt/aslan-deploy/deploy-likei-services.sh --mode preload gateway order
/opt/aslan-deploy/deploy-likei-services.sh --mode preload other external console
/opt/aslan-deploy/deploy-likei-services.sh --fast wallet
USE_GIT_SOURCE=1 GIT_REF=aslan_prod /opt/aslan-deploy/deploy-likei-services.sh --mode preload other
@ -104,7 +106,9 @@ update_source_from_git() {
service_module() {
case "$1" in
gateway) echo "rc-gateway" ;;
wallet) echo "rc-service/rc-service-wallet/wallet-start" ;;
order) echo "rc-service/rc-service-order/order-start" ;;
other) echo "rc-service/rc-service-other/other-start" ;;
external) echo "rc-service/rc-service-external/external-start" ;;
console) echo "rc-service/rc-service-console/console-start" ;;
@ -114,7 +118,9 @@ service_module() {
service_dir() {
case "$1" in
gateway) echo "rc-gateway" ;;
wallet) echo "rc-service/rc-service-wallet" ;;
order) echo "rc-service/rc-service-order" ;;
other) echo "rc-service/rc-service-other" ;;
external) echo "rc-service/rc-service-external" ;;
console) echo "rc-service/rc-service-console" ;;
@ -124,7 +130,9 @@ service_dir() {
image_repo() {
case "$1" in
gateway) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/gateway" ;;
wallet) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/wallet" ;;
order) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/order" ;;
other) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/other" ;;
external) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/external" ;;
console) echo "$IMAGE_REGISTRY/$IMAGE_PROJECT/console" ;;
@ -295,7 +303,7 @@ rollout() {
}
status() {
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy wallet other external console -o wide
kubectl --kubeconfig "$KUBECONFIG" -n "$NAMESPACE" get deploy gateway wallet order other external console -o wide
kubectl --kubeconfig "$KUBECONFIG" get nodes -o wide
}
@ -344,6 +352,7 @@ main() {
local services=("$@")
if [[ "${#services[@]}" -eq 0 ]]; then
# 保留既有无参数发布范围gateway/order 只在显式指定时发布,避免老运维命令意外扩大生产变更面。
services=(other external console)
fi

View File

@ -26,6 +26,7 @@ Usage:
Examples:
/opt/aslan-test/deploy-likei-services.sh status
/opt/aslan-test/deploy-likei-services.sh other
/opt/aslan-test/deploy-likei-services.sh order
/opt/aslan-test/deploy-likei-services.sh --fast other external console
GIT_REF=aslan_test /opt/aslan-test/deploy-likei-services.sh auth gateway external wallet order live other console

View File

@ -70,6 +70,13 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
return chain.filter(exchange);
}
if (isV5PayCallbackPath(request)) {
// V5Pay 的签名覆盖请求原始字段文本读取为 Object 再序列化会把 14400.00 改成 14400.0
// 同时常规网关日志会泄露 sign/appKey该单一路径只补 trace headerbody Flux 原样转发且完全不上传 body 日志
ServerHttpRequest forwarded = request.mutate().headers(this::setRequestTraceId).build();
return chain.filter(exchange.mutate().request(forwarded).build());
}
GatewayLogging logging = ApiLoggingUtils.createGatewayLogging(exchange);
return checkBodyType(exchange.getRequest())
@ -83,6 +90,13 @@ public class ApiLoggingFilter implements GlobalFilter, Ordered {
|| MediaType.APPLICATION_JSON.isCompatibleWith(mediaType);
}
static boolean isV5PayCallbackPath(ServerHttpRequest request) {
String path = request.getURI().getRawPath();
// ApiLoggingFilter 可能看到 ForwardRoute 处理前的 /order 前缀也可能在测试/后续过滤器顺序中看到已剥离路径
return "/order/play-server-notice/v5pay/receive_payment".equals(path)
|| "/play-server-notice/v5pay/receive_payment".equals(path);
}
private Mono<Void> writeBasicLog(ServerWebExchange exchange, GatewayFilterChain chain,
GatewayLogging logging) {

View File

@ -0,0 +1,51 @@
package com.red.circle.gateway.filter;
import com.red.circle.gateway.logging.LoggingUploadService;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.MediaType;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
public class ApiLoggingFilterTest {
@Test
public void v5PayCallbackForwardsOriginalJsonBytesWithoutLogging() {
LoggingUploadService loggingUploadService = Mockito.mock(LoggingUploadService.class);
ApiLoggingFilter filter = new ApiLoggingFilter(loggingUploadService);
String rawBody = "{\"amount\":14400.00,\"appKey\":\"secret-app\",\"sign\":\"abc\"}";
MockServerWebExchange exchange = MockServerWebExchange.from(
MockServerHttpRequest.post("/order/play-server-notice/v5pay/receive_payment")
.contentType(MediaType.APPLICATION_JSON)
.body(rawBody));
AtomicReference<byte[]> forwarded = new AtomicReference<>();
GatewayFilterChain chain = current -> DataBufferUtils.join(current.getRequest().getBody())
.doOnNext(buffer -> {
byte[] bytes = new byte[buffer.readableByteCount()];
buffer.read(bytes);
DataBufferUtils.release(buffer);
forwarded.set(bytes);
})
.then();
filter.filter(exchange, chain).block();
Assertions.assertArrayEquals(rawBody.getBytes(StandardCharsets.UTF_8), forwarded.get());
Mockito.verifyNoInteractions(loggingUploadService);
}
@Test
public void v5PayCallbackMatchesBothExternalAndStrippedGatewayPaths() {
Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
.post("/order/play-server-notice/v5pay/receive_payment").build()));
Assertions.assertTrue(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
.post("/play-server-notice/v5pay/receive_payment").build()));
Assertions.assertFalse(ApiLoggingFilter.isV5PayCallbackPath(MockServerHttpRequest
.post("/order/play-server-notice/mifa_pay/receive_payment").build()));
}
}

View File

@ -24,6 +24,9 @@ public enum MonthlyRechargeType {
*/
MIFA_PAY,
/** V5Pay web 充值。 */
V5_PAY,
/**
* Airwallex.
*/

View File

@ -1,5 +1,8 @@
package com.red.circle.order.adapter.app;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.component.pay.paymax.PayMaxNoticeResponse;
import com.red.circle.component.pay.paymax.service.PayMaxResponse;
@ -12,7 +15,9 @@ import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
import com.red.circle.order.app.scheduler.GoogleReimburseCheckTask;
import com.red.circle.order.app.service.InAppPurchaseProductService;
import com.red.circle.tool.core.json.JacksonUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
@ -90,6 +95,53 @@ public class PayServerNoticeController extends BaseController {
return inAppPurchaseProductService.miFaPayServerNoticeReceivePayment(body);
}
/** V5Pay JSON 回调;必须返回小写 text/plain不能套统一 JSON 响应结构。 */
@IgnoreResultResponse
@PostMapping(value = "/v5pay/receive_payment",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String v5PayReceivePaymentJson(@RequestBody(required = false) byte[] body) {
try {
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(
parseV5PayJsonFields(body));
} catch (IOException exception) {
// V5Pay 需要纯文本失败响应JSON 解析错误不能再经过全局异常处理包装成业务 JSON
return "fail";
}
}
/** V5Pay form 回调与 JSON 走同一验签/订单核验链路。 */
@IgnoreResultResponse
@PostMapping(value = "/v5pay/receive_payment",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String v5PayReceivePaymentForm(@RequestParam Map<String, String> fields) {
return inAppPurchaseProductService.v5PayServerNoticeReceivePayment(fields);
}
static Map<String, String> parseV5PayJsonFields(byte[] body) throws IOException {
Map<String, String> fields = new LinkedHashMap<>();
if (Objects.isNull(body) || body.length == 0) {
return fields;
}
try (JsonParser parser = new JsonFactory().createParser(body)) {
if (parser.nextToken() != JsonToken.START_OBJECT) {
return fields;
}
while (parser.nextToken() != JsonToken.END_OBJECT) {
String key = parser.currentName();
JsonToken valueToken = parser.nextToken();
if (valueToken != null && valueToken.isScalarValue()) {
// getText 保留 14400.00 这类数字的原始小数位避免反序列化成 Double 后改变签名原文
fields.put(key, valueToken == JsonToken.VALUE_NULL ? "" : parser.getText());
} else {
parser.skipChildren();
}
}
}
return fields;
}
/**
* payerMax 退款回调地址-自定义.
*/

View File

@ -123,9 +123,9 @@ public class WebPayRestController {
}
/**
* H5 MiFaPay 下单.
* H5 三方支付下单MiFaPay/V5Pay.
*
* @eo.name H5 MiFaPay 下单.
* @eo.name H5 三方支付下单.
* @eo.url /h5/orders
* @eo.method post
* @eo.request-type json

View File

@ -0,0 +1,20 @@
package com.red.circle.order.adapter.app;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.junit.Assert;
import org.junit.Test;
public class PayServerNoticeControllerTest {
@Test
public void v5PayJsonParserPreservesSignedNumberText() throws Exception {
Map<String, String> fields = PayServerNoticeController.parseV5PayJsonFields(
"{\"amount\":14400.00,\"status\":2,\"orderNo\":\"ASLAN-123\"}"
.getBytes(StandardCharsets.UTF_8));
Assert.assertEquals("14400.00", fields.get("amount"));
Assert.assertEquals("2", fields.get("status"));
Assert.assertEquals("ASLAN-123", fields.get("orderNo"));
}
}

View File

@ -18,6 +18,12 @@
<artifactId>order-infrastructure</artifactId>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<parent>

View File

@ -31,12 +31,13 @@ import com.red.circle.tool.core.text.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/**
* Aslan H5 MiFaPay 下单和查单
* Aslan H5 MiFaPay/V5Pay 下单和查单
*
* <p>前端只提交商品 id 和支付方式标识金额金币数商品类型币种和汇率全部从后台表重新生成</p>
*
@ -47,7 +48,9 @@ import org.springframework.stereotype.Component;
public class PayWebH5PlaceOrderCmdExe {
private static final String PROVIDER_MIFAPAY = "mifapay";
private static final String PROVIDER_V5PAY = "v5pay";
private static final String FACTORY_MIFAPAY = "MIFA_PAY";
private static final String FACTORY_V5PAY = "V5_PAY";
private final PayCountryService payCountryService;
private final PayApplicationService payApplicationService;
@ -55,23 +58,30 @@ public class PayWebH5PlaceOrderCmdExe {
private final PayPlaceAnOrderService payPlaceAnOrderService;
private final InAppPurchaseDetailsService inAppPurchaseDetailsService;
private final PayApplicationCommodityService payApplicationCommodityService;
private final V5PayOrderStateService v5PayOrderStateService;
public PayWebH5OrderCO execute(PayWebH5PlaceOrderCmd cmd) {
String providerCode = Objects.toString(cmd.getProviderCode(), "").trim()
.toLowerCase(Locale.ROOT);
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
PROVIDER_MIFAPAY.equalsIgnoreCase(Objects.toString(cmd.getProviderCode(), "")));
PROVIDER_MIFAPAY.equals(providerCode) || PROVIDER_V5PAY.equals(providerCode));
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
Objects.nonNull(cmd.getApplicationId())
&& Objects.nonNull(cmd.getGoodsId())
&& Objects.nonNull(cmd.getPayCountryId())
&& Objects.nonNull(cmd.getUserId())
&& StringUtils.isNotBlank(cmd.getPayWay())
&& StringUtils.isNotBlank(cmd.getPayType()));
// MiFaPay payWay 是独立必填字段V5Pay 只把 payType 作为 productType仍保留 payWay/channelCode H5 配置透传
if (PROVIDER_MIFAPAY.equals(providerCode)) {
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
StringUtils.isNotBlank(cmd.getPayWay()));
}
PayPlaceAnOrderDetailsV2 details = buildDetails(cmd);
PayPlaceAnOrderDetailsV2 details = buildDetails(cmd, providerCode);
PlaceAnOrderResponseCO response = payPlaceAnOrderService.doRequest(details);
return new PayWebH5OrderCO()
.setOrderId(details.getOrderId())
.setProviderCode(PROVIDER_MIFAPAY)
.setProviderCode(providerCode)
.setPayUrl(response.getRequestUrl())
.setStatus("redirected")
.setProviderAmountMinor(minor(details.getAmount(), 100L))
@ -83,9 +93,14 @@ public class PayWebH5PlaceOrderCmdExe {
public PayWebH5OrderCO getOrder(String orderId) {
InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(orderId);
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, order);
// 只有 V5Pay 待处理订单主动查三方MiFaPay 保持原有本地状态查询避免改变存量渠道行为
if (Objects.nonNull(order.getFactory())
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode())) {
order = v5PayOrderStateService.refresh(order);
}
return new PayWebH5OrderCO()
.setOrderId(order.getId())
.setProviderCode(PROVIDER_MIFAPAY)
.setProviderCode(providerCode(order))
.setStatus(toH5Status(order.getStatus()))
.setProviderAmountMinor(minor(order.getAmount(), 100L))
.setUsdMinorAmount(minor(order.getAmountUsd(), 100L))
@ -93,7 +108,8 @@ public class PayWebH5PlaceOrderCmdExe {
.setFailureReason(order.getReason());
}
private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd) {
private PayPlaceAnOrderDetailsV2 buildDetails(PayWebH5PlaceOrderCmd cmd,
String providerCode) {
PayApplication application = payApplicationService.getById(cmd.getApplicationId());
ResponseAssert.notNull(CommonErrorCode.NOT_FOUND_RECORD_INFO, application);
@ -143,7 +159,7 @@ public class PayWebH5PlaceOrderCmdExe {
.setAmountUsd(commodity.getAmountUsd())
.setQuantity(1)));
details.setProductDescriptor(productDescriptor(commodity));
details.setChannelDetails(mifaPayChannel(cmd, payCountry));
details.setChannelDetails(paymentChannel(cmd, payCountry, providerCode));
details.setCountry(sysCountryCode);
details.setPayCountry(payCountry);
details.setRechargeUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
@ -151,19 +167,25 @@ public class PayWebH5PlaceOrderCmdExe {
details.setCancelRedirectUrl(StringUtils.isBlankOrElse(cmd.getReturnUrl(), ""));
details.setNewVersion(Boolean.TRUE);
details.setRequestIp(IpUtils.getIpAddr());
details.setLanguage(cmd.getLanguage());
return details;
}
private PayCountryChannelDetails mifaPayChannel(PayWebH5PlaceOrderCmd cmd, PayCountry payCountry) {
private PayCountryChannelDetails paymentChannel(PayWebH5PlaceOrderCmd cmd,
PayCountry payCountry, String providerCode) {
boolean v5Pay = PROVIDER_V5PAY.equals(providerCode);
String fallbackChannel = v5Pay
? cmd.getPayType()
: cmd.getPayWay() + "_" + cmd.getPayType();
String channelCode = StringUtils.isNotBlank(cmd.getChannelCode())
? cmd.getChannelCode()
: cmd.getPayWay() + "_" + cmd.getPayType();
// 这里刻意不查后台渠道明细表H5 支付国家和方式来自 app JSON本对象只为复用现有 MiFaPay 策略和订单快照
: fallbackChannel;
// H5 支付方式来自 app JSON本对象用于复用统一策略工厂并把 productType 固化到订单快照
return new PayCountryChannelDetails()
.setId(0L)
.setPayCountryId(payCountry.getId())
.setChannelCode(channelCode)
.setFactoryCode(FACTORY_MIFAPAY)
.setFactoryCode(v5Pay ? FACTORY_V5PAY : FACTORY_MIFAPAY)
.setFactoryChannel(cmd.getPayType())
.setFactoryCurrencyPoint(2)
.setShelf(Boolean.TRUE);
@ -190,6 +212,13 @@ public class PayWebH5PlaceOrderCmdExe {
return "pending";
}
private String providerCode(InAppPurchaseDetails order) {
return Objects.nonNull(order.getFactory())
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode())
? PROVIDER_V5PAY
: PROVIDER_MIFAPAY;
}
private Long minor(BigDecimal amount, Long scale) {
if (Objects.isNull(amount)) {
return 0L;

View File

@ -0,0 +1,216 @@
package com.red.circle.order.app.command.pay.web;
import com.red.circle.order.app.common.InAppPurchaseCommon;
import com.red.circle.order.app.common.V5PayTransactionData;
import com.red.circle.order.app.service.V5PayService;
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService;
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService;
import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails;
import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseEventNotice;
import com.red.circle.order.infra.database.rds.service.order.OrderCacheService;
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
import com.red.circle.tool.core.date.TimestampUtils;
import com.red.circle.tool.core.text.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* V5Pay 查询/回调共用的订单状态处理器
*
* <p>成功状态只在签名已验证且订单号金额币种productType 与本地快照全部一致时入账</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class V5PayOrderStateService {
static final String FACTORY_V5PAY = "V5_PAY";
private final V5PayService v5PayService;
private final InAppPurchaseCommon inAppPurchaseCommon;
private final InAppPurchaseDetailsService inAppPurchaseDetailsService;
private final InAppPurchaseCollectionReceiptService inAppPurchaseCollectionReceiptService;
private final OrderCacheService orderCacheService;
public String handleNotification(Map<String, String> fields) {
try {
V5PayTransactionData data = v5PayService.parseNotification(fields);
InAppPurchaseDetails order = inAppPurchaseDetailsService.getById(data.getOrderNo());
if (Objects.isNull(order)) {
log.error("V5Pay callback order not found: orderNo={}", data.getOrderNo());
return "fail";
}
return apply(order, data, "callback") ? "success" : "fail";
} catch (Exception exception) {
// 回调日志不打印 fieldssign 或异常对象的序列化内容防止签名材料进入日志系统
log.error("V5Pay callback rejected: reason={}", exception.getMessage());
return "fail";
}
}
/** H5 查单时主动向 V5Pay 对账,补偿异步通知延迟或丢失。 */
public InAppPurchaseDetails refresh(InAppPurchaseDetails order) {
if (!isV5PayOrder(order) || isTerminal(order.getStatus())) {
return order;
}
try {
V5PayTransactionData data = v5PayService.queryOrder(
order.getId(), order.getCountryCode(), order.getCurrency());
apply(order, data, "query");
InAppPurchaseDetails refreshed = inAppPurchaseDetailsService.getById(order.getId());
return Objects.requireNonNullElse(refreshed, order);
} catch (Exception exception) {
// 三方暂时不可用时保留本地待支付状态不能把查单网络异常误写成用户支付失败
log.warn("V5Pay query failed: orderNo={}, reason={}", order.getId(),
exception.getMessage());
return order;
}
}
boolean apply(InAppPurchaseDetails order, V5PayTransactionData data, String source) {
if (!isV5PayOrder(order)) {
log.error("V5Pay {} factory mismatch: orderNo={}", source, order.getId());
return false;
}
recordNotice(order, data, source);
String status = Objects.toString(data.getStatus(), "").trim();
if ("2".equals(status)) {
if (Objects.equals(order.getStatus(), InAppPurchaseStatus.SUCCESS)) {
// V5Pay 会重试通知本地成功订单直接确认不能再次触发余额和统计副作用
return true;
}
if (!Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) {
return false;
}
if (!matchesOrderSnapshot(order, data)) {
inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL,
"V5Pay " + source + " order snapshot mismatch");
return false;
}
String claimKey = "V5PAY:CREDIT:" + order.getId();
if (!orderCacheService.lock(claimKey)) {
// Redis SET NX 是跨实例原子 claim竞争失败不能直接 ACK callback否则持锁者稍后失败时三方会停止重试并造成丢单
InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId());
if (Objects.nonNull(latest) && isTerminal(latest.getStatus())) {
return true;
}
// 主动 query 只需保留 pendingcallback 必须返回 fail V5Pay 在首个处理者失败时继续重试
return !"callback".equals(source);
}
try {
InAppPurchaseDetails latest = inAppPurchaseDetailsService.getById(order.getId());
if (Objects.isNull(latest)) {
return false;
}
if (Objects.equals(latest.getStatus(), InAppPurchaseStatus.SUCCESS)) {
return true;
}
if (!Objects.equals(latest.getStatus(), InAppPurchaseStatus.CREATE)
|| !matchesOrderSnapshot(latest, data)) {
return false;
}
if (latest.receiptTypeEqReceipt()) {
inAppPurchaseCommon.inAppPurchaseReceipt(latest);
return true;
}
if (latest.receiptTypeEqPayment()) {
inAppPurchaseCommon.inAppPurchasePayment(latest);
return true;
}
log.error("V5Pay order receipt type invalid: orderNo={}", latest.getId());
return false;
} catch (Exception exception) {
// 失败后 finally 释放 claim 且订单仍为 CREATE后续三方重试或主动查单可恢复成功状态会在重试前被拦截
log.error("V5Pay credit failed: orderNo={}, reason={}", order.getId(),
exception.getMessage());
return false;
} finally {
orderCacheService.unLock(claimKey);
}
}
if ("3".equals(status) || "5".equals(status) || "6".equals(status)) {
if (Objects.equals(order.getStatus(), InAppPurchaseStatus.CREATE)) {
if (order.receiptTypeEqReceipt()) {
inAppPurchaseCollectionReceiptService.updateStatusSuccessFail(order.getId());
}
inAppPurchaseDetailsService.updateStatusReason(order.getId(), InAppPurchaseStatus.FAIL,
"V5Pay payment failed: status=" + status);
}
return true;
}
if (status.isEmpty() || "0".equals(status) || "1".equals(status)) {
// 协议明确的待处理状态可以 ACK但不改变本地订单
return true;
}
// 未知状态不能在 callback 上直接 ACK否则未来新增成功码会被永久吞掉主动 query 仍按 pending 展示
return !"callback".equals(source);
}
static boolean matchesOrderSnapshot(InAppPurchaseDetails order, V5PayTransactionData data) {
if (Objects.isNull(order) || Objects.isNull(data)
|| StringUtils.isBlank(data.getOrderNo())
|| StringUtils.isBlank(data.getAmount())
|| StringUtils.isBlank(data.getCurrency())
|| StringUtils.isBlank(data.getProductType())
|| !Objects.equals(order.getId(), data.getOrderNo())
|| !Objects.toString(order.getCurrency(), "").equalsIgnoreCase(data.getCurrency())
|| Objects.isNull(order.getFactory())
|| !Objects.toString(order.getFactory().getFactoryChannelCode(), "")
.equalsIgnoreCase(data.getProductType())) {
return false;
}
try {
return toMinor(order.getAmount()) == toMinor(data.getAmount());
} catch (ArithmeticException | NumberFormatException exception) {
return false;
}
}
static long toMinor(String amount) {
return toMinor(new BigDecimal(amount.trim()));
}
static long toMinor(BigDecimal amount) {
// V5Pay 金额协议固定两位小数超过两位时拒绝而不是截断避免不同金额被误判为同一订单
return amount.setScale(2, RoundingMode.UNNECESSARY)
.movePointRight(2)
.longValueExact();
}
private void recordNotice(InAppPurchaseDetails order, V5PayTransactionData data, String source) {
Map<String, String> sanitized = new LinkedHashMap<>(data.getFields());
sanitized.remove("sign");
sanitized.remove("appKey");
sanitized.remove("merchantNo");
String eventId = "v5pay-" + source + "-"
+ StringUtils.isBlankOrElse(data.getTransactionId(), data.getOrderNo()) + "-"
+ Objects.toString(data.getStatus(), "pending");
// 重复通知不重复扩张 payNotices订单状态门禁负责业务幂等这里负责审计记录幂等
if (!inAppPurchaseDetailsService.existsPayNoticeByEventId(order.getId(), eventId)) {
inAppPurchaseDetailsService.addPayNotices(order.getId(), new InAppPurchaseEventNotice()
.setEventId(eventId)
.setNoticeType("v5pay-" + source)
.setNoticeData(sanitized)
.setCreateTime(TimestampUtils.now()));
}
}
private boolean isV5PayOrder(InAppPurchaseDetails order) {
return Objects.nonNull(order)
&& Objects.nonNull(order.getFactory())
&& FACTORY_V5PAY.equalsIgnoreCase(order.getFactory().getFactoryCode());
}
private boolean isTerminal(InAppPurchaseStatus status) {
return Objects.equals(status, InAppPurchaseStatus.SUCCESS)
|| Objects.equals(status, InAppPurchaseStatus.FAIL)
|| Objects.equals(status, InAppPurchaseStatus.CANCEL)
|| Objects.equals(status, InAppPurchaseStatus.REFUND);
}
}

View File

@ -0,0 +1,17 @@
package com.red.circle.order.app.command.pay.web;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** V5Pay 回调命令入口。 */
@Component
@RequiredArgsConstructor
public class V5PayServerNoticeReceivePaymentCmdExe {
private final V5PayOrderStateService orderStateService;
public String execute(Map<String, String> fields) {
return orderStateService.handleNotification(fields);
}
}

View File

@ -120,6 +120,9 @@ public class PayPlaceAnOrderDetailsV2 implements Serializable {
*/
private String requestIp;
/** H5 语言,用于让 V5Pay 收银台返回对应语言;非法或空值由客户端降级为 en-US。 */
private String language;
public String getFactoryChannel() {
return channelDetails.getFactoryChannel();
}

View File

@ -59,7 +59,7 @@ public class PayPlaceAnOrderService {
new InAppPurchaseFactory()
.setPlatform(RequestClientEnum.H5.getClientName())
.setFactoryCode(param.getFactoryCode())
.setFactoryChannelCode(param.getChannelCode())
.setFactoryChannelCode(factoryChannelCode(param))
)
.setProducts(param.getProducts())
.setProductDescriptor(param.getProductDescriptor())
@ -93,6 +93,13 @@ public class PayPlaceAnOrderService {
return response;
}
static String factoryChannelCode(PayPlaceAnOrderDetailsV2 param) {
// 只有 V5Pay 回调必须用 productType 做严格快照校验存量 MiFa/PayMax 继续保存 channelCode避免改变历史查询语义
return "V5_PAY".equalsIgnoreCase(param.getFactoryCode())
? param.getFactoryChannel()
: param.getChannelCode();
}
private String env() {
return envProperties.isProd() ? PayEnv.PROD.name() : PayEnv.TEST.name();
}

View File

@ -0,0 +1,54 @@
package com.red.circle.order.app.command.pay.web.strategy;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.order.app.common.V5PayGatewayResponse;
import com.red.circle.order.app.common.V5PayPlaceAnOrderParam;
import com.red.circle.order.app.dto.clientobject.pay.PlaceAnOrderResponseCO;
import com.red.circle.order.app.service.V5PayService;
import com.red.circle.order.infra.util.MemberInfoGenerator;
import com.red.circle.order.inner.asserts.OrderErrorCode;
import com.red.circle.tool.core.text.StringUtils;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** V5Pay web 支付策略,复用统一的预支付订单持久化流程。 */
@Component("V5_PAY_PLACE_AN_ORDER")
@RequiredArgsConstructor
public class V5PayWebPayPlaceAnOrderStrategy implements PayWebPlaceAnOrderStrategyV2 {
private final V5PayService v5PayService;
@Override
public PlaceAnOrderResponseCO doOperation(PayPlaceAnOrderDetailsV2 details) {
MemberInfoGenerator.MemberInfo member = MemberInfoGenerator.generate(
Objects.toString(details.getAcceptUserId()));
V5PayPlaceAnOrderParam param = V5PayPlaceAnOrderParam.builder()
.orderNo(details.getOrderId())
.countryCode(details.getCountryCode())
.currency(details.getCurrency())
.amount(details.getAmount())
.productType(details.getFactoryChannel())
.returnUrl(details.getSuccessRedirectUrl())
.language(details.getLanguage())
.tradeSummary(details.getProductDescriptor())
.userId(details.getAcceptUserId())
.email(member.getEmail())
.build();
V5PayGatewayResponse response = v5PayService.createOrder(param);
ResponseAssert.notNull(OrderErrorCode.CREATE_ORDER_ERROR, response);
return new PlaceAnOrderResponseCO()
.setTradeNo(StringUtils.isBlankOrElse(response.text("transactionId"),
response.text("orderNo")))
.setOrderId(details.getOrderId())
.setCurrency(details.getCurrency())
.setCountryCode(details.getCountryCode())
.setFactoryCode(details.getFactoryCode())
.setFactoryChannelCode(details.getFactoryChannel())
.setRequestUrl(response.text("checkoutUrl"))
// 订单快照只保存非敏感业务参数商户号appKeysecretKey 不进入订单扩展数据
.putRequestParam(param)
.putResponseParam(response.sanitizedFields());
}
}

View File

@ -0,0 +1,34 @@
package com.red.circle.order.app.common;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
/**
* 保留 V5Pay 响应的全部顶层字段
*
* <p>响应签名覆盖全部非空字段不能只反序列化当前业务使用的几个属性否则新增字段会导致误判验签失败</p>
*/
@Getter
public class V5PayGatewayResponse {
private final Map<String, Object> fields = new LinkedHashMap<>();
@JsonAnySetter
public void put(String key, Object value) {
fields.put(key, value);
}
public String text(String key) {
return V5PaySignUtils.value(fields.get(key));
}
public Map<String, Object> sanitizedFields() {
Map<String, Object> sanitized = new LinkedHashMap<>(fields);
sanitized.remove("sign");
sanitized.remove("appKey");
sanitized.remove("merchantNo");
return sanitized;
}
}

View File

@ -0,0 +1,30 @@
package com.red.circle.order.app.common;
import java.util.Locale;
import java.util.Objects;
/** 共享 V5Pay 商户时隔离 Aslan 与其他系统订单号。 */
public final class V5PayOrderNoUtils {
private V5PayOrderNoUtils() {
}
public static String providerOrderNo(String prefix, String localOrderNo) {
return normalizedPrefix(prefix) + "-" + Objects.toString(localOrderNo, "").trim();
}
public static String localOrderNo(String prefix, String providerOrderNo) {
String expected = normalizedPrefix(prefix) + "-";
String value = Objects.toString(providerOrderNo, "").trim();
if (!value.regionMatches(true, 0, expected, 0, expected.length())
|| value.length() == expected.length()) {
return "";
}
return value.substring(expected.length());
}
private static String normalizedPrefix(String prefix) {
String value = Objects.toString(prefix, "ASLAN").trim();
return (value.isBlank() ? "ASLAN" : value).toUpperCase(Locale.ROOT);
}
}

View File

@ -0,0 +1,22 @@
package com.red.circle.order.app.common;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Data;
/** V5Pay 收银台下单所需的非敏感业务参数。 */
@Data
@Builder
public class V5PayPlaceAnOrderParam {
private String orderNo;
private String countryCode;
private String currency;
private BigDecimal amount;
private String productType;
private String returnUrl;
private String language;
private String tradeSummary;
private Long userId;
private String email;
}

View File

@ -0,0 +1,63 @@
package com.red.circle.order.app.common;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
/**
* V5Pay MD5 签名工具
*
* <p>协议要求过滤空值和 sign 字段 key 排序后拼成 key=value&amp;...最后直接追加 secretKey</p>
*/
public final class V5PaySignUtils {
private V5PaySignUtils() {
}
public static String sign(Map<String, ?> fields, String secretKey) {
List<String> keys = new ArrayList<>();
for (Map.Entry<String, ?> entry : fields.entrySet()) {
String value = value(entry.getValue());
if (!"sign".equals(entry.getKey()) && !value.isBlank()) {
keys.add(entry.getKey());
}
}
keys.sort(Comparator.naturalOrder());
StringBuilder source = new StringBuilder();
for (String key : keys) {
if (!source.isEmpty()) {
source.append('&');
}
source.append(key).append('=').append(value(fields.get(key)));
}
// V5Pay 不是 key=secret 的形式排序字段串后必须直接追加密钥否则网关会报签名错误
source.append(Objects.toString(secretKey, ""));
try {
MessageDigest digest = MessageDigest.getInstance("MD5");
return HexFormat.of().formatHex(digest.digest(source.toString()
.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("MD5 algorithm is unavailable", exception);
}
}
public static boolean verify(Map<String, ?> fields, String secretKey) {
String actual = value(fields.get("sign")).trim();
return !actual.isBlank() && MessageDigest.isEqual(
actual.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII),
sign(fields, secretKey).getBytes(StandardCharsets.US_ASCII)
);
}
public static String value(Object value) {
return Objects.toString(value, "");
}
}

View File

@ -0,0 +1,20 @@
package com.red.circle.order.app.common;
import java.util.Map;
import lombok.Data;
import lombok.experimental.Accessors;
/** V5Pay 查询或回调中用于本地订单核验的统一字段。 */
@Data
@Accessors(chain = true)
public class V5PayTransactionData {
private String orderNo;
private String providerOrderNo;
private String transactionId;
private String status;
private String amount;
private String currency;
private String productType;
private Map<String, String> fields;
}

View File

@ -29,6 +29,7 @@ import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeReceivePayme
import com.red.circle.order.app.command.pay.web.PayerMaxServerNoticeRefundNoticeCmdExe;
import com.red.circle.order.app.command.pay.web.PaynicornServerNoticeCmdExe;
import com.red.circle.order.app.command.pay.web.MiFaPayServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.V5PayServerNoticeReceivePaymentCmdExe;
import com.red.circle.order.app.command.pay.web.ReceiptPayWebPlaceAnOrderReceiptCmdExe;
import com.red.circle.order.app.dto.clientobject.PayMaxResponseCO;
import com.red.circle.order.app.dto.clientobject.PayerMaxResponseV2CO;
@ -71,6 +72,7 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
private final PayerMaxServerNoticeRefundNoticeCmdExe payerMaxServerNoticeRefundNoticeCmdExe;
private final PayerMaxServerNoticeReceivePaymentCmdExe payerMaxServerNoticeReceivePaymentCmdExe;
private final MiFaPayServerNoticeReceivePaymentCmdExe miFaPayServerNoticeReceivePaymentCmdExe;
private final V5PayServerNoticeReceivePaymentCmdExe v5PayServerNoticeReceivePaymentCmdExe;
private final TaskMqMessage taskMqMessage;
@Override
@ -184,4 +186,9 @@ public class InAppPurchaseProductServiceImpl implements InAppPurchaseProductServ
return miFaPayServerNoticeReceivePaymentCmdExe.execute(body);
}
@Override
public String v5PayServerNoticeReceivePayment(Map<String, String> fields) {
return v5PayServerNoticeReceivePaymentCmdExe.execute(fields);
}
}

View File

@ -0,0 +1,197 @@
package com.red.circle.order.app.service;
import com.red.circle.framework.core.asserts.ResponseAssert;
import com.red.circle.framework.core.response.ResponseErrorCode;
import com.red.circle.order.app.common.V5PayGatewayResponse;
import com.red.circle.order.app.common.V5PayOrderNoUtils;
import com.red.circle.order.app.common.V5PayPlaceAnOrderParam;
import com.red.circle.order.app.common.V5PaySignUtils;
import com.red.circle.order.app.common.V5PayTransactionData;
import com.red.circle.order.infra.config.V5PayProperties;
import com.red.circle.tool.core.http.RcHttpClient;
import com.red.circle.tool.core.text.StringUtils;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** V5Pay 下单、查单和通知验签客户端。 */
@Slf4j
@Service
@RequiredArgsConstructor
public class V5PayService {
private static final String CREATE_ORDER_PATH = "/cgi/cashier/v2/payin";
private static final String QUERY_ORDER_PATH = "/cgi/payment/v1/payin/query";
private static final String SUCCESS_CODE = "1000";
private final V5PayProperties properties;
private final RcHttpClient httpClient = RcHttpClient.builder().build();
public V5PayGatewayResponse createOrder(V5PayPlaceAnOrderParam param) {
requireCreateConfigured();
Map<String, Object> request = new LinkedHashMap<>();
request.put("merchantNo", properties.getMerchantNo());
request.put("appKey", properties.getAppKey());
request.put("sysCountryCode", upper(param.getCountryCode()));
request.put("currency", upper(param.getCurrency()));
request.put("orderNo", V5PayOrderNoUtils.providerOrderNo(
properties.getOrderPrefix(), param.getOrderNo()));
request.put("amount", param.getAmount().setScale(2, RoundingMode.UNNECESSARY).toPlainString());
request.put("productType", param.getProductType());
request.put("callbackUrl", properties.getNotifyUrl());
request.put("redirectUrl", param.getReturnUrl());
request.put("merchantParam", param.getOrderNo());
request.put("language", normalizeLanguage(param.getLanguage()));
request.put("tradeSummary", param.getTradeSummary());
request.put("email", StringUtils.isBlankOrElse(param.getEmail(),
"v5pay-payer-" + param.getUserId() + "@haiyihy.com"));
request.put("merchantCustomerId", Objects.toString(param.getUserId(), ""));
request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey()));
V5PayGatewayResponse response = post(CREATE_ORDER_PATH, request);
validateResponse(response, "create", param.getOrderNo());
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR,
StringUtils.isNotBlank(response.text("checkoutUrl")));
return response;
}
public V5PayTransactionData queryOrder(String orderNo, String countryCode, String currency) {
requireGatewayConfigured();
Map<String, Object> request = new LinkedHashMap<>();
request.put("merchantNo", properties.getMerchantNo());
request.put("appKey", properties.getAppKey());
request.put("sysCountryCode", upper(countryCode));
request.put("currency", upper(currency));
request.put("orderNo", V5PayOrderNoUtils.providerOrderNo(properties.getOrderPrefix(), orderNo));
request.put("sign", V5PaySignUtils.sign(request, properties.getSecretKey()));
V5PayGatewayResponse response = post(QUERY_ORDER_PATH, request);
validateResponse(response, "query", orderNo);
return transactionData(toStringFields(response.getFields()));
}
/**
* 回调验签必须先于订单查询和入账调用方只能拿到通过签名验证后的业务字段
*/
public V5PayTransactionData parseNotification(Map<String, String> fields) {
requireSigningConfigured();
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
Objects.nonNull(fields) && !fields.isEmpty()
&& V5PaySignUtils.verify(fields, properties.getSecretKey()));
V5PayTransactionData data = transactionData(fields);
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
StringUtils.isNotBlank(data.getOrderNo()));
return data;
}
public boolean isConfigured() {
return isGatewayConfigured()
&& StringUtils.isNotBlank(properties.getNotifyUrl());
}
private boolean isGatewayConfigured() {
return isSigningConfigured()
&& StringUtils.isNotBlank(properties.getMerchantNo())
&& StringUtils.isNotBlank(properties.getAppKey())
&& StringUtils.isNotBlank(properties.getGatewayBaseUrl());
}
private boolean isSigningConfigured() {
return properties.isEnabled()
&& StringUtils.isNotBlank(properties.getSecretKey());
}
private V5PayGatewayResponse post(String path, Map<String, Object> request) {
String baseUrl = properties.getGatewayBaseUrl().replaceAll("/+$", "");
return httpClient.post()
.uri(baseUrl + path)
.contentType("application/json")
.bodyValue(request)
.retrieve()
.bodyToEntity(V5PayGatewayResponse.class)
.block();
}
private void validateResponse(V5PayGatewayResponse response, String operation, String orderNo) {
ResponseAssert.notNull(ResponseErrorCode.INTERNAL_SERVER_ERROR, response);
String code = response.text("code");
if (StringUtils.isNotBlank(code) && !SUCCESS_CODE.equals(code)) {
// 只记录订单号操作和错误码不输出响应签名或任何商户密钥
log.error("V5Pay {} rejected: orderNo={}, code={}", operation, orderNo, code);
ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR,
"V5Pay request rejected: " + code);
}
if (!V5PaySignUtils.verify(response.getFields(), properties.getSecretKey())) {
log.error("V5Pay {} response signature invalid: orderNo={}", operation, orderNo);
ResponseAssert.failure(ResponseErrorCode.INTERNAL_SERVER_ERROR,
"V5Pay response signature invalid");
}
}
private void requireCreateConfigured() {
// enabled 默认开启缺少真实环境配置时明确拒绝调用避免使用伪造默认凭证发起请求
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isConfigured());
}
private void requireGatewayConfigured() {
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isGatewayConfigured());
}
private void requireSigningConfigured() {
ResponseAssert.isTrue(ResponseErrorCode.INTERNAL_SERVER_ERROR, isSigningConfigured());
}
private V5PayTransactionData transactionData(Map<String, String> fields) {
String providerOrderNo = first(fields, "orderNo", "order_no");
String localOrderNo = V5PayOrderNoUtils.localOrderNo(
properties.getOrderPrefix(), providerOrderNo);
// 共享商户收到其他系统前缀的回调时必须拒绝不能用 provider orderNo 直接查询 Aslan 本地订单
ResponseAssert.isTrue(ResponseErrorCode.REQUEST_PARAMETER_ERROR,
StringUtils.isNotBlank(localOrderNo));
return new V5PayTransactionData()
.setOrderNo(localOrderNo)
.setProviderOrderNo(providerOrderNo)
.setTransactionId(first(fields, "transactionId", "transaction_id"))
.setStatus(first(fields, "status", "orderStatus", "order_status"))
.setAmount(first(fields, "amount"))
.setCurrency(first(fields, "currency"))
.setProductType(first(fields, "productType", "product_type"))
.setFields(fields);
}
private Map<String, String> toStringFields(Map<String, Object> fields) {
Map<String, String> values = new LinkedHashMap<>();
fields.forEach((key, value) -> values.put(key, V5PaySignUtils.value(value)));
return values;
}
private String first(Map<String, String> fields, String... keys) {
for (String key : keys) {
if (StringUtils.isNotBlank(fields.get(key))) {
return fields.get(key).trim();
}
}
return "";
}
private String upper(String value) {
return Objects.toString(value, "").trim().toUpperCase(Locale.ROOT);
}
private String normalizeLanguage(String value) {
return switch (Objects.toString(value, "").trim().toLowerCase(Locale.ROOT)) {
case "zh", "zh-cn" -> "zh-CN";
case "zh-tw", "zh-hk", "zh-tc" -> "zh-TC";
case "es", "es-es" -> "es-ES";
case "pt", "pt-pt" -> "pt-PT";
case "ru", "ru-ru" -> "ru-RU";
case "ja", "jp", "ja-jp", "jp-jp" -> "jp-JP";
default -> "en-US";
};
}
}

View File

@ -0,0 +1,223 @@
package com.red.circle.order.app.command.pay.web;
import com.red.circle.order.app.common.V5PayTransactionData;
import com.red.circle.order.app.common.InAppPurchaseCommon;
import com.red.circle.order.app.service.V5PayService;
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseCollectionReceiptService;
import com.red.circle.order.infra.database.mongo.order.InAppPurchaseDetailsService;
import com.red.circle.order.infra.database.mongo.order.entity.InAppPurchaseDetails;
import com.red.circle.order.infra.database.mongo.order.entity.assist.InAppPurchaseFactory;
import com.red.circle.order.infra.database.rds.service.order.OrderCacheService;
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseReceiptType;
import com.red.circle.order.inner.model.enums.inapp.InAppPurchaseStatus;
import java.math.BigDecimal;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
public class V5PayOrderStateServiceTest {
@Test
public void successfulProviderDataMustMatchEveryLocalSnapshotField() {
InAppPurchaseDetails order = order();
V5PayTransactionData data = data();
Assert.assertTrue(V5PayOrderStateService.matchesOrderSnapshot(order, data));
data.setOrderNo("other");
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
data = data().setAmount("14400.01");
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
data = data().setCurrency("USD");
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
data = data().setProductType("other-product");
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order, data));
}
@Test
public void amountConversionRejectsPrecisionLoss() {
Assert.assertEquals(1_440_000L, V5PayOrderStateService.toMinor("14400.00"));
Assert.assertEquals(100L, V5PayOrderStateService.toMinor("1"));
try {
V5PayOrderStateService.toMinor("1.001");
Assert.fail("more than two decimals must be rejected");
} catch (ArithmeticException expected) {
// 精度超出协议时抛错调用方会拒绝入账
}
}
@Test
public void missingProviderSnapshotFieldNeverCredits() {
V5PayTransactionData data = data().setProductType("");
Assert.assertFalse(V5PayOrderStateService.matchesOrderSnapshot(order(), data));
}
@Test
public void concurrentCallbackDoesNotAckBeforeClaimOwnerFinishes() throws Exception {
AtomicBoolean claimed = new AtomicBoolean();
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
CountDownLatch creditStarted = new CountDownLatch(1);
CountDownLatch allowCreditFinish = new CountDownLatch(1);
TestContext context = context(claimed, status);
Mockito.doAnswer(invocation -> {
creditStarted.countDown();
Assert.assertTrue(allowCreditFinish.await(5, TimeUnit.SECONDS));
status.set(InAppPurchaseStatus.SUCCESS);
return null;
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> first = executor.submit(() -> context.service.apply(order(), data(), "callback"));
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
Future<Boolean> duplicate = executor.submit(
() -> context.service.apply(order(), data(), "callback"));
Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS));
allowCreditFinish.countDown();
Assert.assertTrue(first.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
Mockito.verify(context.purchaseCommon, Mockito.times(1))
.inAppPurchasePayment(Mockito.any());
Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get());
}
@Test
public void concurrentCallbackStaysUnacknowledgedWhenClaimOwnerLaterFails() throws Exception {
AtomicBoolean claimed = new AtomicBoolean();
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
CountDownLatch creditStarted = new CountDownLatch(1);
CountDownLatch allowFailure = new CountDownLatch(1);
TestContext context = context(claimed, status);
Mockito.doAnswer(invocation -> {
creditStarted.countDown();
Assert.assertTrue(allowFailure.await(5, TimeUnit.SECONDS));
throw new IllegalStateException("owner failed");
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<Boolean> owner = executor.submit(
() -> context.service.apply(order(), data(), "callback"));
Assert.assertTrue(creditStarted.await(5, TimeUnit.SECONDS));
Future<Boolean> duplicate = executor.submit(
() -> context.service.apply(order(), data(), "callback"));
Assert.assertFalse(duplicate.get(5, TimeUnit.SECONDS));
allowFailure.countDown();
Assert.assertFalse(owner.get(5, TimeUnit.SECONDS));
} finally {
executor.shutdownNow();
}
Assert.assertEquals(InAppPurchaseStatus.CREATE, status.get());
Assert.assertFalse(claimed.get());
}
@Test
public void failedCreditReleasesClaimAndNextNotificationCanRetry() {
AtomicBoolean claimed = new AtomicBoolean();
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
AtomicInteger attempts = new AtomicInteger();
TestContext context = context(claimed, status);
Mockito.doAnswer(invocation -> {
if (attempts.incrementAndGet() == 1) {
throw new IllegalStateException("temporary downstream failure");
}
status.set(InAppPurchaseStatus.SUCCESS);
return null;
}).when(context.purchaseCommon).inAppPurchasePayment(Mockito.any());
Assert.assertFalse(context.service.apply(order(), data(), "callback"));
Assert.assertFalse(claimed.get());
Assert.assertTrue(context.service.apply(order(), data(), "callback"));
Assert.assertEquals(2, attempts.get());
Assert.assertEquals(InAppPurchaseStatus.SUCCESS, status.get());
}
@Test
public void unknownProviderStatusIsNotAcknowledgedByCallback() {
AtomicBoolean claimed = new AtomicBoolean();
AtomicReference<InAppPurchaseStatus> status = new AtomicReference<>(InAppPurchaseStatus.CREATE);
TestContext context = context(claimed, status);
V5PayTransactionData unknown = data().setStatus("7");
Assert.assertFalse(context.service.apply(order(), unknown, "callback"));
Assert.assertTrue(context.service.apply(order(), unknown, "query"));
Mockito.verifyNoInteractions(context.purchaseCommon);
}
private InAppPurchaseDetails order() {
return new InAppPurchaseDetails()
.setId("123")
.setCurrency("TRY")
.setAmount(new BigDecimal("14400"))
.setReceiptType(InAppPurchaseReceiptType.PAYMENT)
.setStatus(InAppPurchaseStatus.CREATE)
.setFactory(new InAppPurchaseFactory()
.setFactoryCode("V5_PAY")
.setFactoryChannelCode("1263"));
}
private V5PayTransactionData data() {
return new V5PayTransactionData()
.setOrderNo("123")
.setAmount("14400.00")
.setCurrency("try")
.setProductType("1263")
.setStatus("2")
.setFields(Map.of(
"orderNo", "ASLAN-123",
"amount", "14400.00",
"currency", "TRY",
"productType", "1263",
"status", "2"));
}
private TestContext context(AtomicBoolean claimed,
AtomicReference<InAppPurchaseStatus> status) {
V5PayService v5PayService = Mockito.mock(V5PayService.class);
InAppPurchaseCommon purchaseCommon = Mockito.mock(InAppPurchaseCommon.class);
InAppPurchaseDetailsService detailsService = Mockito.mock(InAppPurchaseDetailsService.class);
InAppPurchaseCollectionReceiptService receiptService = Mockito.mock(
InAppPurchaseCollectionReceiptService.class);
OrderCacheService cacheService = Mockito.mock(OrderCacheService.class);
Mockito.when(detailsService.getById("123")).thenAnswer(invocation -> {
InAppPurchaseDetails latest = order();
latest.setStatus(status.get());
return latest;
});
Mockito.when(detailsService.existsPayNoticeByEventId(Mockito.anyString(), Mockito.anyString()))
.thenReturn(true);
Mockito.when(cacheService.lock(Mockito.anyString()))
.thenAnswer(invocation -> claimed.compareAndSet(false, true));
Mockito.doAnswer(invocation -> {
claimed.set(false);
return null;
}).when(cacheService).unLock(Mockito.anyString());
return new TestContext(
new V5PayOrderStateService(v5PayService, purchaseCommon, detailsService,
receiptService, cacheService),
purchaseCommon
);
}
private static final class TestContext {
private final V5PayOrderStateService service;
private final InAppPurchaseCommon purchaseCommon;
private TestContext(V5PayOrderStateService service,
InAppPurchaseCommon purchaseCommon) {
this.service = service;
this.purchaseCommon = purchaseCommon;
}
}
}

View File

@ -0,0 +1,29 @@
package com.red.circle.order.app.command.pay.web.strategy;
import com.red.circle.order.infra.database.rds.entity.pay.PayCountryChannelDetails;
import org.junit.Assert;
import org.junit.Test;
public class PayPlaceAnOrderServiceTest {
@Test
public void onlyV5PayPersistsFactoryProductTypeAsSnapshot() {
PayPlaceAnOrderDetailsV2 details = details("V5_PAY");
Assert.assertEquals("1263", PayPlaceAnOrderService.factoryChannelCode(details));
}
@Test
public void existingFactoriesKeepHistoricalChannelCodeSnapshot() {
PayPlaceAnOrderDetailsV2 details = details("MIFA_PAY");
Assert.assertEquals("card_visa", PayPlaceAnOrderService.factoryChannelCode(details));
}
private PayPlaceAnOrderDetailsV2 details(String factoryCode) {
PayPlaceAnOrderDetailsV2 details = new PayPlaceAnOrderDetailsV2();
details.setChannelDetails(new PayCountryChannelDetails()
.setFactoryCode(factoryCode)
.setChannelCode("card_visa")
.setFactoryChannel("1263"));
return details;
}
}

View File

@ -0,0 +1,43 @@
package com.red.circle.order.app.common;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.Assert;
import org.junit.Test;
public class V5PaySignUtilsTest {
@Test
public void signSortsFieldsSkipsEmptyAndAppendsSecretDirectly() {
Map<String, Object> fields = new LinkedHashMap<>();
fields.put("orderNo", "ASLAN-123");
fields.put("sign", "must-be-ignored");
fields.put("merchantNo", "M");
fields.put("empty", "");
fields.put("appKey", "A");
fields.put("amount", "10.00");
Assert.assertEquals("a092fa6ad4f5ba4829c0a8ff1986d75c",
V5PaySignUtils.sign(fields, "secret"));
}
@Test
public void verifyAcceptsUppercaseMd5ButRejectsChangedBusinessField() {
Map<String, Object> fields = new LinkedHashMap<>();
fields.put("orderNo", "ASLAN-123");
fields.put("amount", "10.00");
fields.put("sign", V5PaySignUtils.sign(fields, "secret").toUpperCase());
Assert.assertTrue(V5PaySignUtils.verify(fields, "secret"));
fields.put("amount", "10.01");
Assert.assertFalse(V5PaySignUtils.verify(fields, "secret"));
}
@Test
public void orderPrefixOnlyAcceptsAslanNamespace() {
Assert.assertEquals("ASLAN-123", V5PayOrderNoUtils.providerOrderNo("aslan", "123"));
Assert.assertEquals("123", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN-123"));
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "LALU-123"));
Assert.assertEquals("", V5PayOrderNoUtils.localOrderNo("ASLAN", "ASLAN-"));
}
}

View File

@ -51,4 +51,7 @@ public interface InAppPurchaseProductService {
*/
String miFaPayServerNoticeReceivePayment(byte[] body);
/** V5Pay 支付回调通知,返回小写纯文本 success/fail。 */
String v5PayServerNoticeReceivePayment(Map<String, String> fields);
}

View File

@ -0,0 +1,39 @@
package com.red.circle.order.infra.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;
/**
* V5Pay 商户和网关配置
*
* <p>功能默认开启但商户号应用 Key签名密钥和地址必须由环境或 Nacos 注入代码仓库不提供可用凭证</p>
*/
@Data
@Component
@RefreshScope
@ConfigurationProperties(prefix = "red-circle.pay.v5-pay")
public class V5PayProperties {
/** 是否允许创建和查询 V5Pay 订单。 */
private boolean enabled = true;
/** V5Pay 分配的商户号。 */
private String merchantNo;
/** V5Pay 分配的应用 Key。 */
private String appKey;
/** MD5 签名密钥,只能通过部署环境注入。 */
private String secretKey;
/** V5Pay API 根地址。 */
private String gatewayBaseUrl;
/** V5Pay 可从公网访问的异步回调地址。 */
private String notifyUrl;
/** 共享商户下的订单隔离前缀;默认 ASLAN可由部署环境覆盖。 */
private String orderPrefix = "ASLAN";
}

View File

@ -30,4 +30,15 @@ management:
health:
show-details: always
# V5Pay 功能默认打开;商户凭证和地址全部由环境/Nacos 注入,仓库内不放置任何可用密钥。
red-circle:
pay:
v5-pay:
enabled: ${V5PAY_ENABLED:true}
merchant-no: ${V5PAY_MERCHANT_NO:}
app-key: ${V5PAY_APP_KEY:}
secret-key: ${V5PAY_SECRET_KEY:}
gateway-base-url: ${V5PAY_GATEWAY_BASE_URL:}
notify-url: ${V5PAY_NOTIFY_URL:}
order-prefix: ${V5PAY_ORDER_PREFIX:ASLAN}