|
|
@@ -0,0 +1,351 @@
|
|
|
+package com.payment.platform.module.payment.unified.client;
|
|
|
+
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.fasterxml.jackson.databind.node.ObjectNode;
|
|
|
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+
|
|
|
+import java.net.URLDecoder;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+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.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 上游客户端(spec §4.2–§4.7 / Task 4 brief)。
|
|
|
+ *
|
|
|
+ * <p>全部走 {@link StubTransport},不发真实网络请求。成功响应用
|
|
|
+ * {@link UnifiedSigner#sign} 按「data 顶层键值」造出正确签名 —— 签名器本身由
|
|
|
+ * {@code UnifiedSignerTest} 的独立向量钉住,这里只验证客户端是否把
|
|
|
+ * {@code data} 解包后再验。
|
|
|
+ */
|
|
|
+class UnifiedClientTest {
|
|
|
+
|
|
|
+ private static final String SECRET = "secret123";
|
|
|
+ private static final String BASE = "https://gw.example.test/gateway";
|
|
|
+ private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
|
+
|
|
|
+ private static final List<String> ORDER_KEYS = List.of(
|
|
|
+ "version", "signType", "reqTime", "mchNo", "appId", "mchOrderNo", "amount",
|
|
|
+ "currency", "clientIp", "subject", "body", "wayCode", "channelExtra",
|
|
|
+ "notifyUrl", "returnUrl", "expiredTime", "extParam", "sign");
|
|
|
+
|
|
|
+ private final StubTransport transport = new StubTransport();
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unifiedOrder_sendsExactCamelCaseFieldSet() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"orderState\":\"0\",\"payOrderId\":\"P1\"}", false);
|
|
|
+
|
|
|
+ JsonNode data = client().unifiedOrder(merchant(), payload());
|
|
|
+
|
|
|
+ assertEquals(BASE + "/har-pay/api/pay/unifiedOrder", transport.lastUrl);
|
|
|
+ assertEquals("P1", data.get("payOrderId").asText());
|
|
|
+
|
|
|
+ JsonNode body = MAPPER.readTree(transport.lastBody);
|
|
|
+ assertEquals(ORDER_KEYS, fieldNames(body));
|
|
|
+ assertEquals("1.0", body.get("version").asText());
|
|
|
+ assertEquals("MD5", body.get("signType").asText());
|
|
|
+ assertTrue(body.get("reqTime").asText().matches("\\d{14}"), transport.lastBody);
|
|
|
+ assertEquals("M1000", body.get("mchNo").asText());
|
|
|
+ assertEquals("A1", body.get("appId").asText());
|
|
|
+ assertEquals("O1", body.get("mchOrderNo").asText());
|
|
|
+ assertTrue(body.get("amount").isNumber());
|
|
|
+ assertEquals(100L, body.get("amount").asLong());
|
|
|
+ assertEquals("cny", body.get("currency").asText());
|
|
|
+ assertEquals("127.0.0.1", body.get("clientIp").asText());
|
|
|
+ assertEquals("测试订单", body.get("subject").asText());
|
|
|
+ assertEquals("说明", body.get("body").asText());
|
|
|
+ assertEquals("QR_CASHIER", body.get("wayCode").asText());
|
|
|
+ assertTrue(body.get("channelExtra").isTextual());
|
|
|
+ assertEquals("{\"payDataType\":\"codeUrl\"}", body.get("channelExtra").asText());
|
|
|
+ assertEquals("https://cb.example.test/o", body.get("notifyUrl").asText());
|
|
|
+ assertEquals("https://ret.example.test/r", body.get("returnUrl").asText());
|
|
|
+ assertTrue(body.get("expiredTime").isTextual());
|
|
|
+ assertEquals("7200", body.get("expiredTime").asText());
|
|
|
+ assertEquals("ext", body.get("extParam").asText());
|
|
|
+ assertEquals(derivedSign(body), body.get("sign").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unifiedOrder_omitsNullAndBlankFields() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"orderState\":\"0\"}", false);
|
|
|
+ UnifiedOrderPayload p = new UnifiedOrderPayload("O2", 1, "标题", null, "WX_NATIVE",
|
|
|
+ null, "https://cb.example.test/o", "", "7200", null, null);
|
|
|
+
|
|
|
+ client().unifiedOrder(merchant(), p);
|
|
|
+
|
|
|
+ JsonNode body = MAPPER.readTree(transport.lastBody);
|
|
|
+ assertFalse(body.has("body"));
|
|
|
+ assertFalse(body.has("channelExtra"));
|
|
|
+ assertFalse(body.has("returnUrl"));
|
|
|
+ assertFalse(body.has("clientIp"));
|
|
|
+ assertFalse(body.has("extParam"));
|
|
|
+ assertEquals(derivedSign(body), body.get("sign").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void reqTime_supportsAllThreeFormats() {
|
|
|
+ OffsetDateTime fixed = OffsetDateTime.of(2026, 10, 20, 12, 0, 0, 0, ZoneOffset.ofHours(8));
|
|
|
+ UnifiedPayProperties props = new UnifiedPayProperties();
|
|
|
+
|
|
|
+ props.setReqTimeFormat("compact14");
|
|
|
+ String compact = UnifiedClient.reqTime(props, fixed);
|
|
|
+ assertEquals(14, compact.length());
|
|
|
+ assertTrue(compact.matches("\\d{14}"));
|
|
|
+ assertEquals("20261020120000", compact);
|
|
|
+
|
|
|
+ props.setReqTimeFormat("epoch13");
|
|
|
+ String ms = UnifiedClient.reqTime(props, fixed);
|
|
|
+ assertEquals(13, ms.length());
|
|
|
+ assertEquals(String.valueOf(fixed.toInstant().toEpochMilli()), ms);
|
|
|
+
|
|
|
+ props.setReqTimeFormat("epoch10");
|
|
|
+ String seconds = UnifiedClient.reqTime(props, fixed);
|
|
|
+ assertEquals(10, seconds.length());
|
|
|
+ assertEquals(String.valueOf(fixed.toInstant().getEpochSecond()), seconds);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void successCodes0And200_bothReturnData() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false);
|
|
|
+ JsonNode zero = client().queryOrder(merchant(), "O1");
|
|
|
+ assertEquals("2", zero.get("state").asText());
|
|
|
+
|
|
|
+ transport.response = signedResponse(200, "SUCCESS", "{\"state\":\"1\"}", false);
|
|
|
+ JsonNode twoHundred = client().queryOrder(merchant(), "O1");
|
|
|
+ assertEquals("1", twoHundred.get("state").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void businessError_throwsWithUpstreamCodeAndMessage() {
|
|
|
+ transport.response = "{\"code\":5,\"msg\":\"余额不足\",\"data\":{},\"sign\":\"\"}";
|
|
|
+
|
|
|
+ UnifiedApiException e = assertThrows(UnifiedApiException.class,
|
|
|
+ () -> client().queryOrder(merchant(), "O1"));
|
|
|
+
|
|
|
+ assertEquals(5, e.getCode());
|
|
|
+ assertEquals("余额不足", e.getMessage());
|
|
|
+ assertTrue(e.isBusinessRejection());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void responseSignedWithWrongSecret_throwsSignCode() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false, "other-secret");
|
|
|
+
|
|
|
+ UnifiedApiException e = assertThrows(UnifiedApiException.class,
|
|
|
+ () -> client().queryOrder(merchant(), "O1"));
|
|
|
+
|
|
|
+ assertEquals(UnifiedApiException.CODE_SIGN, e.getCode());
|
|
|
+ assertFalse(e.isBusinessRejection());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void textualJsonData_isUnwrappedBeforeVerificationAndReturned() throws Exception {
|
|
|
+ // 若客户端拿未解包的 TextNode 去验签,verifyData 会按空参数集处理并失败;
|
|
|
+ // 这条测试钉住「先 data(root) 解包,再验签」。
|
|
|
+ transport.response = signedResponse(0, "SUCCESS",
|
|
|
+ "{\"state\":\"2\",\"payOrderId\":\"P9\"}", true);
|
|
|
+
|
|
|
+ JsonNode data = client().queryOrder(merchant(), "O1");
|
|
|
+
|
|
|
+ assertTrue(data.isObject());
|
|
|
+ assertEquals("P9", data.get("payOrderId").asText());
|
|
|
+ assertEquals("2", data.get("state").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundOrder_bodyHasRefundFieldsAndNoPayOrderId() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"1\",\"refundOrderId\":\"R1\"}", false);
|
|
|
+
|
|
|
+ client().refundOrder(merchant(), "O1", "RF1", 50L, "商户申请退款");
|
|
|
+
|
|
|
+ assertEquals(BASE + "/har-pay/api/refund/refundOrder", transport.lastUrl);
|
|
|
+ JsonNode body = MAPPER.readTree(transport.lastBody);
|
|
|
+ assertEquals(List.of("version", "signType", "reqTime", "mchNo", "appId", "mchOrderNo",
|
|
|
+ "mchRefundNo", "refundAmount", "refundReason", "currency", "notifyUrl", "sign"),
|
|
|
+ fieldNames(body));
|
|
|
+ assertEquals("O1", body.get("mchOrderNo").asText());
|
|
|
+ assertEquals("RF1", body.get("mchRefundNo").asText());
|
|
|
+ assertTrue(body.get("refundAmount").isNumber());
|
|
|
+ assertEquals(50L, body.get("refundAmount").asLong());
|
|
|
+ assertEquals("商户申请退款", body.get("refundReason").asText());
|
|
|
+ assertEquals("cny", body.get("currency").asText());
|
|
|
+ assertEquals("https://qcsj88888.com/api/v1/payment/notify/unified/refund",
|
|
|
+ body.get("notifyUrl").asText());
|
|
|
+ assertFalse(body.has("payOrderId"));
|
|
|
+ assertEquals(derivedSign(body), body.get("sign").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void queryOrder_bodyUsesMchOrderNo() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false);
|
|
|
+
|
|
|
+ client().queryOrder(merchant(), "O1");
|
|
|
+
|
|
|
+ assertEquals(BASE + "/har-pay/api/pay/query", transport.lastUrl);
|
|
|
+ JsonNode body = MAPPER.readTree(transport.lastBody);
|
|
|
+ assertEquals(List.of("version", "signType", "reqTime", "mchNo", "appId", "mchOrderNo", "sign"),
|
|
|
+ fieldNames(body));
|
|
|
+ assertEquals("O1", body.get("mchOrderNo").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void queryRefund_bodyUsesMchRefundNo() throws Exception {
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false);
|
|
|
+
|
|
|
+ client().queryRefund(merchant(), "RF1");
|
|
|
+
|
|
|
+ assertEquals(BASE + "/har-pay/api/refund/query", transport.lastUrl);
|
|
|
+ JsonNode body = MAPPER.readTree(transport.lastBody);
|
|
|
+ assertEquals(List.of("version", "signType", "reqTime", "mchNo", "appId", "mchRefundNo", "sign"),
|
|
|
+ fieldNames(body));
|
|
|
+ assertEquals("RF1", body.get("mchRefundNo").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void jumpUrl_isSignedOverPreEncodingValuesAndNeverLeaksSecret() throws Exception {
|
|
|
+ String url = client().buildChannelUserJumpUrl(merchant(), "fuioupay",
|
|
|
+ "https://merchant.example.test/cb?a=1&b=2", "order=1");
|
|
|
+
|
|
|
+ assertTrue(url.startsWith(BASE + "/har-pay/api/channelUserId/jump?"), url);
|
|
|
+ assertTrue(url.contains("redirectUrl=https%3A%2F%2Fmerchant.example.test%2Fcb%3Fa%3D1%26b%3D2"), url);
|
|
|
+ assertTrue(url.contains("extParam=order%3D1"), url);
|
|
|
+ assertFalse(url.contains(SECRET), url);
|
|
|
+
|
|
|
+ Map<String, Object> params = decodeQuery(url);
|
|
|
+ String sign = (String) params.remove("sign");
|
|
|
+ assertNotNull(sign);
|
|
|
+ assertEquals("1.0", params.get("version"));
|
|
|
+ assertEquals("MD5", params.get("signType"));
|
|
|
+ assertEquals("M1000", params.get("mchNo"));
|
|
|
+ assertEquals("A1", params.get("appId"));
|
|
|
+ assertEquals("fuioupay", params.get("ifCode"));
|
|
|
+ assertEquals("https://merchant.example.test/cb?a=1&b=2", params.get("redirectUrl"));
|
|
|
+ assertEquals("order=1", params.get("extParam"));
|
|
|
+ // 签名对编码前的值计算
|
|
|
+ assertEquals(UnifiedSigner.sign(params, SECRET), sign);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void trailingSlashOnBaseUrl_isTrimmedForPostsAndJumpUrl() throws Exception {
|
|
|
+ UnifiedPayProperties props = new UnifiedPayProperties();
|
|
|
+ props.setBaseUrl(BASE + "/");
|
|
|
+ UnifiedClient client = new UnifiedClient(props, transport);
|
|
|
+ transport.response = signedResponse(0, "SUCCESS", "{\"state\":\"2\"}", false);
|
|
|
+
|
|
|
+ client.queryOrder(merchant(), "O1");
|
|
|
+ assertEquals(BASE + "/har-pay/api/pay/query", transport.lastUrl);
|
|
|
+
|
|
|
+ String jump = client.buildChannelUserJumpUrl(merchant(), "fuioupay", "https://a.test/cb", null);
|
|
|
+ assertTrue(jump.startsWith(BASE + "/har-pay/api/channelUserId/jump?"), jump);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- helpers ----
|
|
|
+
|
|
|
+ private UnifiedClient client() {
|
|
|
+ UnifiedPayProperties props = new UnifiedPayProperties();
|
|
|
+ props.setBaseUrl(BASE);
|
|
|
+ props.setNotifyBaseUrl("https://qcsj88888.com/api/v1");
|
|
|
+ return new UnifiedClient(props, transport);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity merchant() {
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setMchNo("M1000");
|
|
|
+ cfg.setAppId("A1");
|
|
|
+ cfg.setAppSecret(SECRET);
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderPayload payload() {
|
|
|
+ return new UnifiedOrderPayload("O1", 100L, "测试订单", "说明", "QR_CASHIER",
|
|
|
+ "{\"payDataType\":\"codeUrl\"}", "https://cb.example.test/o",
|
|
|
+ "https://ret.example.test/r", "7200", "127.0.0.1", "ext");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String signedResponse(int code, String msg, String dataJson, boolean dataAsString)
|
|
|
+ throws Exception {
|
|
|
+ return signedResponse(code, msg, dataJson, dataAsString, SECRET);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 按 {@link UnifiedSigner} 的口径对 data 顶层键值签名,构造一份上游成功响应。 */
|
|
|
+ private static String signedResponse(int code, String msg, String dataJson, boolean dataAsString,
|
|
|
+ String secret) throws Exception {
|
|
|
+ JsonNode data = MAPPER.readTree(dataJson);
|
|
|
+ ObjectNode root = MAPPER.createObjectNode();
|
|
|
+ root.put("code", code);
|
|
|
+ root.put("msg", msg);
|
|
|
+ root.put("sign", UnifiedSigner.sign(flatten(data), secret));
|
|
|
+ if (dataAsString) {
|
|
|
+ root.put("data", dataJson);
|
|
|
+ } else {
|
|
|
+ root.set("data", data);
|
|
|
+ }
|
|
|
+ return MAPPER.writeValueAsString(root);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 复刻 verifyData 的取文本规则:标量 asText,嵌套对象/数组取紧凑 JSON。 */
|
|
|
+ private static Map<String, Object> flatten(JsonNode data) {
|
|
|
+ Map<String, Object> fields = new LinkedHashMap<>();
|
|
|
+ data.fields().forEachRemaining(e -> {
|
|
|
+ JsonNode value = e.getValue();
|
|
|
+ if (value == null || value.isNull()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ String text = (value.isObject() || value.isArray()) ? value.toString() : value.asText();
|
|
|
+ if (!text.isEmpty()) {
|
|
|
+ fields.put(e.getKey(), text);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return fields;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String derivedSign(JsonNode body) {
|
|
|
+ return UnifiedSigner.sign(flatten(body), SECRET);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> fieldNames(JsonNode node) {
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ node.fieldNames().forEachRemaining(names::add);
|
|
|
+ return names;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, Object> decodeQuery(String url) {
|
|
|
+ Map<String, Object> params = new LinkedHashMap<>();
|
|
|
+ String query = url.substring(url.indexOf('?') + 1);
|
|
|
+ for (String pair : query.split("&")) {
|
|
|
+ int eq = pair.indexOf('=');
|
|
|
+ String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8);
|
|
|
+ String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8);
|
|
|
+ params.put(key, value);
|
|
|
+ }
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ static class StubTransport implements UnifiedTransport {
|
|
|
+ String lastUrl, lastBody;
|
|
|
+ String response;
|
|
|
+
|
|
|
+ public String post(String url, String body) {
|
|
|
+ lastUrl = url;
|
|
|
+ lastBody = body;
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+
|
|
|
+ public String get(String url) {
|
|
|
+ lastUrl = url;
|
|
|
+ return response;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|