fix(order): use alphanumeric Yumi V5Pay order numbers

This commit is contained in:
hy001 2026-07-10 20:39:04 +08:00
parent 0732b6b484
commit ac18a90da4
8 changed files with 118 additions and 30 deletions

View File

@ -27,7 +27,7 @@ public class ApiLoggingFilterTest {
@Test
public void v5PayJsonBodyPassesThroughWithoutNumericReserialization() {
byte[] raw = ("{\"orderNo\":\"YUMI-42\",\"amount\":14400.00,"
byte[] raw = ("{\"orderNo\":\"YUMI42\",\"amount\":14400.00,"
+ "\"appKey\":\"sensitive\",\"sign\":\"signature\"}")
.getBytes(StandardCharsets.UTF_8);
MockServerWebExchange exchange = MockServerWebExchange.from(

View File

@ -37,7 +37,7 @@ public class V5PayWebPayPlaceAnOrderStrategy implements PayWebPlaceAnOrderStrate
return new PlaceAnOrderResponseCO()
.setTradeNo(result.getTransactionId())
// H5 和本地状态查询始终使用无前缀本地单号三方 YUMI- 前缀只存在于 V5Pay 协议边界.
// H5 和本地状态查询始终使用无前缀本地单号三方 YUMI 前缀只存在于 V5Pay 协议边界.
.setOrderId(details.getOrderId())
.setCurrency(details.getCurrency())
.setCountryCode(details.getCountryCode())

View File

@ -1,6 +1,7 @@
package com.red.circle.order.app.common;
import com.red.circle.tool.core.text.StringUtils;
import java.util.Locale;
import java.util.Objects;
/**
@ -12,10 +13,11 @@ public final class V5PayOrderNumber {
}
public static String providerOrderNo(String prefix, Long localOrderId) {
if (localOrderId == null) {
if (localOrderId == null || localOrderId <= 0) {
throw new IllegalArgumentException("V5Pay local order id is missing");
}
return normalizedPrefix(prefix) + "-" + localOrderId;
// V5Pay orderNo 只接受字母数字共享商户命名空间通过固定大写前缀直接拼接本地 Long.
return normalizedPrefix(prefix) + localOrderId;
}
/**
@ -23,22 +25,49 @@ public final class V5PayOrderNumber {
* 不能直接截取最后一段数字否则其他系统订单可能被错误入账到 Yumi.
*/
public static Long localOrderId(String prefix, String providerOrderNo) {
String expectedPrefix = normalizedPrefix(prefix) + "-";
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("-")) {
// Long 正数最多 19 先做纯 ASCII 数字和长度校验避免符号Unicode 数字或超长值
// 进入宽松的数值转换后产生跨系统订单号碰撞.
if (StringUtils.isBlank(local) || local.length() > 19 || !isAsciiDigits(local)) {
return null;
}
return MiFaPayMysqlOrderSupport.parseOrderId(local);
Long parsed = MiFaPayMysqlOrderSupport.parseOrderId(local);
if (parsed == null || parsed <= 0 || !Objects.equals(local, parsed.toString())) {
return null;
}
return parsed;
}
private static String normalizedPrefix(String prefix) {
String normalized = Objects.toString(prefix, "").trim();
if (StringUtils.isBlank(normalized) || normalized.contains("-")) {
String normalized = Objects.toString(prefix, "").trim().toUpperCase(Locale.ROOT);
if (StringUtils.isBlank(normalized) || !isAsciiLettersOrDigits(normalized)) {
throw new IllegalArgumentException("V5Pay order prefix is invalid");
}
return normalized;
}
private static boolean isAsciiDigits(String value) {
for (int index = 0; index < value.length(); index++) {
char item = value.charAt(index);
if (item < '0' || item > '9') {
return false;
}
}
return true;
}
private static boolean isAsciiLettersOrDigits(String value) {
for (int index = 0; index < value.length(); index++) {
char item = value.charAt(index);
boolean letter = (item >= 'A' && item <= 'Z') || (item >= 'a' && item <= 'z');
if (!letter && (item < '0' || item > '9')) {
return false;
}
}
return true;
}
}

View File

@ -104,7 +104,7 @@ public class V5PayOrderSettlementServiceTest {
.setSysOrigin("LIKEI")
.setUserId(100L)
.setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY)
.setFactoryOrderId("YUMI-42")
.setFactoryOrderId("YUMI42")
.setReferenceId("2048200000000000701")
.setReceiptType(InAppPurchaseReceiptType.PAYMENT.name())
.setPaymentChannel("UPI_UPI")
@ -121,12 +121,12 @@ public class V5PayOrderSettlementServiceTest {
private V5PayOrderData data() {
return new V5PayOrderData()
.setOrderNo("YUMI-42")
.setOrderNo("YUMI42")
.setTransactionId("tx-42")
.setStatus("2")
.setAmount("10.00")
.setCurrency("USD")
.setProductType("UPI")
.setSanitizedPayload(Map.of("orderNo", "YUMI-42", "status", "2"));
.setSanitizedPayload(Map.of("orderNo", "YUMI42", "status", "2"));
}
}

View File

@ -25,7 +25,7 @@ public class V5PayServerNoticeReceivePaymentCmdExeTest {
public void acceptsSignedJsonWithExactDecimalAndReturnsLowercaseSuccess() {
Fixture fixture = fixture();
Map<String, String> fields = successFields();
String json = "{\"orderNo\":\"YUMI-42\",\"transactionId\":\"tx-42\","
String json = "{\"orderNo\":\"YUMI42\",\"transactionId\":\"tx-42\","
+ "\"status\":2,\"amount\":10.00,\"currency\":\"USD\","
+ "\"productType\":\"UPI\",\"sign\":\"" + fields.get("sign") + "\"}";
@ -41,7 +41,7 @@ public class V5PayServerNoticeReceivePaymentCmdExeTest {
fixture.order.get().setPayStatus(MiFaPayMysqlOrderSupport.PAY_STATUS_PREPAYMENT);
Map<String, String> wrongPrefix = successFields();
wrongPrefix.put("orderNo", "LALU-42");
wrongPrefix.put("orderNo", "LALU42");
wrongPrefix.put("sign", V5PaySignature.sign(wrongPrefix, "secret"));
assertEquals("fail", fixture.executor.executeForm(wrongPrefix));
}
@ -81,7 +81,7 @@ public class V5PayServerNoticeReceivePaymentCmdExeTest {
private Map<String, String> successFields() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("orderNo", "YUMI-42");
fields.put("orderNo", "YUMI42");
fields.put("transactionId", "tx-42");
fields.put("status", "2");
fields.put("amount", "10.00");
@ -98,7 +98,7 @@ public class V5PayServerNoticeReceivePaymentCmdExeTest {
.setSysOrigin("LIKEI")
.setUserId(100L)
.setFactoryCode(WebPayProviderSupport.FACTORY_V5PAY)
.setFactoryOrderId("YUMI-42")
.setFactoryOrderId("YUMI42")
.setReferenceId("2048200000000000701")
.setReceiptType(InAppPurchaseReceiptType.PAYMENT.name())
.setPaymentChannel("UPI")

View File

@ -14,23 +14,23 @@ public class V5PayOrderDataTest {
@Test
public void successDataMustMatchPrefixedOrderAmountCurrencyAndProductType() {
OrderUserPurchasePay order = order();
V5PayOrderData data = data("YUMI-42", "10.00", "usd", "UPI");
V5PayOrderData data = data("YUMI42", "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"));
assertFalse(data("LALU42", "10.00", "USD", "UPI").matches(order, "YUMI"));
assertFalse(data("YUMI42", "10.01", "USD", "UPI").matches(order, "YUMI"));
assertFalse(data("YUMI42", "10.00", "INR", "UPI").matches(order, "YUMI"));
assertFalse(data("YUMI42", "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());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("2").isSuccessful());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("3").isFailed());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("5").isFailed());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("6").isFailed());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("0").isPending());
assertTrue(data("YUMI42", "10.00", "USD", "UPI").setStatus("1").isPending());
}
private OrderUserPurchasePay order() {

View File

@ -0,0 +1,59 @@
package com.red.circle.order.app.common;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.fail;
import org.junit.Test;
public class V5PayOrderNumberTest {
@Test
public void generatesUppercaseAlphanumericProviderOrderNumber() {
assertEquals("YUMI42", V5PayOrderNumber.providerOrderNo(" yumi ", 42L));
assertEquals(Long.valueOf(42L), V5PayOrderNumber.localOrderId("yumi", "YUMI42"));
assertEquals(Long.valueOf(42L), V5PayOrderNumber.localOrderId("YUMI", "YUMI42"));
}
@Test
public void rejectsWrongPrefixEmptySuffixSeparatorsAndNonDigits() {
assertNull(V5PayOrderNumber.localOrderId("YUMI", null));
assertNull(V5PayOrderNumber.localOrderId("YUMI", ""));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "LALU42"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI-42"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI42A"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "yumi42"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI٤٢"));
}
@Test
public void rejectsNonCanonicalOrOverflowingLongSuffix() {
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI0"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI00042"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI9223372036854775808"));
assertNull(V5PayOrderNumber.localOrderId("YUMI", "YUMI12345678901234567890"));
assertEquals(Long.valueOf(Long.MAX_VALUE),
V5PayOrderNumber.localOrderId("YUMI", "YUMI" + Long.MAX_VALUE));
}
@Test
public void rejectsInvalidPrefixAndNonPositiveLocalId() {
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("", 42L));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("YU-MI", 42L));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("YUMI_", 42L));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("余米", 42L));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("YUMI", null));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("YUMI", 0L));
assertIllegalArgument(() -> V5PayOrderNumber.providerOrderNo("YUMI", -1L));
}
private void assertIllegalArgument(Runnable action) {
try {
action.run();
fail("expected IllegalArgumentException");
} catch (IllegalArgumentException expected) {
// expected
}
}
}

View File

@ -13,7 +13,7 @@ public class V5PaySignatureTest {
@Test
public void signsSortedNonEmptyFieldsAndAppendsSecretWithoutSeparator() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("orderNo", "YUMI-123");
fields.put("orderNo", "YUMI123");
fields.put("currency", "USD");
fields.put("merchantNo", "merchant");
fields.put("empty", "");
@ -21,14 +21,14 @@ public class V5PaySignatureTest {
fields.put("amount", "100.00");
fields.put("sign", "must-be-excluded");
assertEquals("40e7e9f5fe267470a4448991b99e7bdd",
assertEquals("cb2ffdac30ec4a464c62d872aea2248a",
V5PaySignature.sign(fields, "secret"));
}
@Test
public void verifiesOnlyWhenSignIsPresentAndMatches() {
Map<String, String> fields = new LinkedHashMap<>();
fields.put("orderNo", "YUMI-123");
fields.put("orderNo", "YUMI123");
fields.put("status", "2");
fields.put("sign", V5PaySignature.sign(fields, "secret"));