fields, EpayCredential credential) {
+ return credential != null
+ && hasText(credential.merchantPublicKey())
+ && EpaySigner.verifyRsa(fields, credential.merchantPublicKey(), fields.get("sign"));
+ }
+
+ public EpayCompatResponse success(EpayCreateResult result, EpayCredential credential) {
+ return response(0, "success", result.tradeNo(), result.payType(), result.payInfo(), credential);
+ }
+
+ public EpayCompatResponse failure(String message, EpayCredential credential) {
+ return response(1, message, "", "", "", credential);
+ }
+
+ private EpayCompatResponse response(int code, String message, String tradeNo, String payType,
+ String payInfo, EpayCredential credential) {
+ JSONObject body = new JSONObject(true);
+ body.put("code", code);
+ body.put("msg", message);
+ body.put("trade_no", tradeNo == null ? "" : tradeNo);
+ body.put("pay_type", payType == null ? "" : payType);
+ body.put("pay_info", payInfo == null ? "" : payInfo);
+ body.put("sign_type", "RSA");
+ try {
+ body.put("sign", sign(body, credential));
+ return new EpayCompatResponse(code == 0, JSON.toJSONString(body), payInfo);
+ } catch (IllegalArgumentException e) {
+ if (code == 0) {
+ JSONObject failure = new JSONObject(true);
+ failure.put("code", 1);
+ failure.put("msg", "V2 RSA响应签名失败");
+ failure.put("trade_no", "");
+ failure.put("pay_type", "");
+ failure.put("pay_info", "");
+ failure.put("sign_type", "RSA");
+ failure.put("sign", "");
+ return new EpayCompatResponse(false, JSON.toJSONString(failure), "");
+ }
+ body.put("sign", "");
+ return new EpayCompatResponse(false, JSON.toJSONString(body), "");
+ }
+ }
+
+ private static String sign(JSONObject body, EpayCredential credential) {
+ String privateKey = credential == null ? null : credential.platformPrivateKey();
+ if (!hasText(privateKey)) {
+ throw new IllegalArgumentException("V2 RSA响应私钥未配置");
+ }
+ return EpaySigner.signRsa(body, privateKey);
+ }
+
+ private static boolean hasText(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+}
diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/payorder/AbstractPayOrderController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/payorder/AbstractPayOrderController.java
index 5d67f51c1..8d3dc6092 100644
--- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/payorder/AbstractPayOrderController.java
+++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/payorder/AbstractPayOrderController.java
@@ -76,6 +76,14 @@ protected ApiRes unifiedOrder(String wayCode, UnifiedOrderRQ bizRQ){
return unifiedOrder(wayCode, bizRQ, null);
}
+ /**
+ * 仅由明确的协议适配入口允许把协议元数据写入订单。
+ * 原生统一下单入口默认不接收该类保留元数据。
+ */
+ protected boolean shouldPersistChannelExtra(UnifiedOrderRQ rq) {
+ return false;
+ }
+
/** 统一下单 **/
protected ApiRes unifiedOrder(String wayCode, UnifiedOrderRQ bizRQ, PayOrder payOrder){
@@ -257,7 +265,9 @@ private PayOrder genPayOrder(UnifiedOrderRQ rq, MchInfo mchInfo, MchApp mchApp,
payOrder.setClientIp(StringUtils.defaultIfEmpty(rq.getClientIp(), getClientIp())); //客户端IP
payOrder.setSubject(rq.getSubject()); //商品标题
payOrder.setBody(rq.getBody()); //商品描述信息
-// payOrder.setChannelExtra(rq.getChannelExtra()); //特殊渠道发起的附件额外参数, 是否应该删除该字段了?? 比如authCode不应该记录, 只是在传输阶段存在的吧? 之前的为了在payOrder对象需要传参。
+ if (shouldPersistChannelExtra(rq)) {
+ payOrder.setChannelExtra(rq.getChannelExtra());
+ }
payOrder.setChannelUser(rq.getChannelUserId()); //渠道用户标志
payOrder.setExtParam(rq.getExtParam()); //商户扩展参数
payOrder.setNotifyUrl(rq.getNotifyUrl()); //异步通知地址
diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/UnifiedOrderRQ.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/UnifiedOrderRQ.java
index d6b409248..3555c3eb3 100644
--- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/UnifiedOrderRQ.java
+++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/UnifiedOrderRQ.java
@@ -25,6 +25,7 @@
import org.hibernate.validator.constraints.Range;
import org.springframework.beans.BeanUtils;
+import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -87,6 +88,25 @@ public class UnifiedOrderRQ extends AbstractMchAppRQ {
@Range(min = 0, max = 2, message = "分账模式设置值有误")
private Byte divisionMode;
+ /**
+ * 统一下单入口先校验 UnifiedOrderRQ,再转换为具体支付方式请求。
+ * 因此 STARPOS_QR 的 payDataType 约束需要在基类阶段同步执行。
+ */
+ @AssertTrue(message = "STARPOS_QR 的 payDataType 必须为 payUrl")
+ @JSONField(serialize = false, deserialize = false)
+ public boolean isStarposQrPayDataTypeValid() {
+ if (!CS.PAY_WAY_CODE.STARPOS_QR.equals(wayCode)) {
+ return true;
+ }
+
+ try {
+ JSONObject extra = JSONObject.parseObject(StringUtils.defaultIfEmpty(channelExtra, "{}"));
+ return extra != null && "payUrl".equals(extra.getString("payDataType"));
+ } catch (RuntimeException ignored) {
+ return false;
+ }
+ }
+
/** 返回真实的bizRQ **/
public UnifiedOrderRQ buildBizRQ(){
@@ -106,6 +126,10 @@ public UnifiedOrderRQ buildBizRQ(){
QrCashierOrderRQ bizRQ = JSONObject.parseObject(StringUtils.defaultIfEmpty(this.channelExtra, "{}"), QrCashierOrderRQ.class);
BeanUtils.copyProperties(this, bizRQ);
return bizRQ;
+ }else if(CS.PAY_WAY_CODE.STARPOS_QR.equals(wayCode)){
+ StarposQrOrderRQ bizRQ = JSONObject.parseObject(StringUtils.defaultIfEmpty(this.channelExtra, "{}"), StarposQrOrderRQ.class);
+ BeanUtils.copyProperties(this, bizRQ);
+ return bizRQ;
}else if(CS.PAY_WAY_CODE.WX_JSAPI.equals(wayCode)){
WxJsapiOrderRQ bizRQ = JSONObject.parseObject(StringUtils.defaultIfEmpty(this.channelExtra, "{}"), WxJsapiOrderRQ.class);
BeanUtils.copyProperties(this, bizRQ);
diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRQ.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRQ.java
new file mode 100644
index 000000000..97946ac74
--- /dev/null
+++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRQ.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2021-2031, 河北计全科技有限公司 (https://www.jeequan.com & jeequan@126.com).
+ *
+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.gnu.org/licenses/lgpl.html
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.jeequan.jeepay.pay.rqrs.payorder.payway;
+
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.pay.rqrs.payorder.CommonPayDataRQ;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+/*
+ * 支付方式:STARPOS_QR
+ */
+@Data
+public class StarposQrOrderRQ extends CommonPayDataRQ {
+
+ /** 构造函数 **/
+ public StarposQrOrderRQ() {
+ this.setWayCode(CS.PAY_WAY_CODE.STARPOS_QR);
+ }
+
+ /** 首期仅支持返回支付链接 **/
+ @Override
+ @NotBlank(message = "payDataType不能为空")
+ @Pattern(regexp = "payUrl", message = "payDataType仅支持payUrl")
+ public String getPayDataType() {
+ return super.getPayDataType();
+ }
+}
diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRS.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRS.java
new file mode 100644
index 000000000..5c4705059
--- /dev/null
+++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRS.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021-2031, 河北计全科技有限公司 (https://www.jeequan.com & jeequan@126.com).
+ *
+ * Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.gnu.org/licenses/lgpl.html
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.jeequan.jeepay.pay.rqrs.payorder.payway;
+
+import com.jeequan.jeepay.pay.rqrs.payorder.CommonPayDataRS;
+import lombok.Data;
+
+/*
+ * 支付方式:STARPOS_QR
+ */
+@Data
+public class StarposQrOrderRS extends CommonPayDataRS {
+
+}
diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java
index 5022264af..e674550ae 100644
--- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java
+++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java
@@ -24,6 +24,8 @@
import com.jeequan.jeepay.core.entity.TransferOrder;
import com.jeequan.jeepay.core.utils.JeepayKit;
import com.jeequan.jeepay.core.utils.StringKit;
+import com.jeequan.jeepay.pay.compat.epay.EpayCompatMetadata;
+import com.jeequan.jeepay.pay.compat.epay.EpayCompatNotifyService;
import com.jeequan.jeepay.pay.rqrs.payorder.QueryPayOrderRS;
import com.jeequan.jeepay.pay.rqrs.refund.QueryRefundOrderRS;
import com.jeequan.jeepay.pay.rqrs.transfer.QueryTransferOrderRS;
@@ -47,6 +49,7 @@ public class PayMchNotifyService {
@Autowired private MchNotifyRecordService mchNotifyRecordService;
@Autowired private ConfigContextQueryService configContextQueryService;
@Autowired private IMQSender mqSender;
+ @Autowired private EpayCompatNotifyService epayCompatNotifyService;
/** 商户通知信息, 只有订单是终态,才会发送通知, 如明确成功和明确失败 **/
@@ -72,6 +75,10 @@ public void payOrderNotify(PayOrder dbPayOrder){
// 封装通知url
String notifyUrl = createNotifyUrl(dbPayOrder, appSecret);
+ if(StringUtils.isEmpty(notifyUrl)){
+ log.warn("订单[{}]通知地址生成为空,本次不创建通知记录。", dbPayOrder.getPayOrderId());
+ return;
+ }
mchNotifyRecord = new MchNotifyRecord();
mchNotifyRecord.setOrderId(dbPayOrder.getPayOrderId());
mchNotifyRecord.setOrderType(MchNotifyRecord.TYPE_PAY_ORDER);
@@ -207,6 +214,9 @@ public void transferOrderNotify(TransferOrder dbTransferOrder){
* 创建响应URL
*/
public String createNotifyUrl(PayOrder payOrder, String appSecret) {
+ if (EpayCompatMetadata.decode(payOrder.getChannelExtra()).isPresent()) {
+ return epayCompatNotifyService.createNotifyUrl(payOrder);
+ }
QueryPayOrderRS queryPayOrderRS = QueryPayOrderRS.buildByPayOrder(payOrder);
JSONObject jsonObject = (JSONObject)JSONObject.toJSON(queryPayOrderRS);
@@ -258,6 +268,9 @@ public String createNotifyUrl(TransferOrder transferOrder, String appSecret) {
* 创建响应URL
*/
public String createReturnUrl(PayOrder payOrder, String appSecret) {
+ if (EpayCompatMetadata.decode(payOrder.getChannelExtra()).isPresent()) {
+ return epayCompatNotifyService.createReturnUrl(payOrder);
+ }
if(StringUtils.isEmpty(payOrder.getReturnUrl())){
return "";
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeServiceTest.java
new file mode 100644
index 000000000..452395732
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeServiceTest.java
@@ -0,0 +1,239 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.exception.ResponseException;
+import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams;
+import com.jeequan.jeepay.pay.channel.IChannelNoticeService;
+import com.jeequan.jeepay.pay.model.MchAppConfigContext;
+import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg;
+import com.jeequan.jeepay.service.impl.PayOrderService;
+import org.apache.commons.lang3.tuple.MutablePair;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.crypto.Cipher;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class StarposChannelNoticeServiceTest {
+
+ private static final KeyPair KEY_PAIR = generateKeyPair();
+ private static final String PUBLIC_KEY = Base64.getEncoder()
+ .encodeToString(KEY_PAIR.getPublic().getEncoded());
+
+ @Test
+ void shouldVerifyDynamicFieldsAndReturnSuccessResponse() {
+ JSONObject params = signedNotice(
+ "CHANNEL-1001", "PAY-1001", "T-1001", "1", "12345"
+ );
+ params.put("NEW_PROTOCOL_FIELD", "signed-too");
+ params.put("sign", signWithPrivateKey(unsignedFields(params)));
+
+ PayOrder payOrder = payOrder("PAY-1001", 12345L, PayOrder.STATE_ING);
+ ChannelRetMsg result = service(params, payOrder)
+ .doNotice(null, params, payOrder, context(), noticeType());
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS,
+ result.getChannelState());
+ assertEquals("CHANNEL-1001", result.getChannelOrderId());
+ assertEquals(
+ "{\"rspCod\":\"\",\"rspMsg\":\"success\"}",
+ result.getResponseEntity().getBody()
+ );
+ assertEquals(
+ "application/json; charset=UTF-8",
+ result.getResponseEntity().getHeaders().getFirst("Content-Type")
+ );
+
+ JSONObject origin = JSONObject.parseObject(result.getChannelOriginResponse());
+ assertEquals("T-1001",
+ origin.getJSONObject("starposExtension").getString("tOrderNo"));
+ assertEquals("PAY-1001",
+ origin.getJSONObject("starposExtension").getString("threeOrderNo"));
+ }
+
+ @Test
+ void shouldResolvePayOrderIdFromThreeOrderNo() {
+ JSONObject params = signedNotice(
+ "CHANNEL-1002", "THREE-1002", "T-1002", "1", "100"
+ );
+ PayOrder resolved = payOrder("PAY-RESOLVED-1002", 100L, PayOrder.STATE_ING);
+
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ when(payOrderService.getById("THREE-1002")).thenReturn(resolved);
+
+ TestableNoticeService service = service(params, resolved);
+ service.setPayOrderService(payOrderService);
+
+ MutablePair parsed = service.parseParams(
+ null, null, noticeType()
+ );
+
+ assertEquals("PAY-RESOLVED-1002", parsed.getLeft());
+ assertEquals(params, parsed.getRight());
+ verify(payOrderService).getById("THREE-1002");
+ }
+
+ @Test
+ void shouldRejectAmountMismatch() {
+ JSONObject params = signedNotice(
+ "CHANNEL-1003", "PAY-1003", "T-1003", "1", "999"
+ );
+ PayOrder payOrder = payOrder("PAY-1003", 1000L, PayOrder.STATE_ING);
+
+ assertThrows(ResponseException.class, () ->
+ service(params, payOrder)
+ .doNotice(null, params, payOrder, context(), noticeType())
+ );
+ }
+
+ @Test
+ void shouldKeepSuccessResponseForDuplicateNotification() {
+ JSONObject params = signedNotice(
+ "CHANNEL-1004", "PAY-1004", "T-1004", "1", "100"
+ );
+ PayOrder payOrder = payOrder("PAY-1004", 100L, PayOrder.STATE_SUCCESS);
+
+ ChannelRetMsg result = service(params, payOrder)
+ .doNotice(null, params, payOrder, context(), noticeType());
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS,
+ result.getChannelState());
+ assertEquals("CHANNEL-1004", result.getChannelOrderId());
+ assertEquals(
+ "{\"rspCod\":\"\",\"rspMsg\":\"success\"}",
+ result.getResponseEntity().getBody()
+ );
+ assertEquals(PayOrder.STATE_SUCCESS, payOrder.getState());
+ }
+
+ @Test
+ void shouldRejectInvalidSignature() {
+ JSONObject params = signedNotice(
+ "CHANNEL-1005", "PAY-1005", "T-1005", "1", "100"
+ );
+ params.put("NEW_PROTOCOL_FIELD", "tampered");
+
+ PayOrder payOrder = payOrder("PAY-1005", 100L, PayOrder.STATE_ING);
+
+ assertThrows(ResponseException.class, () ->
+ service(params, payOrder)
+ .doNotice(null, params, payOrder, context(), noticeType())
+ );
+ }
+
+ private static TestableNoticeService service(
+ JSONObject params,
+ PayOrder payOrder
+ ) {
+ TestableNoticeService service = new TestableNoticeService(params);
+ service.setPayOrderService(mock(PayOrderService.class));
+ return service;
+ }
+
+ private static JSONObject signedNotice(
+ String orderNo,
+ String threeOrderNo,
+ String tOrderNo,
+ String orderStatus,
+ String txamt
+ ) {
+ JSONObject params = new JSONObject(new LinkedHashMap<>());
+ params.put("ORDER_NO", orderNo);
+ params.put("THREE_ORDER_NO", threeOrderNo);
+ params.put("T_ORDER_NO", tOrderNo);
+ params.put("ORDER_STATUS", orderStatus);
+ params.put("TXAMT", txamt);
+ params.put("NEW_PROTOCOL_FIELD", "initial");
+ params.put("sign", signWithPrivateKey(params));
+ return params;
+ }
+
+ private static String signWithPrivateKey(Map fields) {
+ try {
+ Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ cipher.init(Cipher.ENCRYPT_MODE, KEY_PAIR.getPrivate());
+ byte[] digest = StarposSigner.sha256Hex(
+ StarposSigner.canonicalJson(fields)
+ ).getBytes(StandardCharsets.UTF_8);
+ return Base64.getEncoder().encodeToString(cipher.doFinal(digest));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static Map unsignedFields(JSONObject params) {
+ Map fields = new LinkedHashMap<>(params);
+ fields.remove("sign");
+ return fields;
+ }
+
+ private static MchAppConfigContext context() {
+ StarposNormalMchParams params = new StarposNormalMchParams();
+ params.setEnvironment("test");
+ params.setAgetId("AGENT-1001");
+ params.setCustId("CUST-1001");
+ params.setPublicKey(PUBLIC_KEY);
+ params.setVersion("1.0.0");
+
+ MchAppConfigContext context = new MchAppConfigContext();
+ context.getNormalMchParamsMap().put(CS.IF_CODE.STARPOS, params);
+ return context;
+ }
+
+ private static PayOrder payOrder(String payOrderId, long amount, byte state) {
+ return new PayOrder()
+ .setPayOrderId(payOrderId)
+ .setAmount(amount)
+ .setState(state)
+ .setIfCode(CS.IF_CODE.STARPOS);
+ }
+
+ private static IChannelNoticeService.NoticeTypeEnum noticeType() {
+ return IChannelNoticeService.NoticeTypeEnum.DO_NOTIFY;
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private static final class TestableNoticeService
+ extends StarposChannelNoticeService {
+
+ private final JSONObject params;
+
+ private TestableNoticeService(JSONObject params) {
+ this.params = params;
+ }
+
+ @Override
+ protected JSONObject getReqParamJSON() {
+ return params;
+ }
+
+ private void setPayOrderService(PayOrderService payOrderService) {
+ super.setPayOrderServiceForTest(payOrderService);
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfigTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfigTest.java
new file mode 100644
index 000000000..ddb24252d
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfigTest.java
@@ -0,0 +1,107 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+class StarposConfigTest {
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("environmentMappings")
+ void shouldMapOnlySupportedEnvironment(String environment, String expectedBaseUrl) {
+ StarposNormalMchParams params = validParams();
+ params.setEnvironment(environment);
+
+ assertEquals(expectedBaseUrl, StarposConfig.resolveBaseUrl(params));
+ }
+
+ @ParameterizedTest(name = "缺失 {0}")
+ @MethodSource("missingRequiredFields")
+ void shouldRejectMissingRequiredField(String field, Consumer clearField) {
+ StarposNormalMchParams params = validParams();
+ clearField.accept(params);
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> StarposConfig.resolveBaseUrl(params)
+ );
+ assertEquals(field + " 不能为空", exception.getMessage());
+ }
+
+ @Test
+ void shouldRejectMissingParams() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> StarposConfig.resolveBaseUrl(null)
+ );
+ assertEquals("星驿付商户参数不能为空", exception.getMessage());
+ }
+
+ @Test
+ void shouldRejectUnknownEnvironment() {
+ StarposNormalMchParams params = validParams();
+ params.setEnvironment("staging");
+
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> StarposConfig.resolveBaseUrl(params)
+ );
+ assertEquals("不支持的星驿付环境: staging", exception.getMessage());
+ }
+
+ @Test
+ void shouldProvideChannelConstantsAndDefaultVersion() {
+ assertEquals("starpos", CS.IF_CODE.STARPOS);
+ assertEquals("STARPOS_QR", CS.PAY_WAY_CODE.STARPOS_QR);
+ assertEquals("1.0.0", new StarposNormalMchParams().getVersion());
+ }
+
+ @Test
+ void shouldDesensitizePublicKey() {
+ StarposNormalMchParams params = validParams();
+
+ JSONObject desensitized = JSON.parseObject(params.deSenData());
+
+ assertEquals("LOCA******TURE", desensitized.getString("publicKey"));
+ assertFalse(desensitized.getString("publicKey").contains("PUBLIC_KEY"));
+ }
+
+ private static Stream environmentMappings() {
+ return Stream.of(
+ Arguments.of("test", "https://xyf-server-test.postar.cn"),
+ Arguments.of("uat", "https://xyzscxm.postar.cn"),
+ Arguments.of("prod", "https://yyfsvxm.postar.cn")
+ );
+ }
+
+ private static Stream missingRequiredFields() {
+ return Stream.of(
+ Arguments.of("environment", (Consumer) params -> params.setEnvironment(" ")),
+ Arguments.of("agetId", (Consumer) params -> params.setAgetId(null)),
+ Arguments.of("custId", (Consumer) params -> params.setCustId("")),
+ Arguments.of("publicKey", (Consumer) params -> params.setPublicKey(null)),
+ Arguments.of("version", (Consumer) params -> params.setVersion(" "))
+ );
+ }
+
+ private static StarposNormalMchParams validParams() {
+ StarposNormalMchParams params = new StarposNormalMchParams();
+ params.setEnvironment("test");
+ params.setAgetId("LOCAL_AGET_ID");
+ params.setCustId("LOCAL_CUST_ID");
+ params.setPublicKey("LOCAL_PUBLIC_KEY_FIXTURE");
+ return params;
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClientTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClientTest.java
new file mode 100644
index 000000000..12c177be8
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClientTest.java
@@ -0,0 +1,375 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+import com.alibaba.fastjson.JSONObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.Authenticator;
+import java.net.CookieHandler;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpConnectTimeoutException;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposHttpClientTest {
+
+ private static final URI FIXED_BASE_URI = URI.create("https://yyfsvxm.postar.cn");
+
+ @Test
+ void shouldPostUtf8JsonAndParseSuccessfulResponse() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"00\",\"message\":\"成功\"}");
+ StarposHttpClient client = localClient(server, Duration.ofSeconds(5));
+
+ JSONObject response = client.post(
+ "/gateway/pay",
+ JSONObject.parseObject("{\"orderNo\":\"本地订单\",\"amount\":100}"),
+ "LOCAL-ORDER-1001"
+ );
+
+ assertEquals("00", response.getString("code"));
+ assertEquals("成功", response.getString("message"));
+ StarposTestServer.RecordedRequest request = server.takeRequest();
+ assertEquals("/gateway/pay", request.getPath());
+ assertEquals(
+ "application/json; charset=UTF-8",
+ request.getFirstHeader("Content-Type")
+ );
+ assertEquals(
+ "{\"amount\":100,\"orderNo\":\"本地订单\"}",
+ request.getJsonBody()
+ );
+ }
+ }
+
+ @Test
+ void shouldConfigureFiveSecondConnectionTimeout() {
+ assertEquals(
+ Duration.ofSeconds(5),
+ StarposHttpClient.newHttpClient().connectTimeout().orElseThrow()
+ );
+ }
+
+ @Test
+ void shouldPropagateConnectionTimeoutFromPost() {
+ HttpClient timeoutClient = new DelegatingHttpClient(StarposHttpClient.newHttpClient()) {
+ @Override
+ public HttpResponse send(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) throws IOException {
+ throw new HttpConnectTimeoutException("本地连接超时 fixture");
+ }
+ };
+ StarposHttpClient client = new StarposHttpClient(
+ FIXED_BASE_URI,
+ timeoutClient,
+ Duration.ofSeconds(5)
+ );
+
+ HttpConnectTimeoutException exception = assertThrows(
+ HttpConnectTimeoutException.class,
+ () -> client.post("/gateway/query", new JSONObject(), "LOCAL-ORDER-CONNECT")
+ );
+
+ assertEquals("本地连接超时 fixture", exception.getMessage());
+ }
+
+ @Test
+ void shouldApplyRequestTimeout() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"00\"}", 300L);
+ StarposHttpClient client = localClient(server, Duration.ofMillis(50));
+
+ assertThrows(
+ HttpTimeoutException.class,
+ () -> client.post("/gateway/query", new JSONObject(), "LOCAL-ORDER-1002")
+ );
+ }
+ }
+
+ @Test
+ void shouldRejectNon2xxResponse() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(503, "{\"code\":\"SYSTEM_BUSY\"}");
+ StarposHttpClient client = localClient(server, Duration.ofSeconds(5));
+
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> client.post("/gateway/pay", new JSONObject(), "LOCAL-ORDER-1003")
+ );
+
+ assertEquals("星驿付响应状态异常", exception.getMessage());
+ }
+ }
+
+ @Test
+ void shouldRejectInvalidJsonResponse() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{invalid-json");
+ StarposHttpClient client = localClient(server, Duration.ofSeconds(5));
+
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> client.post("/gateway/query", new JSONObject(), "LOCAL-ORDER-1004")
+ );
+
+ assertEquals("星驿付响应不是合法 JSON 对象", exception.getMessage());
+ }
+ }
+
+ @Test
+ void shouldRejectResponseLargerThanOneMib() throws Exception {
+ String oversizedResponse = "{\"data\":\"" + "x".repeat(1024 * 1024) + "\"}";
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, oversizedResponse);
+ StarposHttpClient client = localClient(server, Duration.ofSeconds(5));
+
+ IOException exception = assertThrows(
+ IOException.class,
+ () -> client.post("/gateway/query", new JSONObject(), "LOCAL-ORDER-1005")
+ );
+
+ assertEquals("星驿付响应超过 1 MiB", exception.getMessage());
+ }
+ }
+
+ @Test
+ void shouldRejectConfiguredHostOutsideFixedStarposHosts() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> new StarposHttpClient("https://starpos.example.com")
+ );
+
+ assertEquals("不允许的星驿付服务主机: starpos.example.com", exception.getMessage());
+ }
+
+ @Test
+ void shouldRejectNonHttpsConfiguredEndpoint() {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> new StarposHttpClient("http://yyfsvxm.postar.cn")
+ );
+
+ assertEquals("星驿付服务地址必须使用 HTTPS", exception.getMessage());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://127.0.0.1:8080",
+ "http://localhost:8080",
+ "http://[::1]:8080"
+ })
+ void shouldRejectNonTlsEndpointThroughInternalConstructor(String baseUrl) {
+ IllegalArgumentException exception = assertThrows(
+ IllegalArgumentException.class,
+ () -> new StarposHttpClient(
+ URI.create(baseUrl),
+ StarposHttpClient.newHttpClient(),
+ Duration.ofSeconds(5)
+ )
+ );
+
+ assertEquals("星驿付服务地址必须使用 HTTPS", exception.getMessage());
+ }
+
+ @Test
+ void shouldAcceptFixedStarposEndpoint() {
+ new StarposHttpClient("https://yyfsvxm.postar.cn");
+ }
+
+ @Test
+ void shouldLogOnlyPathElapsedBusinessCodeAndMaskedOrderNo() throws Exception {
+ Logger logger = (Logger) LoggerFactory.getLogger(StarposHttpClient.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"00\",\"secret\":\"RESPONSE_SECRET\"}");
+ StarposHttpClient client = localClient(server, Duration.ofSeconds(5));
+
+ client.post(
+ "/gateway/pay?token=QUERY_SECRET",
+ JSONObject.parseObject("{\"secret\":\"REQUEST_SECRET\"}"),
+ "LOCAL-ORDER-12345678"
+ );
+
+ String message = appender.list.get(0).getFormattedMessage();
+ assertTrue(message.contains("path=/gateway/pay"));
+ assertTrue(message.contains("elapsedMs="));
+ assertTrue(message.contains("businessCode=00"));
+ assertTrue(message.contains("orderNo=LOCA************5678"));
+ assertFalse(message.contains("127.0.0.1"));
+ assertFalse(message.contains("QUERY_SECRET"));
+ assertFalse(message.contains("REQUEST_SECRET"));
+ assertFalse(message.contains("RESPONSE_SECRET"));
+ assertFalse(message.contains("LOCAL-ORDER-12345678"));
+ } finally {
+ logger.detachAppender(appender);
+ appender.stop();
+ }
+ }
+
+ private static StarposHttpClient localClient(
+ StarposTestServer server,
+ Duration requestTimeout
+ ) {
+ URI localBaseUri = URI.create(server.url("/").toString());
+ HttpClient httpClient = new ForwardingHttpClient(
+ StarposHttpClient.newHttpClient(),
+ localBaseUri
+ );
+ return new StarposHttpClient(FIXED_BASE_URI, httpClient, requestTimeout);
+ }
+
+ private static class DelegatingHttpClient extends HttpClient {
+
+ private final HttpClient delegate;
+
+ private DelegatingHttpClient(HttpClient delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public Optional cookieHandler() {
+ return delegate.cookieHandler();
+ }
+
+ @Override
+ public Optional connectTimeout() {
+ return delegate.connectTimeout();
+ }
+
+ @Override
+ public Redirect followRedirects() {
+ return delegate.followRedirects();
+ }
+
+ @Override
+ public Optional proxy() {
+ return delegate.proxy();
+ }
+
+ @Override
+ public SSLContext sslContext() {
+ return delegate.sslContext();
+ }
+
+ @Override
+ public SSLParameters sslParameters() {
+ return delegate.sslParameters();
+ }
+
+ @Override
+ public Optional authenticator() {
+ return delegate.authenticator();
+ }
+
+ @Override
+ public Version version() {
+ return delegate.version();
+ }
+
+ @Override
+ public Optional executor() {
+ return delegate.executor();
+ }
+
+ @Override
+ public HttpResponse send(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) throws IOException, InterruptedException {
+ return delegate.send(request, responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) {
+ return delegate.sendAsync(request, responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler,
+ HttpResponse.PushPromiseHandler pushPromiseHandler
+ ) {
+ return delegate.sendAsync(request, responseBodyHandler, pushPromiseHandler);
+ }
+ }
+
+ private static final class ForwardingHttpClient extends DelegatingHttpClient {
+
+ private final URI localBaseUri;
+
+ private ForwardingHttpClient(HttpClient delegate, URI localBaseUri) {
+ super(delegate);
+ this.localBaseUri = localBaseUri;
+ }
+
+ @Override
+ public HttpResponse send(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) throws IOException, InterruptedException {
+ return super.send(forward(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) {
+ return super.sendAsync(forward(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler,
+ HttpResponse.PushPromiseHandler pushPromiseHandler
+ ) {
+ return super.sendAsync(forward(request), responseBodyHandler, pushPromiseHandler);
+ }
+
+ private HttpRequest forward(HttpRequest request) {
+ String pathAndQuery = request.uri().getRawPath();
+ if (request.uri().getRawQuery() != null) {
+ pathAndQuery += "?" + request.uri().getRawQuery();
+ }
+ HttpRequest.Builder builder = HttpRequest.newBuilder(localBaseUri.resolve(pathAndQuery));
+ request.timeout().ifPresent(builder::timeout);
+ request.version().ifPresent(builder::version);
+ request.headers().map().forEach(
+ (name, values) -> values.forEach(value -> builder.header(name, value))
+ );
+ return builder.method(
+ request.method(),
+ request.bodyPublisher().orElse(HttpRequest.BodyPublishers.noBody())
+ ).build();
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryServiceTest.java
new file mode 100644
index 000000000..dd3ac4c50
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryServiceTest.java
@@ -0,0 +1,528 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams;
+import com.jeequan.jeepay.pay.model.MchAppConfigContext;
+import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.net.Authenticator;
+import java.net.CookieHandler;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.text.SimpleDateFormat;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.stream.Stream;
+import javax.crypto.Cipher;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposPayOrderQueryServiceTest {
+
+ private static final URI FIXED_BASE_URI = URI.create("https://xyf-server-test.postar.cn");
+ private static final KeyPair KEY_PAIR = generateKeyPair();
+ private static final String PUBLIC_KEY = Base64.getEncoder()
+ .encodeToString(KEY_PAIR.getPublic().getEncoded());
+
+ @Test
+ void shouldQuerySuccessWithDateAndSignedRequest() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", "STARPOS-PLATFORM-1001");
+ data.put("threeOrderNo", "ORDER-QUERY-1001");
+ data.put("torderNo", "T-ORDER-1001");
+ data.put("orderStatus", "1");
+ data.put("txamt", "12345");
+
+ server.enqueueJson(200, signedDataResponse("000000", "成功", data));
+
+ PayOrder payOrder = payOrder("ORDER-QUERY-1001", 12345L);
+ payOrder.setCreatedAt(new Date(1782396000000L));
+
+ ChannelRetMsg result = query(service(server), payOrder);
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS,
+ result.getChannelState());
+ assertEquals("STARPOS-PLATFORM-1001", result.getChannelOrderId());
+
+ JSONObject origin = JSONObject.parseObject(result.getChannelOriginResponse());
+ assertEquals("T-ORDER-1001",
+ origin.getJSONObject("starposExtension").getString("torderNo"));
+ assertEquals("ORDER-QUERY-1001",
+ origin.getJSONObject("starposExtension").getString("threeOrderNo"));
+
+ StarposTestServer.RecordedRequest request = server.takeRequest();
+ assertEquals("/yyfsevr/order/orderQuery", request.getPath());
+ JSONObject requestBody = JSONObject.parseObject(request.getJsonBody());
+ assertEquals("ORDER-QUERY-1001", requestBody.getString("orderNo"));
+ assertEquals(
+ new SimpleDateFormat("yyyyMMdd").format(payOrder.getCreatedAt()),
+ requestBody.getString("orderTime")
+ );
+ assertEquals("AGENT-1001", requestBody.getString("agetId"));
+ assertEquals("CUST-1001", requestBody.getString("custId"));
+ assertFalse(requestBody.getString("sign").isBlank());
+ assertEquals(
+ StarposSigner.sha256Hex(StarposSigner.canonicalJson(withoutSign(requestBody))),
+ decryptRequestHash(requestBody.getString("sign"), KEY_PAIR.getPrivate())
+ );
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("waitingResponses")
+ void shouldKeepOrderInProgressForPendingResponses(
+ String code,
+ String orderStatus
+ ) throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", "STARPOS-PENDING-1001");
+ data.put("orderStatus", orderStatus);
+ server.enqueueJson(200, signedDataResponse(code, "处理中", data));
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-PENDING-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.WAITING,
+ result.getChannelState());
+ assertEquals(code, result.getChannelErrCode());
+ assertFalse(result.isNeedQuery());
+ }
+ }
+
+ private static Stream waitingResponses() {
+ return Stream.of(
+ Arguments.of("222222", "2"),
+ Arguments.of("000000", "2")
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("failedStatuses")
+ void shouldMapTerminalFailureStatusesToConfirmedFailure(String orderStatus) throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", "STARPOS-FAILED-1001");
+ data.put("orderStatus", orderStatus);
+ server.enqueueJson(200, signedDataResponse("000000", "订单失败", data));
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-FAILED-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL,
+ result.getChannelState());
+ assertEquals("000000", result.getChannelErrCode());
+ assertFalse(result.isNeedQuery());
+ }
+ }
+
+ private static Stream failedStatuses() {
+ return Stream.of("0", "99");
+ }
+
+ @Test
+ void shouldPreserveLocalStateWhenUpstreamOrderIsNotFound() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject response = new JSONObject();
+ response.put("code", "000002");
+ response.put("message", "订单不存在");
+ response.put("orderNo", "ORDER-NOT-FOUND-1001");
+ response.put("sign", privateEncryptHash(
+ withoutSign(response),
+ KEY_PAIR.getPrivate()
+ ));
+ server.enqueueJson(200, response.toJSONString());
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-NOT-FOUND-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.WAITING,
+ result.getChannelState());
+ assertEquals("000002", result.getChannelErrCode());
+ assertFalse(result.isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldReturnUnknownForUpstreamUnknownResult() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject response = new JSONObject();
+ response.put("code", "-80000");
+ response.put("message", "结果未知");
+ response.put("sign", privateEncryptHash(
+ withoutSign(response),
+ KEY_PAIR.getPrivate()
+ ));
+ server.enqueueJson(200, response.toJSONString());
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-UNKNOWN-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ result.getChannelState());
+ assertEquals("-80000", result.getChannelErrCode());
+ assertTrue(result.isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldRejectTopLevelCodeTamperingWhenOnlyNestedDataIsSigned() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject response = JSONObject.parseObject(signedDataResponse(
+ "000000",
+ "成功",
+ signedSuccessData("STARPOS-TAMPER-1001", 100L)
+ ));
+ response.put("code", "000002");
+ server.enqueueJson(200, response.toJSONString());
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-TAMPER-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ result.getChannelState());
+ assertTrue(result.isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldConfirmFailureForInvalidConfiguration() {
+ MchAppConfigContext context = context();
+ StarposNormalMchParams params = context.getNormalMchParamsByIfCode(
+ CS.IF_CODE.STARPOS,
+ StarposNormalMchParams.class
+ );
+ params.setEnvironment("invalid");
+
+ ChannelRetMsg result = new StarposPayOrderQueryService()
+ .query(payOrder("ORDER-CONFIG-1001", 100L), context);
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL,
+ result.getChannelState());
+ assertEquals("STARPOS_QUERY_ERROR", result.getChannelErrCode());
+ assertFalse(result.isNeedQuery());
+ }
+
+ @Test
+ void shouldReturnUnknownForHttpErrorAndAllowFurtherQuery() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(502, "{\"code\":\"GATEWAY_ERROR\"}");
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-HTTP-ERROR-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ result.getChannelState());
+ assertEquals("STARPOS_QUERY_ERROR", result.getChannelErrCode());
+ assertTrue(result.isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldNotConfirmSuccessWhenReturnedAmountDoesNotMatch() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", "STARPOS-AMOUNT-1001");
+ data.put("threeOrderNo", "ORDER-AMOUNT-1001");
+ data.put("orderStatus", "1");
+ data.put("txamt", "101");
+ server.enqueueJson(200, signedDataResponse("000000", "成功", data));
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-AMOUNT-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ result.getChannelState());
+ assertTrue(result.isNeedQuery());
+ assertNotNull(result.getChannelErrMsg());
+ }
+ }
+
+ @Test
+ void shouldRejectInvalidResponseSignatureWithoutChangingLocalState() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", "STARPOS-SIGN-1001");
+ data.put("threeOrderNo", "ORDER-SIGN-1001");
+ data.put("orderStatus", "1");
+ data.put("txamt", "100");
+ data.put("sign", "invalid-signature");
+ JSONObject response = new JSONObject();
+ response.put("code", "000000");
+ response.put("data", data);
+ server.enqueueJson(200, response.toJSONString());
+
+ ChannelRetMsg result = query(
+ service(server),
+ payOrder("ORDER-SIGN-1001", 100L)
+ );
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ result.getChannelState());
+ assertTrue(result.isNeedQuery());
+ }
+ }
+
+ private static ChannelRetMsg query(
+ StarposPayOrderQueryService service,
+ PayOrder payOrder
+ ) throws Exception {
+ return service.query(payOrder, context());
+ }
+
+ private static StarposPayOrderQueryService service(StarposTestServer server) {
+ URI localBaseUri = URI.create(server.url("/").toString());
+ HttpClient client = new ForwardingHttpClient(
+ StarposHttpClient.newHttpClient(),
+ localBaseUri
+ );
+ StarposHttpClient httpClient = new StarposHttpClient(
+ FIXED_BASE_URI,
+ client,
+ Duration.ofSeconds(5)
+ );
+ return new StarposPayOrderQueryService() {
+ @Override
+ protected StarposHttpClient createHttpClient(String baseUrl) {
+ return httpClient;
+ }
+ };
+ }
+
+ private static MchAppConfigContext context() {
+ StarposNormalMchParams params = new StarposNormalMchParams();
+ params.setEnvironment("test");
+ params.setAgetId("AGENT-1001");
+ params.setCustId("CUST-1001");
+ params.setPublicKey(PUBLIC_KEY);
+ params.setVersion("1.0.0");
+
+ MchAppConfigContext context = new MchAppConfigContext();
+ context.setMchNo("MCH-1001");
+ context.setAppId("APP-1001");
+ context.getNormalMchParamsMap().put(CS.IF_CODE.STARPOS, params);
+ return context;
+ }
+
+ private static PayOrder payOrder(String orderNo, long amount) {
+ return new PayOrder()
+ .setPayOrderId(orderNo)
+ .setMchNo("MCH-1001")
+ .setAppId("APP-1001")
+ .setIfCode(CS.IF_CODE.STARPOS)
+ .setWayCode(CS.PAY_WAY_CODE.STARPOS_QR)
+ .setAmount(amount)
+ .setSubject("标题")
+ .setBody("描述")
+ .setCreatedAt(new Date());
+ }
+
+ private static String signedDataResponse(
+ String code,
+ String message,
+ JSONObject data
+ ) {
+ data.put("code", code);
+ data.put("message", message);
+ data.put("sign", privateEncryptHash(withoutSign(data), KEY_PAIR.getPrivate()));
+ JSONObject response = new JSONObject();
+ response.put("code", code);
+ response.put("message", message);
+ response.put("data", data);
+ return response.toJSONString();
+ }
+
+ private static JSONObject signedSuccessData(String platformOrderNo, long amount) {
+ JSONObject data = new JSONObject();
+ data.put("orderNo", platformOrderNo);
+ data.put("threeOrderNo", "ORDER-TAMPER-1001");
+ data.put("torderNo", "T-ORDER-TAMPER-1001");
+ data.put("orderStatus", "1");
+ data.put("txamt", String.valueOf(amount));
+ return data;
+ }
+
+ private static Map withoutSign(JSONObject body) {
+ Map fields = new LinkedHashMap<>(body);
+ fields.remove("sign");
+ return fields;
+ }
+
+ private static String decryptRequestHash(String sign, PrivateKey privateKey) {
+ try {
+ Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ cipher.init(Cipher.DECRYPT_MODE, privateKey);
+ return new String(
+ cipher.doFinal(Base64.getDecoder().decode(sign)),
+ StandardCharsets.UTF_8
+ );
+ } catch (Exception e) {
+ throw new AssertionError("请求签名无法用本地 fixture 私钥解密", e);
+ }
+ }
+
+ private static String privateEncryptHash(
+ Map fields,
+ PrivateKey privateKey
+ ) {
+ try {
+ Cipher encryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ encryptor.init(Cipher.ENCRYPT_MODE, privateKey);
+ byte[] hash = StarposSigner.sha256Hex(StarposSigner.canonicalJson(fields))
+ .getBytes(StandardCharsets.UTF_8);
+ return Base64.getEncoder().encodeToString(encryptor.doFinal(hash));
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to create local private-key response fixture", e);
+ }
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new AssertionError("生成 RSA 测试密钥失败", e);
+ }
+ }
+
+ private static class ForwardingHttpClient extends HttpClient {
+
+ private final HttpClient delegate;
+ private final URI localBaseUri;
+
+ private ForwardingHttpClient(HttpClient delegate, URI localBaseUri) {
+ this.delegate = delegate;
+ this.localBaseUri = localBaseUri;
+ }
+
+ @Override
+ public HttpResponse send(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) throws IOException, InterruptedException {
+ return delegate.send(rewrite(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) {
+ return delegate.sendAsync(rewrite(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler,
+ HttpResponse.PushPromiseHandler pushPromiseHandler
+ ) {
+ return delegate.sendAsync(
+ rewrite(request),
+ responseBodyHandler,
+ pushPromiseHandler
+ );
+ }
+
+ private HttpRequest rewrite(HttpRequest request) {
+ URI target = localBaseUri.resolve(request.uri().getRawPath()
+ + Optional.ofNullable(request.uri().getRawQuery())
+ .map(query -> "?" + query)
+ .orElse(""));
+ HttpRequest.Builder builder = HttpRequest.newBuilder(target)
+ .method(request.method(), request.bodyPublisher()
+ .map(publisher -> publisher)
+ .orElse(HttpRequest.BodyPublishers.noBody()));
+ request.headers().map().forEach((name, values) ->
+ values.forEach(value -> builder.header(name, value)));
+ request.timeout().ifPresent(builder::timeout);
+ return builder.build();
+ }
+
+ @Override
+ public Optional cookieHandler() {
+ return delegate.cookieHandler();
+ }
+
+ @Override
+ public Optional connectTimeout() {
+ return delegate.connectTimeout();
+ }
+
+ @Override
+ public Redirect followRedirects() {
+ return delegate.followRedirects();
+ }
+
+ @Override
+ public Optional proxy() {
+ return delegate.proxy();
+ }
+
+ @Override
+ public SSLContext sslContext() {
+ return delegate.sslContext();
+ }
+
+ @Override
+ public SSLParameters sslParameters() {
+ return delegate.sslParameters();
+ }
+
+ @Override
+ public Optional authenticator() {
+ return delegate.authenticator();
+ }
+
+ @Override
+ public Version version() {
+ return delegate.version();
+ }
+
+ @Override
+ public Optional executor() {
+ return delegate.executor();
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentServiceTest.java
new file mode 100644
index 000000000..72e2fe314
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentServiceTest.java
@@ -0,0 +1,411 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams;
+import com.jeequan.jeepay.pay.channel.starpos.payway.StarposQr;
+import com.jeequan.jeepay.pay.model.MchAppConfigContext;
+import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg;
+import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ;
+import com.jeequan.jeepay.pay.rqrs.payorder.payway.StarposQrOrderRS;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.net.Authenticator;
+import java.net.CookieHandler;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import javax.crypto.Cipher;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposPaymentServiceTest {
+
+ private static final URI FIXED_BASE_URI = URI.create("https://xyf-server-test.postar.cn");
+ private static final KeyPair KEY_PAIR = generateKeyPair();
+ private static final String PUBLIC_KEY = Base64.getEncoder()
+ .encodeToString(KEY_PAIR.getPublic().getEncoded());
+
+ @Test
+ void shouldNotUseNestedPaywayLookupForConcreteQrService() {
+ assertNull(new StarposQr().preCheck(new UnifiedOrderRQ(), payOrder(
+ "ORDER-PRECHECK", 100L, "标题", "描述"
+ )));
+ }
+
+ @Test
+ void shouldMapPaymentFieldsSignRequestAndOmitDynamicAsyncNotify() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(
+ 200,
+ "{\"code\":\"000000\",\"message\":\"成功\","
+ + "\"data\":\"https://xyf-server-test.postar.cn/cashier/ORDER-1001\"}"
+ );
+
+ PayOrder payOrder = payOrder("ORDER-1001", 12345L, "商品标题", "这是商品描述");
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)), payOrder);
+
+ assertEquals(CS.PAY_DATA_TYPE.PAY_URL, response.buildPayDataType());
+ assertEquals(
+ "https://xyf-server-test.postar.cn/cashier/ORDER-1001",
+ response.buildPayData()
+ );
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS,
+ response.getChannelRetMsg().getChannelState());
+
+ StarposTestServer.RecordedRequest request = server.takeRequest();
+ assertEquals("/yyfsevr/order/getCodeUrl", request.getPath());
+ JSONObject body = JSONObject.parseObject(request.getJsonBody());
+ assertEquals("AGENT-1001", body.getString("agetId"));
+ assertEquals("CUST-1001", body.getString("custId"));
+ assertEquals("1.0.0", body.getString("version"));
+ assertEquals("ORDER-1001", body.getString("orderNo"));
+ assertEquals(12345L, body.getLongValue("txamt"));
+ assertEquals("商品标题", body.getString("title"));
+ assertEquals("这是商品描述", body.getString("remark"));
+ assertFalse(body.containsKey("asyncNotify"));
+ assertTrue(!body.getString("timeStamp").isBlank());
+ assertTrue(!body.getString("sign").isBlank());
+
+ assertEquals(
+ StarposSigner.sha256Hex(StarposSigner.canonicalJson(withoutSign(body))),
+ decryptRequestHash(body.getString("sign"), KEY_PAIR.getPrivate())
+ );
+ }
+ }
+
+ @Test
+ void shouldLimitRemarkLengthWithoutChangingOrderMapping() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(
+ 200,
+ "{\"code\":\"000000\",\"data\":\"https://xyf-server-test.postar.cn/cashier/ORDER-1002\"}"
+ );
+
+ String longBody = "描述".repeat(300);
+ PayOrder payOrder = payOrder("ORDER-1002", 1L, "标题", longBody);
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)), payOrder);
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS,
+ response.getChannelRetMsg().getChannelState());
+ JSONObject body = JSONObject.parseObject(server.takeRequest().getJsonBody());
+ assertEquals("ORDER-1002", body.getString("orderNo"));
+ assertEquals(1L, body.getLongValue("txamt"));
+ assertEquals("标题", body.getString("title"));
+ assertTrue(body.getString("remark").length() <= 128);
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"222222", "555555", "-35200"})
+ void shouldMapKnownBusinessErrorsToConfirmedFailure(String code) throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"" + code + "\",\"message\":\"上游失败\"}");
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)),
+ payOrder("ORDER-" + code, 100L, "标题", "描述"));
+
+ ChannelRetMsg channelRetMsg = response.getChannelRetMsg();
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL, channelRetMsg.getChannelState());
+ assertEquals(code, channelRetMsg.getChannelErrCode());
+ assertEquals("上游失败", channelRetMsg.getChannelErrMsg());
+ }
+ }
+
+ @Test
+ void shouldKeepOrderQueryableWhenUpstreamReturnsUnknownCode() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"-80000\",\"message\":\"结果未知\"}");
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)),
+ payOrder("ORDER-UNKNOWN", 100L, "标题", "描述"));
+
+ ChannelRetMsg channelRetMsg = response.getChannelRetMsg();
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN, channelRetMsg.getChannelState());
+ assertEquals("-80000", channelRetMsg.getChannelErrCode());
+ assertTrue(channelRetMsg.isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldKeepOrderQueryableAfterRequestTimeout() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"000000\"}", 300L);
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofMillis(50)),
+ payOrder("ORDER-TIMEOUT", 100L, "标题", "描述"));
+
+ assertEquals(ChannelRetMsg.ChannelState.UNKNOWN,
+ response.getChannelRetMsg().getChannelState());
+ assertTrue(response.getChannelRetMsg().isNeedQuery());
+ }
+ }
+
+ @Test
+ void shouldMapInvalidConfigurationToConfirmedFailureAfterOrderCreation() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ MchAppConfigContext context = context();
+ StarposNormalMchParams params = context
+ .getNormalMchParamsByIfCode(CS.IF_CODE.STARPOS, StarposNormalMchParams.class);
+ params.setEnvironment("invalid");
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)),
+ payOrder("ORDER-BAD-CONFIG", 100L, "标题", "描述"), context);
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL,
+ response.getChannelRetMsg().getChannelState());
+ assertFalse(response.getChannelRetMsg().isNeedQuery());
+ assertFalse(response.getChannelRetMsg().getChannelErrMsg().isBlank());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "http://xyf-server-test.postar.cn/cashier/ORDER-BAD",
+ "https://xyzscxm.postar.cn/cashier/ORDER-BAD"
+ })
+ void shouldRejectCashierLinkThatIsNotHttpsOrCurrentEnvironmentHost(String cashierUrl)
+ throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(200, "{\"code\":\"000000\",\"data\":\"" + cashierUrl + "\"}");
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)),
+ payOrder("ORDER-BAD-LINK", 100L, "标题", "描述"));
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL,
+ response.getChannelRetMsg().getChannelState());
+ assertFalse(response.getChannelRetMsg().getChannelErrMsg().isBlank());
+ assertEquals("", response.buildPayData());
+ }
+ }
+
+ @Test
+ void shouldRejectCashierLinkWithNonDefaultPort() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(
+ 200,
+ "{\"code\":\"000000\","
+ + "\"data\":\"https://xyf-server-test.postar.cn:8443/cashier/ORDER-PORT\"}"
+ );
+
+ StarposQrOrderRS response = pay(service(server, Duration.ofSeconds(5)),
+ payOrder("ORDER-PORT", 100L, "标题", "描述"));
+
+ assertEquals(ChannelRetMsg.ChannelState.CONFIRM_FAIL,
+ response.getChannelRetMsg().getChannelState());
+ assertEquals("", response.buildPayData());
+ }
+ }
+
+ private static StarposQrOrderRS pay(StarposQr service, PayOrder payOrder) throws Exception {
+ return pay(service, payOrder, context());
+ }
+
+ private static StarposQrOrderRS pay(
+ StarposQr service,
+ PayOrder payOrder,
+ MchAppConfigContext context
+ ) throws Exception {
+ return (StarposQrOrderRS) service.pay(
+ new UnifiedOrderRQ(),
+ payOrder,
+ context
+ );
+ }
+
+ private static StarposQr service(StarposTestServer server, Duration requestTimeout) {
+ URI localBaseUri = URI.create(server.url("/").toString());
+ HttpClient client = new ForwardingHttpClient(
+ StarposHttpClient.newHttpClient(),
+ localBaseUri
+ );
+ StarposHttpClient httpClient = new StarposHttpClient(
+ FIXED_BASE_URI,
+ client,
+ requestTimeout
+ );
+ return new StarposQr() {
+ @Override
+ protected StarposHttpClient createHttpClient(String baseUrl) {
+ return httpClient;
+ }
+ };
+ }
+
+ private static MchAppConfigContext context() {
+ StarposNormalMchParams params = new StarposNormalMchParams();
+ params.setEnvironment("test");
+ params.setAgetId("AGENT-1001");
+ params.setCustId("CUST-1001");
+ params.setPublicKey(PUBLIC_KEY);
+ params.setVersion("1.0.0");
+
+ MchAppConfigContext context = new MchAppConfigContext();
+ context.setMchNo("MCH-1001");
+ context.setAppId("APP-1001");
+ context.getNormalMchParamsMap().put(CS.IF_CODE.STARPOS, params);
+ return context;
+ }
+
+ private static PayOrder payOrder(String orderNo, long amount, String subject, String body) {
+ return new PayOrder()
+ .setPayOrderId(orderNo)
+ .setMchNo("MCH-1001")
+ .setAppId("APP-1001")
+ .setIfCode(CS.IF_CODE.STARPOS)
+ .setWayCode(CS.PAY_WAY_CODE.STARPOS_QR)
+ .setAmount(amount)
+ .setSubject(subject)
+ .setBody(body);
+ }
+
+ private static java.util.Map withoutSign(JSONObject body) {
+ java.util.Map fields = new java.util.LinkedHashMap<>(body);
+ fields.remove("sign");
+ return fields;
+ }
+
+ private static String decryptRequestHash(String sign, PrivateKey privateKey) {
+ try {
+ Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ cipher.init(Cipher.DECRYPT_MODE, privateKey);
+ return new String(
+ cipher.doFinal(Base64.getDecoder().decode(sign)),
+ StandardCharsets.UTF_8
+ );
+ } catch (Exception e) {
+ throw new AssertionError("请求签名无法用本地 fixture 私钥解密", e);
+ }
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new AssertionError("生成 RSA 测试密钥失败", e);
+ }
+ }
+
+ private static class ForwardingHttpClient extends HttpClient {
+
+ private final HttpClient delegate;
+ private final URI localBaseUri;
+
+ private ForwardingHttpClient(HttpClient delegate, URI localBaseUri) {
+ this.delegate = delegate;
+ this.localBaseUri = localBaseUri;
+ }
+
+ @Override
+ public HttpResponse send(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) throws IOException, InterruptedException {
+ return delegate.send(rewrite(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler
+ ) {
+ return delegate.sendAsync(rewrite(request), responseBodyHandler);
+ }
+
+ @Override
+ public CompletableFuture> sendAsync(
+ HttpRequest request,
+ HttpResponse.BodyHandler responseBodyHandler,
+ HttpResponse.PushPromiseHandler pushPromiseHandler
+ ) {
+ return delegate.sendAsync(
+ rewrite(request),
+ responseBodyHandler,
+ pushPromiseHandler
+ );
+ }
+
+ private HttpRequest rewrite(HttpRequest request) {
+ URI target = localBaseUri.resolve(request.uri().getRawPath()
+ + Optional.ofNullable(request.uri().getRawQuery())
+ .map(query -> "?" + query)
+ .orElse(""));
+ HttpRequest.Builder builder = HttpRequest.newBuilder(target)
+ .method(request.method(), request.bodyPublisher()
+ .map(publisher -> publisher)
+ .orElse(HttpRequest.BodyPublishers.noBody()));
+ request.headers().map().forEach((name, values) ->
+ values.forEach(value -> builder.header(name, value)));
+ request.timeout().ifPresent(builder::timeout);
+ return builder.build();
+ }
+
+ @Override
+ public Optional cookieHandler() {
+ return delegate.cookieHandler();
+ }
+
+ @Override
+ public Optional connectTimeout() {
+ return delegate.connectTimeout();
+ }
+
+ @Override
+ public Redirect followRedirects() {
+ return delegate.followRedirects();
+ }
+
+ @Override
+ public Optional proxy() {
+ return delegate.proxy();
+ }
+
+ @Override
+ public SSLContext sslContext() {
+ return delegate.sslContext();
+ }
+
+ @Override
+ public SSLParameters sslParameters() {
+ return delegate.sslParameters();
+ }
+
+ @Override
+ public Optional authenticator() {
+ return delegate.authenticator();
+ }
+
+ @Override
+ public Version version() {
+ return delegate.version();
+ }
+
+ @Override
+ public Optional executor() {
+ return delegate.executor();
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposSignerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposSignerTest.java
new file mode 100644
index 000000000..ee4a9d625
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposSignerTest.java
@@ -0,0 +1,227 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.parser.Feature;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import javax.crypto.Cipher;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposSignerTest {
+
+ private static final String SIGNING_VECTORS_RESOURCE = "contracts/starpos/signing-vectors.json";
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("canonicalJsonVectors")
+ void canonicalJson_matches_auditable_local_fixture(
+ String name,
+ Map fields,
+ String expectedCanonicalJson
+ ) {
+ assertEquals(expectedCanonicalJson, StarposSigner.canonicalJson(fields), name);
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("canonicalJsonVectors")
+ void sha256Hex_matches_auditable_local_fixture(
+ String name,
+ Map fields,
+ String expectedCanonicalJson,
+ String expectedSha256Hex
+ ) {
+ assertEquals(expectedSha256Hex, StarposSigner.sha256Hex(expectedCanonicalJson), name);
+ assertEquals(expectedSha256Hex, StarposSigner.sha256Hex(StarposSigner.canonicalJson(fields)), name);
+ }
+
+ @Test
+ void canonicalJson_excludes_null_and_sign_but_keeps_empty_string() {
+ JSONObject vector = vectorByName("null-is-excluded-empty-and-sign-are-handled");
+ assertEquals(
+ "{\"empty\":\"\",\"present\":\"value\"}",
+ StarposSigner.canonicalJson(fieldsOf(vector))
+ );
+ }
+
+ @Test
+ void canonicalJson_keeps_nested_object_input_order() {
+ JSONObject vector = vectorByName("nested-object-keeps-input-order");
+ assertEquals(
+ "{\"alpha\":\"first\",\"outer\":{\"z\":1,\"a\":2}}",
+ StarposSigner.canonicalJson(fieldsOf(vector))
+ );
+ }
+
+ @Test
+ void rsa_fixture_documents_public_key_protocol() {
+ JSONObject rsa = loadFixture().getJSONObject("rsa");
+ assertEquals("protocol_locked", rsa.getString("status"));
+ assertEquals("RSA/ECB/PKCS1Padding", rsa.getString("algorithm"));
+ assertEquals(
+ "SHA-256 lowercase hex over UTF-8 canonical parameter string",
+ rsa.getString("hash")
+ );
+ assertEquals("X.509 PUBLIC KEY PEM/Base64", rsa.getString("keyFormat"));
+ assertEquals("public-key encrypt", rsa.getJSONObject("direction").getString("request"));
+ assertEquals("public-key decrypt", rsa.getJSONObject("direction").getString("notification"));
+ assertEquals(
+ "XyfClient.php: openssl_public_encrypt/openssl_public_decrypt; postar.cn/xyf/doc/7306848m0",
+ rsa.getString("source")
+ );
+ assertEquals(
+ "Generated local RSA public key; direction/implementation test, not a merchant key sample",
+ rsa.getString("testMaterial")
+ );
+ }
+
+ @Test
+ void rsa_protocol_uses_public_key_encrypt_for_request_and_public_key_decrypt_for_notification()
+ throws Exception {
+ KeyPair keyPair = generateKeyPair();
+ Map request = requestFields();
+
+ String publicKeyPem = pem("PUBLIC KEY", keyPair.getPublic().getEncoded());
+ String publicKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
+ String requestCiphertext = StarposSigner.signRequest(request, publicKeyPem);
+
+ Cipher requestDecryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ requestDecryptor.init(Cipher.DECRYPT_MODE, keyPair.getPrivate());
+ String decryptedHash = new String(
+ requestDecryptor.doFinal(Base64.getDecoder().decode(requestCiphertext)),
+ StandardCharsets.UTF_8
+ );
+
+ assertEquals(
+ StarposSigner.sha256Hex(StarposSigner.canonicalJson(request)),
+ decryptedHash
+ );
+
+ Map notification = new LinkedHashMap<>(request);
+ notification.put("sign", privateEncryptHash(request, keyPair.getPrivate()));
+ assertTrue(StarposSigner.verifyNotification(notification, publicKeyBase64));
+ }
+
+ @Test
+ void rsa_ciphertext_tampering_fails() {
+ KeyPair keyPair = generateKeyPair();
+ Map notification = signedNotification(keyPair);
+ byte[] tamperedCiphertext = Base64.getDecoder().decode((String) notification.get("sign"));
+ tamperedCiphertext[tamperedCiphertext.length - 1] ^= 1;
+ notification.put("sign", Base64.getEncoder().encodeToString(tamperedCiphertext));
+
+ assertFalse(StarposSigner.verifyNotification(
+ notification,
+ Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded())
+ ));
+ }
+
+ @Test
+ void rsa_field_tampering_fails() {
+ KeyPair keyPair = generateKeyPair();
+ Map notification = signedNotification(keyPair);
+ notification.put("amount", "9.99");
+
+ assertFalse(StarposSigner.verifyNotification(
+ notification,
+ pem("PUBLIC KEY", keyPair.getPublic().getEncoded())
+ ));
+ }
+
+ private static KeyPair generateKeyPair() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to generate local RSA test key pair", e);
+ }
+ }
+
+ private static Map signedNotification(KeyPair keyPair) {
+ Map request = requestFields();
+ String signature = privateEncryptHash(request, keyPair.getPrivate());
+ Map notification = new LinkedHashMap<>(request);
+ notification.put("sign", signature);
+ return notification;
+ }
+
+ private static String privateEncryptHash(Map fields, PrivateKey privateKey) {
+ try {
+ Cipher encryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+ encryptor.init(Cipher.ENCRYPT_MODE, privateKey);
+ byte[] hash = StarposSigner.sha256Hex(StarposSigner.canonicalJson(fields))
+ .getBytes(StandardCharsets.UTF_8);
+ return Base64.getEncoder().encodeToString(encryptor.doFinal(hash));
+ } catch (Exception e) {
+ throw new IllegalStateException("failed to create local private-key response fixture", e);
+ }
+ }
+
+ private static Map requestFields() {
+ Map request = new LinkedHashMap<>();
+ request.put("amount", "1.00");
+ request.put("merchantNo", "LOCAL_FIXTURE");
+ return request;
+ }
+
+ private static Stream canonicalJsonVectors() {
+ JSONArray vectors = loadFixture().getJSONArray("canonicalJsonVectors");
+ return vectors.stream()
+ .map(JSONObject.class::cast)
+ .map(vector -> Arguments.of(
+ vector.getString("name"),
+ fieldsOf(vector),
+ vector.getString("canonicalJson"),
+ vector.getString("sha256Hex")
+ ));
+ }
+
+ private static JSONObject vectorByName(String name) {
+ return loadFixture().getJSONArray("canonicalJsonVectors").stream()
+ .map(JSONObject.class::cast)
+ .filter(vector -> Objects.equals(name, vector.getString("name")))
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private static Map fieldsOf(JSONObject vector) {
+ return new LinkedHashMap<>(vector.getJSONObject("fields"));
+ }
+
+ private static JSONObject loadFixture() {
+ try (InputStream stream = StarposSignerTest.class.getClassLoader()
+ .getResourceAsStream(SIGNING_VECTORS_RESOURCE)) {
+ assertNotNull(stream, "missing test fixture: " + SIGNING_VECTORS_RESOURCE);
+ String json = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ return JSON.parseObject(json, Feature.OrderedField);
+ } catch (IOException e) {
+ throw new IllegalStateException("failed to load " + SIGNING_VECTORS_RESOURCE, e);
+ }
+ }
+
+ private static String pem(String type, byte[] encoded) {
+ String body = Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded);
+ return "-----BEGIN " + type + "-----\n"
+ + body
+ + "\n-----END " + type + "-----";
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServer.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServer.java
new file mode 100644
index 000000000..c95ff56e8
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServer.java
@@ -0,0 +1,176 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import com.sun.net.httpserver.Headers;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Local HTTP server used by StarPOS channel tests.
+ */
+class StarposTestServer implements AutoCloseable {
+
+ private static final Response DEFAULT_RESPONSE = new Response(200, "{}", 0L);
+
+ private final HttpServer server;
+ private final ExecutorService executorService;
+ private final Queue responses = new ConcurrentLinkedQueue<>();
+ private final List requests = new CopyOnWriteArrayList<>();
+
+ private StarposTestServer(HttpServer server, ExecutorService executorService) {
+ this.server = server;
+ this.executorService = executorService;
+ }
+
+ static StarposTestServer start() {
+ try {
+ HttpServer httpServer = HttpServer.create(new InetSocketAddress(0), 0);
+ ExecutorService executorService = Executors.newCachedThreadPool();
+ StarposTestServer testServer = new StarposTestServer(httpServer, executorService);
+ httpServer.createContext("/", testServer::handle);
+ httpServer.setExecutor(executorService);
+ httpServer.start();
+ return testServer;
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ URL url(String path) {
+ try {
+ return new URL("http", "127.0.0.1", server.getAddress().getPort(), path);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ void enqueueJson(int statusCode, String json) {
+ enqueueJson(statusCode, json, 0L);
+ }
+
+ void enqueueJson(int statusCode, String json, long delayMillis) {
+ responses.add(new Response(statusCode, json, delayMillis));
+ }
+
+ List getRequests() {
+ return Collections.unmodifiableList(requests);
+ }
+
+ RecordedRequest takeRequest() {
+ if (requests.isEmpty()) {
+ throw new IllegalStateException("No recorded StarPOS request");
+ }
+ return requests.get(0);
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ executorService.shutdownNow();
+ }
+
+ private void handle(HttpExchange exchange) throws IOException {
+ requests.add(RecordedRequest.from(exchange));
+
+ Response response = responses.poll();
+ if (response == null) {
+ response = DEFAULT_RESPONSE;
+ }
+ response.delay();
+
+ byte[] responseBody = response.json.getBytes(StandardCharsets.UTF_8);
+ Headers headers = exchange.getResponseHeaders();
+ headers.set("Content-Type", "application/json;charset=UTF-8");
+ exchange.sendResponseHeaders(response.statusCode, responseBody.length);
+ exchange.getResponseBody().write(responseBody);
+ exchange.close();
+ }
+
+ static class RecordedRequest {
+
+ private final String path;
+ private final String jsonBody;
+ private final Map> headers;
+
+ private RecordedRequest(String path, String jsonBody, Map> headers) {
+ this.path = path;
+ this.jsonBody = jsonBody;
+ this.headers = headers;
+ }
+
+ static RecordedRequest from(HttpExchange exchange) throws IOException {
+ String path = exchange.getRequestURI().toString();
+ String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ Map> headers = copyHeaders(exchange.getRequestHeaders());
+ return new RecordedRequest(path, body, headers);
+ }
+
+ String getPath() {
+ return path;
+ }
+
+ String getJsonBody() {
+ return jsonBody;
+ }
+
+ Map> getHeaders() {
+ return headers;
+ }
+
+ String getFirstHeader(String name) {
+ List values = headers.get(name);
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+ return values.get(0);
+ }
+
+ private static Map> copyHeaders(Headers source) {
+ Headers copy = new Headers();
+ for (Map.Entry> entry : source.entrySet()) {
+ copy.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+ }
+ return Collections.unmodifiableMap(copy);
+ }
+ }
+
+ private static class Response {
+
+ private final int statusCode;
+ private final String json;
+ private final long delayMillis;
+
+ private Response(int statusCode, String json, long delayMillis) {
+ this.statusCode = statusCode;
+ this.json = json;
+ this.delayMillis = delayMillis;
+ }
+
+ private void delay() {
+ if (delayMillis <= 0L) {
+ return;
+ }
+ try {
+ TimeUnit.MILLISECONDS.sleep(delayMillis);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(e);
+ }
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServerTest.java
new file mode 100644
index 000000000..f7805ebba
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServerTest.java
@@ -0,0 +1,90 @@
+package com.jeequan.jeepay.pay.channel.starpos;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposTestServerTest {
+
+ @Test
+ void shouldBeDiscoveredBySurefire() {
+ }
+
+ @Test
+ void shouldRecordRequestPathJsonBodyAndHeaders() throws Exception {
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(201, "{\"code\":\"00\"}");
+
+ HttpURLConnection connection = postJson(server.url("/gateway/pay?merchantNo=M100"), "{\"amount\":100}",
+ "X-Starpos-Sign", "SIGNATURE");
+
+ assertEquals(201, connection.getResponseCode());
+ assertEquals("{\"code\":\"00\"}", readBody(connection));
+
+ StarposTestServer.RecordedRequest request = server.takeRequest();
+ assertEquals("/gateway/pay?merchantNo=M100", request.getPath());
+ assertEquals("{\"amount\":100}", request.getJsonBody());
+ assertEquals("SIGNATURE", request.getFirstHeader("X-Starpos-Sign"));
+ assertTrue(request.getHeaders().containsKey("Content-Type"));
+ }
+ }
+
+ @Test
+ void shouldReturnConfiguredDelayAndLargeJsonResponse() throws Exception {
+ String largeResponse = "{\"data\":\"" + String.join("", Collections.nCopies(8192, "x")) + "\"}";
+
+ try (StarposTestServer server = StarposTestServer.start()) {
+ server.enqueueJson(202, largeResponse, 25L);
+
+ long startedAt = System.currentTimeMillis();
+ HttpURLConnection connection = postJson(server.url("/gateway/query"), "{\"orderNo\":\"P100\"}");
+
+ assertEquals(202, connection.getResponseCode());
+ assertEquals(largeResponse, readBody(connection));
+ assertTrue(System.currentTimeMillis() - startedAt >= 20L);
+ assertFalse(server.getRequests().isEmpty());
+ }
+ }
+
+ private HttpURLConnection postJson(URL url, String body) throws Exception {
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
+ return postJson(connection, body);
+ }
+
+ private HttpURLConnection postJson(URL url, String body, String headerName, String headerValue) throws Exception {
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
+ connection.setRequestProperty(headerName, headerValue);
+ return postJson(connection, body);
+ }
+
+ private HttpURLConnection postJson(HttpURLConnection connection, String body) throws Exception {
+ try (OutputStream outputStream = connection.getOutputStream()) {
+ outputStream.write(body.getBytes(StandardCharsets.UTF_8));
+ }
+ return connection;
+ }
+
+ private String readBody(HttpURLConnection connection) throws Exception {
+ InputStream inputStream = connection.getResponseCode() >= 400
+ ? connection.getErrorStream()
+ : connection.getInputStream();
+ try (InputStream body = inputStream) {
+ return new String(body.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/AbstractPayOrderControllerChannelExtraTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/AbstractPayOrderControllerChannelExtraTest.java
new file mode 100644
index 000000000..26f96d7c6
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/AbstractPayOrderControllerChannelExtraTest.java
@@ -0,0 +1,125 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.jeequan.jeepay.core.entity.MchApp;
+import com.jeequan.jeepay.core.entity.MchInfo;
+import com.jeequan.jeepay.core.entity.MchPayPassage;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.beans.RequestKitBean;
+import com.jeequan.jeepay.core.ctrls.AbstractCtrl;
+import com.jeequan.jeepay.core.model.ApiRes;
+import com.jeequan.jeepay.pay.ctrl.payorder.AbstractPayOrderController;
+import com.jeequan.jeepay.pay.ctrl.payorder.UnifiedOrderController;
+import com.jeequan.jeepay.pay.rqrs.AbstractRQ;
+import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ;
+import com.jeequan.jeepay.service.impl.PayOrderService;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class AbstractPayOrderControllerChannelExtraTest {
+
+ @Test
+ void copiesRequestChannelExtraIntoInitialPayOrder() throws Exception {
+ UnifiedOrderRQ request = new UnifiedOrderRQ();
+ request.setChannelExtra(EpayCompatMetadata.encode(new EpayMetadataValue(
+ "v1", "PID-001", null, "alipay", "ORDER-001",
+ "https://merchant.example/notify", "https://merchant.example/return",
+ "subject", "12.34", "203.0.113.10", null, null)));
+
+ PayOrder payOrder = generate(request);
+
+ assertThat(payOrder.getChannelExtra()).isEqualTo(request.getChannelExtra());
+ }
+
+ @Test
+ void keepsNativeOrderChannelExtraEmptyWhenRequestHasNativeChannelExtra() throws Exception {
+ UnifiedOrderRQ request = new UnifiedOrderRQ();
+ request.setChannelExtra("{\"payDataType\":\"payUrl\"}");
+
+ PayOrder payOrder = generate(request);
+
+ assertThat(payOrder.getChannelExtra()).isNull();
+ }
+
+ @Test
+ void nativeUnifiedOrderControllerDoesNotPersistFullEpayMetadata() throws Exception {
+ UnifiedOrderRQ request = new UnifiedOrderRQ();
+ request.setChannelExtra(EpayCompatMetadata.encode(new EpayMetadataValue(
+ "v1", "PID-001", null, "alipay", "ORDER-001",
+ "https://merchant.example/notify", "https://merchant.example/return",
+ "subject", "12.34", "203.0.113.10", null, null)));
+
+ NativeEndpointController controller = new NativeEndpointController(request);
+
+ ApiRes response = controller.unifiedOrder();
+
+ assertThat(response.getData()).isNull();
+ assertThat(controller.generatedPayOrder.getChannelExtra()).isNull();
+ }
+
+ private static PayOrder generate(UnifiedOrderRQ request) throws Exception {
+ return generate(new EpayCompatController(
+ new EpayCompatProperties(),
+ mock(EpayCredentialResolver.class),
+ new EpayRequestNormalizer(java.time.Clock.systemUTC(), new EpayCompatProperties()),
+ new EpayCompatOrderService(mock(PayOrderService.class))), request);
+ }
+
+ private static PayOrder generate(AbstractPayOrderController controller, UnifiedOrderRQ request) throws Exception {
+ request.setAmount(1234L);
+ request.setClientIp("203.0.113.10");
+ RequestKitBean requestKitBean = mock(RequestKitBean.class);
+ when(requestKitBean.getClientIp()).thenReturn("203.0.113.10");
+ Field requestKitBeanField = AbstractCtrl.class.getDeclaredField("requestKitBean");
+ requestKitBeanField.setAccessible(true);
+ requestKitBeanField.set(controller, requestKitBean);
+ Method method = AbstractPayOrderController.class.getDeclaredMethod(
+ "genPayOrder", UnifiedOrderRQ.class, MchInfo.class, MchApp.class, String.class, MchPayPassage.class);
+ method.setAccessible(true);
+ return (PayOrder) method.invoke(
+ controller,
+ request,
+ new MchInfo().setMchNo("MCH-1001"),
+ new MchApp().setAppId("APP-1001"),
+ "STARPOS",
+ null);
+ }
+
+ private static final class NativeEndpointController extends UnifiedOrderController {
+ private final UnifiedOrderRQ request;
+ private PayOrder generatedPayOrder;
+
+ private NativeEndpointController(UnifiedOrderRQ request) {
+ this.request = request;
+ request.setMchNo("MCH-1001");
+ request.setAppId("APP-1001");
+ request.setMchOrderNo("ORDER-001");
+ request.setWayCode(com.jeequan.jeepay.core.constants.CS.PAY_WAY_CODE.QR_CASHIER);
+ request.setAmount(1234L);
+ request.setCurrency("CNY");
+ request.setClientIp("203.0.113.10");
+ request.setSubject("subject");
+ request.setBody("subject");
+ }
+
+ @Override
+ protected T getRQByWithMchSign(Class cls) {
+ return cls.cast(request);
+ }
+
+ @Override
+ protected ApiRes unifiedOrder(String wayCode, UnifiedOrderRQ request) {
+ try {
+ generatedPayOrder = generate(this, request);
+ } catch (Exception e) {
+ throw new AssertionError(e);
+ }
+ return ApiRes.customFail("test stop");
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java
new file mode 100644
index 000000000..4ef375928
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java
@@ -0,0 +1,50 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.verify;
+
+class EpayCompatCallbackControllerTest {
+
+ @Test
+ void notifyEndpointReturnsProtocolSuccessBody() {
+ EpayCompatNotifyService notifyService = mock(EpayCompatNotifyService.class);
+ when(notifyService.handleCallback(Map.of("trade_status", "TRADE_SUCCESS")))
+ .thenReturn("success");
+ EpayCompatController controller = controller(notifyService);
+
+ ResponseEntity response = controller.notify(Map.of("trade_status", "TRADE_SUCCESS"));
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isEqualTo("success");
+ }
+
+ @Test
+ void returnEndpointReturnsProtocolFailureBody() {
+ EpayCompatNotifyService notifyService = mock(EpayCompatNotifyService.class);
+ when(notifyService.handleReturn(Map.of("trade_status", "TRADE_CLOSED")))
+ .thenReturn("fail");
+ EpayCompatController controller = controller(notifyService);
+
+ ResponseEntity response = controller.returnCallback(Map.of("trade_status", "TRADE_CLOSED"));
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(response.getBody()).isEqualTo("fail");
+ verify(notifyService).handleReturn(Map.of("trade_status", "TRADE_CLOSED"));
+ }
+
+ private static EpayCompatController controller(EpayCompatNotifyService notifyService) {
+ return new EpayCompatController(
+ new EpayCompatProperties(),
+ mock(EpayCredentialResolver.class),
+ new EpayRequestNormalizer(null, new EpayCompatProperties()),
+ mock(EpayCompatOrderService.class),
+ notifyService);
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java
new file mode 100644
index 000000000..f9135cf4c
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java
@@ -0,0 +1,309 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.model.ApiRes;
+import com.jeequan.jeepay.pay.rqrs.payorder.payway.StarposQrOrderRS;
+import com.jeequan.jeepay.service.impl.PayOrderService;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.ResponseEntity;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.time.Clock;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EpayCompatControllerTest {
+
+ private static final String STARPOS_CASHIER_BASE = "https://xyf-server-test.postar.cn/cashier/";
+
+ @Test
+ void v1MapiReturnsCodeOneAndPayurlOnSuccess() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ controller.apiResult = successApi("JPAY-V1", STARPOS_CASHIER_BASE + "JPAY-V1");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(null);
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-V1", null))
+ .thenReturn(EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L), "JPAY-V1", STARPOS_CASHIER_BASE + "JPAY-V1"));
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(body.getInteger("code")).isEqualTo(1);
+ assertThat(body.getString("trade_no")).isEqualTo("JPAY-V1");
+ assertThat(body.getString("payurl")).isEqualTo(STARPOS_CASHIER_BASE + "JPAY-V1");
+ assertThat(EpaySigner.verifyMd5(body, "merchant-key", body.getString("sign"))).isTrue();
+ assertThat(controller.unifiedCalls).isEqualTo(1);
+ assertThat(controller.capturedRequest.getExtParam()).isNull();
+ assertThat(controller.capturedRequest.getChannelExtra()).contains("__epay_compat");
+ }
+
+ @Test
+ void v2MapiReturnsCodeZeroAndRedirectPayInfoOnSuccess() throws Exception {
+ KeyPair merchantKeys = keyPair();
+ KeyPair platformKeys = keyPair();
+ EpayCredential credential = new EpayCredential(
+ null, pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"),
+ pem(platformKeys.getPrivate().getEncoded(), "PRIVATE")
+ );
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, credential);
+ controller.apiResult = successApi("JPAY-V2", STARPOS_CASHIER_BASE + "JPAY-V2");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(null);
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-V2", null))
+ .thenReturn(EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L), "JPAY-V2", STARPOS_CASHIER_BASE + "JPAY-V2"));
+ Map fields = v2Fields();
+ fields.put("sign", EpaySigner.signRsa(fields, pem(merchantKeys.getPrivate().getEncoded(), "PRIVATE")));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(body.getInteger("code")).isZero();
+ assertThat(body.getString("trade_no")).isEqualTo("JPAY-V2");
+ assertThat(body.getString("pay_type")).isEqualTo("redirect");
+ assertThat(body.getString("pay_info")).isEqualTo(STARPOS_CASHIER_BASE + "JPAY-V2");
+ assertThat(EpaySigner.verifyRsa(body, pem(platformKeys.getPublic().getEncoded(), "PUBLIC"), body.getString("sign")))
+ .isTrue();
+ }
+
+ @Test
+ void submitEndpointRedirectsOnlyToValidatedHttpsCashierUrl() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ controller.apiResult = successApi("JPAY-SUBMIT", STARPOS_CASHIER_BASE + "JPAY-SUBMIT");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(null);
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-SUBMIT", null))
+ .thenReturn(EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L), "JPAY-SUBMIT", STARPOS_CASHIER_BASE + "JPAY-SUBMIT"));
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ ResponseEntity> response = controller.submit(fields);
+
+ assertThat(response.getStatusCode().value()).isEqualTo(302);
+ assertThat(response.getHeaders().getLocation()).hasToString(STARPOS_CASHIER_BASE + "JPAY-SUBMIT");
+
+ controller.apiResult = successApi("JPAY-UNTRUSTED", "https://cashier.example/JPAY-UNTRUSTED");
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-UNTRUSTED", null))
+ .thenReturn(EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L), "JPAY-UNTRUSTED", "https://cashier.example/JPAY-UNTRUSTED"));
+ ResponseEntity> rejected = controller.submit(fields);
+
+ assertThat(rejected.getStatusCode().value()).isEqualTo(200);
+ }
+
+ @Test
+ void invalidSignatureReturnsVersionSpecificFailureJson() throws Exception {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController v1 = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+
+ JSONObject v1Body = JSON.parseObject(v1.mapi(v1Fields()).getBody());
+ assertThat(v1Body.getInteger("code")).isZero();
+ assertThat(v1Body.getString("trade_no")).isEmpty();
+
+ KeyPair merchantKeys = keyPair();
+ KeyPair platformKeys = keyPair();
+ TestController v2 = controller(payOrderService, new EpayCredential(
+ null, pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"),
+ pem(platformKeys.getPrivate().getEncoded(), "PRIVATE")
+ ));
+ JSONObject v2Body = JSON.parseObject(v2.mapi(v2Fields()).getBody());
+ assertThat(v2Body.getInteger("code")).isEqualTo(1);
+ assertThat(v2Body.getString("trade_no")).isEmpty();
+ }
+
+ @Test
+ void malformedV2PlatformPrivateKeyReturnsFailureWithoutCallingUnifiedOrder() throws Exception {
+ KeyPair merchantKeys = keyPair();
+ EpayCredential invalidCredential = new EpayCredential(
+ null, pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"), "not-a-private-key");
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, invalidCredential);
+ Map fields = v2Fields();
+ fields.put("sign", EpaySigner.signRsa(fields, pem(merchantKeys.getPrivate().getEncoded(), "PRIVATE")));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(response.getStatusCode().value()).isEqualTo(200);
+ assertThat(body.getInteger("code")).isEqualTo(1);
+ assertThat(body.getString("sign")).isEmpty();
+ assertThat(controller.unifiedCalls).isZero();
+ verify(payOrderService, never()).queryMchOrder("MCH-1001", null, "ORDER-1001");
+ }
+
+ @Test
+ void duplicateControllerRequestReturnsExistingOrderWithoutCallingUnifiedOrder() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ PayOrder existing = EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L),
+ "JPAY-EXISTING", STARPOS_CASHIER_BASE + "JPAY-EXISTING");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(existing);
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(body.getInteger("code")).isEqualTo(1);
+ assertThat(body.getString("trade_no")).isEqualTo("JPAY-EXISTING");
+ assertThat(controller.unifiedCalls).isZero();
+ }
+
+ @Test
+ void uniqueKeyRaceReloadsExistingOrderWithoutCallingUnifiedOrderAgain() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ controller.apiResult = ApiRes.customFail("商户订单[ORDER-1001]已存在");
+ PayOrder racedOrder = EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L),
+ "JPAY-RACE", STARPOS_CASHIER_BASE + "JPAY-RACE");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(null, racedOrder);
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(body.getInteger("code")).isEqualTo(1);
+ assertThat(body.getString("trade_no")).isEqualTo("JPAY-RACE");
+ assertThat(controller.unifiedCalls).isEqualTo(1);
+ verify(payOrderService, org.mockito.Mockito.times(2))
+ .queryMchOrder("MCH-1001", null, "ORDER-1001");
+ }
+
+ @Test
+ void nonDuplicateFailurePreservesOriginalMessageWithoutSecondLookup() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ controller.apiResult = ApiRes.customFail("商户应用参数未配置");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(null);
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ JSONObject body = JSON.parseObject(controller.mapi(fields).getBody());
+
+ assertThat(body.getInteger("code")).isZero();
+ assertThat(body.getString("msg")).isEqualTo("商户应用参数未配置");
+ verify(payOrderService).queryMchOrder("MCH-1001", null, "ORDER-1001");
+ }
+
+ @Test
+ void uniqueKeyRaceReloadConflictUsesSameIdentityValidation() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ TestController controller = controller(payOrderService, new EpayCredential("merchant-key", null, null));
+ controller.apiResult = ApiRes.customFail("商户订单[ORDER-1001]已存在");
+ PayOrder racedOrder = EpayCompatOrderServiceTest.compatibleOrder(
+ EpayCompatOrderServiceTest.command("alipay", 1234L),
+ "JPAY-RACE", STARPOS_CASHIER_BASE + "JPAY-RACE");
+ JSONObject metadata = JSON.parseObject(racedOrder.getChannelExtra());
+ metadata.getJSONObject("__epay_compat").put("out_trade_no", "ORDER-OTHER");
+ racedOrder.setChannelExtra(metadata.toJSONString());
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(null, racedOrder);
+ Map fields = v1Fields();
+ fields.put("sign", EpaySigner.signMd5(fields, "merchant-key"));
+
+ ResponseEntity response = controller.mapi(fields);
+ JSONObject body = JSON.parseObject(response.getBody());
+
+ assertThat(body.getInteger("code")).isZero();
+ assertThat(body.getString("msg")).contains("冲突");
+ assertThat(body.getString("trade_no")).isEmpty();
+ assertThat(controller.unifiedCalls).isEqualTo(1);
+ verify(payOrderService, org.mockito.Mockito.times(2))
+ .queryMchOrder("MCH-1001", null, "ORDER-1001");
+ }
+
+ private static TestController controller(PayOrderService payOrderService, EpayCredential credential) {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ properties.setDefaultAppId("APP-1001");
+ EpayCredentialResolver resolver = mock(EpayCredentialResolver.class);
+ when(resolver.resolve("MCH-1001", "APP-1001", EpayProtocolVersion.V1)).thenReturn(credential);
+ when(resolver.resolve("MCH-1001", "APP-1001", EpayProtocolVersion.V2)).thenReturn(credential);
+ EpayCompatOrderService orderService = new EpayCompatOrderService(payOrderService);
+ return new TestController(properties, resolver,
+ new EpayRequestNormalizer(Clock.systemUTC(), properties), orderService);
+ }
+
+ private static ApiRes successApi(String tradeNo, String payUrl) {
+ StarposQrOrderRS rs = new StarposQrOrderRS();
+ rs.setPayOrderId(tradeNo);
+ rs.setMchOrderNo("ORDER-1001");
+ rs.setOrderState(PayOrder.STATE_SUCCESS);
+ rs.setPayUrl(payUrl);
+ return ApiRes.ok(rs);
+ }
+
+ private static Map v1Fields() {
+ return baseFields();
+ }
+
+ private static Map v2Fields() {
+ Map fields = baseFields();
+ fields.put("sign_type", "RSA");
+ fields.put("timestamp", String.valueOf(java.time.Instant.now().getEpochSecond()));
+ return fields;
+ }
+
+ private static Map baseFields() {
+ Map fields = new LinkedHashMap<>();
+ fields.put("pid", "MCH-1001");
+ fields.put("type", "alipay");
+ fields.put("out_trade_no", "ORDER-1001");
+ fields.put("money", "12.34");
+ fields.put("name", "test subject");
+ fields.put("notify_url", "https://merchant.example/notify.php");
+ fields.put("return_url", "https://merchant.example/return.php");
+ fields.put("clientip", "203.0.113.10");
+ return fields;
+ }
+
+ private static KeyPair keyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+
+ private static String pem(byte[] encoded, String type) {
+ return "-----BEGIN " + type + " KEY-----\n"
+ + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded)
+ + "\n-----END " + type + " KEY-----";
+ }
+
+ private static final class TestController extends EpayCompatController {
+ private ApiRes apiResult;
+ private int unifiedCalls;
+ private com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ capturedRequest;
+
+ private TestController(EpayCompatProperties properties,
+ EpayCredentialResolver credentialResolver,
+ EpayRequestNormalizer requestNormalizer,
+ EpayCompatOrderService orderService) {
+ super(properties, credentialResolver, requestNormalizer, orderService);
+ }
+
+ @Override
+ protected ApiRes unifiedOrder(String wayCode, com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ request) {
+ unifiedCalls++;
+ capturedRequest = request;
+ return apiResult;
+ }
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadataTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadataTest.java
new file mode 100644
index 000000000..bca512a03
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadataTest.java
@@ -0,0 +1,95 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EpayCompatMetadataTest {
+
+ @Test
+ void metadataRoundTripDoesNotExposeSecret() {
+ String channelExtra = EpayCompatMetadata.encode(valueWithTradeNo("JPAY_ORDER"));
+
+ assertThat(EpayCompatMetadata.decode(channelExtra)).isPresent();
+ assertThat(channelExtra).doesNotContain("secret", "private", "publicKey", "merchantKey", "platformPrivateKey");
+ }
+
+ @Test
+ void encodesOnlyReservedTopLevelNamespace() {
+ String channelExtra = EpayCompatMetadata.encode(valueWithTradeNo("JPAY_ORDER"));
+ JSONObject root = JSON.parseObject(channelExtra);
+
+ assertThat(root.keySet()).containsExactly("__epay_compat");
+ assertThat(root.getJSONObject("__epay_compat").getString("out_trade_no")).isEqualTo("ORDER_TARGET");
+ assertThat(root.getJSONObject("__epay_compat").getString("app_id")).isEqualTo("APP_TARGET");
+ }
+
+ @Test
+ void decodesRequiredFieldsAndPreservesNullableOptionalFields() {
+ EpayMetadataValue decoded = EpayCompatMetadata.decode(EpayCompatMetadata.encode(valueWithTradeNo("JPAY_ORDER")))
+ .orElseThrow();
+
+ assertThat(decoded.version()).isEqualTo("v1");
+ assertThat(decoded.pid()).isEqualTo("MCH_TARGET");
+ assertThat(decoded.appId()).isEqualTo("APP_TARGET");
+ assertThat(decoded.type()).isEqualTo("alipay");
+ assertThat(decoded.outTradeNo()).isEqualTo("ORDER_TARGET");
+ assertThat(decoded.notifyUrl()).isEqualTo("https://merchant.example/notify.php");
+ assertThat(decoded.returnUrl()).isEqualTo("https://merchant.example/return.php");
+ assertThat(decoded.name()).isEqualTo("subject");
+ assertThat(decoded.money()).isEqualTo("12.34");
+ assertThat(decoded.clientIp()).isEqualTo("203.0.113.9");
+ assertThat(decoded.device()).isEqualTo("pc");
+ assertThat(decoded.tradeNo()).isEqualTo("JPAY_ORDER");
+ }
+
+ @Test
+ void returnsEmptyForMalformedJsonWrongShapeAndMissingRequiredFields() {
+ assertThat(EpayCompatMetadata.decode("not-json")).isEmpty();
+ assertThat(EpayCompatMetadata.decode("[]")).isEmpty();
+ assertThat(EpayCompatMetadata.decode("{\"other\":{}}" )).isEmpty();
+ assertThat(EpayCompatMetadata.decode("{\"__epay_compat\":\"bad\"}" )).isEmpty();
+ assertThat(EpayCompatMetadata.decode("{\"__epay_compat\":{\"version\":\"v1\"}}" )).isEmpty();
+ }
+
+ @Test
+ void longMetadataRoundTripsBeyondLegacyVarcharLimit() {
+ String longSubject = "subject-".concat("x".repeat(700));
+ EpayMetadataValue value = new EpayMetadataValue(
+ "v1",
+ "MCH_TARGET",
+ "APP_TARGET",
+ "alipay",
+ "ORDER_TARGET",
+ "https://merchant.example/notify/" + "n".repeat(180),
+ "https://merchant.example/return/" + "r".repeat(180),
+ longSubject,
+ "12.34",
+ "203.0.113.9",
+ "pc",
+ "JPAY_ORDER");
+
+ String encoded = EpayCompatMetadata.encode(value);
+
+ assertThat(encoded.length()).isGreaterThan(512);
+ assertThat(EpayCompatMetadata.decode(encoded)).contains(value);
+ }
+
+ private static EpayMetadataValue valueWithTradeNo(String tradeNo) {
+ return new EpayMetadataValue(
+ "v1",
+ "MCH_TARGET",
+ "APP_TARGET",
+ "alipay",
+ "ORDER_TARGET",
+ "https://merchant.example/notify.php",
+ "https://merchant.example/return.php",
+ "subject",
+ "12.34",
+ "203.0.113.9",
+ "pc",
+ tradeNo);
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java
new file mode 100644
index 000000000..d27c4b7cb
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java
@@ -0,0 +1,328 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.pay.service.PayOrderProcessService;
+import com.jeequan.jeepay.pay.rqrs.payorder.QueryPayOrderRS;
+import com.jeequan.jeepay.service.impl.PayOrderService;
+import org.junit.jupiter.api.Test;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EpayCompatNotifyServiceTest {
+
+ @Test
+ void createsSignedV1NotifyUrlWithTradeSuccess() {
+ PayOrder order = compatOrder("v1", PayOrder.STATE_SUCCESS);
+ EpayCompatNotifyService service = service(new EpayCredential("merchant-key", null, null));
+
+ String notifyUrl = service.createNotifyUrl(order);
+ Map fields = query(notifyUrl);
+
+ assertThat(fields).containsEntry("pid", "MCH-1001")
+ .containsEntry("trade_no", "JPAY-1001")
+ .containsEntry("out_trade_no", "ORDER-1001")
+ .containsEntry("type", "alipay")
+ .containsEntry("money", "12.34")
+ .containsEntry("trade_status", "TRADE_SUCCESS")
+ .containsEntry("sign_type", "MD5");
+ assertThat(EpaySigner.verifyMd5(fields, "merchant-key", fields.get("sign"))).isTrue();
+ }
+
+ @Test
+ void createsSignedV2NotifyUrlWithPlatformPrivateKey() throws Exception {
+ KeyPair merchantKeys = keyPair();
+ KeyPair platformKeys = keyPair();
+ EpayCredential credential = new EpayCredential(
+ null,
+ pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"),
+ pem(platformKeys.getPrivate().getEncoded(), "PRIVATE"));
+ EpayCompatNotifyService service = service(credential);
+ PayOrder order = compatOrder("v2", PayOrder.STATE_SUCCESS);
+
+ Map fields = query(service.createNotifyUrl(order));
+
+ assertThat(fields).containsEntry("sign_type", "RSA")
+ .containsEntry("trade_status", "TRADE_SUCCESS");
+ assertThat(EpaySigner.verifyRsa(
+ fields,
+ pem(platformKeys.getPublic().getEncoded(), "PUBLIC"),
+ fields.get("sign"))).isTrue();
+ }
+
+ @Test
+ void v2NotifyUrlIsEmptyWithoutPlatformPrivateKey() {
+ EpayCompatNotifyService service = service(
+ new EpayCredential(null, "merchant-public", null));
+
+ assertThat(service.createNotifyUrl(compatOrder("v2", PayOrder.STATE_SUCCESS))).isEmpty();
+ }
+
+ @Test
+ void v2CallbackVerifiesWithMerchantPublicKeyWhenPlatformPrivateKeyIsAbsent() throws Exception {
+ KeyPair merchantKeys = keyPair();
+ EpayCredential credential = new EpayCredential(
+ null,
+ pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"),
+ null);
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v2", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ when(payOrderService.updateIng2Success("JPAY-1001", "JPAY-1001", null)).thenReturn(true);
+ EpayCompatProperties properties = properties();
+ properties.setDefaultAppId("WRONG-APP");
+ EpayCompatNotifyService service = new EpayCompatNotifyService(
+ payOrderService,
+ properties,
+ (pid, appId, version) -> {
+ if (!"APP-1001".equals(appId)) {
+ throw new IllegalStateException("metadata app id was not used");
+ }
+ return credential;
+ });
+
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign_type", "RSA");
+ callback.put("sign", EpaySigner.signRsa(
+ callback, pem(merchantKeys.getPrivate().getEncoded(), "PRIVATE")));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("success");
+ verify(payOrderService).updateIng2Success("JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void returnUrlIsEmptyWhenCompatMetadataHasNoReturnUrl() {
+ PayOrder order = compatOrder("v1", PayOrder.STATE_SUCCESS)
+ .setChannelExtra(EpayCompatMetadata.encode(new EpayMetadataValue(
+ "v1", "MCH-1001", "APP-1001", "alipay", "ORDER-1001",
+ "https://merchant.example/notify.php", "", "Demo", "12.34",
+ null, null, "JPAY-1001")));
+ EpayCompatNotifyService service = service(new EpayCredential("merchant-key", null, null));
+
+ assertThat(service.createReturnUrl(order)).isEmpty();
+ }
+
+ @Test
+ void intermediateOrderDoesNotCreateCompatSuccessNotification() {
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ EpayCompatNotifyService service = service(new EpayCredential("merchant-key", null, null));
+
+ assertThat(service.createNotifyUrl(order)).isEmpty();
+ assertThat(service.createReturnUrl(order)).isEmpty();
+ }
+
+ @Test
+ void duplicateSuccessfulCallbackReturnsSuccessWithoutSecondStateTransition() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_SUCCESS);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ EpayCompatNotifyService service = service(payOrderService, new EpayCredential("merchant-key", null, null));
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("success");
+ verify(payOrderService, never()).updateIng2Success(
+ "JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void validReturnCallbackDoesNotTransitionOrderState() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ EpayCompatNotifyService service = service(payOrderService,
+ new EpayCredential("merchant-key", null, null));
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleReturn(callback)).isEqualTo("success");
+ verify(payOrderService, never()).updateIng2Success(
+ "JPAY-1001", "JPAY-1001", null);
+ verify(payOrderService, never()).updateIng2Fail(
+ "JPAY-1001", "JPAY-1001", null, "TRADE_CLOSED", "EPAY交易关闭");
+ }
+
+ @Test
+ void successfulCallbackRunsCommonSuccessPostProcessingOnce() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrderProcessService processService = mock(PayOrderProcessService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ when(payOrderService.updateIng2Success("JPAY-1001", "JPAY-1001", null)).thenReturn(true);
+ EpayCompatNotifyService service = service(payOrderService,
+ new EpayCredential("merchant-key", null, null), processService);
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("success");
+ verify(payOrderService).updateIng2Success("JPAY-1001", "JPAY-1001", null);
+ verify(processService).confirmSuccess(order);
+ }
+
+ @Test
+ void concurrentSuccessfulCallbackReturnsSuccessAfterStateRefresh() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ PayOrder refreshed = compatOrder("v1", PayOrder.STATE_SUCCESS);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(order, refreshed);
+ when(payOrderService.updateIng2Success("JPAY-1001", "JPAY-1001", null)).thenReturn(false);
+ EpayCompatNotifyService service = service(payOrderService,
+ new EpayCredential("merchant-key", null, null));
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("success");
+ verify(payOrderService).updateIng2Success("JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void callbackAmountMismatchReturnsFail() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ EpayCompatNotifyService service = service(payOrderService, new EpayCredential("merchant-key", null, null));
+ Map callback = callbackFields("12.35", order);
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("fail");
+ verify(payOrderService, never()).updateIng2Success(
+ "JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void v2OrderRejectsMd5CallbackEvenWhenMerchantKeyIsAvailable() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v2", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ EpayCompatNotifyService service = service(payOrderService,
+ new EpayCredential("merchant-key", "merchant-public", null));
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign_type", "MD5");
+ callback.put("sign", EpaySigner.signMd5(callback, "merchant-key"));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("fail");
+ verify(payOrderService, never()).updateIng2Success(
+ "JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void v1OrderRejectsRsaCallbackEvenWhenMerchantKeyIsAvailable() throws Exception {
+ KeyPair merchantKeys = keyPair();
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ PayOrder order = compatOrder("v1", PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(order);
+ EpayCompatNotifyService service = service(payOrderService,
+ new EpayCredential("merchant-key",
+ pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"), null));
+ Map callback = callbackFields("12.34", order);
+ callback.put("sign_type", "RSA");
+ callback.put("sign", EpaySigner.signRsa(
+ callback, pem(merchantKeys.getPrivate().getEncoded(), "PRIVATE")));
+
+ assertThat(service.handleCallback(callback)).isEqualTo("fail");
+ verify(payOrderService, never()).updateIng2Success(
+ "JPAY-1001", "JPAY-1001", null);
+ }
+
+ @Test
+ void nativePayOrderStillUsesExistingJeepayNotifyShape() {
+ PayOrder order = new PayOrder()
+ .setPayOrderId("JPAY-NATIVE")
+ .setMchNo("MCH-1001")
+ .setMchOrderNo("ORDER-NATIVE")
+ .setAmount(1234L)
+ .setState(PayOrder.STATE_SUCCESS)
+ .setNotifyUrl("https://merchant.example/native");
+
+ String notifyUrl = new com.jeequan.jeepay.pay.service.PayMchNotifyService()
+ .createNotifyUrl(order, "merchant-key");
+ assertThat(QueryPayOrderRS.buildByPayOrder(order).getPayOrderId()).isEqualTo("JPAY-NATIVE");
+ assertThat(notifyUrl).contains("payOrderId=JPAY-NATIVE")
+ .contains("mchOrderNo=ORDER-NATIVE")
+ .doesNotContain("trade_status=TRADE_SUCCESS");
+ }
+
+ private static EpayCompatNotifyService service(EpayCredential credential) {
+ return service(mock(PayOrderService.class), credential);
+ }
+
+ private static EpayCompatNotifyService service(PayOrderService payOrderService, EpayCredential credential) {
+ return new EpayCompatNotifyService(payOrderService, properties(), (pid, appId, version) -> credential);
+ }
+
+ private static EpayCompatNotifyService service(PayOrderService payOrderService,
+ EpayCredential credential,
+ PayOrderProcessService processService) {
+ return new EpayCompatNotifyService(
+ payOrderService, properties(), (pid, appId, version) -> credential, processService);
+ }
+
+ private static EpayCompatProperties properties() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ properties.setDefaultAppId("APP-1001");
+ return properties;
+ }
+
+ private static PayOrder compatOrder(String version, byte state) {
+ return new PayOrder()
+ .setPayOrderId("JPAY-1001")
+ .setMchNo("MCH-1001")
+ .setAppId("APP-1001")
+ .setMchOrderNo("ORDER-1001")
+ .setAmount(1234L)
+ .setState(state)
+ .setChannelOrderNo("JPAY-1001")
+ .setWayCode("STARPOS_QR")
+ .setNotifyUrl("https://merchant.example/notify.php")
+ .setReturnUrl("https://merchant.example/return.php")
+ .setChannelExtra(EpayCompatMetadata.encode(new EpayMetadataValue(
+ version, "MCH-1001", "APP-1001", "alipay", "ORDER-1001",
+ "https://merchant.example/notify.php", "https://merchant.example/return.php",
+ "Demo", "12.34", null, null, "JPAY-1001")));
+ }
+
+ private static Map callbackFields(String money, PayOrder order) {
+ Map fields = new LinkedHashMap<>();
+ fields.put("pid", "MCH-1001");
+ fields.put("trade_no", order.getPayOrderId());
+ fields.put("out_trade_no", order.getMchOrderNo());
+ fields.put("type", "alipay");
+ fields.put("name", "Demo");
+ fields.put("money", money);
+ fields.put("trade_status", "TRADE_SUCCESS");
+ fields.put("sign_type", "MD5");
+ return fields;
+ }
+
+ private static Map query(String url) {
+ Map result = new LinkedHashMap<>();
+ String query = url.substring(url.indexOf('?') + 1);
+ for (String part : query.split("&")) {
+ String[] pair = part.split("=", 2);
+ result.put(pair[0], pair.length == 1 ? "" : pair[1]);
+ }
+ return result;
+ }
+
+ private static KeyPair keyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ }
+
+ private static String pem(byte[] encoded, String type) {
+ return "-----BEGIN " + type + " KEY-----\n"
+ + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(encoded)
+ + "\n-----END " + type + " KEY-----";
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java
new file mode 100644
index 000000000..ade6466f7
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java
@@ -0,0 +1,285 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.jeequan.jeepay.core.constants.CS;
+import com.jeequan.jeepay.core.entity.MchApp;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.core.model.ApiRes;
+import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRS;
+import com.jeequan.jeepay.pay.rqrs.payorder.payway.StarposQrOrderRS;
+import com.jeequan.jeepay.service.impl.PayOrderService;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EpayCompatOrderServiceTest {
+
+ @Test
+ void duplicateSameOrderReturnsOriginalTradeNoWithoutSecondStarposCall() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("alipay", 1234L);
+ PayOrder existing = compatibleOrder(command, "JPAY-EXISTING", "https://cashier.example/JPAY-EXISTING");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(existing);
+
+ PayOrder result = service.findExisting(command);
+
+ assertThat(result).isSameAs(existing);
+ assertThat(result.getPayOrderId()).isEqualTo("JPAY-EXISTING");
+ verify(payOrderService).queryMchOrder("MCH-1001", null, "ORDER-1001");
+ }
+
+ @Test
+ void duplicateAmountMismatchReturnsConflict() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("alipay", 1234L);
+ PayOrder existing = compatibleOrder(command, "JPAY-EXISTING", "https://cashier.example/JPAY-EXISTING");
+ existing.setAmount(999L);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(existing);
+
+ assertThatThrownBy(() -> service.findExisting(command))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("金额");
+ }
+
+ @Test
+ void duplicateAppIdMismatchReturnsConflict() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("alipay", 1234L);
+ PayOrder existing = compatibleOrder(command, "JPAY-EXISTING", "https://cashier.example/JPAY-EXISTING")
+ .setAppId("APP-OTHER");
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(existing);
+
+ assertThatThrownBy(() -> service.findExisting(command))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("冲突");
+ }
+
+ @Test
+ void duplicateMetadataIdentityMismatchReturnsConflict() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("alipay", 1234L);
+ JSONObject channelExtra = JSON.parseObject(
+ compatibleOrder(command, "JPAY-EXISTING", "https://cashier.example/JPAY-EXISTING")
+ .getChannelExtra());
+ channelExtra.getJSONObject("__epay_compat").put("pid", "MCH-OTHER");
+ PayOrder existing = compatibleOrder(command, "JPAY-EXISTING", "https://cashier.example/JPAY-EXISTING")
+ .setChannelExtra(channelExtra.toJSONString());
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001"))
+ .thenReturn(existing);
+
+ assertThatThrownBy(() -> service.findExisting(command))
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessageContaining("冲突");
+ }
+
+ @Test
+ void starposTimeoutReturnsUnknownAndKeepsOrderQueryable() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("alipay", 1234L);
+ PayOrder queryable = compatibleOrder(command, "JPAY-TIMEOUT", null)
+ .setState(PayOrder.STATE_ING);
+ UnifiedOrderRS rs = new UnifiedOrderRS();
+ rs.setPayOrderId("JPAY-TIMEOUT");
+ rs.setMchOrderNo(command.orderNo());
+ rs.setOrderState(PayOrder.STATE_ING);
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-TIMEOUT", null))
+ .thenReturn(queryable);
+
+ EpayCreateResult result = service.fromApiResult(ApiRes.ok(rs), command);
+
+ assertThat(result.success()).isFalse();
+ assertThat(result.tradeNo()).isEqualTo("JPAY-TIMEOUT");
+ assertThat(result.payOrder()).isSameAs(queryable);
+ assertThat(result.message()).contains("未知");
+ verify(payOrderService).queryMchOrder("MCH-1001", "JPAY-TIMEOUT", null);
+ }
+
+ @Test
+ void unifiedRequestPersistsEpayMetadataInChannelExtraWithoutExtParam() {
+ EpayCompatOrderService service = new EpayCompatOrderService(mock(PayOrderService.class));
+ EpayCreateCommand command = command("wxpay", 1234L);
+
+ var rq = service.toUnifiedOrderRequest(command);
+ JSONObject channelExtra = JSON.parseObject(rq.getChannelExtra());
+
+ assertThat(rq.getWayCode()).isEqualTo(CS.PAY_WAY_CODE.STARPOS_QR);
+ assertThat(channelExtra.getString("payDataType")).isEqualTo("payUrl");
+ assertThat(EpayCompatMetadata.decode(rq.getChannelExtra())).isPresent();
+ assertThat(rq.getExtParam()).isNull();
+ assertThat(rq.getChannelExtra()).contains("__epay_compat");
+ }
+
+ @Test
+ void initialMetadataLeavesTradeNoEmptyAndKeepsInitOrderIdempotent() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("wxpay", 1234L);
+ PayOrder initialOrder = compatibleOrder(command, null, null)
+ .setPayOrderId("JPAY-INIT")
+ .setState(PayOrder.STATE_INIT);
+ when(payOrderService.queryMchOrder("MCH-1001", null, "ORDER-1001")).thenReturn(initialOrder);
+
+ var request = service.toUnifiedOrderRequest(command);
+ EpayMetadataValue initialMetadata = EpayCompatMetadata.decode(request.getChannelExtra()).orElseThrow();
+ PayOrder existing = service.findExisting(command);
+ EpayCreateResult result = service.fromExisting(existing, command);
+
+ assertThat(initialMetadata.tradeNo()).isNull();
+ assertThat(existing).isSameAs(initialOrder);
+ assertThat(result.success()).isFalse();
+ assertThat(result.tradeNo()).isEqualTo("JPAY-INIT");
+ assertThat(result.message()).contains("可通过交易号查询");
+ }
+
+ @Test
+ void apiResultPersistsEpayMetadataInPayOrderChannelExtraWithoutExtParam() {
+ PayOrderService payOrderService = mock(PayOrderService.class);
+ EpayCompatOrderService service = new EpayCompatOrderService(payOrderService);
+ EpayCreateCommand command = command("wxpay", 1234L);
+ PayOrder savedOrder = new PayOrder()
+ .setPayOrderId("JPAY-1001")
+ .setExtParam(null);
+ StarposQrOrderRS rs = new StarposQrOrderRS();
+ rs.setPayOrderId("JPAY-1001");
+ rs.setMchOrderNo(command.orderNo());
+ rs.setOrderState(PayOrder.STATE_SUCCESS);
+ rs.setPayUrl("https://cashier.example/JPAY-1001");
+ when(payOrderService.queryMchOrder("MCH-1001", "JPAY-1001", null)).thenReturn(savedOrder);
+
+ EpayCreateResult result = service.fromApiResult(ApiRes.ok(rs), command);
+
+ ArgumentCaptor orderCaptor = ArgumentCaptor.forClass(PayOrder.class);
+ verify(payOrderService).updateById(orderCaptor.capture());
+ PayOrder persisted = orderCaptor.getValue();
+ JSONObject channelExtra = JSON.parseObject(persisted.getChannelExtra());
+ assertThat(result.success()).isTrue();
+ assertThat(persisted.getPayOrderId()).isEqualTo("JPAY-1001");
+ assertThat(persisted.getState()).isNull();
+ assertThat(persisted.getChannelOrderNo()).isNull();
+ assertThat(persisted.getAmount()).isNull();
+ assertThat(EpayCompatMetadata.decode(persisted.getChannelExtra())).isPresent();
+ assertThat(channelExtra.getString("payDataType")).isEqualTo("payUrl");
+ assertThat(channelExtra.getJSONObject("__epay_compat").getString("pay_info"))
+ .isEqualTo("https://cashier.example/JPAY-1001");
+ assertThat(persisted.getExtParam()).isNull();
+ }
+
+ @Test
+ void compatTypesExposeUnambiguousSpringWiring() throws Exception {
+ assertThat(EpayCompatOrderService.class.isAnnotationPresent(Service.class)).isTrue();
+ assertThat(EpayCompatController.class
+ .getConstructor(EpayCompatProperties.class, EpayCredentialResolver.class, EpayCompatOrderService.class)
+ .isAnnotationPresent(Autowired.class)).isTrue();
+ }
+
+ @Test
+ void credentialResolverUsesAppSecretForV1AndExternalRsaKeysForV2() throws Exception {
+ MchApp mchApp = new MchApp().setAppSecret("v1-merchant-key");
+ EpayCompatProperties properties = new EpayCompatProperties();
+ EpayCompatProperties.CredentialProperties rsa = new EpayCompatProperties.CredentialProperties();
+ KeyPair platformKeys = keyPair();
+ rsa.setMerchantPublicKey("merchant-public");
+ rsa.setPlatformPrivateKey(pem(platformKeys.getPrivate().getEncoded(), "PRIVATE"));
+ properties.setCredentials(Map.of("MCH-1001", Map.of("APP-1001", rsa)));
+ var queryService = mock(com.jeequan.jeepay.pay.service.ConfigContextQueryService.class);
+ when(queryService.queryMchApp("MCH-1001", "APP-1001")).thenReturn(mchApp);
+ DefaultEpayCredentialResolver resolver = new DefaultEpayCredentialResolver(queryService, properties);
+
+ assertThat(resolver.resolve("MCH-1001", "APP-1001", EpayProtocolVersion.V1))
+ .isEqualTo(new EpayCredential("v1-merchant-key", null, null));
+ assertThat(resolver.resolve("MCH-1001", "APP-1001", EpayProtocolVersion.V2))
+ .isEqualTo(new EpayCredential(null, "merchant-public", rsa.getPlatformPrivateKey()));
+ }
+
+ @Test
+ void credentialResolverForInboundV2OnlyRequiresMerchantPublicKey() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ EpayCompatProperties.CredentialProperties rsa = new EpayCompatProperties.CredentialProperties();
+ rsa.setMerchantPublicKey("merchant-public");
+ properties.setCredentials(Map.of("MCH-1001", Map.of("APP-1001", rsa)));
+ var queryService = mock(com.jeequan.jeepay.pay.service.ConfigContextQueryService.class);
+ DefaultEpayCredentialResolver resolver = new DefaultEpayCredentialResolver(queryService, properties);
+
+ assertThat(resolver.resolveForVerification("MCH-1001", "APP-1001", EpayProtocolVersion.V2))
+ .isEqualTo(new EpayCredential(null, "merchant-public", null));
+ }
+
+ @Test
+ void credentialResolverRejectsMalformedV2PlatformPrivateKey() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ EpayCompatProperties.CredentialProperties rsa = new EpayCompatProperties.CredentialProperties();
+ rsa.setMerchantPublicKey("merchant-public");
+ rsa.setPlatformPrivateKey("not-a-private-key");
+ properties.setCredentials(Map.of("MCH-1001", Map.of("APP-1001", rsa)));
+ var queryService = mock(com.jeequan.jeepay.pay.service.ConfigContextQueryService.class);
+ DefaultEpayCredentialResolver resolver = new DefaultEpayCredentialResolver(queryService, properties);
+
+ assertThatThrownBy(() -> resolver.resolve("MCH-1001", "APP-1001", EpayProtocolVersion.V2))
+ .hasMessageContaining("私钥");
+ }
+
+ static EpayCreateCommand command(String type, long amountFen) {
+ return new EpayCreateCommand(
+ "MCH-1001", "APP-1001", "ORDER-1001", CS.PAY_WAY_CODE.STARPOS_QR,
+ "test subject", amountFen,
+ "https://merchant.example/notify.php", "https://merchant.example/return.php",
+ "203.0.113.10", "pc", "v1", "mapi", type,
+ new EpayCredential("merchant-key", null, null)
+ );
+ }
+
+ static PayOrder compatibleOrder(EpayCreateCommand command, String tradeNo, String payInfo) {
+ JSONObject channelExtra = JSON.parseObject(EpayCompatMetadata.encode(new EpayMetadataValue(
+ command.version(), command.merchantId(), command.appId(), command.epayType(), command.orderNo(),
+ command.notifyUrl(), command.returnUrl(), command.subject(), "12.34", command.clientIp(),
+ command.device(), tradeNo
+ )));
+ channelExtra.put("payDataType", "payUrl");
+ channelExtra.getJSONObject("__epay_compat").put("pay_info", payInfo);
+ return new PayOrder()
+ .setPayOrderId(tradeNo)
+ .setMchNo(command.merchantId())
+ .setAppId(command.appId())
+ .setMchOrderNo(command.orderNo())
+ .setAmount(command.amountFen())
+ .setIfCode(CS.IF_CODE.STARPOS)
+ .setWayCode(CS.PAY_WAY_CODE.STARPOS_QR)
+ .setNotifyUrl(command.notifyUrl())
+ .setReturnUrl(command.returnUrl())
+ .setChannelExtra(channelExtra.toJSONString());
+ }
+
+ private static KeyPair keyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(1024);
+ return generator.generateKeyPair();
+ }
+
+ private static String pem(byte[] encoded, String type) {
+ return "-----BEGIN " + type + " KEY-----\n"
+ + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(encoded)
+ + "\n-----END " + type + " KEY-----";
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatSqlSchemaTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatSqlSchemaTest.java
new file mode 100644
index 000000000..5a4776af0
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatSqlSchemaTest.java
@@ -0,0 +1,28 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EpayCompatSqlSchemaTest {
+
+ @Test
+ void payOrderCallbackColumnsAreUnboundedForCompatUrls() throws IOException {
+ String initSql = Files.readString(repoFile("docs/sql/init.sql"));
+ String patchSql = Files.readString(repoFile("docs/sql/patch.sql"));
+
+ assertThat(initSql).contains("`notify_url` TEXT NOT NULL COMMENT '异步通知地址'");
+ assertThat(initSql).contains("`return_url` TEXT COMMENT '页面跳转地址'");
+ assertThat(patchSql).contains("MODIFY COLUMN `notify_url` TEXT NOT NULL COMMENT '异步通知地址'");
+ assertThat(patchSql).contains("MODIFY COLUMN `return_url` TEXT COMMENT '页面跳转地址'");
+ }
+
+ private static Path repoFile(String relativePath) {
+ Path path = Path.of(relativePath);
+ return Files.exists(path) ? path : Path.of("..").resolve(relativePath);
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayContractFixtureTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayContractFixtureTest.java
new file mode 100644
index 000000000..93bcbd800
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayContractFixtureTest.java
@@ -0,0 +1,75 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EpayContractFixtureTest {
+
+ @Test
+ void v1FixtureCanonicalAndSignatureAreStable() throws IOException {
+ JSONObject request = fixture("epay/fixtures/v1-request.json");
+ Map fields = fields(request.getJSONObject("fields"));
+
+ assertThat(EpayCanonicalizer.canonical(fields))
+ .isEqualTo(request.getString("canonical"));
+ String signature = EpaySigner.signMd5(fields, request.getString("merchant_key"));
+ assertThat(signature).isEqualTo(request.getString("signature"));
+ assertThat(EpaySigner.verifyMd5(fields, request.getString("merchant_key"), signature)).isTrue();
+
+ JSONObject notify = fixture("epay/fixtures/v1-notify.json");
+ assertThat(notify.getString("success_body")).isEqualTo("success");
+ assertThat(notify.getString("trade_status")).isEqualTo("TRADE_SUCCESS");
+ }
+
+ @Test
+ void v2FixtureCanonicalAndSignatureAreStable() throws IOException {
+ JSONObject request = fixture("epay/fixtures/v2-request.json");
+ Map fields = fields(request.getJSONObject("fields"));
+
+ assertThat(EpayCanonicalizer.canonical(fields))
+ .isEqualTo(request.getString("canonical"));
+ String signature = EpaySigner.signRsa(fields, request.getString("fixture_private_key"));
+ assertThat(signature).isEqualTo(request.getString("signature"));
+ assertThat(EpaySigner.verifyRsa(
+ fields, request.getString("fixture_public_key"), signature)).isTrue();
+
+ JSONObject notify = fixture("epay/fixtures/v2-notify.json");
+ assertThat(notify.getString("sign_type")).isEqualTo("RSA");
+ assertThat(notify.getString("trade_status")).isEqualTo("TRADE_SUCCESS");
+ }
+
+ @Test
+ void responseFixturesContainRequiredVersionFields() throws IOException {
+ JSONObject v1 = fixture("epay/fixtures/v1-response.json");
+ assertThat(v1.getJSONObject("fields"))
+ .containsKeys("code", "msg", "trade_no", "payurl", "sign_type", "sign");
+ assertThat(v1.getJSONObject("fields").getInteger("code")).isEqualTo(1);
+ assertThat(v1.getJSONObject("fields").getString("sign_type")).isEqualTo("MD5");
+
+ JSONObject v2 = fixture("epay/fixtures/v2-response.json");
+ assertThat(v2.getJSONObject("fields"))
+ .containsKeys("code", "msg", "trade_no", "pay_type", "pay_info", "sign_type", "sign");
+ assertThat(v2.getJSONObject("fields").getInteger("code")).isEqualTo(0);
+ assertThat(v2.getJSONObject("fields").getString("sign_type")).isEqualTo("RSA");
+ }
+
+ private static JSONObject fixture(String path) throws IOException {
+ try (InputStream stream = EpayContractFixtureTest.class.getClassLoader().getResourceAsStream(path)) {
+ assertThat(stream).as("fixture %s", path).isNotNull();
+ return JSON.parseObject(new String(stream.readAllBytes(), StandardCharsets.UTF_8));
+ }
+ }
+
+ private static Map fields(JSONObject object) {
+ return new LinkedHashMap<>(object.getInnerMap());
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java
new file mode 100644
index 000000000..ddb2a276f
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java
@@ -0,0 +1,280 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.jeequan.jeepay.core.constants.CS;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class EpayRequestNormalizerTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-27T12:00:00Z");
+
+ private EpayRequestNormalizer normalizer;
+
+ @BeforeEach
+ void setUp() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ properties.setClockSkewSeconds(300);
+ normalizer = new EpayRequestNormalizer(Clock.fixed(NOW, ZoneOffset.UTC), properties);
+ }
+
+ @Test
+ void normalizesV1YuanAmountAndAlipayToStarposQr() {
+ Map fields = signedFields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "12.34",
+ "name", "subject",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php");
+
+ EpayCreateCommand command = normalizer.normalize(
+ fields, "APP_TARGET", credential("secret"), EpayProtocolVersion.V1);
+
+ assertThat(command.merchantId()).isEqualTo("MCH_TARGET");
+ assertThat(command.appId()).isEqualTo("APP_TARGET");
+ assertThat(command.paymentType()).isEqualTo(CS.PAY_WAY_CODE.STARPOS_QR);
+ assertThat(command.amountFen()).isEqualTo(1234L);
+ assertThat(command.orderNo()).isEqualTo("ORDER_TARGET");
+ assertThat(command.subject()).isEqualTo("subject");
+ assertThat(command.notifyUrl()).isEqualTo("https://merchant.example/notify.php");
+ assertThat(command.returnUrl()).isEqualTo("https://merchant.example/return.php");
+ assertThat(command.version()).isEqualTo("v1");
+ assertThat(command.credential()).isEqualTo(credential("secret"));
+ }
+
+ @Test
+ void normalizesWxpayToStarposQrAndUsesOrderNoWhenSubjectBlank() {
+ EpayCreateCommand command = normalizer.normalize(fields(
+ "pid", "MCH_TARGET",
+ "type", "wxpay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1",
+ "name", " ",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1);
+
+ assertThat(command.paymentType()).isEqualTo(CS.PAY_WAY_CODE.STARPOS_QR);
+ assertThat(command.amountFen()).isEqualTo(100L);
+ assertThat(command.subject()).isEqualTo("ORDER_TARGET");
+ }
+
+ @Test
+ void rejectsMoreThanTwoDecimalPlacesAndUnknownType() {
+ assertThatThrownBy(() -> normalizer.normalize(
+ validFields("money", "1.001", "type", "alipay"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("money");
+ assertThatThrownBy(() -> normalizer.normalize(
+ validFields("money", "1.00", "type", "bank"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("type");
+ }
+
+ @Test
+ void rejectsNonPositiveMoneyAndMissingRequiredFields() {
+ assertThatThrownBy(() -> normalizer.normalize(
+ fields("pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "0.00",
+ "notify_url", "https://merchant.example/notify.php", "return_url", "https://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("money");
+ assertThatThrownBy(() -> normalizer.normalize(
+ fields("type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php", "return_url", "https://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("pid");
+ }
+
+ @Test
+ void acceptsHttpAndHttpsNotifyAndReturnUrlsButRejectsOtherSchemes() {
+ EpayCreateCommand httpCommand = normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "http://merchant.example/notify.php", "return_url", "http://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1);
+ assertThat(httpCommand.notifyUrl()).isEqualTo("http://merchant.example/notify.php");
+ assertThat(httpCommand.returnUrl()).isEqualTo("http://merchant.example/return.php");
+
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "ftp://merchant.example/notify.php", "return_url", "https://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("notify_url");
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php", "return_url", "ftp://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("return_url");
+ }
+
+ @Test
+ void rejectsHttpUrlsWithoutAnAuthority() {
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "https://", "return_url", "https://merchant.example/return.php"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("notify_url");
+ }
+
+ @Test
+ void enforcesVersionSpecificSignType() {
+ assertThatThrownBy(() -> normalizer.normalize(validFields("sign_type", "RSA"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1))
+ .hasMessageContaining("sign_type");
+
+ assertThatThrownBy(() -> normalizer.normalize(validFields(
+ "timestamp", String.valueOf(NOW.getEpochSecond()), "sign_type", "MD5"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("sign_type");
+
+ assertThatThrownBy(() -> normalizer.normalize(validFields(
+ "timestamp", String.valueOf(NOW.getEpochSecond())),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("sign_type");
+ }
+
+ @Test
+ void validatesV2TimestampAsUnixSecondsWithinConfiguredSkew() {
+ EpayCreateCommand command = normalizer.normalize(fields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php",
+ "timestamp", String.valueOf(NOW.getEpochSecond()),
+ "sign_type", "RSA",
+ "method", "web"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2);
+
+ assertThat(command.version()).isEqualTo("v2");
+ assertThat(command.method()).isEqualTo("web");
+
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php", "return_url", "https://merchant.example/return.php",
+ "timestamp", String.valueOf(NOW.minusSeconds(301).getEpochSecond()), "sign_type", "RSA"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("timestamp");
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET", "type", "alipay", "out_trade_no", "ORDER_TARGET", "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php", "return_url", "https://merchant.example/return.php",
+ "timestamp", "2026-07-27T12:00:00Z", "sign_type", "RSA"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("timestamp");
+ }
+
+ @Test
+ void rejectsTimestampArithmeticOverflow() {
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php",
+ "timestamp", String.valueOf(Long.MIN_VALUE),
+ "sign_type", "RSA"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("timestamp");
+ assertThatThrownBy(() -> normalizer.normalize(fields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php",
+ "timestamp", String.valueOf(Long.MAX_VALUE),
+ "sign_type", "RSA"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V2))
+ .hasMessageContaining("timestamp");
+ }
+
+ @Test
+ void ignoresUnknownFieldsButKeepsParamClientIpAndDevice() {
+ EpayCreateCommand command = normalizer.normalize(fields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php",
+ "clientip", "203.0.113.9",
+ "device", "mobile",
+ "param", "opaque=1",
+ "extra", "ignored"),
+ "APP_TARGET", credential("secret"), EpayProtocolVersion.V1);
+
+ assertThat(command.clientIp()).isEqualTo("203.0.113.9");
+ assertThat(command.device()).isEqualTo("mobile");
+ }
+
+ @Test
+ void compatPropertiesIsSpringScannableForExternalBinding() {
+ assertThat(EpayCompatProperties.class.isAnnotationPresent(Component.class)).isTrue();
+ }
+
+ @Test
+ void resolvesConfiguredAppIdWithoutGuessingFromRequestFields() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+ properties.setDefaultAppId("APP_DEFAULT");
+ properties.getAppIds().put("MCH_TARGET", "APP_MAPPED");
+
+ assertThat(properties.resolveAppId("MCH_TARGET")).isEqualTo("APP_MAPPED");
+ assertThat(properties.resolveAppId("OTHER_MCH")).isEqualTo("APP_DEFAULT");
+
+ EpayCompatProperties empty = new EpayCompatProperties();
+ assertThatThrownBy(() -> empty.resolveAppId("MCH_TARGET"))
+ .hasMessageContaining("epay.compat.default-app-id");
+ }
+
+ @Test
+ void rejectsNegativeClockSkewConfiguration() {
+ EpayCompatProperties properties = new EpayCompatProperties();
+
+ assertThatThrownBy(() -> properties.setClockSkewSeconds(-1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("clockSkewSeconds");
+ }
+
+ private static Map signedFields(String... values) {
+ Map fields = fields(values);
+ fields.put("sign", "SIGN_TARGET");
+ fields.put("sign_type", "MD5");
+ return fields;
+ }
+
+ private static Map validFields(String... overrides) {
+ Map fields = fields(
+ "pid", "MCH_TARGET",
+ "type", "alipay",
+ "out_trade_no", "ORDER_TARGET",
+ "money", "1.00",
+ "notify_url", "https://merchant.example/notify.php",
+ "return_url", "https://merchant.example/return.php");
+ fields.putAll(fields(overrides));
+ return fields;
+ }
+
+ private static Map fields(String... values) {
+ Map fields = new LinkedHashMap<>();
+ for (int i = 0; i < values.length; i += 2) {
+ fields.put(values[i], values[i + 1]);
+ }
+ return fields;
+ }
+
+ private static EpayCredential credential(String secret) {
+ return new EpayCredential(secret, null, null);
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpaySignerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpaySignerTest.java
new file mode 100644
index 000000000..c705e69bd
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpaySignerTest.java
@@ -0,0 +1,96 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EpaySignerTest {
+
+ @Test
+ void canonicalSortsKeysAndOmitsEmptySignFields() {
+ Map fields = new LinkedHashMap<>();
+ fields.put("z", "last");
+ fields.put("empty", "");
+ fields.put("zero", 0);
+ fields.put("sign_type", "MD5");
+ fields.put("sign", "old");
+ fields.put("a", "first");
+
+ assertThat(EpayCanonicalizer.canonical(fields))
+ .isEqualTo("a=first&z=last&zero=0");
+ }
+
+ @Test
+ void md5UsesLowercaseHexOfCanonicalPlusKey() {
+ Map fields = Map.of("pid", "1001", "money", "1.00");
+
+ assertThat(EpaySigner.signMd5(fields, "secret"))
+ .isEqualTo("db2367d18af244cca18fd9ca6ad7b6e1");
+ }
+
+ @Test
+ void rsaRoundTripUsesSha256Pkcs1v15WithPemKeys() throws Exception {
+ KeyPair pair = keyPair();
+ Map fields = Map.of("money", "1.00", "pid", "1001");
+
+ String signature = EpaySigner.signRsa(fields, pem(pair.getPrivate()));
+
+ assertThat(EpaySigner.verifyRsa(fields, pem(pair.getPublic()), signature)).isTrue();
+ assertThat(EpaySigner.verifyRsa(
+ Map.of("money", "2.00", "pid", "1001"),
+ pem(pair.getPublic()),
+ signature)).isFalse();
+ }
+
+ @Test
+ void rsaRoundTripUsesSha256Pkcs1v15WithBase64DerKeys() throws Exception {
+ KeyPair pair = keyPair();
+ Map fields = Map.of("money", "1.00", "pid", "1001");
+
+ String signatureFromDerPrivateKey = EpaySigner.signRsa(fields, base64Der(pair.getPrivate()));
+ String signatureFromPemPrivateKey = EpaySigner.signRsa(fields, pem(pair.getPrivate()));
+
+ assertThat(EpaySigner.verifyRsa(fields, base64Der(pair.getPublic()), signatureFromDerPrivateKey)).isTrue();
+ assertThat(EpaySigner.verifyRsa(fields, pem(pair.getPublic()), signatureFromDerPrivateKey)).isTrue();
+ assertThat(EpaySigner.verifyRsa(fields, base64Der(pair.getPublic()), signatureFromPemPrivateKey)).isTrue();
+ assertThat(EpaySigner.verifyRsa(
+ Map.of("money", "2.00", "pid", "1001"),
+ base64Der(pair.getPublic()),
+ signatureFromDerPrivateKey)).isFalse();
+ }
+
+ private static KeyPair keyPair() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(1024);
+ return generator.generateKeyPair();
+ }
+
+ private static String pem(PrivateKey privateKey) {
+ return "-----BEGIN PRIVATE KEY-----\n" + wrapBase64(privateKey.getEncoded()) + "\n-----END PRIVATE KEY-----";
+ }
+
+ private static String pem(PublicKey publicKey) {
+ return "-----BEGIN PUBLIC KEY-----\n" + wrapBase64(publicKey.getEncoded()) + "\n-----END PUBLIC KEY-----";
+ }
+
+ private static String base64Der(PrivateKey privateKey) {
+ return Base64.getEncoder().encodeToString(privateKey.getEncoded());
+ }
+
+ private static String base64Der(PublicKey publicKey) {
+ return Base64.getEncoder().encodeToString(publicKey.getEncoded());
+ }
+
+ private static String wrapBase64(byte[] der) {
+ return Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.UTF_8)).encodeToString(der);
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayV2AdapterTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayV2AdapterTest.java
new file mode 100644
index 000000000..f268c742b
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayV2AdapterTest.java
@@ -0,0 +1,26 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EpayV2AdapterTest {
+
+ @Test
+ void successfulResponseSigningFailureBecomesExplicitFailure() {
+ EpayCreateResult result = new EpayCreateResult(
+ true, "success", "JPAY-001", "redirect", "https://cashier.example/JPAY-001", null);
+
+ EpayCompatResponse response = new EpayV2Adapter().success(
+ result, new EpayCredential(null, null, "not-a-private-key"));
+ JSONObject body = JSON.parseObject(response.asJson().getBody());
+
+ assertThat(response.asSubmitResponse().getStatusCode().value()).isEqualTo(200);
+ assertThat(body.getInteger("code")).isEqualTo(1);
+ assertThat(body.getString("msg")).contains("签名失败");
+ assertThat(body.getString("trade_no")).isEmpty();
+ assertThat(body.getString("sign")).isEmpty();
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/PayMchNotifyServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/PayMchNotifyServiceTest.java
new file mode 100644
index 000000000..4d74bff76
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/PayMchNotifyServiceTest.java
@@ -0,0 +1,53 @@
+package com.jeequan.jeepay.pay.compat.epay;
+
+import com.jeequan.jeepay.components.mq.vender.IMQSender;
+import com.jeequan.jeepay.core.entity.MchApp;
+import com.jeequan.jeepay.core.entity.PayOrder;
+import com.jeequan.jeepay.pay.service.ConfigContextQueryService;
+import com.jeequan.jeepay.pay.service.PayMchNotifyService;
+import com.jeequan.jeepay.service.impl.MchNotifyRecordService;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class PayMchNotifyServiceTest {
+
+ @Test
+ void missingCompatNotifyUrlDoesNotCreateRecordOrSendMessage() {
+ MchNotifyRecordService recordService = mock(MchNotifyRecordService.class);
+ ConfigContextQueryService configService = mock(ConfigContextQueryService.class);
+ IMQSender mqSender = mock(IMQSender.class);
+ EpayCompatNotifyService compatNotifyService = mock(EpayCompatNotifyService.class);
+ when(recordService.findByPayOrder("JPAY-1001")).thenReturn(null);
+ when(configService.queryMchApp("MCH-1001", "APP-1001"))
+ .thenReturn(new MchApp().setAppSecret("native-secret"));
+ when(compatNotifyService.createNotifyUrl(org.mockito.ArgumentMatchers.any(PayOrder.class)))
+ .thenReturn("");
+
+ PayMchNotifyService service = new PayMchNotifyService();
+ ReflectionTestUtils.setField(service, "mchNotifyRecordService", recordService);
+ ReflectionTestUtils.setField(service, "configContextQueryService", configService);
+ ReflectionTestUtils.setField(service, "mqSender", mqSender);
+ ReflectionTestUtils.setField(service, "epayCompatNotifyService", compatNotifyService);
+
+ PayOrder order = new PayOrder()
+ .setPayOrderId("JPAY-1001")
+ .setMchNo("MCH-1001")
+ .setAppId("APP-1001")
+ .setMchOrderNo("ORDER-1001")
+ .setNotifyUrl("https://merchant.example/notify.php")
+ .setChannelExtra(EpayCompatMetadata.encode(new EpayMetadataValue(
+ "v2", "MCH-1001", "APP-1001", "alipay", "ORDER-1001",
+ "https://merchant.example/notify.php", "https://merchant.example/return.php",
+ "Demo", "12.34", null, null, "JPAY-1001")));
+
+ service.payOrderNotify(order);
+
+ verify(recordService, never()).save(org.mockito.ArgumentMatchers.any());
+ verify(mqSender, never()).send(org.mockito.ArgumentMatchers.any());
+ }
+}
diff --git a/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/rqrs/payorder/StarposQrOrderRQTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/rqrs/payorder/StarposQrOrderRQTest.java
new file mode 100644
index 000000000..21445f6a5
--- /dev/null
+++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/rqrs/payorder/StarposQrOrderRQTest.java
@@ -0,0 +1,91 @@
+package com.jeequan.jeepay.pay.rqrs.payorder;
+
+import com.jeequan.jeepay.core.constants.CS;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validation;
+import jakarta.validation.Validator;
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StarposQrOrderRQTest {
+
+ private static final String STARPOS_QR_RQ_CLASS =
+ "com.jeequan.jeepay.pay.rqrs.payorder.payway.StarposQrOrderRQ";
+
+ private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
+
+ @Test
+ void shouldBuildStarposQrOrderRequestWithPayUrl() {
+ UnifiedOrderRQ bizRQ = buildBizRQ("{\"payDataType\":\"payUrl\"}");
+
+ assertEquals(STARPOS_QR_RQ_CLASS, bizRQ.getClass().getName());
+ CommonPayDataRQ payDataRQ = assertInstanceOf(CommonPayDataRQ.class, bizRQ);
+ assertEquals(CS.PAY_WAY_CODE.STARPOS_QR, payDataRQ.getWayCode());
+ assertEquals("payUrl", payDataRQ.getPayDataType());
+ }
+
+ @Test
+ void shouldRejectMissingPayDataType() {
+ UnifiedOrderRQ bizRQ = buildBizRQ("{}");
+
+ assertHasViolation(bizRQ, "payDataType", "payDataType不能为空");
+ }
+
+ @Test
+ void shouldRejectMissingPayDataTypeBeforeBizRequestConversion() {
+ UnifiedOrderRQ rq = buildUnifiedOrderRQ("{}");
+
+ assertHasUnifiedOrderViolation(rq);
+ }
+
+ @Test
+ void shouldRejectUnsupportedPayDataType() {
+ UnifiedOrderRQ bizRQ = buildBizRQ("{\"payDataType\":\"payurl\"}");
+
+ assertHasViolation(bizRQ, "payDataType", "payDataType仅支持payUrl");
+ }
+
+ @Test
+ void shouldRejectUnsupportedPayDataTypeBeforeBizRequestConversion() {
+ UnifiedOrderRQ rq = buildUnifiedOrderRQ("{\"payDataType\":\"payurl\"}");
+
+ assertHasUnifiedOrderViolation(rq);
+ }
+
+ private UnifiedOrderRQ buildBizRQ(String channelExtra) {
+ return buildUnifiedOrderRQ(channelExtra).buildBizRQ();
+ }
+
+ private UnifiedOrderRQ buildUnifiedOrderRQ(String channelExtra) {
+ UnifiedOrderRQ rq = new UnifiedOrderRQ();
+ rq.setWayCode(CS.PAY_WAY_CODE.STARPOS_QR);
+ rq.setChannelExtra(channelExtra);
+ return rq;
+ }
+
+ private void assertHasViolation(UnifiedOrderRQ bizRQ, String property, String message) {
+ Set> violations = validator.validate(bizRQ);
+
+ assertTrue(
+ violations.stream().anyMatch(violation ->
+ property.equals(violation.getPropertyPath().toString())
+ && message.equals(violation.getMessage())),
+ () -> "未找到预期校验错误,实际为: " + violations
+ );
+ }
+
+ private void assertHasUnifiedOrderViolation(UnifiedOrderRQ rq) {
+ Set> violations = validator.validate(rq);
+
+ assertTrue(
+ violations.stream().anyMatch(violation ->
+ "starposQrPayDataTypeValid".equals(violation.getPropertyPath().toString())),
+ () -> "统一下单基类校验未拦截非法 payDataType,实际为: " + violations
+ );
+ }
+}
diff --git a/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json
new file mode 100644
index 000000000..ea2125919
--- /dev/null
+++ b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json
@@ -0,0 +1,51 @@
+{
+ "schemaVersion": 1,
+ "canonicalJsonVectors": [
+ {
+ "name": "ascii-key-order-is-case-sensitive",
+ "fields": {
+ "z": "last",
+ "a": "lower",
+ "A": "upper",
+ "10": "numeric-key"
+ },
+ "canonicalJson": "{\"10\":\"numeric-key\",\"A\":\"upper\",\"a\":\"lower\",\"z\":\"last\"}",
+ "sha256Hex": "112fdf80e172a181bf2c9db8a8bce5156f51e3c61ea5b87aac83236120f19939"
+ },
+ {
+ "name": "null-is-excluded-empty-and-sign-are-handled",
+ "fields": {
+ "sign": "ignored-signature",
+ "nullable": null,
+ "present": "value",
+ "empty": ""
+ },
+ "canonicalJson": "{\"empty\":\"\",\"present\":\"value\"}",
+ "sha256Hex": "d10b29fb47e6a57488ffda159b40fb5613509b62c35c6a231e04526ff6174dc4"
+ },
+ {
+ "name": "nested-object-keeps-input-order",
+ "fields": {
+ "alpha": "first",
+ "outer": {
+ "z": 1,
+ "a": 2
+ }
+ },
+ "canonicalJson": "{\"alpha\":\"first\",\"outer\":{\"z\":1,\"a\":2}}",
+ "sha256Hex": "c072577cb63b2b0925ae530c9628371c9cbb8161252ac6c002df22d5df7b7642"
+ }
+ ],
+ "rsa": {
+ "status": "protocol_locked",
+ "algorithm": "RSA/ECB/PKCS1Padding",
+ "hash": "SHA-256 lowercase hex over UTF-8 canonical parameter string",
+ "keyFormat": "X.509 PUBLIC KEY PEM/Base64",
+ "direction": {
+ "request": "public-key encrypt",
+ "notification": "public-key decrypt"
+ },
+ "source": "XyfClient.php: openssl_public_encrypt/openssl_public_decrypt; postar.cn/xyf/doc/7306848m0",
+ "testMaterial": "Generated local RSA public key; direction/implementation test, not a merchant key sample"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v1-notify.json b/jeepay-payment/src/test/resources/epay/fixtures/v1-notify.json
new file mode 100644
index 000000000..e8789ea5d
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v1-notify.json
@@ -0,0 +1,15 @@
+{
+ "version": "V1",
+ "trade_status": "TRADE_SUCCESS",
+ "success_body": "success",
+ "fields": {
+ "pid": "MCH_TARGET",
+ "trade_no": "PAY_TARGET",
+ "out_trade_no": "ORDER_TARGET",
+ "type": "alipay",
+ "name": "Target order",
+ "money": "12.34",
+ "trade_status": "TRADE_SUCCESS",
+ "sign_type": "MD5"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v1-request.json b/jeepay-payment/src/test/resources/epay/fixtures/v1-request.json
new file mode 100644
index 000000000..ba9b02f57
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v1-request.json
@@ -0,0 +1,16 @@
+{
+ "version": "V1",
+ "merchant_key": "MCH_TARGET_KEY",
+ "canonical": "money=12.34&name=Target order¬ify_url=https://merchant.example/epay/notify&out_trade_no=ORDER_TARGET&pid=MCH_TARGET&return_url=https://merchant.example/epay/return&type=alipay",
+ "signature": "8403e34176a7f96f025faf5e2c55dd51",
+ "fields": {
+ "pid": "MCH_TARGET",
+ "type": "alipay",
+ "out_trade_no": "ORDER_TARGET",
+ "notify_url": "https://merchant.example/epay/notify",
+ "return_url": "https://merchant.example/epay/return",
+ "name": "Target order",
+ "money": "12.34",
+ "sign_type": "MD5"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v1-response.json b/jeepay-payment/src/test/resources/epay/fixtures/v1-response.json
new file mode 100644
index 000000000..1a296e846
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v1-response.json
@@ -0,0 +1,13 @@
+{
+ "version": "V1",
+ "fields": {
+ "code": 1,
+ "msg": "success",
+ "trade_no": "PAY_TARGET",
+ "payurl": "https://xyf-server-test.postar.cn/cashier/PAY_TARGET",
+ "qrcode": "",
+ "urlscheme": "",
+ "sign_type": "MD5",
+ "sign": "23aa80203d80b246a52518694d83bb9c"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v2-notify.json b/jeepay-payment/src/test/resources/epay/fixtures/v2-notify.json
new file mode 100644
index 000000000..83a4132db
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v2-notify.json
@@ -0,0 +1,16 @@
+{
+ "version": "V2",
+ "sign_type": "RSA",
+ "trade_status": "TRADE_SUCCESS",
+ "fields": {
+ "pid": "MCH_TARGET",
+ "trade_no": "PAY_TARGET_V2",
+ "out_trade_no": "ORDER_TARGET_V2",
+ "type": "wxpay",
+ "name": "Target order V2",
+ "money": "8.00",
+ "trade_status": "TRADE_SUCCESS",
+ "timestamp": "1750000000",
+ "sign_type": "RSA"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v2-request.json b/jeepay-payment/src/test/resources/epay/fixtures/v2-request.json
new file mode 100644
index 000000000..ad335b0a7
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v2-request.json
@@ -0,0 +1,19 @@
+{
+ "version": "V2",
+ "fixture_private_key": "-----BEGIN PRIVATE KEY-----\nMIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBANNbRm47OLcJ2EGI\nffn6FcuLV/UsDeS4hfjqa8ah0JsNx6XxmPvpnhfs2ZAkV4JoNETq0wuqum0zlT8Y\njtGNbGFQg/3a1o7F2xjmZBF458alzv0/uIqNYKp3qTd/CWWHSjjJwGOJRaOF1uFp\nUOvj0b+UBcy2xH7bBsP28Yi5KMYHAgMBAAECgYBXuEpC9dFUJoHasQuX9HOXRv36\ng45An1T7OlAA4KfzsoRqGxujchJrW6HwcSTgut72u39cGD/HkuEejtK3pjqUZ3L/\nGs1ihEcFj5uu3FMia/04eAEElVSyBqLjAk2rJkqAByTzI9k0VveqRSPQVJxtH7kb\nhJ0BM6+CLa6apYnWAQJBAPro/qmusrLrVINPjzX8wcshY1yqAE1ob7QH3srzke1+\nvSwicDRnpeymb04n5LBfzKNdOxjwZ8aLfM2Un6ujreMCQQDXpN+9YupdPMaVXWhv\neS+hKCNTJ974p3HsZLggpysOJXmkf0jIZxl0okdefcIAbR0kGdsgAdByl68YCbUP\nlgCNAkBe3Px7lutFLigzxT62AKwcarEwda3LlnK1NEyNmu1PR52qNBMMHSvkf0CR\n4g9ah73M/hC76xixPkefSlPcBvRVAkAYjm7oGyaDHZOjmLJ21z8ERBTZuFWczTyc\n5c9N8Jtnjf7vK4t3Pr5YWM51xS5dbA+Ad+1LFMnO8tT+kMRslPFVAkAFTH7Fhf3V\no8ybfgXqQXD9Uavv6v/ATqLoMHWNBHeZNezrWx/lWioP30aBf0EpOAy3bkQMTmzN\nZLEgVQ+L6pM3\n-----END PRIVATE KEY-----",
+ "fixture_public_key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDTW0ZuOzi3CdhBiH35+hXLi1f1\nLA3kuIX46mvGodCbDcel8Zj76Z4X7NmQJFeCaDRE6tMLqrptM5U/GI7RjWxhUIP9\n2taOxdsY5mQReOfGpc79P7iKjWCqd6k3fwllh0o4ycBjiUWjhdbhaVDr49G/lAXM\ntsR+2wbD9vGIuSjGBwIDAQAB\n-----END PUBLIC KEY-----",
+ "canonical": "app_id=APP_TARGET&money=8.00&name=Target order V2¬ify_url=https://merchant.example/epay/notify-v2&out_trade_no=ORDER_TARGET_V2&pid=MCH_TARGET&return_url=https://merchant.example/epay/return-v2×tamp=1750000000&type=wxpay",
+ "signature": "GQG67o47uV1ZKTPMtuFeLkytaDddOc8NLKNPARaqFCW2S6PiZpIQSUlSLqeAowyCBY+cAtPONhljvPfXX3PvSEvZNWM/S+8krtS9BkGGQn74nZ6FXpVnKJl4uIll01AXDjFqyQZ22663kj3N7Hbk99sSiYc7WGt9/wlA3SCdsjA=",
+ "fields": {
+ "pid": "MCH_TARGET",
+ "type": "wxpay",
+ "out_trade_no": "ORDER_TARGET_V2",
+ "notify_url": "https://merchant.example/epay/notify-v2",
+ "return_url": "https://merchant.example/epay/return-v2",
+ "name": "Target order V2",
+ "money": "8.00",
+ "sign_type": "RSA",
+ "timestamp": "1750000000",
+ "app_id": "APP_TARGET"
+ }
+}
diff --git a/jeepay-payment/src/test/resources/epay/fixtures/v2-response.json b/jeepay-payment/src/test/resources/epay/fixtures/v2-response.json
new file mode 100644
index 000000000..917c4b54c
--- /dev/null
+++ b/jeepay-payment/src/test/resources/epay/fixtures/v2-response.json
@@ -0,0 +1,12 @@
+{
+ "version": "V2",
+ "fields": {
+ "code": 0,
+ "msg": "success",
+ "trade_no": "PAY_TARGET_V2",
+ "pay_type": "redirect",
+ "pay_info": "https://xyf-server-test.postar.cn/cashier/PAY_TARGET_V2",
+ "sign_type": "RSA",
+ "sign": "fJ+vnaOHhEfnXxxjB49wqe5B810obX99WpkdFrjrpm+2VTXlN+tMa0r684jYdhtMrNqqhyHKzaND6xO3/0mSOsdLhO3emv6Y5eMC/lg27xtSIBxULNeTt1u/7C3tuPTOQmLy9sCJod3p54yrbExP5YKCwDfcSq5OAFx+1b6y1pE="
+ }
+}