From 2404462c43017ad283d3697a9f45feaaee1af58d Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 19:02:31 +0800 Subject: [PATCH 01/27] test: add starpos channel test harness --- jeepay-payment/pom.xml | 6 + .../channel/starpos/StarposTestServer.java | 176 ++++++++++++++++++ .../starpos/StarposTestServerTest.java | 90 +++++++++ 3 files changed, 272 insertions(+) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServer.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposTestServerTest.java diff --git a/jeepay-payment/pom.xml b/jeepay-payment/pom.xml index 4bab8ce25..ece145360 100644 --- a/jeepay-payment/pom.xml +++ b/jeepay-payment/pom.xml @@ -129,6 +129,12 @@ jeepay-sdk-java pls-1.3.0 + + + org.springframework.boot + spring-boot-starter-test + test + 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); + } + } +} From 9f651071ee050068bfb46d77bbea47069ec4c43f Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 19:39:16 +0800 Subject: [PATCH 02/27] feat: implement starpos signing contract --- .../pay/channel/starpos/StarposSigner.java | 150 ++++++++++++++++++ .../channel/starpos/StarposSignerTest.java | 144 +++++++++++++++++ .../contracts/starpos/signing-vectors.json | 46 ++++++ 3 files changed, 340 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposSignerTest.java create mode 100644 jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java new file mode 100644 index 000000000..56b2c40d0 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java @@ -0,0 +1,150 @@ +package com.jeequan.jeepay.pay.channel.starpos; + +import com.alibaba.fastjson.JSON; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * 星驿付签名边界。 + * + *

canonical JSON 和 SHA-256 规则由当前项目契约锁定。RSA 部分集中在 + * {@link RsaCodec} 内,正式星驿付资料接入时只需替换该边界及测试 fixture, + * 不影响调用方的签名内容构造。

+ */ +public final class StarposSigner { + + private static final String SIGN_FIELD = "sign"; + private static final RsaCodec RSA_CODEC = new Sha256WithRsaCodec(); + + private StarposSigner() { + } + + /** + * 生成签名用 canonical JSON。 + * + *
    + *
  • 顶层 key 按 ASCII 大小写敏感顺序排序;
  • + *
  • 顶层 null 值和 sign 字段排除;
  • + *
  • 空字符串保留;
  • + *
  • 嵌套对象保留调用方提供的输入顺序。
  • + *
+ */ + public static String canonicalJson(Map fields) { + Objects.requireNonNull(fields, "fields"); + + Map sortedFields = new TreeMap<>(); + fields.forEach((key, value) -> { + Objects.requireNonNull(key, "field key"); + if (value != null && !SIGN_FIELD.equals(key)) { + sortedFields.put(key, value); + } + }); + return JSON.toJSONString(sortedFields); + } + + /** + * 返回 UTF-8 内容的 SHA-256 小写十六进制摘要。 + */ + public static String sha256Hex(String content) { + Objects.requireNonNull(content, "content"); + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(content.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("SHA-256 不可用", e); + } + } + + /** + * 对 canonical JSON 执行 RSA 签名并返回 Base64。 + * + *

当前实现采用标准 SHA256withRSA 适配器;传入材料为 PKCS#8 私钥。 + * 正式星驿付签名方向和协议样例由可替换 RSA 边界及 fixture 锁定。

+ */ + public static String signRequest(Map fields, String keyMaterial) { + return RSA_CODEC.sign(canonicalJson(fields), keyMaterial); + } + + /** + * 使用通知中的 sign 字段验证 canonical JSON。 + * + *

传入材料为 X.509 公钥;格式错误或签名不匹配均返回 false。

+ */ + public static boolean verifyNotification(Map fields, String keyMaterial) { + Objects.requireNonNull(fields, "fields"); + Object signature = fields.get(SIGN_FIELD); + if (!(signature instanceof String) || ((String) signature).isBlank()) { + return false; + } + return RSA_CODEC.verify(canonicalJson(fields), (String) signature, keyMaterial); + } + + private interface RsaCodec { + + String sign(String content, String keyMaterial); + + boolean verify(String content, String signature, String keyMaterial); + } + + /** + * RSA 算法适配边界。正式协议资料到位时替换此实现即可。 + */ + private static final class Sha256WithRsaCodec implements RsaCodec { + + @Override + public String sign(String content, String keyMaterial) { + try { + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initSign(readPrivateKey(keyMaterial)); + signer.update(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signer.sign()); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalArgumentException("RSA 私钥签名失败", e); + } + } + + @Override + public boolean verify(String content, String signature, String keyMaterial) { + try { + Signature verifier = Signature.getInstance("SHA256withRSA"); + verifier.initVerify(readPublicKey(keyMaterial)); + verifier.update(content.getBytes(StandardCharsets.UTF_8)); + return verifier.verify(Base64.getDecoder().decode(signature)); + } catch (GeneralSecurityException | IllegalArgumentException e) { + return false; + } + } + + private static PrivateKey readPrivateKey(String keyMaterial) throws GeneralSecurityException { + byte[] encoded = decodePemOrBase64(keyMaterial, "PRIVATE KEY"); + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(encoded)); + } + + private static PublicKey readPublicKey(String keyMaterial) throws GeneralSecurityException { + byte[] encoded = decodePemOrBase64(keyMaterial, "PUBLIC KEY"); + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(encoded)); + } + + private static byte[] decodePemOrBase64(String keyMaterial, String type) { + Objects.requireNonNull(keyMaterial, "keyMaterial"); + String normalized = keyMaterial + .replace("-----BEGIN " + type + "-----", "") + .replace("-----END " + type + "-----", "") + .replaceAll("\\s+", ""); + return Base64.getDecoder().decode(normalized); + } + } +} 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..ccda01ede --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposSignerTest.java @@ -0,0 +1,144 @@ +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.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_boundary_is_replaceable_and_supports_local_round_trip() throws Exception { + JSONObject rsa = loadFixture().getJSONObject("rsa"); + assertEquals("pending_formal_starpos_material", rsa.getString("status")); + assertNotNull(rsa.getString("replacementNotes")); + + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + + Map request = new LinkedHashMap<>(); + request.put("amount", "1.00"); + request.put("merchantNo", "LOCAL_FIXTURE"); + + String signature = StarposSigner.signRequest(request, pem("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + assertFalse(signature.isBlank()); + + Map notification = new LinkedHashMap<>(request); + notification.put("sign", signature); + assertTrue(StarposSigner.verifyNotification( + notification, + pem("PUBLIC KEY", keyPair.getPublic().getEncoded()) + )); + + notification.put("amount", "9.99"); + assertFalse(StarposSigner.verifyNotification( + notification, + pem("PUBLIC KEY", keyPair.getPublic().getEncoded()) + )); + } + + 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/resources/contracts/starpos/signing-vectors.json b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json new file mode 100644 index 000000000..7570f8383 --- /dev/null +++ b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json @@ -0,0 +1,46 @@ +{ + "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": "pending_formal_starpos_material", + "algorithm": null, + "direction": null, + "vectors": [], + "replacementNotes": "正式星驿付资料接入后,仅替换本节向量与算法元数据;本地 RSA 往返测试不代表正式协议样例。" + } +} From 7bcc1972178589e5a574b58e595c27dc62b401ea Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 19:58:00 +0800 Subject: [PATCH 03/27] wip: capture starpos rsa protocol direction --- .../channel/starpos/StarposSignerTest.java | 100 +++++++++++++++--- .../contracts/starpos/signing-vectors.json | 15 ++- 2 files changed, 96 insertions(+), 19 deletions(-) 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 index ccda01ede..a7a840694 100644 --- 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 @@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; +import javax.crypto.Cipher; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; @@ -70,36 +71,107 @@ void canonicalJson_keeps_nested_object_input_order() { } @Test - void rsa_boundary_is_replaceable_and_supports_local_round_trip() throws Exception { + void rsa_fixture_documents_public_key_protocol() { JSONObject rsa = loadFixture().getJSONObject("rsa"); - assertEquals("pending_formal_starpos_material", rsa.getString("status")); - assertNotNull(rsa.getString("replacementNotes")); - - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(2048); - KeyPair keyPair = generator.generateKeyPair(); + 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") + ); + } - Map request = new LinkedHashMap<>(); - request.put("amount", "1.00"); - request.put("merchantNo", "LOCAL_FIXTURE"); + @Test + void rsa_protocol_uses_public_key_encrypt_then_public_key_decrypt() 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 signature = StarposSigner.signRequest(request, publicKeyPem); + + Cipher decryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + decryptor.init(Cipher.DECRYPT_MODE, keyPair.getPublic()); + String decryptedHash = new String( + decryptor.doFinal(Base64.getDecoder().decode(signature)), + StandardCharsets.UTF_8 + ); - String signature = StarposSigner.signRequest(request, pem("PRIVATE KEY", keyPair.getPrivate().getEncoded())); - assertFalse(signature.isBlank()); + assertEquals( + StarposSigner.sha256Hex(StarposSigner.canonicalJson(request)), + decryptedHash + ); Map notification = new LinkedHashMap<>(request); notification.put("sign", signature); - assertTrue(StarposSigner.verifyNotification( + assertTrue(StarposSigner.verifyNotification(notification, publicKeyBase64)); + } + + @Test + void rsa_signature_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, - pem("PUBLIC KEY", keyPair.getPublic().getEncoded()) + 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 = StarposSigner.signRequest( + request, + pem("PUBLIC KEY", keyPair.getPublic().getEncoded()) + ); + Map notification = new LinkedHashMap<>(request); + notification.put("sign", signature); + return notification; + } + + 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() diff --git a/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json index 7570f8383..ea2125919 100644 --- a/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json +++ b/jeepay-payment/src/test/resources/contracts/starpos/signing-vectors.json @@ -37,10 +37,15 @@ } ], "rsa": { - "status": "pending_formal_starpos_material", - "algorithm": null, - "direction": null, - "vectors": [], - "replacementNotes": "正式星驿付资料接入后,仅替换本节向量与算法元数据;本地 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" } } From 221b897ac07e24521525a3f4fb7fae831feebea9 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 26 Jul 2026 21:37:36 +0800 Subject: [PATCH 04/27] fix: align starpos rsa direction --- .../pay/channel/starpos/StarposSigner.java | 53 ++++++++----------- .../channel/starpos/StarposSignerTest.java | 33 ++++++++---- 2 files changed, 44 insertions(+), 42 deletions(-) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java index 56b2c40d0..5ccd8d3ef 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposSigner.java @@ -5,16 +5,14 @@ import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; import java.security.KeyFactory; -import java.security.PrivateKey; import java.security.PublicKey; -import java.security.Signature; -import java.security.spec.PKCS8EncodedKeySpec; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.HexFormat; import java.util.Map; import java.util.Objects; import java.util.TreeMap; +import javax.crypto.Cipher; /** * 星驿付签名边界。 @@ -26,7 +24,7 @@ public final class StarposSigner { private static final String SIGN_FIELD = "sign"; - private static final RsaCodec RSA_CODEC = new Sha256WithRsaCodec(); + private static final RsaCodec RSA_CODEC = new PublicKeyRsaCodec(); private StarposSigner() { } @@ -69,19 +67,15 @@ public static String sha256Hex(String content) { } /** - * 对 canonical JSON 执行 RSA 签名并返回 Base64。 - * - *

当前实现采用标准 SHA256withRSA 适配器;传入材料为 PKCS#8 私钥。 - * 正式星驿付签名方向和协议样例由可替换 RSA 边界及 fixture 锁定。

+ * 对 canonical JSON 的 SHA-256 小写 hex 摘要执行 RSA 公钥加密并返回 Base64。 */ public static String signRequest(Map fields, String keyMaterial) { - return RSA_CODEC.sign(canonicalJson(fields), keyMaterial); + String canonical = canonicalJson(fields); + return RSA_CODEC.sign(sha256Hex(canonical), keyMaterial); } /** - * 使用通知中的 sign 字段验证 canonical JSON。 - * - *

传入材料为 X.509 公钥;格式错误或签名不匹配均返回 false。

+ * 使用通知中的 sign 字段进行 RSA 公钥解密并验证 canonical JSON 摘要。 */ public static boolean verifyNotification(Map fields, String keyMaterial) { Objects.requireNonNull(fields, "fields"); @@ -89,7 +83,8 @@ public static boolean verifyNotification(Map fields, String keyM if (!(signature instanceof String) || ((String) signature).isBlank()) { return false; } - return RSA_CODEC.verify(canonicalJson(fields), (String) signature, keyMaterial); + String canonical = canonicalJson(fields); + return RSA_CODEC.verify(sha256Hex(canonical), (String) signature, keyMaterial); } private interface RsaCodec { @@ -99,40 +94,36 @@ private interface RsaCodec { boolean verify(String content, String signature, String keyMaterial); } - /** - * RSA 算法适配边界。正式协议资料到位时替换此实现即可。 - */ - private static final class Sha256WithRsaCodec implements RsaCodec { + private static final class PublicKeyRsaCodec implements RsaCodec { @Override public String sign(String content, String keyMaterial) { try { - Signature signer = Signature.getInstance("SHA256withRSA"); - signer.initSign(readPrivateKey(keyMaterial)); - signer.update(content.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(signer.sign()); + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, readPublicKey(keyMaterial)); + byte[] encrypted = cipher.doFinal(content.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); } catch (GeneralSecurityException | IllegalArgumentException e) { - throw new IllegalArgumentException("RSA 私钥签名失败", e); + throw new IllegalArgumentException("RSA 公钥加密失败", e); } } @Override public boolean verify(String content, String signature, String keyMaterial) { try { - Signature verifier = Signature.getInstance("SHA256withRSA"); - verifier.initVerify(readPublicKey(keyMaterial)); - verifier.update(content.getBytes(StandardCharsets.UTF_8)); - return verifier.verify(Base64.getDecoder().decode(signature)); + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(Cipher.DECRYPT_MODE, readPublicKey(keyMaterial)); + byte[] encrypted = Base64.getDecoder().decode(signature); + String decrypted = new String( + cipher.doFinal(encrypted), + StandardCharsets.UTF_8 + ); + return content.equals(decrypted); } catch (GeneralSecurityException | IllegalArgumentException e) { return false; } } - private static PrivateKey readPrivateKey(String keyMaterial) throws GeneralSecurityException { - byte[] encoded = decodePemOrBase64(keyMaterial, "PRIVATE KEY"); - return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(encoded)); - } - private static PublicKey readPublicKey(String keyMaterial) throws GeneralSecurityException { byte[] encoded = decodePemOrBase64(keyMaterial, "PUBLIC KEY"); return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(encoded)); 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 index a7a840694..ee4a9d625 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -93,18 +94,19 @@ void rsa_fixture_documents_public_key_protocol() { } @Test - void rsa_protocol_uses_public_key_encrypt_then_public_key_decrypt() throws Exception { + 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 signature = StarposSigner.signRequest(request, publicKeyPem); + String requestCiphertext = StarposSigner.signRequest(request, publicKeyPem); - Cipher decryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding"); - decryptor.init(Cipher.DECRYPT_MODE, keyPair.getPublic()); + Cipher requestDecryptor = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + requestDecryptor.init(Cipher.DECRYPT_MODE, keyPair.getPrivate()); String decryptedHash = new String( - decryptor.doFinal(Base64.getDecoder().decode(signature)), + requestDecryptor.doFinal(Base64.getDecoder().decode(requestCiphertext)), StandardCharsets.UTF_8 ); @@ -114,12 +116,12 @@ void rsa_protocol_uses_public_key_encrypt_then_public_key_decrypt() throws Excep ); Map notification = new LinkedHashMap<>(request); - notification.put("sign", signature); + notification.put("sign", privateEncryptHash(request, keyPair.getPrivate())); assertTrue(StarposSigner.verifyNotification(notification, publicKeyBase64)); } @Test - void rsa_signature_tampering_fails() { + void rsa_ciphertext_tampering_fails() { KeyPair keyPair = generateKeyPair(); Map notification = signedNotification(keyPair); byte[] tamperedCiphertext = Base64.getDecoder().decode((String) notification.get("sign")); @@ -156,15 +158,24 @@ private static KeyPair generateKeyPair() { private static Map signedNotification(KeyPair keyPair) { Map request = requestFields(); - String signature = StarposSigner.signRequest( - request, - pem("PUBLIC KEY", keyPair.getPublic().getEncoded()) - ); + 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"); From eeac480a3ed5667b219d0d5bd161ac36b29b5e4b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 00:10:25 +0800 Subject: [PATCH 05/27] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=98=9F?= =?UTF-8?q?=E9=A9=BF=E4=BB=98=E9=80=9A=E9=81=93=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/jeequan/jeepay/core/constants/CS.java | 2 + .../starpos/StarposNormalMchParams.java | 39 +++++++ .../pay/channel/starpos/StarposConfig.java | 48 ++++++++ .../channel/starpos/StarposConfigTest.java | 107 ++++++++++++++++++ 4 files changed, 196 insertions(+) create mode 100644 jeepay-core/src/main/java/com/jeequan/jeepay/core/model/params/starpos/StarposNormalMchParams.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfigTest.java diff --git a/jeepay-core/src/main/java/com/jeequan/jeepay/core/constants/CS.java b/jeepay-core/src/main/java/com/jeequan/jeepay/core/constants/CS.java index 4a289ba20..331449644 100644 --- a/jeepay-core/src/main/java/com/jeequan/jeepay/core/constants/CS.java +++ b/jeepay-core/src/main/java/com/jeequan/jeepay/core/constants/CS.java @@ -148,6 +148,7 @@ public interface IF_CODE{ String XXPAY = "xxpay"; // 小新支付 String PPPAY = "pppay"; // Paypal 支付 String PLSPAY = "plspay"; // 计全支付plus + String STARPOS = "starpos"; // 星驿付 } @@ -157,6 +158,7 @@ public interface PAY_WAY_CODE{ // 特殊支付方式 String QR_CASHIER = "QR_CASHIER"; // ( 通过二维码跳转到收银台完成支付, 已集成获取用户ID的实现。 ) String AUTO_BAR = "AUTO_BAR"; // 条码聚合支付(自动分类条码类型) + String STARPOS_QR = "STARPOS_QR"; // 星驿付二维码支付 String ALI_BAR = "ALI_BAR"; //支付宝条码支付 String ALI_JSAPI = "ALI_JSAPI"; //支付宝服务窗支付 diff --git a/jeepay-core/src/main/java/com/jeequan/jeepay/core/model/params/starpos/StarposNormalMchParams.java b/jeepay-core/src/main/java/com/jeequan/jeepay/core/model/params/starpos/StarposNormalMchParams.java new file mode 100644 index 000000000..1cd01734d --- /dev/null +++ b/jeepay-core/src/main/java/com/jeequan/jeepay/core/model/params/starpos/StarposNormalMchParams.java @@ -0,0 +1,39 @@ +package com.jeequan.jeepay.core.model.params.starpos; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.jeequan.jeepay.core.model.params.NormalMchParams; +import com.jeequan.jeepay.core.utils.StringKit; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; + +/** + * 星驿付普通商户参数。 + */ +@Data +public class StarposNormalMchParams extends NormalMchParams { + + /** 环境:test、uat 或 prod。 */ + private String environment; + + /** 代理商编号。 */ + private String agetId; + + /** 商户编号。 */ + private String custId; + + /** 星驿付公钥。 */ + private String publicKey; + + /** 接口版本。 */ + private String version = "1.0.0"; + + @Override + public String deSenData() { + StarposNormalMchParams mchParams = this; + if (StringUtils.isNotBlank(this.publicKey)) { + mchParams.setPublicKey(StringKit.str2Star(this.publicKey, 4, 4, 6)); + } + return ((JSONObject) JSON.toJSON(mchParams)).toJSONString(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java new file mode 100644 index 000000000..a7226717c --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java @@ -0,0 +1,48 @@ +package com.jeequan.jeepay.pay.channel.starpos; + +import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams; +import org.apache.commons.lang3.StringUtils; + +import java.util.Map; + +/** + * 星驿付固定环境配置。 + */ +public final class StarposConfig { + + private static final Map BASE_URLS = Map.of( + "test", "https://xyf-server-test.postar.cn", + "uat", "https://xyzscxm.postar.cn", + "prod", "https://yyfsvxm.postar.cn" + ); + + private StarposConfig() { + } + + /** + * 校验商户参数并返回对应环境的服务地址。 + */ + public static String resolveBaseUrl(StarposNormalMchParams params) { + if (params == null) { + throw new IllegalArgumentException("星驿付商户参数不能为空"); + } + + requireNotBlank(params.getEnvironment(), "environment"); + requireNotBlank(params.getAgetId(), "agetId"); + requireNotBlank(params.getCustId(), "custId"); + requireNotBlank(params.getPublicKey(), "publicKey"); + requireNotBlank(params.getVersion(), "version"); + + String baseUrl = BASE_URLS.get(params.getEnvironment()); + if (baseUrl == null) { + throw new IllegalArgumentException("不支持的星驿付环境: " + params.getEnvironment()); + } + return baseUrl; + } + + private static void requireNotBlank(String value, String field) { + if (StringUtils.isBlank(value)) { + throw new IllegalArgumentException(field + " 不能为空"); + } + } +} 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; + } +} From 8fad1a7f85fb1811fc3d9d2d810704f415faa2cd Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 00:39:55 +0800 Subject: [PATCH 06/27] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=A5?= =?UTF-8?q?=E6=A0=BC=E6=98=9F=E9=A9=BF=E4=BB=98=20HTTP=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/starpos/StarposHttpClient.java | 197 ++++++++++++++++++ .../starpos/StarposHttpClientTest.java | 182 ++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClientTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java new file mode 100644 index 000000000..61bbce78b --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java @@ -0,0 +1,197 @@ +package com.jeequan.jeepay.pay.channel.starpos; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONException; +import com.alibaba.fastjson.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +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.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * 使用 JVM 默认 TLS 信任链和主机名校验的星驿付 HTTP 客户端。 + */ +public final class StarposHttpClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(StarposHttpClient.class); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15); + private static final int MAX_RESPONSE_BYTES = 1024 * 1024; + private static final String CONTENT_TYPE = "application/json; charset=UTF-8"; + private static final Set FIXED_HOSTS = Set.of( + "xyf-server-test.postar.cn", + "xyzscxm.postar.cn", + "yyfsvxm.postar.cn" + ); + + private final URI baseUri; + private final HttpClient httpClient; + private final Duration requestTimeout; + + /** + * 使用固定星驿付环境地址创建客户端。 + */ + public StarposHttpClient(String baseUrl) { + this(validateConfiguredBaseUri(baseUrl), newHttpClient(), REQUEST_TIMEOUT); + } + + StarposHttpClient(URI baseUri, HttpClient httpClient, Duration requestTimeout) { + this.baseUri = validateClientBaseUri(baseUri); + this.httpClient = Objects.requireNonNull(httpClient, "httpClient"); + this.requestTimeout = requirePositive(requestTimeout, "requestTimeout"); + } + + static HttpClient newHttpClient() { + return HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .build(); + } + + /** + * POST UTF-8 JSON,并返回不超过 1 MiB 的 JSON 对象响应。 + */ + public JSONObject post(String path, JSONObject requestBody, String orderNo) + throws IOException, InterruptedException { + URI requestUri = resolvePath(path); + HttpRequest request = HttpRequest.newBuilder(requestUri) + .timeout(requestTimeout) + .header("Content-Type", CONTENT_TYPE) + .POST(HttpRequest.BodyPublishers.ofString( + Objects.requireNonNull(requestBody, "requestBody").toJSONString(), + StandardCharsets.UTF_8 + )) + .build(); + + long startedAt = System.nanoTime(); + String businessCode = "-"; + try { + HttpResponse response = httpClient.send( + request, + HttpResponse.BodyHandlers.ofInputStream() + ); + try (InputStream responseBody = response.body()) { + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("星驿付响应状态异常"); + } + + byte[] bytes = responseBody.readNBytes(MAX_RESPONSE_BYTES + 1); + if (bytes.length > MAX_RESPONSE_BYTES) { + throw new IOException("星驿付响应超过 1 MiB"); + } + + JSONObject json = parseJsonObject(bytes); + businessCode = Objects.toString(json.getString("code"), "-"); + return json; + } + } finally { + long elapsedMillis = Duration.ofNanos(System.nanoTime() - startedAt).toMillis(); + LOGGER.info( + "星驿付HTTP请求 path={}, elapsedMs={}, businessCode={}, orderNo={}", + requestUri.getPath(), + elapsedMillis, + businessCode, + maskOrderNo(orderNo) + ); + } + } + + private static JSONObject parseJsonObject(byte[] bytes) throws IOException { + try { + Object parsed = JSON.parse(new String(bytes, StandardCharsets.UTF_8)); + if (parsed instanceof JSONObject) { + return (JSONObject) parsed; + } + } catch (JSONException ignored) { + // 统一转换为不含响应内容的异常,避免敏感响应进入上层日志。 + } + throw new IOException("星驿付响应不是合法 JSON 对象"); + } + + private URI resolvePath(String path) { + Objects.requireNonNull(path, "path"); + URI relative = URI.create(path); + if (!path.startsWith("/") || relative.isAbsolute() || relative.getRawAuthority() != null) { + throw new IllegalArgumentException("星驿付请求路径必须为绝对路径"); + } + return baseUri.resolve(relative); + } + + private static URI validateConfiguredBaseUri(String baseUrl) { + Objects.requireNonNull(baseUrl, "baseUrl"); + URI uri = URI.create(baseUrl); + if (!"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException("星驿付服务地址必须使用 HTTPS"); + } + validateRootUri(uri); + + String host = normalizedHost(uri); + if (!FIXED_HOSTS.contains(host)) { + throw new IllegalArgumentException("不允许的星驿付服务主机: " + host); + } + return uri; + } + + private static URI validateClientBaseUri(URI uri) { + Objects.requireNonNull(uri, "baseUri"); + validateRootUri(uri); + String host = normalizedHost(uri); + boolean fixedHttps = "https".equalsIgnoreCase(uri.getScheme()) && FIXED_HOSTS.contains(host); + boolean localHttp = "http".equalsIgnoreCase(uri.getScheme()) + && ("127.0.0.1".equals(host) || "localhost".equals(host) || "::1".equals(host)); + if (!fixedHttps && !localHttp) { + throw new IllegalArgumentException("不允许的星驿付服务主机: " + host); + } + return uri; + } + + private static void validateRootUri(URI uri) { + String path = uri.getPath(); + if (uri.getHost() == null + || uri.getUserInfo() != null + || uri.getQuery() != null + || uri.getFragment() != null + || (path != null && !path.isEmpty() && !"/".equals(path))) { + throw new IllegalArgumentException("星驿付服务地址必须为固定根地址"); + } + } + + private static String normalizedHost(URI uri) { + String host = uri.getHost(); + return host == null ? "" : host.toLowerCase(Locale.ROOT); + } + + private static Duration requirePositive(Duration duration, String field) { + Objects.requireNonNull(duration, field); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(field + " 必须大于零"); + } + return duration; + } + + private static String maskOrderNo(String orderNo) { + if (orderNo == null || orderNo.isBlank()) { + return "-"; + } + if (orderNo.length() <= 4) { + return "*".repeat(orderNo.length()); + } + if (orderNo.length() <= 8) { + return orderNo.substring(0, 2) + + "*".repeat(orderNo.length() - 4) + + orderNo.substring(orderNo.length() - 2); + } + return orderNo.substring(0, 4) + + "*".repeat(orderNo.length() - 8) + + orderNo.substring(orderNo.length() - 4); + } +} 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..19a4f2f37 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClientTest.java @@ -0,0 +1,182 @@ +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.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpTimeoutException; +import java.time.Duration; + +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 { + + @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 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()); + } + + @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 baseUri = URI.create(server.url("/").toString()); + HttpClient httpClient = StarposHttpClient.newHttpClient(); + return new StarposHttpClient(baseUri, httpClient, requestTimeout); + } +} From 9ef4d68c00a24734cbca0c005146c2a5e7f77faa Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 00:57:16 +0800 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=98=9F?= =?UTF-8?q?=E9=A9=BF=E4=BB=98=20HTTP=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20TLS?= =?UTF-8?q?=20=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../channel/starpos/StarposHttpClient.java | 19 +- .../starpos/StarposHttpClientTest.java | 199 +++++++++++++++++- 2 files changed, 201 insertions(+), 17 deletions(-) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java index 61bbce78b..5b435c363 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposHttpClient.java @@ -128,7 +128,11 @@ private URI resolvePath(String path) { private static URI validateConfiguredBaseUri(String baseUrl) { Objects.requireNonNull(baseUrl, "baseUrl"); - URI uri = URI.create(baseUrl); + return validateClientBaseUri(URI.create(baseUrl)); + } + + private static URI validateClientBaseUri(URI uri) { + Objects.requireNonNull(uri, "baseUri"); if (!"https".equalsIgnoreCase(uri.getScheme())) { throw new IllegalArgumentException("星驿付服务地址必须使用 HTTPS"); } @@ -141,19 +145,6 @@ private static URI validateConfiguredBaseUri(String baseUrl) { return uri; } - private static URI validateClientBaseUri(URI uri) { - Objects.requireNonNull(uri, "baseUri"); - validateRootUri(uri); - String host = normalizedHost(uri); - boolean fixedHttps = "https".equalsIgnoreCase(uri.getScheme()) && FIXED_HOSTS.contains(host); - boolean localHttp = "http".equalsIgnoreCase(uri.getScheme()) - && ("127.0.0.1".equals(host) || "localhost".equals(host) || "::1".equals(host)); - if (!fixedHttps && !localHttp) { - throw new IllegalArgumentException("不允许的星驿付服务主机: " + host); - } - return uri; - } - private static void validateRootUri(URI uri) { String path = uri.getPath(); if (uri.getHost() == null 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 index 19a4f2f37..12c177be8 100644 --- 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 @@ -5,13 +5,26 @@ 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; @@ -20,6 +33,8 @@ 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()) { @@ -55,6 +70,31 @@ void shouldConfigureFiveSecondConnectionTimeout() { ); } + @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()) { @@ -134,6 +174,25 @@ void shouldRejectNonHttpsConfiguredEndpoint() { 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"); @@ -175,8 +234,142 @@ private static StarposHttpClient localClient( StarposTestServer server, Duration requestTimeout ) { - URI baseUri = URI.create(server.url("/").toString()); - HttpClient httpClient = StarposHttpClient.newHttpClient(); - return new StarposHttpClient(baseUri, httpClient, 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(); + } } } From c190a452e4b6de159a6e6620cb38010c22753e9e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 01:24:14 +0800 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20STARPOS=5FQR?= =?UTF-8?q?=20=E8=AF=B7=E6=B1=82=E5=93=8D=E5=BA=94=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pay/rqrs/payorder/UnifiedOrderRQ.java | 4 ++ .../payorder/payway/StarposQrOrderRQ.java | 42 +++++++++++++ .../payorder/payway/StarposQrOrderRS.java | 27 ++++++++ .../rqrs/payorder/StarposQrOrderRQTest.java | 63 +++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRQ.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/rqrs/payorder/payway/StarposQrOrderRS.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/rqrs/payorder/StarposQrOrderRQTest.java 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..eee28016b 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 @@ -106,6 +106,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/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..77ad97b4b --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/rqrs/payorder/StarposQrOrderRQTest.java @@ -0,0 +1,63 @@ +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 shouldRejectUnsupportedPayDataType() { + UnifiedOrderRQ bizRQ = buildBizRQ("{\"payDataType\":\"payurl\"}"); + + assertHasViolation(bizRQ, "payDataType", "payDataType仅支持payUrl"); + } + + private UnifiedOrderRQ buildBizRQ(String channelExtra) { + UnifiedOrderRQ rq = new UnifiedOrderRQ(); + rq.setWayCode(CS.PAY_WAY_CODE.STARPOS_QR); + rq.setChannelExtra(channelExtra); + return rq.buildBizRQ(); + } + + 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 + ); + } +} From c799bd79a7fee9b1894bd8371f6c575fbc99245c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 01:41:59 +0800 Subject: [PATCH 09/27] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=20STARPOS=5FQR?= =?UTF-8?q?=20=E8=AF=B7=E6=B1=82=E5=8F=82=E6=95=B0=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pay/rqrs/payorder/UnifiedOrderRQ.java | 20 +++++++++++++ .../rqrs/payorder/StarposQrOrderRQTest.java | 30 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) 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 eee28016b..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(){ 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 index 77ad97b4b..21445f6a5 100644 --- 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 @@ -36,6 +36,13 @@ void shouldRejectMissingPayDataType() { assertHasViolation(bizRQ, "payDataType", "payDataType不能为空"); } + @Test + void shouldRejectMissingPayDataTypeBeforeBizRequestConversion() { + UnifiedOrderRQ rq = buildUnifiedOrderRQ("{}"); + + assertHasUnifiedOrderViolation(rq); + } + @Test void shouldRejectUnsupportedPayDataType() { UnifiedOrderRQ bizRQ = buildBizRQ("{\"payDataType\":\"payurl\"}"); @@ -43,11 +50,22 @@ void shouldRejectUnsupportedPayDataType() { 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.buildBizRQ(); + return rq; } private void assertHasViolation(UnifiedOrderRQ bizRQ, String property, String message) { @@ -60,4 +78,14 @@ private void assertHasViolation(UnifiedOrderRQ bizRQ, String property, String me () -> "未找到预期校验错误,实际为: " + violations ); } + + private void assertHasUnifiedOrderViolation(UnifiedOrderRQ rq) { + Set> violations = validator.validate(rq); + + assertTrue( + violations.stream().anyMatch(violation -> + "starposQrPayDataTypeValid".equals(violation.getPropertyPath().toString())), + () -> "统一下单基类校验未拦截非法 payDataType,实际为: " + violations + ); + } } From 6ad63f72b08afcbbedaaad0d0c4c73cee89ac711 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 02:57:46 +0800 Subject: [PATCH 10/27] =?UTF-8?q?feat:=20=E5=88=9B=E5=BB=BA=E6=98=9F?= =?UTF-8?q?=E9=A9=BF=E4=BB=98=E4=BA=8C=E7=BB=B4=E7=A0=81=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starpos/StarposPaymentService.java | 237 +++++++++++ .../pay/channel/starpos/payway/StarposQr.java | 39 ++ .../starpos/StarposPaymentServiceTest.java | 385 ++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/payway/StarposQr.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java new file mode 100644 index 000000000..e7f1865c6 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java @@ -0,0 +1,237 @@ +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.AbstractPaymentService; +import com.jeequan.jeepay.pay.model.MchAppConfigContext; +import com.jeequan.jeepay.pay.rqrs.AbstractRS; +import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg; +import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ; +import com.jeequan.jeepay.pay.util.PaywayUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 星驿付支付通道公共服务。 + * + *

首期只负责固定环境、请求字段和结果边界;通知地址不动态下发,避免 + * 在正式协议字段未完全固化前把本地地址暴露给上游。

+ */ +@Slf4j +@Service +public class StarposPaymentService extends AbstractPaymentService { + + static final String CREATE_ORDER_PATH = "/yyfsevr/order/getCodeUrl"; + static final String SUCCESS_CODE = "000000"; + static final String UNKNOWN_CODE = "-80000"; + static final int MAX_REMARK_LENGTH = 128; + + @Override + public String getIfCode() { + return CS.IF_CODE.STARPOS; + } + + @Override + public boolean isSupport(String wayCode) { + return CS.PAY_WAY_CODE.STARPOS_QR.equals(wayCode); + } + + @Override + public String preCheck(UnifiedOrderRQ bizRQ, PayOrder payOrder) { + return PaywayUtil.getRealPaywayService(this, payOrder.getWayCode()) + .preCheck(bizRQ, payOrder); + } + + @Override + public AbstractRS pay( + UnifiedOrderRQ bizRQ, + PayOrder payOrder, + MchAppConfigContext mchAppConfigContext + ) throws Exception { + return PaywayUtil.getRealPaywayService(this, payOrder.getWayCode()) + .pay(bizRQ, payOrder, mchAppConfigContext); + } + + /** + * 执行星驿付二维码下单。 + */ + protected StarposPayResult createQrOrder( + PayOrder payOrder, + MchAppConfigContext mchAppConfigContext + ) { + StarposNormalMchParams params = getParams(mchAppConfigContext); + String baseUrl = StarposConfig.resolveBaseUrl(params); + JSONObject request = buildCreateOrderRequest(params, payOrder); + + try { + JSONObject response = createHttpClient(baseUrl) + .post(CREATE_ORDER_PATH, request, payOrder.getPayOrderId()); + return mapResponse(response, params, payOrder); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return unknownResult("星驿付下单请求被中断", e); + } catch (IOException | RuntimeException e) { + log.warn("星驿付下单请求异常,订单进入可查询状态,orderNo={}", + maskOrderNo(payOrder.getPayOrderId()), e); + return unknownResult("星驿付下单结果未知", e); + } + } + + /** + * 测试使用本地转发客户端覆盖;生产使用固定 HTTPS 环境客户端。 + */ + protected StarposHttpClient createHttpClient(String baseUrl) { + return new StarposHttpClient(baseUrl); + } + + private StarposNormalMchParams getParams(MchAppConfigContext context) { + if (context == null) { + throw new IllegalArgumentException("星驿付商户配置上下文不能为空"); + } + StarposNormalMchParams params = context.getNormalMchParamsByIfCode( + getIfCode(), StarposNormalMchParams.class + ); + if (params == null) { + throw new IllegalArgumentException("星驿付商户参数不能为空"); + } + return params; + } + + private JSONObject buildCreateOrderRequest( + StarposNormalMchParams params, + PayOrder payOrder + ) { + Map fields = new LinkedHashMap<>(); + fields.put("agetId", params.getAgetId()); + fields.put("custId", params.getCustId()); + fields.put("version", params.getVersion()); + fields.put("timeStamp", String.valueOf(System.currentTimeMillis())); + fields.put("orderNo", payOrder.getPayOrderId()); + fields.put("txamt", payOrder.getAmount()); + fields.put("title", payOrder.getSubject()); + fields.put("remark", controlledRemark(payOrder)); + + fields.put("sign", StarposSigner.signRequest(fields, params.getPublicKey())); + return new JSONObject(fields); + } + + private String controlledRemark(PayOrder payOrder) { + String remark = StringUtils.defaultIfBlank(payOrder.getBody(), payOrder.getSubject()); + if (remark == null) { + return ""; + } + return remark.length() <= MAX_REMARK_LENGTH + ? remark + : remark.substring(0, MAX_REMARK_LENGTH); + } + + private StarposPayResult mapResponse( + JSONObject response, + StarposNormalMchParams params, + PayOrder payOrder + ) { + if (response == null) { + return unknownResult("星驿付返回为空", null); + } + + String code = response.getString("code"); + String message = StringUtils.defaultIfBlank( + response.getString("message"), + response.getString("msg") + ); + ChannelRetMsg retMsg = new ChannelRetMsg(); + retMsg.setChannelErrCode(code); + retMsg.setChannelErrMsg(message); + retMsg.setChannelOriginResponse(response.toJSONString()); + + if (SUCCESS_CODE.equals(code)) { + String cashierUrl = response.getString("data"); + if (!isCashierUrlForEnvironment(cashierUrl, params)) { + retMsg.setChannelState(ChannelRetMsg.ChannelState.CONFIRM_FAIL); + retMsg.setChannelErrMsg("星驿付收银链接不是当前环境的 HTTPS 地址"); + return new StarposPayResult(retMsg, null); + } + retMsg.setChannelState(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS); + retMsg.setChannelOrderId(payOrder.getPayOrderId()); + return new StarposPayResult(retMsg, cashierUrl); + } + + if (UNKNOWN_CODE.equals(code)) { + retMsg.setChannelState(ChannelRetMsg.ChannelState.UNKNOWN); + retMsg.setNeedQuery(true); + return new StarposPayResult(retMsg, null); + } + + retMsg.setChannelState(ChannelRetMsg.ChannelState.CONFIRM_FAIL); + return new StarposPayResult(retMsg, null); + } + + private StarposPayResult unknownResult(String message, Exception cause) { + ChannelRetMsg retMsg = ChannelRetMsg.unknown(message); + retMsg.setChannelErrCode(UNKNOWN_CODE); + retMsg.setNeedQuery(true); + if (cause != null) { + log.debug("星驿付下单结果未知:{}", message, cause); + } + return new StarposPayResult(retMsg, null); + } + + private boolean isCashierUrlForEnvironment( + String cashierUrl, + StarposNormalMchParams params + ) { + if (StringUtils.isBlank(cashierUrl)) { + return false; + } + try { + URI cashierUri = URI.create(cashierUrl); + URI baseUri = URI.create(StarposConfig.resolveBaseUrl(params)); + return "https".equalsIgnoreCase(cashierUri.getScheme()) + && cashierUri.getUserInfo() == null + && cashierUri.getPort() == -1 + && cashierUri.getQuery() == null + && cashierUri.getFragment() == null + && baseUri.getHost().equalsIgnoreCase(cashierUri.getHost()); + } catch (IllegalArgumentException e) { + return false; + } + } + + private String maskOrderNo(String orderNo) { + if (StringUtils.isBlank(orderNo)) { + return "-"; + } + if (orderNo.length() <= 4) { + return "*".repeat(orderNo.length()); + } + return orderNo.substring(0, 2) + "***" + + orderNo.substring(orderNo.length() - 2); + } + + protected static final class StarposPayResult { + + private final ChannelRetMsg channelRetMsg; + private final String cashierUrl; + + private StarposPayResult(ChannelRetMsg channelRetMsg, String cashierUrl) { + this.channelRetMsg = channelRetMsg; + this.cashierUrl = cashierUrl; + } + + public ChannelRetMsg getChannelRetMsg() { + return channelRetMsg; + } + + public String getCashierUrl() { + return cashierUrl; + } + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/payway/StarposQr.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/payway/StarposQr.java new file mode 100644 index 000000000..6676f486a --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/payway/StarposQr.java @@ -0,0 +1,39 @@ +package com.jeequan.jeepay.pay.channel.starpos.payway; + +import com.jeequan.jeepay.core.entity.PayOrder; +import com.jeequan.jeepay.pay.channel.starpos.StarposPaymentService; +import com.jeequan.jeepay.pay.model.MchAppConfigContext; +import com.jeequan.jeepay.pay.rqrs.AbstractRS; +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 com.jeequan.jeepay.pay.util.ApiResBuilder; +import org.springframework.stereotype.Service; + +/** + * 星驿付聚合收银台二维码下单。 + */ +@Service("starposPaymentByStarposQrService") +public class StarposQr extends StarposPaymentService { + + @Override + public String preCheck(UnifiedOrderRQ bizRQ, PayOrder payOrder) { + return null; + } + + @Override + public AbstractRS pay( + UnifiedOrderRQ bizRQ, + PayOrder payOrder, + MchAppConfigContext mchAppConfigContext + ) { + StarposPayResult result = createQrOrder(payOrder, mchAppConfigContext); + StarposQrOrderRS response = ApiResBuilder.buildSuccess(StarposQrOrderRS.class); + response.setChannelRetMsg(result.getChannelRetMsg()); + if (result.getChannelRetMsg().getChannelState() + == ChannelRetMsg.ChannelState.CONFIRM_SUCCESS) { + response.setPayUrl(result.getCashierUrl()); + } + return response; + } +} 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..5f93781c5 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentServiceTest.java @@ -0,0 +1,385 @@ +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()); + } + } + + @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 (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(); + } + } +} From 47941e88eb7b76f13e4c61ff7a3d054e240c0e8d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 03:22:55 +0800 Subject: [PATCH 11/27] =?UTF-8?q?fix:=20=E5=A4=84=E7=90=86=E6=98=9F?= =?UTF-8?q?=E9=A9=BF=E4=BB=98=E4=B8=8B=E5=8D=95=E9=85=8D=E7=BD=AE=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starpos/StarposPaymentService.java | 24 ++++++++++++++-- .../starpos/StarposPaymentServiceTest.java | 28 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java index e7f1865c6..42c3a30dd 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPaymentService.java @@ -32,6 +32,7 @@ public class StarposPaymentService extends AbstractPaymentService { static final String CREATE_ORDER_PATH = "/yyfsevr/order/getCodeUrl"; static final String SUCCESS_CODE = "000000"; static final String UNKNOWN_CODE = "-80000"; + static final String CONFIG_ERROR_CODE = "STARPOS_CONFIG_ERROR"; static final int MAX_REMARK_LENGTH = 128; @Override @@ -67,9 +68,18 @@ protected StarposPayResult createQrOrder( PayOrder payOrder, MchAppConfigContext mchAppConfigContext ) { - StarposNormalMchParams params = getParams(mchAppConfigContext); - String baseUrl = StarposConfig.resolveBaseUrl(params); - JSONObject request = buildCreateOrderRequest(params, payOrder); + StarposNormalMchParams params; + String baseUrl; + JSONObject request; + try { + params = getParams(mchAppConfigContext); + baseUrl = StarposConfig.resolveBaseUrl(params); + request = buildCreateOrderRequest(params, payOrder); + } catch (RuntimeException e) { + log.error("星驿付下单配置或签名构造失败,订单标记为失败,orderNo={}", + maskOrderNo(payOrder == null ? null : payOrder.getPayOrderId()), e); + return configurationFailure(); + } try { JSONObject response = createHttpClient(baseUrl) @@ -174,6 +184,14 @@ private StarposPayResult mapResponse( return new StarposPayResult(retMsg, null); } + private StarposPayResult configurationFailure() { + ChannelRetMsg retMsg = ChannelRetMsg.confirmFail( + CONFIG_ERROR_CODE, + "星驿付下单配置无效" + ); + return new StarposPayResult(retMsg, null); + } + private StarposPayResult unknownResult(String message, Exception cause) { ChannelRetMsg retMsg = ChannelRetMsg.unknown(message); retMsg.setChannelErrCode(UNKNOWN_CODE); 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 index 5f93781c5..72e2fe314 100644 --- 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 @@ -162,6 +162,24 @@ void shouldKeepOrderQueryableAfterRequestTimeout() throws Exception { } } + @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", @@ -201,10 +219,18 @@ void shouldRejectCashierLinkWithNonDefaultPort() throws Exception { } 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() + context ); } From f902f2253489413e19df062b72b71ab2a7bc4538 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 03:50:40 +0800 Subject: [PATCH 12/27] =?UTF-8?q?feat:=20=E6=9F=A5=E8=AF=A2=E6=98=9F?= =?UTF-8?q?=E9=A9=BF=E4=BB=98=E6=94=AF=E4=BB=98=E8=AE=A2=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starpos/StarposPayOrderQueryService.java | 295 ++++++++++ .../StarposPayOrderQueryServiceTest.java | 528 ++++++++++++++++++ 2 files changed, 823 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryService.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryService.java new file mode 100644 index 000000000..4530e6b1c --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposPayOrderQueryService.java @@ -0,0 +1,295 @@ +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.IPayOrderQueryService; +import com.jeequan.jeepay.pay.model.MchAppConfigContext; +import com.jeequan.jeepay.pay.rqrs.msg.ChannelRetMsg; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TimeZone; + +/** + * 星驿付支付订单查询服务。 + * + *

查询结果必须先通过星驿付公钥验签,再参与本地订单状态推进。对无法确认 + * 的结果统一保留本地进行中状态,交由后续查单继续收敛。

+ */ +@Slf4j +@Service +public class StarposPayOrderQueryService implements IPayOrderQueryService { + + static final String QUERY_ORDER_PATH = "/yyfsevr/order/orderQuery"; + static final String SUCCESS_CODE = "000000"; + static final String WAITING_CODE = "222222"; + static final String NOT_FOUND_CODE = "000002"; + static final String UNKNOWN_CODE = "-80000"; + static final String QUERY_ERROR_CODE = "STARPOS_QUERY_ERROR"; + private static final String ORDER_DATE_FORMAT = "yyyyMMdd"; + private static final String ORDER_TIME_ZONE = "Asia/Shanghai"; + + @Override + public String getIfCode() { + return CS.IF_CODE.STARPOS; + } + + @Override + public ChannelRetMsg query( + PayOrder payOrder, + MchAppConfigContext mchAppConfigContext + ) { + if (payOrder == null) { + return waitingResult(QUERY_ERROR_CODE, "星驿付查单订单不能为空", null); + } + + StarposNormalMchParams params; + String baseUrl; + JSONObject request; + try { + params = getParams(mchAppConfigContext); + baseUrl = StarposConfig.resolveBaseUrl(params); + request = buildQueryRequest(params, payOrder); + } catch (RuntimeException e) { + log.warn("星驿付查单配置或签名构造失败,orderNo={}", + maskOrderNo(payOrder.getPayOrderId()), e); + return confirmedFailure(QUERY_ERROR_CODE, "星驿付查单配置无效", null); + } + + try { + JSONObject response = createHttpClient(baseUrl) + .post(QUERY_ORDER_PATH, request, payOrder.getPayOrderId()); + return mapResponse(response, params, payOrder); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return unknownResult(QUERY_ERROR_CODE, "星驿付查单请求被中断", null); + } catch (IOException | RuntimeException e) { + log.warn("星驿付查单请求异常,订单进入可查询状态,orderNo={}", + maskOrderNo(payOrder.getPayOrderId()), e); + return unknownResult(QUERY_ERROR_CODE, "星驿付查单请求异常", null); + } + } + + /** + * 测试使用本地转发客户端覆盖;生产使用固定 HTTPS 环境客户端。 + */ + protected StarposHttpClient createHttpClient(String baseUrl) { + return new StarposHttpClient(baseUrl); + } + + private StarposNormalMchParams getParams(MchAppConfigContext context) { + if (context == null) { + throw new IllegalArgumentException("星驿付商户配置上下文不能为空"); + } + StarposNormalMchParams params = context.getNormalMchParamsByIfCode( + getIfCode(), StarposNormalMchParams.class + ); + if (params == null) { + throw new IllegalArgumentException("星驿付商户参数不能为空"); + } + return params; + } + + private JSONObject buildQueryRequest( + StarposNormalMchParams params, + PayOrder payOrder + ) { + Map fields = new LinkedHashMap<>(); + fields.put("agetId", params.getAgetId()); + fields.put("custId", params.getCustId()); + fields.put("version", params.getVersion()); + fields.put("timeStamp", String.valueOf(System.currentTimeMillis())); + fields.put("orderNo", payOrder.getPayOrderId()); + fields.put("orderTime", orderDate(payOrder.getCreatedAt())); + fields.put("sign", StarposSigner.signRequest(fields, params.getPublicKey())); + return new JSONObject(fields); + } + + private String orderDate(Date createdAt) { + Date value = createdAt == null ? new Date() : createdAt; + SimpleDateFormat formatter = new SimpleDateFormat(ORDER_DATE_FORMAT); + formatter.setTimeZone(TimeZone.getTimeZone(ORDER_TIME_ZONE)); + return formatter.format(value); + } + + private ChannelRetMsg mapResponse( + JSONObject response, + StarposNormalMchParams params, + PayOrder payOrder + ) { + if (response == null) { + return waitingResult(QUERY_ERROR_CODE, "星驿付查单返回为空", null); + } + + String envelopeCode = firstNonBlank(response, "code", "retCode"); + String message = message(response); + JSONObject data = response.getJSONObject("data"); + + boolean topLevelVerified = verify(response, params); + boolean nestedVerified = data != null && verify(data, params); + if (!topLevelVerified && !nestedVerified) { + return unknownResult(envelopeCode, "星驿付查单响应验签失败", + response.toJSONString()); + } + + JSONObject business = data == null ? response : data; + String signedCode = firstNonBlank(business, "code", "retCode"); + if (data != null && nestedVerified && !topLevelVerified) { + if (StringUtils.isBlank(signedCode) + || !StringUtils.equals(envelopeCode, signedCode)) { + return unknownResult(envelopeCode, + "星驿付查单响应状态码未被签名覆盖", response.toJSONString()); + } + } + String code = data != null && nestedVerified && !topLevelVerified + ? signedCode + : envelopeCode; + String status = firstNonBlank(business, "orderStatus", "status"); + String businessMessage = StringUtils.defaultIfBlank( + firstNonBlank(business, "message", "msg"), + message + ); + String origin = originWithExtension(response, business); + + if (NOT_FOUND_CODE.equals(code)) { + return waitingResult(code, businessMessage, origin); + } + if (UNKNOWN_CODE.equals(code)) { + return unknownResult(code, businessMessage, origin); + } + if (WAITING_CODE.equals(code) || "2".equals(status)) { + return waitingResult(code, businessMessage, origin); + } + if ("0".equals(status) || "99".equals(status)) { + return confirmedFailure(code, businessMessage, origin); + } + if (SUCCESS_CODE.equals(code) && "1".equals(status)) { + String channelOrderId = firstNonBlank(business, "orderNo"); + if (StringUtils.isBlank(channelOrderId) + || !amountMatches(business, payOrder.getAmount())) { + return unknownResult(code, "星驿付查单成功结果校验失败", origin); + } + ChannelRetMsg result = ChannelRetMsg.confirmSuccess(channelOrderId); + result.setChannelOriginResponse(origin); + return result; + } + + return unknownResult(code, "星驿付查单返回了未识别状态", origin); + } + + private boolean verify(JSONObject body, StarposNormalMchParams params) { + return StarposSigner.verifyNotification( + new LinkedHashMap<>(body), + params.getPublicKey() + ); + } + + private boolean amountMatches(JSONObject business, Long localAmount) { + String upstreamAmount = firstNonBlank(business, "txamt", "amount"); + if (StringUtils.isBlank(upstreamAmount) || localAmount == null) { + return false; + } + try { + long parsed = Long.parseLong(upstreamAmount); + return parsed >= 0 && parsed == localAmount; + } catch (NumberFormatException e) { + return false; + } + } + + private String originWithExtension(JSONObject response, JSONObject business) { + JSONObject origin = JSONObject.parseObject(response.toJSONString()); + JSONObject extension = new JSONObject(); + putIfPresent(extension, "torderNo", firstNonBlank(business, "torderNo")); + putIfPresent(extension, "threeOrderNo", firstNonBlank(business, "threeOrderNo")); + if (!extension.isEmpty()) { + origin.put("starposExtension", extension); + } + return origin.toJSONString(); + } + + private void putIfPresent(JSONObject target, String key, String value) { + if (StringUtils.isNotBlank(value)) { + target.put(key, value); + } + } + + private ChannelRetMsg waitingResult( + String code, + String message, + String origin + ) { + ChannelRetMsg result = ChannelRetMsg.waiting(); + result.setChannelErrCode(code); + result.setChannelErrMsg(message); + if (origin != null) { + result.setChannelOriginResponse(origin); + } + return result; + } + + private ChannelRetMsg unknownResult( + String code, + String message, + String origin + ) { + ChannelRetMsg result = ChannelRetMsg.unknown(message); + result.setChannelErrCode(StringUtils.defaultIfBlank(code, UNKNOWN_CODE)); + result.setNeedQuery(true); + if (origin != null) { + result.setChannelOriginResponse(origin); + } + return result; + } + + private ChannelRetMsg confirmedFailure( + String code, + String message, + String origin + ) { + ChannelRetMsg result = ChannelRetMsg.confirmFail(code, message); + if (origin != null) { + result.setChannelOriginResponse(origin); + } + return result; + } + + private String firstNonBlank(JSONObject object, String... keys) { + if (object == null) { + return null; + } + for (String key : keys) { + String value = object.getString(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + private String message(JSONObject response) { + return StringUtils.defaultIfBlank( + firstNonBlank(response, "message", "msg"), + "星驿付查单返回异常" + ); + } + + private String maskOrderNo(String orderNo) { + if (StringUtils.isBlank(orderNo)) { + return "-"; + } + if (orderNo.length() <= 4) { + return "*".repeat(orderNo.length()); + } + return orderNo.substring(0, 2) + "***" + + orderNo.substring(orderNo.length() - 2); + } +} 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(); + } + } +} From 43a94827286767ba61f6974b4b3efe252b14d2da Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 04:12:32 +0800 Subject: [PATCH 13/27] feat: process starpos payment notifications --- .../starpos/StarposChannelNoticeService.java | 272 ++++++++++++++++++ .../StarposChannelNoticeServiceTest.java | 239 +++++++++++++++ 2 files changed, 511 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeService.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeService.java new file mode 100644 index 000000000..c90883237 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposChannelNoticeService.java @@ -0,0 +1,272 @@ +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.AbstractChannelNoticeService; +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 lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.MutablePair; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 星驿付支付异步通知服务。 + * + *

通知字段不使用固定白名单参与验签,保证上游新增字段仍然进入 + * canonical JSON。订单关联使用 {@code THREE_ORDER_NO} 查找 JeePay + * {@code payOrderId},再校验商户订单、金额和支付状态。

+ */ +@Slf4j +@Service +public class StarposChannelNoticeService extends AbstractChannelNoticeService { + + private static final String SUCCESS_STATUS = "1"; + private static final String FAILED_STATUS = "0"; + private static final String CLOSED_STATUS = "99"; + + @Autowired + private PayOrderService payOrderService; + + @Override + public String getIfCode() { + return CS.IF_CODE.STARPOS; + } + + @Override + public MutablePair parseParams( + HttpServletRequest request, + String urlOrderId, + NoticeTypeEnum noticeTypeEnum + ) { + try { + JSONObject params = getReqParamJSON(); + String threeOrderNo = firstNonBlank( + params, "THREE_ORDER_NO", "threeOrderNo", "three_order_no" + ); + String orderId = threeOrderNo; + if (StringUtils.isBlank(orderId)) { + orderId = firstNonBlank( + params, "ORDER_NO", "orderNo", "order_no" + ); + } + if (StringUtils.isBlank(orderId)) { + throw ResponseException.buildText("ERROR"); + } + + if (StringUtils.isNotBlank(threeOrderNo)) { + PayOrder payOrder = findPayOrderByThreeOrderNo(threeOrderNo); + if (payOrder != null && StringUtils.isNotBlank(payOrder.getPayOrderId())) { + orderId = payOrder.getPayOrderId(); + } + } + return MutablePair.of(orderId, params); + } catch (ResponseException e) { + throw e; + } catch (Exception e) { + log.error("解析星驿付支付通知失败", e); + throw ResponseException.buildText("ERROR"); + } + } + + @Override + public ChannelRetMsg doNotice( + HttpServletRequest request, + Object params, + PayOrder payOrder, + MchAppConfigContext mchAppConfigContext, + NoticeTypeEnum noticeTypeEnum + ) { + try { + JSONObject jsonParams = toJsonObject(params); + StarposNormalMchParams starposParams = getParams(mchAppConfigContext); + + if (!verifyNotification(jsonParams, starposParams)) { + log.warn("星驿付支付通知验签失败,payOrderId={}", + maskOrderNo(payOrder == null ? null : payOrder.getPayOrderId())); + throw ResponseException.buildText("ERROR"); + } + + validateOrderAndAmount(jsonParams, payOrder); + + String orderNo = firstNonBlank( + jsonParams, "ORDER_NO", "orderNo", "order_no" + ); + String orderStatus = firstNonBlank( + jsonParams, "ORDER_STATUS", "orderStatus", "status" + ); + + ChannelRetMsg result = new ChannelRetMsg(); + result.setChannelOrderId(orderNo); + result.setChannelOriginResponse(originWithExtension(jsonParams).toJSONString()); + result.setResponseEntity(successResponse()); + + if (SUCCESS_STATUS.equals(orderStatus)) { + result.setChannelState(ChannelRetMsg.ChannelState.CONFIRM_SUCCESS); + } else if (FAILED_STATUS.equals(orderStatus) + || CLOSED_STATUS.equals(orderStatus)) { + result.setChannelState(ChannelRetMsg.ChannelState.CONFIRM_FAIL); + result.setChannelErrCode(orderStatus); + result.setChannelErrMsg("星驿付支付未成功"); + } else { + result.setChannelState(ChannelRetMsg.ChannelState.WAITING); + } + return result; + } catch (ResponseException e) { + throw e; + } catch (Exception e) { + log.error("处理星驿付支付通知失败,payOrderId={}", + maskOrderNo(payOrder == null ? null : payOrder.getPayOrderId()), e); + throw ResponseException.buildText("ERROR"); + } + } + + protected PayOrder findPayOrderByThreeOrderNo(String threeOrderNo) { + if (payOrderService == null) { + throw new IllegalStateException("支付订单服务未初始化"); + } + return payOrderService.getById(threeOrderNo); + } + + /** + * 仅供同包测试替换 Spring 注入依赖,不参与生产调用。 + */ + protected void setPayOrderServiceForTest(PayOrderService payOrderService) { + this.payOrderService = payOrderService; + } + + private StarposNormalMchParams getParams(MchAppConfigContext context) { + if (context == null) { + throw new IllegalArgumentException("星驿付商户配置上下文不能为空"); + } + StarposNormalMchParams params = context.getNormalMchParamsByIfCode( + getIfCode(), StarposNormalMchParams.class + ); + if (params == null || StringUtils.isBlank(params.getPublicKey())) { + throw new IllegalArgumentException("星驿付商户参数不能为空"); + } + return params; + } + + private boolean verifyNotification( + JSONObject params, + StarposNormalMchParams starposParams + ) { + Map fields = new LinkedHashMap<>(params); + if (!fields.containsKey("sign") && fields.containsKey("SIGN")) { + fields.put("sign", fields.remove("SIGN")); + } + return StarposSigner.verifyNotification( + fields, starposParams.getPublicKey() + ); + } + + private void validateOrderAndAmount(JSONObject params, PayOrder payOrder) { + if (payOrder == null + || !CS.IF_CODE.STARPOS.equals(payOrder.getIfCode())) { + throw ResponseException.buildText("ERROR"); + } + + String threeOrderNo = firstNonBlank( + params, "THREE_ORDER_NO", "threeOrderNo", "three_order_no" + ); + String orderNo = firstNonBlank( + params, "ORDER_NO", "orderNo", "order_no" + ); + if (StringUtils.isBlank(threeOrderNo) + || !StringUtils.equals(threeOrderNo, payOrder.getPayOrderId()) + || StringUtils.isBlank(orderNo)) { + log.warn("星驿付支付通知订单关系不匹配,threeOrderNo={}, payOrderId={}", + threeOrderNo, maskOrderNo(payOrder.getPayOrderId())); + throw ResponseException.buildText("ERROR"); + } + + String amount = firstNonBlank( + params, "TXAMT", "txamt", "amount" + ); + if (StringUtils.isBlank(amount) || payOrder.getAmount() == null) { + throw ResponseException.buildText("ERROR"); + } + try { + if (Long.parseLong(amount) != payOrder.getAmount()) { + log.warn("星驿付支付通知金额不匹配,payOrderId={}, upstream={}, local={}", + maskOrderNo(payOrder.getPayOrderId()), + amount, payOrder.getAmount()); + throw ResponseException.buildText("ERROR"); + } + } catch (NumberFormatException e) { + throw ResponseException.buildText("ERROR"); + } + } + + private JSONObject toJsonObject(Object params) { + if (params instanceof JSONObject) { + return (JSONObject) params; + } + if (params instanceof Map) { + return new JSONObject((Map) params); + } + throw ResponseException.buildText("ERROR"); + } + + private JSONObject originWithExtension(JSONObject params) { + JSONObject origin = JSONObject.parseObject(params.toJSONString()); + JSONObject extension = new JSONObject(); + putIfPresent(extension, "tOrderNo", + firstNonBlank(params, "T_ORDER_NO", "tOrderNo", "t_order_no")); + putIfPresent(extension, "threeOrderNo", + firstNonBlank(params, "THREE_ORDER_NO", "threeOrderNo", "three_order_no")); + if (!extension.isEmpty()) { + origin.put("starposExtension", extension); + } + return origin; + } + + private void putIfPresent(JSONObject target, String key, String value) { + if (StringUtils.isNotBlank(value)) { + target.put(key, value); + } + } + + private String firstNonBlank(JSONObject params, String... keys) { + for (String key : keys) { + String value = params.getString(key); + if (StringUtils.isNotBlank(value)) { + return value; + } + } + return null; + } + + private ResponseEntity successResponse() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HttpHeaders.CONTENT_TYPE, "application/json; charset=UTF-8"); + return new ResponseEntity<>( + "{\"rspCod\":\"\",\"rspMsg\":\"success\"}", + headers, + HttpStatus.OK + ); + } + + private String maskOrderNo(String orderNo) { + if (StringUtils.isBlank(orderNo) || orderNo.length() <= 4) { + return orderNo; + } + return orderNo.substring(0, 2) + "***" + + orderNo.substring(orderNo.length() - 2); + } +} 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); + } + } +} From f9ce5a5e8e2090e23a87bb7052cf3696d39eb824 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 04:20:02 +0800 Subject: [PATCH 14/27] feat: register starpos interface and payway --- docs/sql/init.sql | 33 +++++++++++++++++++++++++++++++++ docs/sql/patch.sql | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/docs/sql/init.sql b/docs/sql/init.sql index 52202a4d4..6d843d964 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -732,6 +732,11 @@ INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_BAR', '银联二维码( INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_B2B', '银联企业网银支付'); INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_PC', '银联网关支付'); INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_JSAPI', '银联Js支付'); +INSERT INTO t_pay_way (way_code, way_name) +SELECT 'STARPOS_QR', '星驿付聚合收银台' +WHERE NOT EXISTS ( + SELECT 1 FROM t_pay_way WHERE way_code = 'STARPOS_QR' +); -- 初始化支付接口定义 @@ -776,3 +781,31 @@ VALUES ('plspay', '计全付', 1, 0, 1, '[{"wayCode": "ALI_APP"}, {"wayCode": "ALI_BAR"}, {"wayCode": "ALI_JSAPI"}, {"wayCode": "ALI_LITE"}, {"wayCode": "ALI_PC"}, {"wayCode": "ALI_QR"}, {"wayCode": "ALI_WAP"}, {"wayCode": "WX_APP"}, {"wayCode": "WX_BAR"}, {"wayCode": "WX_H5"}, {"wayCode": "WX_JSAPI"}, {"wayCode": "WX_LITE"}, {"wayCode": "WX_NATIVE"}]', 'http://jeequan.oss-cn-beijing.aliyuncs.com/jeepay/img/plspay.svg', '#0CACFF', 1, '计全付'); +-- 星驿付普通商户通道 +INSERT INTO t_pay_interface_define ( + if_code, if_name, is_mch_mode, is_isv_mode, config_page_type, + isv_params, isvsub_mch_params, normal_mch_params, way_codes, + icon, bg_color, state, remark +) +SELECT + 'starpos', + '星驿付', + 1, + 0, + 1, + NULL, + NULL, + '[{"name":"environment","desc":"环境配置","type":"radio","verify":"required","values":"test,uat,prod","titles":"测试环境,UAT环境,生产环境"},{"name":"agetId","desc":"代理商编号","type":"text","verify":"required"},{"name":"custId","desc":"商户编号","type":"text","verify":"required"},{"name":"publicKey","desc":"星驿付公钥","type":"textarea","verify":"required","star":"1"},{"name":"version","desc":"接口版本","type":"text","verify":"required"}]', + '[{"wayCode":"STARPOS_QR"}]', + NULL, + '#3B82F6', + 1, + '星驿付官方通道' +WHERE NOT EXISTS ( + SELECT 1 FROM t_pay_interface_define WHERE if_code = 'starpos' +); + +UPDATE t_pay_interface_define +SET way_codes = JSON_ARRAY_APPEND(way_codes, '$', JSON_OBJECT('wayCode', 'STARPOS_QR')) +WHERE if_code = 'starpos' + AND JSON_CONTAINS(way_codes, '{"wayCode":"STARPOS_QR"}') = 0; diff --git a/docs/sql/patch.sql b/docs/sql/patch.sql index ead5f0c19..52d654a86 100644 --- a/docs/sql/patch.sql +++ b/docs/sql/patch.sql @@ -303,3 +303,42 @@ alter table t_transfer_order add column `channel_res_data` TEXT DEFAULT NULL COM ## -- ++++ [v3.1.0] ===> NEXT + + +## -- ++++ [20260726] ===> STARPOS + +-- 星驿付聚合收银台支付方式 +INSERT INTO t_pay_way (way_code, way_name) +SELECT 'STARPOS_QR', '星驿付聚合收银台' +WHERE NOT EXISTS ( + SELECT 1 FROM t_pay_way WHERE way_code = 'STARPOS_QR' +); + +-- 星驿付普通商户通道 +INSERT INTO t_pay_interface_define ( + if_code, if_name, is_mch_mode, is_isv_mode, config_page_type, + isv_params, isvsub_mch_params, normal_mch_params, way_codes, + icon, bg_color, state, remark +) +SELECT + 'starpos', + '星驿付', + 1, + 0, + 1, + NULL, + NULL, + '[{"name":"environment","desc":"环境配置","type":"radio","verify":"required","values":"test,uat,prod","titles":"测试环境,UAT环境,生产环境"},{"name":"agetId","desc":"代理商编号","type":"text","verify":"required"},{"name":"custId","desc":"商户编号","type":"text","verify":"required"},{"name":"publicKey","desc":"星驿付公钥","type":"textarea","verify":"required","star":"1"},{"name":"version","desc":"接口版本","type":"text","verify":"required"}]', + '[{"wayCode":"STARPOS_QR"}]', + NULL, + '#3B82F6', + 1, + '星驿付官方通道' +WHERE NOT EXISTS ( + SELECT 1 FROM t_pay_interface_define WHERE if_code = 'starpos' +); + +UPDATE t_pay_interface_define +SET way_codes = JSON_ARRAY_APPEND(way_codes, '$', JSON_OBJECT('wayCode', 'STARPOS_QR')) +WHERE if_code = 'starpos' + AND JSON_CONTAINS(way_codes, '{"wayCode":"STARPOS_QR"}') = 0; From 0719decb0d185374213bf1c7241ba4c25596a9a0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 04:26:21 +0800 Subject: [PATCH 15/27] fix: harden starpos sql idempotency --- docs/sql/init.sql | 23 ++++++++++++----------- docs/sql/patch.sql | 23 ++++++++++++----------- 2 files changed, 24 insertions(+), 22 deletions(-) diff --git a/docs/sql/init.sql b/docs/sql/init.sql index 6d843d964..68bd0d136 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -732,11 +732,14 @@ INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_BAR', '银联二维码( INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_B2B', '银联企业网银支付'); INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_PC', '银联网关支付'); INSERT INTO t_pay_way (way_code, way_name) VALUES ('UP_JSAPI', '银联Js支付'); -INSERT INTO t_pay_way (way_code, way_name) -SELECT 'STARPOS_QR', '星驿付聚合收银台' +INSERT INTO t_pay_way (way_code, way_name, created_at, updated_at) +SELECT 'STARPOS_QR', '星驿付聚合收银台', + '2026-07-26 00:00:00.000', + '2026-07-26 00:00:00.000' WHERE NOT EXISTS ( SELECT 1 FROM t_pay_way WHERE way_code = 'STARPOS_QR' -); +) +ON DUPLICATE KEY UPDATE way_code = VALUES(way_code); -- 初始化支付接口定义 @@ -785,7 +788,7 @@ VALUES ('plspay', '计全付', 1, 0, 1, INSERT INTO t_pay_interface_define ( if_code, if_name, is_mch_mode, is_isv_mode, config_page_type, isv_params, isvsub_mch_params, normal_mch_params, way_codes, - icon, bg_color, state, remark + icon, bg_color, state, remark, created_at, updated_at ) SELECT 'starpos', @@ -800,12 +803,10 @@ SELECT NULL, '#3B82F6', 1, - '星驿付官方通道' + '星驿付官方通道', + '2026-07-26 00:00:00.000', + '2026-07-26 00:00:00.000' WHERE NOT EXISTS ( SELECT 1 FROM t_pay_interface_define WHERE if_code = 'starpos' -); - -UPDATE t_pay_interface_define -SET way_codes = JSON_ARRAY_APPEND(way_codes, '$', JSON_OBJECT('wayCode', 'STARPOS_QR')) -WHERE if_code = 'starpos' - AND JSON_CONTAINS(way_codes, '{"wayCode":"STARPOS_QR"}') = 0; +) +ON DUPLICATE KEY UPDATE if_code = VALUES(if_code); diff --git a/docs/sql/patch.sql b/docs/sql/patch.sql index 52d654a86..372776a92 100644 --- a/docs/sql/patch.sql +++ b/docs/sql/patch.sql @@ -308,17 +308,20 @@ alter table t_transfer_order add column `channel_res_data` TEXT DEFAULT NULL COM ## -- ++++ [20260726] ===> STARPOS -- 星驿付聚合收银台支付方式 -INSERT INTO t_pay_way (way_code, way_name) -SELECT 'STARPOS_QR', '星驿付聚合收银台' +INSERT INTO t_pay_way (way_code, way_name, created_at, updated_at) +SELECT 'STARPOS_QR', '星驿付聚合收银台', + '2026-07-26 00:00:00.000', + '2026-07-26 00:00:00.000' WHERE NOT EXISTS ( SELECT 1 FROM t_pay_way WHERE way_code = 'STARPOS_QR' -); +) +ON DUPLICATE KEY UPDATE way_code = VALUES(way_code); -- 星驿付普通商户通道 INSERT INTO t_pay_interface_define ( if_code, if_name, is_mch_mode, is_isv_mode, config_page_type, isv_params, isvsub_mch_params, normal_mch_params, way_codes, - icon, bg_color, state, remark + icon, bg_color, state, remark, created_at, updated_at ) SELECT 'starpos', @@ -333,12 +336,10 @@ SELECT NULL, '#3B82F6', 1, - '星驿付官方通道' + '星驿付官方通道', + '2026-07-26 00:00:00.000', + '2026-07-26 00:00:00.000' WHERE NOT EXISTS ( SELECT 1 FROM t_pay_interface_define WHERE if_code = 'starpos' -); - -UPDATE t_pay_interface_define -SET way_codes = JSON_ARRAY_APPEND(way_codes, '$', JSON_OBJECT('wayCode', 'STARPOS_QR')) -WHERE if_code = 'starpos' - AND JSON_CONTAINS(way_codes, '{"wayCode":"STARPOS_QR"}') = 0; +) +ON DUPLICATE KEY UPDATE if_code = VALUES(if_code); From 4ca0ea990374f558291a40534f4765f76eccb66d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 09:40:10 +0800 Subject: [PATCH 16/27] feat: add epay canonical signing --- .../pay/compat/epay/EpayCanonicalizer.java | 33 +++++++ .../pay/compat/epay/EpayCredential.java | 4 + .../compat/epay/EpayCredentialResolver.java | 5 ++ .../pay/compat/epay/EpayProtocolVersion.java | 5 ++ .../jeepay/pay/compat/epay/EpaySignType.java | 5 ++ .../jeepay/pay/compat/epay/EpaySigner.java | 82 +++++++++++++++++ .../pay/compat/epay/EpaySignerTest.java | 89 +++++++++++++++++++ 7 files changed, 223 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCanonicalizer.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredential.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayProtocolVersion.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySignType.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpaySignerTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCanonicalizer.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCanonicalizer.java new file mode 100644 index 000000000..a0c0566aa --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCanonicalizer.java @@ -0,0 +1,33 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +public final class EpayCanonicalizer { + + private EpayCanonicalizer() { + } + + public static String canonical(Map fields) { + TreeMap sorted = new TreeMap<>(); + if (fields == null) { + return ""; + } + for (Map.Entry entry : fields.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (key == null || "sign".equals(key) || "sign_type".equals(key) || value == null) { + continue; + } + String text = String.valueOf(value); + if (text.isEmpty()) { + continue; + } + sorted.put(key, text); + } + return sorted.entrySet().stream() + .map(entry -> entry.getKey() + "=" + entry.getValue()) + .collect(Collectors.joining("&")); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredential.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredential.java new file mode 100644 index 000000000..756e36948 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredential.java @@ -0,0 +1,4 @@ +package com.jeequan.jeepay.pay.compat.epay; + +public record EpayCredential(String merchantKey, String merchantPublicKey, String platformPrivateKey) { +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java new file mode 100644 index 000000000..83471221d --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java @@ -0,0 +1,5 @@ +package com.jeequan.jeepay.pay.compat.epay; + +public interface EpayCredentialResolver { + EpayCredential resolve(String merchantId, String appId, EpayProtocolVersion version); +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayProtocolVersion.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayProtocolVersion.java new file mode 100644 index 000000000..af1d12141 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayProtocolVersion.java @@ -0,0 +1,5 @@ +package com.jeequan.jeepay.pay.compat.epay; + +public enum EpayProtocolVersion { + V1, V2 +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySignType.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySignType.java new file mode 100644 index 000000000..8427bfef6 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySignType.java @@ -0,0 +1,5 @@ +package com.jeequan.jeepay.pay.compat.epay; + +public enum EpaySignType { + MD5, RSA +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java new file mode 100644 index 000000000..6cb746b0c --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java @@ -0,0 +1,82 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Map; + +public final class EpaySigner { + + private EpaySigner() { + } + + public static String signMd5(Map fields, String merchantKey) { + byte[] digest = md5Bytes(EpayCanonicalizer.canonical(fields) + merchantKey); + return HexFormat.of().formatHex(digest); + } + + public static boolean verifyMd5(Map fields, String merchantKey, String signature) { + if (signature == null) { + return false; + } + byte[] expected = signMd5(fields, merchantKey).getBytes(StandardCharsets.UTF_8); + byte[] actual = signature.toLowerCase().getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(expected, actual); + } + + public static String signRsa(Map fields, String privateKeyPem) { + try { + Signature signer = Signature.getInstance("SHA256withRSA"); + signer.initSign(privateKey(privateKeyPem)); + signer.update(EpayCanonicalizer.canonical(fields).getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(signer.sign()); + } catch (Exception e) { + throw new IllegalArgumentException("RSA签名失败", e); + } + } + + public static boolean verifyRsa(Map fields, String publicKeyPem, String signature) { + try { + Signature verifier = Signature.getInstance("SHA256withRSA"); + verifier.initVerify(publicKey(publicKeyPem)); + verifier.update(EpayCanonicalizer.canonical(fields).getBytes(StandardCharsets.UTF_8)); + return verifier.verify(Base64.getDecoder().decode(signature)); + } catch (Exception e) { + return false; + } + } + + private static byte[] md5Bytes(String text) { + try { + MessageDigest digest = MessageDigest.getInstance("MD5"); + return digest.digest(text.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalArgumentException("MD5签名失败", e); + } + } + + private static PrivateKey privateKey(String pem) throws Exception { + return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der(pem))); + } + + private static PublicKey publicKey(String pem) throws Exception { + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(der(pem))); + } + + private static byte[] der(String pem) { + String base64 = pem + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + return Base64.getDecoder().decode(base64); + } +} 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..9cd826fcb --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpaySignerTest.java @@ -0,0 +1,89 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import org.junit.jupiter.api.Test; + +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +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 rsaRoundTripUsesSha256Pkcs1v15() 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(); + } + + private static KeyPair keyPair() throws Exception { + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(PRIVATE_KEY_DER))); + PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(PUBLIC_KEY_DER))); + return new KeyPair(publicKey, privateKey); + } + + private static String pem(PrivateKey privateKey) { + return "-----BEGIN PRIVATE KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded()) + "\n-----END PRIVATE KEY-----"; + } + + private static String pem(PublicKey publicKey) { + return "-----BEGIN PUBLIC KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(publicKey.getEncoded()) + "\n-----END PUBLIC KEY-----"; + } + + private static final String PRIVATE_KEY_DER = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKGcBgREGlRrjvAr" + + "RoU2fBdgKQog3OeCE1Huf9uXSWV1+yw+6dR5Tc+b8PnTclZ5Il0VwbIWEX9zXGVm" + + "iM/cOU7EIBsN4u0K5ldKcodoNzX7TVfoo52OWOUCEVdcCO/EYrNNgJGGqLWOEdC1" + + "4FooBEZ2k+9qA40yjnUVaCYHFQDBAgMBAAECgYAxc6LLr2M3LeqBR1y+6psyM/Sa" + + "6s9t7/mleouZUEPfTijbsyyuHahXvOoSKEe2ej6vqTaqHeKg01YUmTmIEWVfXlz4" + + "BmtBtS1kf8WIImhinpwSfRX1mXyX+77PNJDoq405TIJ/WEa4zuiO/EYasEB4HW0H" + + "iA7EIcY0cKUJFHaCgQJBAMvkd8OfRZN2UEzUfzk7QfIV2161GFbtViXP1C5pM/17" + + "AhVef8a3Y2KfpckS4XsqsmmLJddp6joR1dbdLgVBPbkCQQDK6Tf3HmcY4cxBHv2W" + + "wudcIgA2l7S8o4dmplKMrLaJh5umH57GBKjgwAntoZjDYQqeV7ETEZRCAxDfSwrh" + + "iR9JAkAGdjYpd/m/g6dl1/I0QSGE55ZtOPYNgzYQurZxxhnEtcpKHRWVmahHruGV" + + "LskAm0jOOX+4hP3MW1ZYmefkeL3ZAkEAl0xtCZum3jvHlKsqBZdQ8jb8F7jo8Fuz" + + "I4xTM0e5WDVAjw820Yo57lPjU0hSYyThyQ20IHbUKKCmnQkUlPUs2QJAEdfBeftC" + + "MMOCS8XXDTVgudh8anjLhPt+DzsbjJzu4A5iZHyqjU0dTmhjHdbqHiP1TX9sT1x+" + + "nNwvrHRBmc9hfA=="; + + private static final String PUBLIC_KEY_DER = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQChnAYERBpUa47wK0aFNnwXYCkK" + + "INznghNR7n/bl0lldfssPunUeU3Pm/D503JWeSJdFcGyFhF/c1xlZojP3DlOxCAb" + + "DeLtCuZXSnKHaDc1+01X6KOdjljlAhFXXAjvxGKzTYCRhqi1jhHQteBaKARGdpPv" + + "agONMo51FWgmBxUAwQIDAQAB"; +} From 5fe71697abd50e1d6962d349694c03b75629080e Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 09:45:32 +0800 Subject: [PATCH 17/27] test: generate epay rsa keys at runtime --- .../pay/compat/epay/EpaySignerTest.java | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) 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 index 9cd826fcb..c705e69bd 100644 --- 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 @@ -2,12 +2,11 @@ import org.junit.jupiter.api.Test; -import java.security.KeyFactory; +import java.nio.charset.StandardCharsets; import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.PrivateKey; import java.security.PublicKey; -import java.security.spec.PKCS8EncodedKeySpec; -import java.security.spec.X509EncodedKeySpec; import java.util.Base64; import java.util.LinkedHashMap; import java.util.Map; @@ -39,7 +38,7 @@ void md5UsesLowercaseHexOfCanonicalPlusKey() { } @Test - void rsaRoundTripUsesSha256Pkcs1v15() throws Exception { + void rsaRoundTripUsesSha256Pkcs1v15WithPemKeys() throws Exception { KeyPair pair = keyPair(); Map fields = Map.of("money", "1.00", "pid", "1001"); @@ -52,38 +51,46 @@ void rsaRoundTripUsesSha256Pkcs1v15() throws Exception { 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 { - KeyFactory keyFactory = KeyFactory.getInstance("RSA"); - PrivateKey privateKey = keyFactory.generatePrivate(new PKCS8EncodedKeySpec(Base64.getDecoder().decode(PRIVATE_KEY_DER))); - PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(PUBLIC_KEY_DER))); - return new KeyPair(publicKey, privateKey); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(1024); + return generator.generateKeyPair(); } private static String pem(PrivateKey privateKey) { - return "-----BEGIN PRIVATE KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(privateKey.getEncoded()) + "\n-----END PRIVATE KEY-----"; + return "-----BEGIN PRIVATE KEY-----\n" + wrapBase64(privateKey.getEncoded()) + "\n-----END PRIVATE KEY-----"; } private static String pem(PublicKey publicKey) { - return "-----BEGIN PUBLIC KEY-----\n" + Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(publicKey.getEncoded()) + "\n-----END PUBLIC KEY-----"; + 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 final String PRIVATE_KEY_DER = "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAKGcBgREGlRrjvAr" - + "RoU2fBdgKQog3OeCE1Huf9uXSWV1+yw+6dR5Tc+b8PnTclZ5Il0VwbIWEX9zXGVm" - + "iM/cOU7EIBsN4u0K5ldKcodoNzX7TVfoo52OWOUCEVdcCO/EYrNNgJGGqLWOEdC1" - + "4FooBEZ2k+9qA40yjnUVaCYHFQDBAgMBAAECgYAxc6LLr2M3LeqBR1y+6psyM/Sa" - + "6s9t7/mleouZUEPfTijbsyyuHahXvOoSKEe2ej6vqTaqHeKg01YUmTmIEWVfXlz4" - + "BmtBtS1kf8WIImhinpwSfRX1mXyX+77PNJDoq405TIJ/WEa4zuiO/EYasEB4HW0H" - + "iA7EIcY0cKUJFHaCgQJBAMvkd8OfRZN2UEzUfzk7QfIV2161GFbtViXP1C5pM/17" - + "AhVef8a3Y2KfpckS4XsqsmmLJddp6joR1dbdLgVBPbkCQQDK6Tf3HmcY4cxBHv2W" - + "wudcIgA2l7S8o4dmplKMrLaJh5umH57GBKjgwAntoZjDYQqeV7ETEZRCAxDfSwrh" - + "iR9JAkAGdjYpd/m/g6dl1/I0QSGE55ZtOPYNgzYQurZxxhnEtcpKHRWVmahHruGV" - + "LskAm0jOOX+4hP3MW1ZYmefkeL3ZAkEAl0xtCZum3jvHlKsqBZdQ8jb8F7jo8Fuz" - + "I4xTM0e5WDVAjw820Yo57lPjU0hSYyThyQ20IHbUKKCmnQkUlPUs2QJAEdfBeftC" - + "MMOCS8XXDTVgudh8anjLhPt+DzsbjJzu4A5iZHyqjU0dTmhjHdbqHiP1TX9sT1x+" - + "nNwvrHRBmc9hfA=="; - - private static final String PUBLIC_KEY_DER = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQChnAYERBpUa47wK0aFNnwXYCkK" - + "INznghNR7n/bl0lldfssPunUeU3Pm/D503JWeSJdFcGyFhF/c1xlZojP3DlOxCAb" - + "DeLtCuZXSnKHaDc1+01X6KOdjljlAhFXXAjvxGKzTYCRhqi1jhHQteBaKARGdpPv" - + "agONMo51FWgmBxUAwQIDAQAB"; + 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); + } } From 37ebb34d61193bebdb5e4eb88699ae37319de576 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 10:23:05 +0800 Subject: [PATCH 18/27] feat: normalize epay requests and metadata --- .../pay/compat/epay/EpayCompatMetadata.java | 86 +++++++ .../pay/compat/epay/EpayCompatProperties.java | 85 +++++++ .../pay/compat/epay/EpayCreateCommand.java | 17 ++ .../compat/epay/EpayRequestNormalizer.java | 115 ++++++++++ .../compat/epay/EpayCompatMetadataTest.java | 72 ++++++ .../epay/EpayRequestNormalizerTest.java | 211 ++++++++++++++++++ 6 files changed, 586 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadata.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadataTest.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadata.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadata.java new file mode 100644 index 000000000..56c821953 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadata.java @@ -0,0 +1,86 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import java.util.Optional; + +public final class EpayCompatMetadata { + + static final String RESERVED_KEY = "__epay_compat"; + + private EpayCompatMetadata() { + } + + public static String encode(EpayMetadataValue value) { + JSONObject compat = new JSONObject(true); + compat.put("version", value.version()); + compat.put("pid", value.pid()); + compat.put("app_id", value.appId()); + compat.put("type", value.type()); + compat.put("out_trade_no", value.outTradeNo()); + compat.put("notify_url", value.notifyUrl()); + compat.put("return_url", value.returnUrl()); + compat.put("name", value.name()); + compat.put("money", value.money()); + compat.put("clientip", value.clientIp()); + compat.put("device", value.device()); + compat.put("trade_no", value.tradeNo()); + + JSONObject root = new JSONObject(true); + root.put(RESERVED_KEY, compat); + return root.toJSONString(); + } + + public static Optional decode(String channelExtra) { + try { + Object parsed = JSON.parse(channelExtra); + if (!(parsed instanceof JSONObject root)) { + return Optional.empty(); + } + Object compatValue = root.get(RESERVED_KEY); + if (!(compatValue instanceof JSONObject compat)) { + return Optional.empty(); + } + EpayMetadataValue value = new EpayMetadataValue( + required(compat, "version"), + required(compat, "pid"), + compat.getString("app_id"), + compat.getString("type"), + required(compat, "out_trade_no"), + required(compat, "notify_url"), + required(compat, "return_url"), + compat.getString("name"), + required(compat, "money"), + compat.getString("clientip"), + compat.getString("device"), + compat.getString("trade_no")); + return Optional.of(value); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static String required(JSONObject object, String key) { + String value = object.getString(key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(key + " is required"); + } + return value; + } +} + +record EpayMetadataValue( + String version, + String pid, + String appId, + String type, + String outTradeNo, + String notifyUrl, + String returnUrl, + String name, + String money, + String clientIp, + String device, + String tradeNo) { +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java new file mode 100644 index 000000000..9d26dd561 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java @@ -0,0 +1,85 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@ConfigurationProperties(prefix = "epay.compat") +public class EpayCompatProperties { + + private String defaultAppId = ""; + private Map appIds = new LinkedHashMap<>(); + private Map> credentials = new LinkedHashMap<>(); + private long clockSkewSeconds = 300; + + public String resolveAppId(String pid) { + String mapped = appIds.get(pid); + if (hasText(mapped)) { + return mapped.trim(); + } + if (hasText(defaultAppId)) { + return defaultAppId.trim(); + } + throw new IllegalStateException("Missing epay.compat.default-app-id or epay.compat.app-ids mapping for pid " + pid); + } + + public String getDefaultAppId() { + return defaultAppId; + } + + public void setDefaultAppId(String defaultAppId) { + this.defaultAppId = defaultAppId; + } + + public Map getAppIds() { + return appIds; + } + + public void setAppIds(Map appIds) { + this.appIds = appIds == null ? new LinkedHashMap<>() : appIds; + } + + public Map> getCredentials() { + return credentials; + } + + public void setCredentials(Map> credentials) { + this.credentials = credentials == null ? new LinkedHashMap<>() : credentials; + } + + public long getClockSkewSeconds() { + return clockSkewSeconds; + } + + public void setClockSkewSeconds(long clockSkewSeconds) { + this.clockSkewSeconds = clockSkewSeconds; + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } + + public static class CredentialProperties { + private String merchantPublicKey; + private String platformPrivateKey; + + public String getMerchantPublicKey() { + return merchantPublicKey; + } + + public void setMerchantPublicKey(String merchantPublicKey) { + this.merchantPublicKey = merchantPublicKey; + } + + public String getPlatformPrivateKey() { + return platformPrivateKey; + } + + public void setPlatformPrivateKey(String platformPrivateKey) { + this.platformPrivateKey = platformPrivateKey; + } + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java new file mode 100644 index 000000000..dcf49ea0b --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java @@ -0,0 +1,17 @@ +package com.jeequan.jeepay.pay.compat.epay; + +public record EpayCreateCommand( + String merchantId, + String appId, + String orderNo, + String paymentType, + String subject, + long amountFen, + String notifyUrl, + String returnUrl, + String clientIp, + String device, + String version, + String method, + EpayCredential credential) { +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java new file mode 100644 index 000000000..6ff25bee4 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java @@ -0,0 +1,115 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.constants.CS; +import com.jeequan.jeepay.core.utils.StringKit; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Clock; +import java.time.Instant; +import java.util.Map; + +public final class EpayRequestNormalizer { + + private final Clock clock; + private final EpayCompatProperties properties; + + public EpayRequestNormalizer(Clock clock, EpayCompatProperties properties) { + this.clock = clock == null ? Clock.systemUTC() : clock; + this.properties = properties == null ? new EpayCompatProperties() : properties; + } + + public EpayCreateCommand normalize(Map fields, + String appId, + EpayCredential credential, + EpayProtocolVersion version) { + if (fields == null) { + throw new IllegalArgumentException("fields is required"); + } + String pid = required(fields, "pid"); + String orderNo = required(fields, "out_trade_no"); + String epayType = required(fields, "type").toLowerCase(); + if (!"alipay".equals(epayType) && !"wxpay".equals(epayType)) { + throw new IllegalArgumentException("type must be alipay or wxpay"); + } + + long amountFen = amountFen(required(fields, "money")); + String notifyUrl = httpsUrl(fields, "notify_url"); + String returnUrl = httpsUrl(fields, "return_url"); + String subject = trimToNull(fields.get("name")); + if (subject == null) { + subject = orderNo; + } + EpayProtocolVersion effectiveVersion = version == null ? EpayProtocolVersion.V1 : version; + if (effectiveVersion == EpayProtocolVersion.V2) { + validateV2Timestamp(fields.get("timestamp")); + } + + return new EpayCreateCommand( + pid, + appId, + orderNo, + CS.PAY_WAY_CODE.STARPOS_QR, + subject, + amountFen, + notifyUrl, + returnUrl, + trimToNull(fields.get("clientip")), + trimToNull(fields.get("device")), + effectiveVersion == EpayProtocolVersion.V1 ? "v1" : "v2", + trimToNull(fields.get("method")), + credential); + } + + private long amountFen(String money) { + try { + BigDecimal yuan = new BigDecimal(money).setScale(2, RoundingMode.UNNECESSARY); + if (yuan.signum() <= 0) { + throw new IllegalArgumentException("money must be positive"); + } + return yuan.movePointRight(2).longValueExact(); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("money must be positive with at most two decimal places", e); + } + } + + private void validateV2Timestamp(String timestamp) { + if (trimToNull(timestamp) == null) { + throw new IllegalArgumentException("timestamp is required"); + } + long epochSeconds; + try { + epochSeconds = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("timestamp must be Unix seconds", e); + } + long now = Instant.now(clock).getEpochSecond(); + if (Math.abs(now - epochSeconds) > properties.getClockSkewSeconds()) { + throw new IllegalArgumentException("timestamp is outside allowed clock skew"); + } + } + + private static String httpsUrl(Map fields, String key) { + String value = required(fields, key); + if (!StringKit.isAvailableUrl(value) || !value.startsWith("https://")) { + throw new IllegalArgumentException(key + " must be a valid HTTPS URL"); + } + return value; + } + + private static String required(Map fields, String key) { + String value = trimToNull(fields.get(key)); + if (value == null) { + throw new IllegalArgumentException(key + " is required"); + } + return value; + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} 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..716342353 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatMetadataTest.java @@ -0,0 +1,72 @@ +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(); + } + + 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/EpayRequestNormalizerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java new file mode 100644 index 000000000..8c616b814 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java @@ -0,0 +1,211 @@ +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 rejectsNonHttpsNotifyAndReturnUrls() { + assertThatThrownBy(() -> 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", "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 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()), + "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())), + "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"), + "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"); + } + + 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); + } +} From 2aee975d93797f5d8483d1a371a6f73571c40b95 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 11:25:41 +0800 Subject: [PATCH 19/27] =?UTF-8?q?feat(payment):=20=E6=96=B0=E5=A2=9EEPAY?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E4=B8=8B=E5=8D=95=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pay/channel/starpos/StarposConfig.java | 24 +++ .../epay/DefaultEpayCredentialResolver.java | 54 +++++ .../pay/compat/epay/EpayCompatController.java | 116 ++++++++++ .../compat/epay/EpayCompatOrderService.java | 156 ++++++++++++++ .../pay/compat/epay/EpayCompatResponse.java | 39 ++++ .../pay/compat/epay/EpayCreateCommand.java | 21 ++ .../pay/compat/epay/EpayCreateResult.java | 12 ++ .../compat/epay/EpayRequestNormalizer.java | 1 + .../jeepay/pay/compat/epay/EpayV1Adapter.java | 42 ++++ .../jeepay/pay/compat/epay/EpayV2Adapter.java | 41 ++++ .../compat/epay/EpayCompatControllerTest.java | 203 ++++++++++++++++++ .../epay/EpayCompatOrderServiceTest.java | 182 ++++++++++++++++ 12 files changed, 891 insertions(+) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatResponse.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateResult.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV1Adapter.java create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java index a7226717c..0f7938a99 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/channel/starpos/StarposConfig.java @@ -3,6 +3,7 @@ import com.jeequan.jeepay.core.model.params.starpos.StarposNormalMchParams; import org.apache.commons.lang3.StringUtils; +import java.net.URI; import java.util.Map; /** @@ -40,6 +41,29 @@ public static String resolveBaseUrl(StarposNormalMchParams params) { return baseUrl; } + /** + * 校验收银台地址是否满足任一已知星驿付环境的地址约束。 + */ + public static boolean isKnownCashierUrl(String cashierUrl) { + if (StringUtils.isBlank(cashierUrl)) { + return false; + } + try { + URI cashierUri = URI.create(cashierUrl); + return "https".equalsIgnoreCase(cashierUri.getScheme()) + && cashierUri.getHost() != null + && cashierUri.getUserInfo() == null + && cashierUri.getPort() == -1 + && cashierUri.getQuery() == null + && cashierUri.getFragment() == null + && BASE_URLS.values().stream() + .map(URI::create) + .anyMatch(baseUri -> baseUri.getHost().equalsIgnoreCase(cashierUri.getHost())); + } catch (IllegalArgumentException e) { + return false; + } + } + private static void requireNotBlank(String value, String field) { if (StringUtils.isBlank(value)) { throw new IllegalArgumentException(field + " 不能为空"); diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java new file mode 100644 index 000000000..a893cdf4e --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java @@ -0,0 +1,54 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.entity.MchApp; +import com.jeequan.jeepay.pay.service.ConfigContextQueryService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class DefaultEpayCredentialResolver implements EpayCredentialResolver { + + private final ConfigContextQueryService configContextQueryService; + private final EpayCompatProperties properties; + + public DefaultEpayCredentialResolver(ConfigContextQueryService configContextQueryService, + EpayCompatProperties properties) { + this.configContextQueryService = configContextQueryService; + this.properties = properties; + } + + @Override + public EpayCredential resolve(String merchantId, String appId, EpayProtocolVersion version) { + if (version == EpayProtocolVersion.V1) { + MchApp mchApp = configContextQueryService.queryMchApp(merchantId, appId); + if (mchApp == null || !hasText(mchApp.getAppSecret())) { + throw new IllegalStateException("商户应用 V1 密钥未配置"); + } + return new EpayCredential(mchApp.getAppSecret(), null, null); + } + + EpayCompatProperties.CredentialProperties credential = credentialProperties(merchantId, appId); + if (!hasText(credential.getMerchantPublicKey()) || !hasText(credential.getPlatformPrivateKey())) { + throw new IllegalStateException("商户应用 V2 RSA 凭据未配置"); + } + return new EpayCredential( + null, + credential.getMerchantPublicKey(), + credential.getPlatformPrivateKey() + ); + } + + private EpayCompatProperties.CredentialProperties credentialProperties(String merchantId, String appId) { + Map> all = properties.getCredentials(); + Map byApp = all.get(merchantId); + if (byApp == null || byApp.get(appId) == null) { + throw new IllegalStateException("商户应用 V2 RSA 凭据未配置"); + } + return byApp.get(appId); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java new file mode 100644 index 000000000..7d3affd4e --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -0,0 +1,116 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.model.ApiRes; +import com.jeequan.jeepay.pay.ctrl.payorder.AbstractPayOrderController; +import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Clock; +import java.util.Map; + +@RestController +public class EpayCompatController extends AbstractPayOrderController { + + private final EpayCompatProperties properties; + private final EpayCredentialResolver credentialResolver; + private final EpayRequestNormalizer requestNormalizer; + private final EpayCompatOrderService orderService; + private final EpayV1Adapter v1Adapter = new EpayV1Adapter(); + private final EpayV2Adapter v2Adapter = new EpayV2Adapter(); + + @Autowired + public EpayCompatController(EpayCompatProperties properties, + EpayCredentialResolver credentialResolver, + EpayCompatOrderService orderService) { + this(properties, credentialResolver, new EpayRequestNormalizer(Clock.systemUTC(), properties), orderService); + } + + public EpayCompatController(EpayCompatProperties properties, + EpayCredentialResolver credentialResolver, + EpayRequestNormalizer requestNormalizer, + EpayCompatOrderService orderService) { + this.properties = properties; + this.credentialResolver = credentialResolver; + this.requestNormalizer = requestNormalizer; + this.orderService = orderService; + } + + @PostMapping("/compat/epay/mapi.php") + public ResponseEntity mapi(@RequestParam Map fields) { + return process(fields).asJson(); + } + + @RequestMapping(value = "/compat/epay/submit.php", method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity submit(@RequestParam Map fields) { + return process(fields).asSubmitResponse(); + } + + private EpayCompatResponse process(Map fields) { + EpayProtocolVersion version = protocolVersion(fields); + EpayCredential credential = null; + try { + String merchantId = required(fields, "pid"); + String appId = properties.resolveAppId(merchantId); + credential = credentialResolver.resolve(merchantId, appId, version); + if (!verify(fields, credential, version)) { + return failure(version, "验签失败", credential); + } + + EpayCreateCommand command = requestNormalizer.normalize(fields, appId, credential, version); + var existing = orderService.findExisting(command); + EpayCreateResult result; + if (existing != null) { + result = orderService.fromExisting(existing, command); + } else { + UnifiedOrderRQ request = orderService.toUnifiedOrderRequest(command); + ApiRes apiRes = unifiedOrder(request.getWayCode(), request); + result = orderService.fromApiResult(apiRes, command); + } + return result.success() + ? success(version, result, credential) + : failure(version, result.message(), credential); + } catch (RuntimeException e) { + return failure(version, e.getMessage(), credential); + } + } + + private EpayProtocolVersion protocolVersion(Map fields) { + return "RSA".equalsIgnoreCase(fields == null ? null : fields.get("sign_type")) + ? EpayProtocolVersion.V2 : EpayProtocolVersion.V1; + } + + private boolean verify(Map fields, EpayCredential credential, EpayProtocolVersion version) { + return version == EpayProtocolVersion.V2 + ? v2Adapter.verify(fields, credential) + : v1Adapter.verify(fields, credential); + } + + private EpayCompatResponse success(EpayProtocolVersion version, EpayCreateResult result, + EpayCredential credential) { + return version == EpayProtocolVersion.V2 + ? v2Adapter.success(result, credential) + : v1Adapter.success(result, credential); + } + + private EpayCompatResponse failure(EpayProtocolVersion version, String message, + EpayCredential credential) { + String safeMessage = message == null || message.trim().isEmpty() ? "请求处理失败" : message; + return version == EpayProtocolVersion.V2 + ? v2Adapter.failure(safeMessage, credential) + : v1Adapter.failure(safeMessage, credential); + } + + private static String required(Map fields, String key) { + String value = fields == null ? null : fields.get(key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(key + " is required"); + } + return value.trim(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java new file mode 100644 index 000000000..8f59a3de8 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java @@ -0,0 +1,156 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.jeequan.jeepay.core.constants.ApiCodeEnum; +import com.jeequan.jeepay.core.constants.CS; +import com.jeequan.jeepay.core.entity.PayOrder; +import com.jeequan.jeepay.core.model.ApiRes; +import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ; +import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRS; +import com.jeequan.jeepay.service.impl.PayOrderService; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Optional; + +@Service +public final class EpayCompatOrderService { + + private static final String PAY_DATA_TYPE = "payUrl"; + private static final String PAY_INFO_KEY = "pay_info"; + + private final PayOrderService payOrderService; + + public EpayCompatOrderService(PayOrderService payOrderService) { + this.payOrderService = payOrderService; + } + + public PayOrder findExisting(EpayCreateCommand command) { + PayOrder existing = payOrderService.queryMchOrder( + command.merchantId(), null, command.orderNo()); + if (existing == null) { + return null; + } + if (!isCompatible(existing, command)) { + throw new IllegalStateException("重复订单参数冲突:金额、支付方式、回调地址或类型不一致"); + } + return existing; + } + + public UnifiedOrderRQ toUnifiedOrderRequest(EpayCreateCommand command) { + UnifiedOrderRQ rq = new UnifiedOrderRQ(); + rq.setMchNo(command.merchantId()); + rq.setAppId(command.appId()); + rq.setMchOrderNo(command.orderNo()); + rq.setWayCode(CS.PAY_WAY_CODE.STARPOS_QR); + rq.setAmount(command.amountFen()); + rq.setCurrency("CNY"); + rq.setClientIp(command.clientIp()); + rq.setSubject(command.subject()); + rq.setBody(command.subject()); + rq.setNotifyUrl(command.notifyUrl()); + rq.setReturnUrl(command.returnUrl()); + rq.setChannelExtra(channelExtra(command, command.orderNo(), null)); + rq.setExtParam(null); + return rq; + } + + public EpayCreateResult fromApiResult(ApiRes apiRes, EpayCreateCommand command) { + if (apiRes == null || apiRes.getCode() == null || apiRes.getCode() != ApiCodeEnum.SUCCESS.getCode() + || !(apiRes.getData() instanceof UnifiedOrderRS response)) { + return new EpayCreateResult(false, messageOf(apiRes), "", "", "", null); + } + + String tradeNo = response.getPayOrderId(); + PayOrder payOrder = payOrderService.queryMchOrder(command.merchantId(), tradeNo, null); + String payInfo = response.buildPayData(); + if (payOrder != null) { + persistMetadata(payOrder, command, tradeNo, payInfo); + } + if (!hasText(payInfo)) { + String message = response.getOrderState() != null && response.getOrderState() == PayOrder.STATE_ING + ? "星驿付下单结果未知,可通过交易号查询" + : "未取得星驿付收银链接"; + return new EpayCreateResult(false, message, tradeNo, "", "", payOrder); + } + return new EpayCreateResult(true, "success", tradeNo, payInfo, "redirect", payOrder); + } + + EpayCreateResult fromExisting(PayOrder existing, EpayCreateCommand command) { + String payInfo = payInfo(existing.getChannelExtra()); + if (!hasText(payInfo)) { + return new EpayCreateResult( + false, + "原订单没有可用的收银链接,可通过交易号查询", + existing.getPayOrderId(), "", "", existing + ); + } + return new EpayCreateResult( + true, "success", existing.getPayOrderId(), payInfo, "redirect", existing + ); + } + + private boolean isCompatible(PayOrder existing, EpayCreateCommand command) { + Optional metadata = EpayCompatMetadata.decode(existing.getChannelExtra()); + return existing.getAmount() != null + && existing.getAmount() == command.amountFen() + && CS.IF_CODE.STARPOS.equals(existing.getIfCode()) + && command.paymentType().equals(existing.getWayCode()) + && command.notifyUrl().equals(existing.getNotifyUrl()) + && command.returnUrl().equals(existing.getReturnUrl()) + && metadata.map(value -> command.epayType().equals(value.type())).orElse(false); + } + + private void persistMetadata(PayOrder payOrder, EpayCreateCommand command, String tradeNo, String payInfo) { + payOrder.setChannelExtra(channelExtra(command, tradeNo, payInfo)); + payOrderService.updateById(payOrder); + } + + private String channelExtra(EpayCreateCommand command, String tradeNo, String payInfo) { + JSONObject root = JSON.parseObject(EpayCompatMetadata.encode(new EpayMetadataValue( + command.version(), + command.merchantId(), + command.appId(), + command.epayType(), + command.orderNo(), + command.notifyUrl(), + command.returnUrl(), + command.subject(), + amountYuan(command.amountFen()), + command.clientIp(), + command.device(), + tradeNo + ))); + root.put("payDataType", PAY_DATA_TYPE); + if (payInfo != null) { + root.getJSONObject(EpayCompatMetadata.RESERVED_KEY).put(PAY_INFO_KEY, payInfo); + } + return root.toJSONString(); + } + + private String payInfo(String channelExtra) { + try { + JSONObject root = JSON.parseObject(channelExtra); + JSONObject metadata = root.getJSONObject(EpayCompatMetadata.RESERVED_KEY); + return metadata == null ? null : metadata.getString(PAY_INFO_KEY); + } catch (RuntimeException e) { + return null; + } + } + + private static String amountYuan(long amountFen) { + return BigDecimal.valueOf(amountFen, 2).toPlainString(); + } + + private static String messageOf(ApiRes apiRes) { + if (apiRes == null || !hasText(apiRes.getMsg())) { + return "统一下单失败"; + } + return apiRes.getMsg(); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatResponse.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatResponse.java new file mode 100644 index 000000000..3f268c6b4 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatResponse.java @@ -0,0 +1,39 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.pay.channel.starpos.StarposConfig; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +public final class EpayCompatResponse { + + private final boolean success; + private final String body; + private final String cashierUrl; + + public EpayCompatResponse(boolean success, String body, String cashierUrl) { + this.success = success; + this.body = body; + this.cashierUrl = cashierUrl; + } + + public ResponseEntity asJson() { + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } + + public ResponseEntity asSubmitResponse() { + if (success && isValidatedHttpsCashierUrl(cashierUrl)) { + return ResponseEntity.status(HttpStatus.FOUND) + .header(HttpHeaders.LOCATION, cashierUrl) + .build(); + } + return asJson(); + } + + private static boolean isValidatedHttpsCashierUrl(String value) { + return StarposConfig.isKnownCashierUrl(value); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java index dcf49ea0b..4d2585448 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateCommand.java @@ -13,5 +13,26 @@ public record EpayCreateCommand( String device, String version, String method, + String epayType, EpayCredential credential) { + + public EpayCreateCommand( + String merchantId, + String appId, + String orderNo, + String paymentType, + String subject, + long amountFen, + String notifyUrl, + String returnUrl, + String clientIp, + String device, + String version, + String method, + EpayCredential credential) { + this( + merchantId, appId, orderNo, paymentType, subject, amountFen, + notifyUrl, returnUrl, clientIp, device, version, method, null, credential + ); + } } diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateResult.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateResult.java new file mode 100644 index 000000000..2746eabdb --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCreateResult.java @@ -0,0 +1,12 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.entity.PayOrder; + +public record EpayCreateResult( + boolean success, + String message, + String tradeNo, + String payInfo, + String payType, + PayOrder payOrder) { +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java index 6ff25bee4..d3396eec9 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java @@ -58,6 +58,7 @@ public EpayCreateCommand normalize(Map fields, trimToNull(fields.get("device")), effectiveVersion == EpayProtocolVersion.V1 ? "v1" : "v2", trimToNull(fields.get("method")), + epayType, credential); } diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV1Adapter.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV1Adapter.java new file mode 100644 index 000000000..9eca11fd2 --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV1Adapter.java @@ -0,0 +1,42 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import java.util.Map; + +public final class EpayV1Adapter { + + public boolean verify(Map fields, EpayCredential credential) { + return credential != null + && hasText(credential.merchantKey()) + && EpaySigner.verifyMd5(fields, credential.merchantKey(), fields.get("sign")); + } + + public EpayCompatResponse success(EpayCreateResult result, EpayCredential credential) { + return response(1, "success", result.tradeNo(), result.payInfo(), credential); + } + + public EpayCompatResponse failure(String message, EpayCredential credential) { + return response(0, message, "", "", credential); + } + + private EpayCompatResponse response(int code, String message, String tradeNo, 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("payurl", payInfo == null ? "" : payInfo); + body.put("qrcode", ""); + body.put("urlscheme", ""); + body.put("sign_type", "MD5"); + body.put("sign", hasText(credential == null ? null : credential.merchantKey()) + ? EpaySigner.signMd5(body, credential.merchantKey()) : ""); + return new EpayCompatResponse(code == 1, JSON.toJSONString(body), payInfo); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java new file mode 100644 index 000000000..e4eff448b --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java @@ -0,0 +1,41 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import java.util.Map; + +public final class EpayV2Adapter { + + public boolean verify(Map 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"); + body.put("sign", hasText(credential == null ? null : credential.platformPrivateKey()) + ? EpaySigner.signRsa(body, credential.platformPrivateKey()) : ""); + return new EpayCompatResponse(code == 0, JSON.toJSONString(body), payInfo); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} 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..51523d978 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java @@ -0,0 +1,203 @@ +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.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(); + } + + 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/EpayCompatOrderServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java new file mode 100644 index 000000000..a86b15f32 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderServiceTest.java @@ -0,0 +1,182 @@ +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.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 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 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(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() { + MchApp mchApp = new MchApp().setAppSecret("v1-merchant-key"); + EpayCompatProperties properties = new EpayCompatProperties(); + EpayCompatProperties.CredentialProperties rsa = new EpayCompatProperties.CredentialProperties(); + rsa.setMerchantPublicKey("merchant-public"); + rsa.setPlatformPrivateKey("platform-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", "platform-private")); + } + + 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()); + } +} From 4dc8a7daf9083a67f665cf8004cdb445ebe71cc2 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 12:02:53 +0800 Subject: [PATCH 20/27] fix(payment): harden epay compat order lifecycle --- .../epay/DefaultEpayCredentialResolver.java | 1 + .../pay/compat/epay/EpayCompatController.java | 11 +++ .../compat/epay/EpayCompatOrderService.java | 2 +- .../jeepay/pay/compat/epay/EpaySigner.java | 8 +++ .../jeepay/pay/compat/epay/EpayV2Adapter.java | 15 +++- .../payorder/AbstractPayOrderController.java | 5 +- ...actPayOrderControllerChannelExtraTest.java | 71 +++++++++++++++++++ .../compat/epay/EpayCompatControllerTest.java | 40 +++++++++++ .../epay/EpayCompatOrderServiceTest.java | 58 ++++++++++++++- 9 files changed, 204 insertions(+), 7 deletions(-) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/AbstractPayOrderControllerChannelExtraTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java index a893cdf4e..e42050f6b 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java @@ -32,6 +32,7 @@ public EpayCredential resolve(String merchantId, String appId, EpayProtocolVersi if (!hasText(credential.getMerchantPublicKey()) || !hasText(credential.getPlatformPrivateKey())) { throw new IllegalStateException("商户应用 V2 RSA 凭据未配置"); } + EpaySigner.validateRsaPrivateKey(credential.getPlatformPrivateKey()); return new EpayCredential( null, credential.getMerchantPublicKey(), diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java index 7d3affd4e..a2b26de11 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -58,6 +58,7 @@ private EpayCompatResponse process(Map fields) { String merchantId = required(fields, "pid"); String appId = properties.resolveAppId(merchantId); credential = credentialResolver.resolve(merchantId, appId, version); + validateResponseCredential(version, credential); if (!verify(fields, credential, version)) { return failure(version, "验签失败", credential); } @@ -91,6 +92,16 @@ private boolean verify(Map fields, EpayCredential credential, Ep : v1Adapter.verify(fields, credential); } + private static void validateResponseCredential(EpayProtocolVersion version, EpayCredential credential) { + if (version == EpayProtocolVersion.V2) { + if (credential == null || credential.platformPrivateKey() == null + || credential.platformPrivateKey().trim().isEmpty()) { + throw new IllegalStateException("商户应用 V2 RSA 凭据未配置"); + } + EpaySigner.validateRsaPrivateKey(credential.platformPrivateKey()); + } + } + private EpayCompatResponse success(EpayProtocolVersion version, EpayCreateResult result, EpayCredential credential) { return version == EpayProtocolVersion.V2 diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java index 8f59a3de8..efbbddee9 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java @@ -51,7 +51,7 @@ public UnifiedOrderRQ toUnifiedOrderRequest(EpayCreateCommand command) { rq.setBody(command.subject()); rq.setNotifyUrl(command.notifyUrl()); rq.setReturnUrl(command.returnUrl()); - rq.setChannelExtra(channelExtra(command, command.orderNo(), null)); + rq.setChannelExtra(channelExtra(command, null, null)); rq.setExtParam(null); return rq; } diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java index 6cb746b0c..edbd6f852 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java @@ -42,6 +42,14 @@ public static String signRsa(Map fields, String privateKeyPem) { } } + public static void validateRsaPrivateKey(String privateKeyPem) { + try { + privateKey(privateKeyPem); + } catch (Exception e) { + throw new IllegalArgumentException("RSA私钥格式错误", e); + } + } + public static boolean verifyRsa(Map fields, String publicKeyPem, String signature) { try { Signature verifier = Signature.getInstance("SHA256withRSA"); diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java index e4eff448b..69ba6e27f 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java @@ -30,11 +30,22 @@ private EpayCompatResponse response(int code, String message, String tradeNo, St body.put("pay_type", payType == null ? "" : payType); body.put("pay_info", payInfo == null ? "" : payInfo); body.put("sign_type", "RSA"); - body.put("sign", hasText(credential == null ? null : credential.platformPrivateKey()) - ? EpaySigner.signRsa(body, credential.platformPrivateKey()) : ""); + body.put("sign", sign(body, credential)); return new EpayCompatResponse(code == 0, JSON.toJSONString(body), payInfo); } + private static String sign(JSONObject body, EpayCredential credential) { + String privateKey = credential == null ? null : credential.platformPrivateKey(); + if (!hasText(privateKey)) { + return ""; + } + try { + return EpaySigner.signRsa(body, privateKey); + } catch (IllegalArgumentException e) { + return ""; + } + } + 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..a8a48100d 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 @@ -32,6 +32,7 @@ import com.jeequan.jeepay.core.utils.SpringBeansUtil; import com.jeequan.jeepay.core.utils.StringKit; import com.jeequan.jeepay.pay.channel.IPaymentService; +import com.jeequan.jeepay.pay.compat.epay.EpayCompatMetadata; import com.jeequan.jeepay.pay.ctrl.ApiController; import com.jeequan.jeepay.pay.exception.ChannelException; import com.jeequan.jeepay.pay.model.MchAppConfigContext; @@ -257,7 +258,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 (EpayCompatMetadata.decode(rq.getChannelExtra()).isPresent()) { + payOrder.setChannelExtra(rq.getChannelExtra()); + } payOrder.setChannelUser(rq.getChannelUserId()); //渠道用户标志 payOrder.setExtParam(rq.getExtParam()); //商户扩展参数 payOrder.setNotifyUrl(rq.getNotifyUrl()); //异步通知地址 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..aa98020e8 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/AbstractPayOrderControllerChannelExtraTest.java @@ -0,0 +1,71 @@ +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.pay.ctrl.payorder.AbstractPayOrderController; +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(); + } + + private static PayOrder generate(UnifiedOrderRQ request) throws Exception { + request.setAmount(1234L); + request.setClientIp("203.0.113.10"); + EpayCompatProperties properties = new EpayCompatProperties(); + EpayCompatController controller = new EpayCompatController( + properties, + mock(EpayCredentialResolver.class), + new EpayRequestNormalizer(java.time.Clock.systemUTC(), properties), + new EpayCompatOrderService(mock(PayOrderService.class))); + 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); + } +} 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 index 51523d978..eff5486f2 100644 --- 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 @@ -18,6 +18,7 @@ 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; @@ -125,6 +126,45 @@ null, pem(merchantKeys.getPublic().getEncoded(), "PUBLIC"), 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(); + } + private static TestController controller(PayOrderService payOrderService, EpayCredential credential) { EpayCompatProperties properties = new EpayCompatProperties(); properties.setDefaultAppId("APP-1001"); 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 index a86b15f32..6089b7361 100644 --- 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 @@ -14,6 +14,9 @@ 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; @@ -94,6 +97,28 @@ void unifiedRequestPersistsEpayMetadataInChannelExtraWithoutExtParam() { 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); @@ -132,12 +157,13 @@ void compatTypesExposeUnambiguousSpringWiring() throws Exception { } @Test - void credentialResolverUsesAppSecretForV1AndExternalRsaKeysForV2() { + 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("platform-private"); + 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); @@ -146,7 +172,21 @@ void credentialResolverUsesAppSecretForV1AndExternalRsaKeysForV2() { 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", "platform-private")); + .isEqualTo(new EpayCredential(null, "merchant-public", rsa.getPlatformPrivateKey())); + } + + @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) { @@ -179,4 +219,16 @@ static PayOrder compatibleOrder(EpayCreateCommand command, String tradeNo, Strin .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-----"; + } } From 9ef6f87e85ce025e73f93697022f6c048e66465a Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 12:10:48 +0800 Subject: [PATCH 21/27] fix(payment): harden epay request normalization --- .../pay/compat/epay/EpayCompatProperties.java | 3 + .../compat/epay/EpayRequestNormalizer.java | 34 ++++++++-- .../epay/EpayRequestNormalizerTest.java | 68 +++++++++++++++++-- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java index 9d26dd561..bde000f8c 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatProperties.java @@ -55,6 +55,9 @@ public long getClockSkewSeconds() { } public void setClockSkewSeconds(long clockSkewSeconds) { + if (clockSkewSeconds < 0) { + throw new IllegalArgumentException("clockSkewSeconds must be non-negative"); + } this.clockSkewSeconds = clockSkewSeconds; } diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java index d3396eec9..d5b55fc93 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java @@ -34,13 +34,14 @@ public EpayCreateCommand normalize(Map fields, } long amountFen = amountFen(required(fields, "money")); - String notifyUrl = httpsUrl(fields, "notify_url"); - String returnUrl = httpsUrl(fields, "return_url"); + EpayProtocolVersion effectiveVersion = version == null ? EpayProtocolVersion.V1 : version; + validateSignType(fields.get("sign_type"), effectiveVersion); + String notifyUrl = callbackUrl(fields, "notify_url"); + String returnUrl = callbackUrl(fields, "return_url"); String subject = trimToNull(fields.get("name")); if (subject == null) { subject = orderNo; } - EpayProtocolVersion effectiveVersion = version == null ? EpayProtocolVersion.V1 : version; if (effectiveVersion == EpayProtocolVersion.V2) { validateV2Timestamp(fields.get("timestamp")); } @@ -85,15 +86,34 @@ private void validateV2Timestamp(String timestamp) { throw new IllegalArgumentException("timestamp must be Unix seconds", e); } long now = Instant.now(clock).getEpochSecond(); - if (Math.abs(now - epochSeconds) > properties.getClockSkewSeconds()) { + final long difference; + try { + difference = Math.subtractExact(now, epochSeconds); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("timestamp is outside allowed clock skew", e); + } + if (Math.abs(difference) > properties.getClockSkewSeconds()) { throw new IllegalArgumentException("timestamp is outside allowed clock skew"); } } - private static String httpsUrl(Map fields, String key) { + private static void validateSignType(String signType, EpayProtocolVersion version) { + String normalized = trimToNull(signType); + if (version == EpayProtocolVersion.V1) { + if (normalized != null && !"MD5".equalsIgnoreCase(normalized)) { + throw new IllegalArgumentException("sign_type must be MD5 for V1"); + } + return; + } + if (!"RSA".equalsIgnoreCase(normalized)) { + throw new IllegalArgumentException("sign_type must be RSA for V2"); + } + } + + private static String callbackUrl(Map fields, String key) { String value = required(fields, key); - if (!StringKit.isAvailableUrl(value) || !value.startsWith("https://")) { - throw new IllegalArgumentException(key + " must be a valid HTTPS URL"); + if (!StringKit.isAvailableUrl(value)) { + throw new IllegalArgumentException(key + " must be a valid HTTP(S) URL"); } return value; } 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 index 8c616b814..12a7c96b3 100644 --- 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 @@ -97,10 +97,17 @@ void rejectsNonPositiveMoneyAndMissingRequiredFields() { } @Test - void rejectsNonHttpsNotifyAndReturnUrls() { + 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", "http://merchant.example/notify.php", "return_url", "https://merchant.example/return.php"), + "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( @@ -110,6 +117,23 @@ void rejectsNonHttpsNotifyAndReturnUrls() { .hasMessageContaining("return_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( @@ -120,6 +144,7 @@ void validatesV2TimestampAsUnixSecondsWithinConfiguredSkew() { "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); @@ -129,13 +154,39 @@ void validatesV2TimestampAsUnixSecondsWithinConfiguredSkew() { 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())), + "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"), + "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"); } @@ -178,6 +229,15 @@ void resolvesConfiguredAppIdWithoutGuessingFromRequestFields() { .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"); From ebbb1f31891f6566bbefb624263e52cc637b649d Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 12:20:17 +0800 Subject: [PATCH 22/27] fix(payment): harden epay compat review findings --- docs/sql/init.sql | 2 +- docs/sql/patch.sql | 4 ++ .../pay/compat/epay/EpayCompatController.java | 12 ++++ .../compat/epay/EpayCompatOrderService.java | 6 +- .../jeepay/pay/compat/epay/EpayV2Adapter.java | 28 +++++--- .../payorder/AbstractPayOrderController.java | 11 +++- ...actPayOrderControllerChannelExtraTest.java | 66 +++++++++++++++++-- .../compat/epay/EpayCompatControllerTest.java | 23 +++++++ .../compat/epay/EpayCompatMetadataTest.java | 23 +++++++ .../epay/EpayCompatOrderServiceTest.java | 4 ++ .../pay/compat/epay/EpayV2AdapterTest.java | 26 ++++++++ 11 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayV2AdapterTest.java diff --git a/docs/sql/init.sql b/docs/sql/init.sql index 68bd0d136..add8aa2b8 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -267,7 +267,7 @@ CREATE TABLE `t_pay_order` ( `client_ip` VARCHAR(32) DEFAULT NULL COMMENT '客户端IP', `subject` VARCHAR(64) NOT NULL COMMENT '商品标题', `body` VARCHAR(256) NOT NULL COMMENT '商品描述信息', - `channel_extra` VARCHAR(512) DEFAULT NULL COMMENT '特定渠道发起额外参数', + `channel_extra` TEXT DEFAULT NULL COMMENT '特定渠道发起额外参数', `channel_user` VARCHAR(64) DEFAULT NULL COMMENT '渠道用户标识,如微信openId,支付宝账号', `channel_order_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道订单号', `refund_state` TINYINT(6) NOT NULL DEFAULT '0' COMMENT '退款状态: 0-未发生实际退款, 1-部分退款, 2-全额退款', diff --git a/docs/sql/patch.sql b/docs/sql/patch.sql index 372776a92..c872c0dd0 100644 --- a/docs/sql/patch.sql +++ b/docs/sql/patch.sql @@ -1,5 +1,9 @@ ##### 增量发布SQL ##### +## -- EPAY 兼容下单:支付订单渠道元数据改为 TEXT,避免长回调/收银台参数被截断 +ALTER TABLE `t_pay_order` + MODIFY COLUMN `channel_extra` TEXT DEFAULT NULL COMMENT '特定渠道发起额外参数'; + ## -- ++++ [v1.1.0] ===> [v1.1.1] ++++ ## -- 新增: 支付测试, 重发通知, 通知最大次数保存到数据库 insert into t_sys_entitlement values('ENT_MCH_PAY_TEST', '支付测试', 'transaction', '/paytest', 'PayTestPage', 'ML', 0, 1, 'ENT_MCH_CENTER', '20', 'MCH', now(), now()); diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java index a2b26de11..4df5d00d5 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -1,6 +1,7 @@ package com.jeequan.jeepay.pay.compat.epay; import com.jeequan.jeepay.core.model.ApiRes; +import com.jeequan.jeepay.core.entity.PayOrder; import com.jeequan.jeepay.pay.ctrl.payorder.AbstractPayOrderController; import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +42,11 @@ public EpayCompatController(EpayCompatProperties properties, this.orderService = orderService; } + @Override + protected boolean shouldPersistChannelExtra(UnifiedOrderRQ rq) { + return EpayCompatMetadata.decode(rq.getChannelExtra()).isPresent(); + } + @PostMapping("/compat/epay/mapi.php") public ResponseEntity mapi(@RequestParam Map fields) { return process(fields).asJson(); @@ -72,6 +78,12 @@ private EpayCompatResponse process(Map fields) { UnifiedOrderRQ request = orderService.toUnifiedOrderRequest(command); ApiRes apiRes = unifiedOrder(request.getWayCode(), request); result = orderService.fromApiResult(apiRes, command); + if (!result.success()) { + PayOrder raced = orderService.findExisting(command); + if (raced != null) { + result = orderService.fromExisting(raced, command); + } + } } return result.success() ? success(version, result, credential) diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java index efbbddee9..47561808e 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java @@ -103,8 +103,10 @@ private boolean isCompatible(PayOrder existing, EpayCreateCommand command) { } private void persistMetadata(PayOrder payOrder, EpayCreateCommand command, String tradeNo, String payInfo) { - payOrder.setChannelExtra(channelExtra(command, tradeNo, payInfo)); - payOrderService.updateById(payOrder); + PayOrder patch = new PayOrder() + .setPayOrderId(payOrder.getPayOrderId()) + .setChannelExtra(channelExtra(command, tradeNo, payInfo)); + payOrderService.updateById(patch); } private String channelExtra(EpayCreateCommand command, String tradeNo, String payInfo) { diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java index 69ba6e27f..fcb9dc8e5 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayV2Adapter.java @@ -30,20 +30,32 @@ private EpayCompatResponse response(int code, String message, String tradeNo, St body.put("pay_type", payType == null ? "" : payType); body.put("pay_info", payInfo == null ? "" : payInfo); body.put("sign_type", "RSA"); - body.put("sign", sign(body, credential)); - return new EpayCompatResponse(code == 0, JSON.toJSONString(body), payInfo); + 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)) { - return ""; - } - try { - return EpaySigner.signRsa(body, privateKey); - } catch (IllegalArgumentException e) { - return ""; + throw new IllegalArgumentException("V2 RSA响应私钥未配置"); } + return EpaySigner.signRsa(body, privateKey); } private static boolean hasText(String value) { 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 a8a48100d..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 @@ -32,7 +32,6 @@ import com.jeequan.jeepay.core.utils.SpringBeansUtil; import com.jeequan.jeepay.core.utils.StringKit; import com.jeequan.jeepay.pay.channel.IPaymentService; -import com.jeequan.jeepay.pay.compat.epay.EpayCompatMetadata; import com.jeequan.jeepay.pay.ctrl.ApiController; import com.jeequan.jeepay.pay.exception.ChannelException; import com.jeequan.jeepay.pay.model.MchAppConfigContext; @@ -77,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){ @@ -258,7 +265,7 @@ 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()); //商品描述信息 - if (EpayCompatMetadata.decode(rq.getChannelExtra()).isPresent()) { + if (shouldPersistChannelExtra(rq)) { payOrder.setChannelExtra(rq.getChannelExtra()); } payOrder.setChannelUser(rq.getChannelUserId()); //渠道用户标志 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 index aa98020e8..26f96d7c6 100644 --- 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 @@ -6,7 +6,10 @@ 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; @@ -43,15 +46,33 @@ void keepsNativeOrderChannelExtraEmptyWhenRequestHasNativeChannelExtra() throws 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"); - EpayCompatProperties properties = new EpayCompatProperties(); - EpayCompatController controller = new EpayCompatController( - properties, - mock(EpayCredentialResolver.class), - new EpayRequestNormalizer(java.time.Clock.systemUTC(), properties), - new EpayCompatOrderService(mock(PayOrderService.class))); RequestKitBean requestKitBean = mock(RequestKitBean.class); when(requestKitBean.getClientIp()).thenReturn("203.0.113.10"); Field requestKitBeanField = AbstractCtrl.class.getDeclaredField("requestKitBean"); @@ -68,4 +89,37 @@ private static PayOrder generate(UnifiedOrderRQ request) throws Exception { "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/EpayCompatControllerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatControllerTest.java index eff5486f2..0133d600a 100644 --- 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 @@ -165,6 +165,29 @@ void duplicateControllerRequestReturnsExistingOrderWithoutCallingUnifiedOrder() 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"); + } + private static TestController controller(PayOrderService payOrderService, EpayCredential credential) { EpayCompatProperties properties = new EpayCompatProperties(); properties.setDefaultAppId("APP-1001"); 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 index 716342353..bca512a03 100644 --- 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 @@ -54,6 +54,29 @@ void returnsEmptyForMalformedJsonWrongShapeAndMissingRequiredFields() { 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", 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 index 6089b7361..e6ca30522 100644 --- 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 @@ -141,6 +141,10 @@ void apiResultPersistsEpayMetadataInPayOrderChannelExtraWithoutExtParam() { 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")) 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(); + } +} From 39580465b281ee426acc1c0b76ec90f662656f72 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 12:39:08 +0800 Subject: [PATCH 23/27] fix(payment): bind epay duplicate orders to identity --- docs/sql/init.sql | 2 +- docs/sql/patch.sql | 2 +- .../compat/epay/EpayCompatOrderService.java | 20 ++++++++--- .../compat/epay/EpayCompatControllerTest.java | 27 +++++++++++++++ .../epay/EpayCompatOrderServiceTest.java | 34 +++++++++++++++++++ 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/sql/init.sql b/docs/sql/init.sql index add8aa2b8..8f072090e 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -267,7 +267,7 @@ CREATE TABLE `t_pay_order` ( `client_ip` VARCHAR(32) DEFAULT NULL COMMENT '客户端IP', `subject` VARCHAR(64) NOT NULL COMMENT '商品标题', `body` VARCHAR(256) NOT NULL COMMENT '商品描述信息', - `channel_extra` TEXT DEFAULT NULL COMMENT '特定渠道发起额外参数', + `channel_extra` TEXT COMMENT '特定渠道发起额外参数', `channel_user` VARCHAR(64) DEFAULT NULL COMMENT '渠道用户标识,如微信openId,支付宝账号', `channel_order_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道订单号', `refund_state` TINYINT(6) NOT NULL DEFAULT '0' COMMENT '退款状态: 0-未发生实际退款, 1-部分退款, 2-全额退款', diff --git a/docs/sql/patch.sql b/docs/sql/patch.sql index c872c0dd0..d0ce203c2 100644 --- a/docs/sql/patch.sql +++ b/docs/sql/patch.sql @@ -2,7 +2,7 @@ ## -- EPAY 兼容下单:支付订单渠道元数据改为 TEXT,避免长回调/收银台参数被截断 ALTER TABLE `t_pay_order` - MODIFY COLUMN `channel_extra` TEXT DEFAULT NULL COMMENT '特定渠道发起额外参数'; + MODIFY COLUMN `channel_extra` TEXT COMMENT '特定渠道发起额外参数'; ## -- ++++ [v1.1.0] ===> [v1.1.1] ++++ ## -- 新增: 支付测试, 重发通知, 通知最大次数保存到数据库 diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java index 47561808e..218b2d0f3 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatOrderService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import java.math.BigDecimal; +import java.util.Objects; import java.util.Optional; @Service @@ -33,7 +34,7 @@ public PayOrder findExisting(EpayCreateCommand command) { return null; } if (!isCompatible(existing, command)) { - throw new IllegalStateException("重复订单参数冲突:金额、支付方式、回调地址或类型不一致"); + throw new IllegalStateException("重复订单参数冲突:商户、应用、订单号、金额、支付方式、回调地址或类型不一致"); } return existing; } @@ -97,9 +98,20 @@ private boolean isCompatible(PayOrder existing, EpayCreateCommand command) { && existing.getAmount() == command.amountFen() && CS.IF_CODE.STARPOS.equals(existing.getIfCode()) && command.paymentType().equals(existing.getWayCode()) - && command.notifyUrl().equals(existing.getNotifyUrl()) - && command.returnUrl().equals(existing.getReturnUrl()) - && metadata.map(value -> command.epayType().equals(value.type())).orElse(false); + && Objects.equals(command.merchantId(), existing.getMchNo()) + && Objects.equals(command.appId(), existing.getAppId()) + && Objects.equals(command.orderNo(), existing.getMchOrderNo()) + && Objects.equals(command.notifyUrl(), existing.getNotifyUrl()) + && Objects.equals(command.returnUrl(), existing.getReturnUrl()) + && metadata.map(value -> + Objects.equals(command.merchantId(), value.pid()) + && Objects.equals(command.appId(), value.appId()) + && Objects.equals(command.orderNo(), value.outTradeNo()) + && Objects.equals(amountYuan(command.amountFen()), value.money()) + && Objects.equals(command.notifyUrl(), value.notifyUrl()) + && Objects.equals(command.returnUrl(), value.returnUrl()) + && Objects.equals(command.epayType(), value.type()) + ).orElse(false); } private void persistMetadata(PayOrder payOrder, EpayCreateCommand command, String tradeNo, String payInfo) { 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 index 0133d600a..c3c161b69 100644 --- 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 @@ -188,6 +188,33 @@ void uniqueKeyRaceReloadsExistingOrderWithoutCallingUnifiedOrderAgain() { .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"); 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 index e6ca30522..6d5424b2b 100644 --- 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 @@ -59,6 +59,40 @@ void duplicateAmountMismatchReturnsConflict() { .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); From 5c80b03f9046861122df55f418809603285121c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 13:00:27 +0800 Subject: [PATCH 24/27] fix(payment): close epay callback persistence gaps --- docs/sql/init.sql | 4 +-- docs/sql/patch.sql | 6 ++-- .../pay/compat/epay/EpayCompatController.java | 12 +++++++- .../compat/epay/EpayRequestNormalizer.java | 17 +++++++++-- .../compat/epay/EpayCompatControllerTest.java | 16 +++++++++++ .../compat/epay/EpayCompatSqlSchemaTest.java | 28 +++++++++++++++++++ .../epay/EpayRequestNormalizerTest.java | 9 ++++++ 7 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatSqlSchemaTest.java diff --git a/docs/sql/init.sql b/docs/sql/init.sql index 8f072090e..1131c6113 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -279,8 +279,8 @@ CREATE TABLE `t_pay_order` ( `err_code` VARCHAR(128) DEFAULT NULL COMMENT '渠道支付错误码', `err_msg` VARCHAR(256) DEFAULT NULL COMMENT '渠道支付错误描述', `ext_param` VARCHAR(128) DEFAULT NULL COMMENT '商户扩展参数', - `notify_url` VARCHAR(128) NOT NULL default '' COMMENT '异步通知地址', - `return_url` VARCHAR(128) DEFAULT '' COMMENT '页面跳转地址', + `notify_url` TEXT NOT NULL COMMENT '异步通知地址', + `return_url` TEXT COMMENT '页面跳转地址', `expired_time` DATETIME DEFAULT NULL COMMENT '订单失效时间', `success_time` DATETIME DEFAULT NULL COMMENT '订单支付成功时间', `created_at` TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/docs/sql/patch.sql b/docs/sql/patch.sql index d0ce203c2..291adfdb5 100644 --- a/docs/sql/patch.sql +++ b/docs/sql/patch.sql @@ -1,8 +1,10 @@ ##### 增量发布SQL ##### -## -- EPAY 兼容下单:支付订单渠道元数据改为 TEXT,避免长回调/收银台参数被截断 +## -- EPAY 兼容下单:支付订单渠道元数据与回调地址改为 TEXT,避免长参数被截断 ALTER TABLE `t_pay_order` - MODIFY COLUMN `channel_extra` TEXT COMMENT '特定渠道发起额外参数'; + MODIFY COLUMN `channel_extra` TEXT COMMENT '特定渠道发起额外参数', + MODIFY COLUMN `notify_url` TEXT NOT NULL COMMENT '异步通知地址', + MODIFY COLUMN `return_url` TEXT COMMENT '页面跳转地址'; ## -- ++++ [v1.1.0] ===> [v1.1.1] ++++ ## -- 新增: 支付测试, 重发通知, 通知最大次数保存到数据库 diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java index 4df5d00d5..9c5c12803 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -1,6 +1,7 @@ package com.jeequan.jeepay.pay.compat.epay; import com.jeequan.jeepay.core.model.ApiRes; +import com.jeequan.jeepay.core.constants.ApiCodeEnum; import com.jeequan.jeepay.core.entity.PayOrder; import com.jeequan.jeepay.pay.ctrl.payorder.AbstractPayOrderController; import com.jeequan.jeepay.pay.rqrs.payorder.UnifiedOrderRQ; @@ -78,7 +79,7 @@ private EpayCompatResponse process(Map fields) { UnifiedOrderRQ request = orderService.toUnifiedOrderRequest(command); ApiRes apiRes = unifiedOrder(request.getWayCode(), request); result = orderService.fromApiResult(apiRes, command); - if (!result.success()) { + if (!result.success() && isDuplicateOrderFailure(apiRes, command)) { PayOrder raced = orderService.findExisting(command); if (raced != null) { result = orderService.fromExisting(raced, command); @@ -93,6 +94,15 @@ private EpayCompatResponse process(Map fields) { } } + private static boolean isDuplicateOrderFailure(ApiRes apiRes, EpayCreateCommand command) { + if (apiRes == null || apiRes.getCode() == null + || apiRes.getCode() == ApiCodeEnum.SUCCESS.getCode() + || apiRes.getMsg() == null) { + return false; + } + return apiRes.getMsg().contains("商户订单[" + command.orderNo() + "]已存在"); + } + private EpayProtocolVersion protocolVersion(Map fields) { return "RSA".equalsIgnoreCase(fields == null ? null : fields.get("sign_type")) ? EpayProtocolVersion.V2 : EpayProtocolVersion.V1; diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java index d5b55fc93..c540dc3a3 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizer.java @@ -1,10 +1,11 @@ package com.jeequan.jeepay.pay.compat.epay; import com.jeequan.jeepay.core.constants.CS; -import com.jeequan.jeepay.core.utils.StringKit; import java.math.BigDecimal; import java.math.RoundingMode; +import java.net.URI; +import java.net.URISyntaxException; import java.time.Clock; import java.time.Instant; import java.util.Map; @@ -112,12 +113,24 @@ private static void validateSignType(String signType, EpayProtocolVersion versio private static String callbackUrl(Map fields, String key) { String value = required(fields, key); - if (!StringKit.isAvailableUrl(value)) { + if (!isValidHttpUrl(value)) { throw new IllegalArgumentException(key + " must be a valid HTTP(S) URL"); } return value; } + private static boolean isValidHttpUrl(String value) { + try { + URI uri = new URI(value); + String scheme = uri.getScheme(); + return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + && uri.getHost() != null + && !uri.getHost().trim().isEmpty(); + } catch (URISyntaxException e) { + return false; + } + } + private static String required(Map fields, String key) { String value = trimToNull(fields.get(key)); if (value == null) { 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 index c3c161b69..f9135cf4c 100644 --- 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 @@ -188,6 +188,22 @@ void uniqueKeyRaceReloadsExistingOrderWithoutCallingUnifiedOrderAgain() { .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); 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/EpayRequestNormalizerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayRequestNormalizerTest.java index 12a7c96b3..ddb2a276f 100644 --- 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 @@ -117,6 +117,15 @@ void acceptsHttpAndHttpsNotifyAndReturnUrlsButRejectsOtherSchemes() { .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"), From 7358a39fd17ac998bde851d07e624ecdfd5abeb0 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 13:24:57 +0800 Subject: [PATCH 25/27] feat: add epay notification compatibility --- .../pay/compat/epay/EpayCompatController.java | 37 ++- .../compat/epay/EpayCompatNotifyService.java | 233 ++++++++++++++++++ .../jeepay/pay/compat/epay/EpaySigner.java | 4 + .../pay/service/PayMchNotifyService.java | 9 + .../EpayCompatCallbackControllerTest.java | 48 ++++ .../epay/EpayCompatNotifyServiceTest.java | 202 +++++++++++++++ 6 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java index 9c5c12803..f411e990c 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -23,24 +23,47 @@ public class EpayCompatController extends AbstractPayOrderController { private final EpayCredentialResolver credentialResolver; private final EpayRequestNormalizer requestNormalizer; private final EpayCompatOrderService orderService; + private EpayCompatNotifyService notifyService; private final EpayV1Adapter v1Adapter = new EpayV1Adapter(); private final EpayV2Adapter v2Adapter = new EpayV2Adapter(); + public EpayCompatController(EpayCompatProperties properties, + EpayCredentialResolver credentialResolver, + EpayCompatOrderService orderService, + EpayCompatNotifyService notifyService) { + this(properties, credentialResolver, new EpayRequestNormalizer(Clock.systemUTC(), properties), + orderService, notifyService); + } + @Autowired public EpayCompatController(EpayCompatProperties properties, EpayCredentialResolver credentialResolver, EpayCompatOrderService orderService) { - this(properties, credentialResolver, new EpayRequestNormalizer(Clock.systemUTC(), properties), orderService); + this(properties, credentialResolver, orderService, null); } public EpayCompatController(EpayCompatProperties properties, EpayCredentialResolver credentialResolver, EpayRequestNormalizer requestNormalizer, - EpayCompatOrderService orderService) { + EpayCompatOrderService orderService, + EpayCompatNotifyService notifyService) { this.properties = properties; this.credentialResolver = credentialResolver; this.requestNormalizer = requestNormalizer; this.orderService = orderService; + this.notifyService = notifyService; + } + + @Autowired + private void setNotifyService(EpayCompatNotifyService notifyService) { + this.notifyService = notifyService; + } + + public EpayCompatController(EpayCompatProperties properties, + EpayCredentialResolver credentialResolver, + EpayRequestNormalizer requestNormalizer, + EpayCompatOrderService orderService) { + this(properties, credentialResolver, requestNormalizer, orderService, null); } @Override @@ -58,6 +81,16 @@ public ResponseEntity submit(@RequestParam Map fields) { return process(fields).asSubmitResponse(); } + @RequestMapping(value = "/compat/epay/notify.php", method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity notify(@RequestParam Map fields) { + return ResponseEntity.ok(notifyService == null ? "fail" : notifyService.handleCallback(fields)); + } + + @RequestMapping(value = "/compat/epay/return.php", method = {RequestMethod.GET, RequestMethod.POST}) + public ResponseEntity returnCallback(@RequestParam Map fields) { + return ResponseEntity.ok(notifyService == null ? "fail" : notifyService.handleCallback(fields)); + } + private EpayCompatResponse process(Map fields) { EpayProtocolVersion version = protocolVersion(fields); EpayCredential credential = null; diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java new file mode 100644 index 000000000..4211fab7b --- /dev/null +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java @@ -0,0 +1,233 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.entity.PayOrder; +import com.jeequan.jeepay.core.utils.StringKit; +import com.jeequan.jeepay.service.impl.PayOrderService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class EpayCompatNotifyService { + + private final PayOrderService payOrderService; + private final EpayCompatProperties properties; + private final EpayCredentialResolver credentialResolver; + + public EpayCompatNotifyService(PayOrderService payOrderService, + EpayCompatProperties properties, + EpayCredentialResolver credentialResolver) { + this.payOrderService = payOrderService; + this.properties = properties; + this.credentialResolver = credentialResolver; + } + + public String createNotifyUrl(PayOrder payOrder) { + return createCallbackUrl(payOrder, true); + } + + public String createReturnUrl(PayOrder payOrder) { + return createCallbackUrl(payOrder, false); + } + + public String handleCallback(Map fields) { + try { + if (fields == null) { + return "fail"; + } + String pid = required(fields, "pid"); + String outTradeNo = required(fields, "out_trade_no"); + EpayProtocolVersion version = protocolVersion(fields.get("sign_type")); + String appId = properties.resolveAppId(pid); + EpayCredential credential = credentialResolver.resolve(pid, appId, version); + if (!verify(fields, credential, version)) { + return "fail"; + } + + PayOrder payOrder = payOrderService.queryMchOrder(pid, null, outTradeNo); + if (payOrder == null) { + return "fail"; + } + Optional metadata = EpayCompatMetadata.decode(payOrder.getChannelExtra()); + if (metadata.isEmpty() || !matchesIdentity(fields, metadata.get(), payOrder)) { + return "fail"; + } + if (!matchesTradeNo(fields.get("trade_no"), metadata.get(), payOrder) + || !matchesMoney(fields.get("money"), payOrder) + || !safeEquals(fields.get("type"), metadata.get().type())) { + return "fail"; + } + + String status = fields.get("trade_status"); + if ("TRADE_SUCCESS".equalsIgnoreCase(status)) { + return confirmSuccess(payOrder, fields); + } + if ("TRADE_CLOSED".equalsIgnoreCase(status)) { + return confirmClosed(payOrder, fields); + } + return "success"; + } catch (RuntimeException e) { + log.warn("EPAY兼容回调处理失败", e); + return "fail"; + } + } + + private String createCallbackUrl(PayOrder payOrder, boolean notify) { + if (payOrder == null) { + return ""; + } + Optional metadata = EpayCompatMetadata.decode(payOrder.getChannelExtra()); + if (metadata.isEmpty() || !isTerminal(payOrder.getState())) { + return ""; + } + + EpayMetadataValue value = metadata.get(); + EpayProtocolVersion version = protocolVersion(value.version()); + EpayCredential credential; + try { + String appId = value.appId(); + credential = credentialResolver.resolve(value.pid(), appId, version); + if (version == EpayProtocolVersion.V2 + && !hasText(credential == null ? null : credential.platformPrivateKey())) { + log.warn("EPAY V2通知未生成:平台私钥配置缺失,pid={}, appId={}", value.pid(), appId); + return ""; + } + } catch (RuntimeException e) { + log.warn("EPAY兼容通知凭据不可用,pid={}, appId={}", value.pid(), value.appId(), e); + return ""; + } + + Map fields = callbackFields(value, payOrder); + if (version == EpayProtocolVersion.V1) { + fields.put("sign_type", "MD5"); + fields.put("sign", EpaySigner.signMd5(fields, credential.merchantKey())); + } else { + fields.put("sign_type", "RSA"); + fields.put("sign", EpaySigner.signRsa(fields, credential.platformPrivateKey())); + } + String callbackUrl = notify ? value.notifyUrl() : value.returnUrl(); + return StringKit.appendUrlQuery(callbackUrl, fields); + } + + private String confirmSuccess(PayOrder payOrder, Map fields) { + if (payOrder.getState() == PayOrder.STATE_SUCCESS) { + return "success"; + } + if (payOrder.getState() != PayOrder.STATE_ING) { + return "fail"; + } + return payOrderService.updateIng2Success( + payOrder.getPayOrderId(), fields.get("trade_no"), null) ? "success" : "fail"; + } + + private String confirmClosed(PayOrder payOrder, Map fields) { + if (payOrder.getState() == PayOrder.STATE_FAIL || payOrder.getState() == PayOrder.STATE_CLOSED) { + return "success"; + } + if (payOrder.getState() != PayOrder.STATE_ING) { + return "fail"; + } + return payOrderService.updateIng2Fail( + payOrder.getPayOrderId(), fields.get("trade_no"), null, + "TRADE_CLOSED", "EPAY交易关闭") ? "success" : "fail"; + } + + private static Map callbackFields(EpayMetadataValue metadata, PayOrder payOrder) { + Map fields = new LinkedHashMap<>(); + fields.put("pid", metadata.pid()); + fields.put("trade_no", tradeNo(metadata, payOrder)); + fields.put("out_trade_no", metadata.outTradeNo()); + fields.put("type", metadata.type()); + fields.put("name", metadata.name()); + fields.put("money", formatAmount(payOrder.getAmount())); + fields.put("trade_status", payOrder.getState() == PayOrder.STATE_SUCCESS + ? "TRADE_SUCCESS" : "TRADE_CLOSED"); + return fields; + } + + private static boolean matchesIdentity(Map fields, + EpayMetadataValue metadata, + PayOrder payOrder) { + return safeEquals(fields.get("pid"), metadata.pid()) + && safeEquals(fields.get("out_trade_no"), metadata.outTradeNo()) + && safeEquals(payOrder.getMchNo(), metadata.pid()) + && safeEquals(payOrder.getMchOrderNo(), metadata.outTradeNo()); + } + + private static boolean matchesTradeNo(String actual, EpayMetadataValue metadata, PayOrder payOrder) { + return hasText(actual) && safeEquals(actual, tradeNo(metadata, payOrder)); + } + + private static boolean matchesMoney(String actual, PayOrder payOrder) { + if (!hasText(actual) || payOrder.getAmount() == null) { + return false; + } + try { + BigDecimal expected = BigDecimal.valueOf(payOrder.getAmount(), 2); + BigDecimal received = new BigDecimal(actual).setScale(2, RoundingMode.UNNECESSARY); + return expected.compareTo(received) == 0; + } catch (ArithmeticException | NumberFormatException e) { + return false; + } + } + + private static String tradeNo(EpayMetadataValue metadata, PayOrder payOrder) { + if (hasText(metadata.tradeNo())) { + return metadata.tradeNo(); + } + if (hasText(payOrder.getChannelOrderNo())) { + return payOrder.getChannelOrderNo(); + } + return payOrder.getPayOrderId(); + } + + private static boolean isTerminal(Byte state) { + return state != null && (state == PayOrder.STATE_SUCCESS + || state == PayOrder.STATE_FAIL + || state == PayOrder.STATE_CLOSED); + } + + private static EpayProtocolVersion protocolVersion(String value) { + if ("RSA".equalsIgnoreCase(value) || "v2".equalsIgnoreCase(value)) { + return EpayProtocolVersion.V2; + } + return EpayProtocolVersion.V1; + } + + private static boolean verify(Map fields, + EpayCredential credential, + EpayProtocolVersion version) { + return version == EpayProtocolVersion.V2 + ? EpaySigner.verifyRsa(fields, credential, fields.get("sign")) + : EpaySigner.verifyMd5(fields, credential.merchantKey(), fields.get("sign")); + } + + private static String required(Map fields, String key) { + String value = fields.get(key); + if (!hasText(value)) { + throw new IllegalArgumentException(key + " is required"); + } + return value.trim(); + } + + private static String formatAmount(Long amount) { + if (amount == null) { + return ""; + } + return BigDecimal.valueOf(amount, 2).setScale(2, RoundingMode.UNNECESSARY).toPlainString(); + } + + private static boolean safeEquals(String left, String right) { + return left != null && left.equals(right); + } + + private static boolean hasText(String value) { + return value != null && !value.trim().isEmpty(); + } +} diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java index edbd6f852..aa541082d 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpaySigner.java @@ -61,6 +61,10 @@ public static boolean verifyRsa(Map fields, String publicKeyPem, Stri } } + public static boolean verifyRsa(Map fields, EpayCredential credential, String signature) { + return credential != null && verifyRsa(fields, credential.merchantPublicKey(), signature); + } + private static byte[] md5Bytes(String text) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); 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..32c81e073 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; /** 商户通知信息, 只有订单是终态,才会发送通知, 如明确成功和明确失败 **/ @@ -207,6 +210,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 +264,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/compat/epay/EpayCompatCallbackControllerTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java new file mode 100644 index 000000000..16ece68b8 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatCallbackControllerTest.java @@ -0,0 +1,48 @@ +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; + +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.handleCallback(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"); + } + + 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/EpayCompatNotifyServiceTest.java b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java new file mode 100644 index 000000000..516fd73d5 --- /dev/null +++ b/jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyServiceTest.java @@ -0,0 +1,202 @@ +package com.jeequan.jeepay.pay.compat.epay; + +import com.jeequan.jeepay.core.entity.PayOrder; +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 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 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 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 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-----"; + } +} From 0e58052d2e63b6b1fe0e7b2b5aa193faa707f118 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 13:40:42 +0800 Subject: [PATCH 26/27] fix: harden epay callback processing --- .../epay/DefaultEpayCredentialResolver.java | 15 ++ .../pay/compat/epay/EpayCompatController.java | 2 +- .../compat/epay/EpayCompatNotifyService.java | 131 +++++++++++++----- .../compat/epay/EpayCredentialResolver.java | 6 + .../pay/service/PayMchNotifyService.java | 4 + .../EpayCompatCallbackControllerTest.java | 4 +- .../epay/EpayCompatNotifyServiceTest.java | 91 ++++++++++++ .../epay/EpayCompatOrderServiceTest.java | 13 ++ .../compat/epay/PayMchNotifyServiceTest.java | 53 +++++++ 9 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/PayMchNotifyServiceTest.java diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java index e42050f6b..4a2636b63 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/DefaultEpayCredentialResolver.java @@ -40,6 +40,21 @@ public EpayCredential resolve(String merchantId, String appId, EpayProtocolVersi ); } + @Override + public EpayCredential resolveForVerification(String merchantId, + String appId, + EpayProtocolVersion version) { + if (version == EpayProtocolVersion.V1) { + return resolve(merchantId, appId, version); + } + + EpayCompatProperties.CredentialProperties credential = credentialProperties(merchantId, appId); + if (!hasText(credential.getMerchantPublicKey())) { + throw new IllegalStateException("商户应用 V2 RSA 公钥未配置"); + } + return new EpayCredential(null, credential.getMerchantPublicKey(), null); + } + private EpayCompatProperties.CredentialProperties credentialProperties(String merchantId, String appId) { Map> all = properties.getCredentials(); Map byApp = all.get(merchantId); diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java index f411e990c..df255008e 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatController.java @@ -88,7 +88,7 @@ public ResponseEntity notify(@RequestParam Map fields) { @RequestMapping(value = "/compat/epay/return.php", method = {RequestMethod.GET, RequestMethod.POST}) public ResponseEntity returnCallback(@RequestParam Map fields) { - return ResponseEntity.ok(notifyService == null ? "fail" : notifyService.handleCallback(fields)); + return ResponseEntity.ok(notifyService == null ? "fail" : notifyService.handleReturn(fields)); } private EpayCompatResponse process(Map fields) { diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java index 4211fab7b..b28745cea 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java @@ -2,8 +2,11 @@ import com.jeequan.jeepay.core.entity.PayOrder; import com.jeequan.jeepay.core.utils.StringKit; +import com.jeequan.jeepay.pay.service.PayOrderProcessService; import com.jeequan.jeepay.service.impl.PayOrderService; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -19,13 +22,23 @@ public class EpayCompatNotifyService { private final PayOrderService payOrderService; private final EpayCompatProperties properties; private final EpayCredentialResolver credentialResolver; + private final PayOrderProcessService payOrderProcessService; + @Autowired public EpayCompatNotifyService(PayOrderService payOrderService, EpayCompatProperties properties, - EpayCredentialResolver credentialResolver) { + EpayCredentialResolver credentialResolver, + @Lazy PayOrderProcessService payOrderProcessService) { this.payOrderService = payOrderService; this.properties = properties; this.credentialResolver = credentialResolver; + this.payOrderProcessService = payOrderProcessService; + } + + public EpayCompatNotifyService(PayOrderService payOrderService, + EpayCompatProperties properties, + EpayCredentialResolver credentialResolver) { + this(payOrderService, properties, credentialResolver, null); } public String createNotifyUrl(PayOrder payOrder) { @@ -38,38 +51,16 @@ public String createReturnUrl(PayOrder payOrder) { public String handleCallback(Map fields) { try { - if (fields == null) { - return "fail"; - } - String pid = required(fields, "pid"); - String outTradeNo = required(fields, "out_trade_no"); - EpayProtocolVersion version = protocolVersion(fields.get("sign_type")); - String appId = properties.resolveAppId(pid); - EpayCredential credential = credentialResolver.resolve(pid, appId, version); - if (!verify(fields, credential, version)) { - return "fail"; - } - - PayOrder payOrder = payOrderService.queryMchOrder(pid, null, outTradeNo); - if (payOrder == null) { + Optional context = validateCallback(fields); + if (context.isEmpty()) { return "fail"; } - Optional metadata = EpayCompatMetadata.decode(payOrder.getChannelExtra()); - if (metadata.isEmpty() || !matchesIdentity(fields, metadata.get(), payOrder)) { - return "fail"; - } - if (!matchesTradeNo(fields.get("trade_no"), metadata.get(), payOrder) - || !matchesMoney(fields.get("money"), payOrder) - || !safeEquals(fields.get("type"), metadata.get().type())) { - return "fail"; - } - String status = fields.get("trade_status"); if ("TRADE_SUCCESS".equalsIgnoreCase(status)) { - return confirmSuccess(payOrder, fields); + return confirmSuccess(context.get()); } if ("TRADE_CLOSED".equalsIgnoreCase(status)) { - return confirmClosed(payOrder, fields); + return confirmClosed(context.get()); } return "success"; } catch (RuntimeException e) { @@ -78,6 +69,45 @@ public String handleCallback(Map fields) { } } + public String handleReturn(Map fields) { + try { + return validateCallback(fields).isPresent() ? "success" : "fail"; + } catch (RuntimeException e) { + log.warn("EPAY兼容同步返回处理失败", e); + return "fail"; + } + } + + private Optional validateCallback(Map fields) { + if (fields == null) { + return Optional.empty(); + } + String pid = required(fields, "pid"); + String outTradeNo = required(fields, "out_trade_no"); + EpayProtocolVersion version = protocolVersion(fields.get("sign_type")); + PayOrder payOrder = payOrderService.queryMchOrder(pid, null, outTradeNo); + if (payOrder == null) { + return Optional.empty(); + } + Optional metadata = EpayCompatMetadata.decode(payOrder.getChannelExtra()); + if (metadata.isEmpty() || !matchesIdentity(fields, metadata.get(), payOrder)) { + return Optional.empty(); + } + if (!matchesTradeNo(fields.get("trade_no"), metadata.get(), payOrder) + || !matchesMoney(fields.get("money"), payOrder) + || !safeEquals(fields.get("type"), metadata.get().type())) { + return Optional.empty(); + } + + String appId = hasText(metadata.get().appId()) + ? metadata.get().appId() : properties.resolveAppId(pid); + EpayCredential credential = credentialResolver.resolveForVerification(pid, appId, version); + if (!verify(fields, credential, version)) { + return Optional.empty(); + } + return Optional.of(new CallbackContext(fields, payOrder, metadata.get(), version)); + } + private String createCallbackUrl(PayOrder payOrder, boolean notify) { if (payOrder == null) { return ""; @@ -115,27 +145,56 @@ private String createCallbackUrl(PayOrder payOrder, boolean notify) { return StringKit.appendUrlQuery(callbackUrl, fields); } - private String confirmSuccess(PayOrder payOrder, Map fields) { + private String confirmSuccess(CallbackContext context) { + PayOrder payOrder = context.payOrder(); + Map fields = context.fields(); if (payOrder.getState() == PayOrder.STATE_SUCCESS) { return "success"; } if (payOrder.getState() != PayOrder.STATE_ING) { return "fail"; } - return payOrderService.updateIng2Success( - payOrder.getPayOrderId(), fields.get("trade_no"), null) ? "success" : "fail"; + if (payOrderService.updateIng2Success( + payOrder.getPayOrderId(), fields.get("trade_no"), null)) { + if (payOrderProcessService != null) { + payOrderProcessService.confirmSuccess(payOrder); + } + return "success"; + } + PayOrder refreshed = payOrderService.queryMchOrder( + context.metadata().pid(), null, context.metadata().outTradeNo()); + return refreshed != null + && refreshed.getState() == PayOrder.STATE_SUCCESS + && matchesCallbackSnapshot(context, refreshed) ? "success" : "fail"; } - private String confirmClosed(PayOrder payOrder, Map fields) { + private String confirmClosed(CallbackContext context) { + PayOrder payOrder = context.payOrder(); + Map fields = context.fields(); if (payOrder.getState() == PayOrder.STATE_FAIL || payOrder.getState() == PayOrder.STATE_CLOSED) { return "success"; } if (payOrder.getState() != PayOrder.STATE_ING) { return "fail"; } - return payOrderService.updateIng2Fail( + if (payOrderService.updateIng2Fail( payOrder.getPayOrderId(), fields.get("trade_no"), null, - "TRADE_CLOSED", "EPAY交易关闭") ? "success" : "fail"; + "TRADE_CLOSED", "EPAY交易关闭")) { + return "success"; + } + PayOrder refreshed = payOrderService.queryMchOrder( + context.metadata().pid(), null, context.metadata().outTradeNo()); + return refreshed != null + && (refreshed.getState() == PayOrder.STATE_FAIL + || refreshed.getState() == PayOrder.STATE_CLOSED) + && matchesCallbackSnapshot(context, refreshed) ? "success" : "fail"; + } + + private static boolean matchesCallbackSnapshot(CallbackContext context, PayOrder payOrder) { + return matchesIdentity(context.fields(), context.metadata(), payOrder) + && matchesTradeNo(context.fields().get("trade_no"), context.metadata(), payOrder) + && matchesMoney(context.fields().get("money"), payOrder) + && safeEquals(context.fields().get("type"), context.metadata().type()); } private static Map callbackFields(EpayMetadataValue metadata, PayOrder payOrder) { @@ -230,4 +289,10 @@ private static boolean safeEquals(String left, String right) { private static boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } + + private record CallbackContext(Map fields, + PayOrder payOrder, + EpayMetadataValue metadata, + EpayProtocolVersion version) { + } } diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java index 83471221d..29c4f122d 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCredentialResolver.java @@ -2,4 +2,10 @@ public interface EpayCredentialResolver { EpayCredential resolve(String merchantId, String appId, EpayProtocolVersion version); + + default EpayCredential resolveForVerification(String merchantId, + String appId, + EpayProtocolVersion version) { + return resolve(merchantId, appId, version); + } } 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 32c81e073..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 @@ -75,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); 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 index 16ece68b8..4ef375928 100644 --- 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 @@ -8,6 +8,7 @@ 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 { @@ -27,7 +28,7 @@ void notifyEndpointReturnsProtocolSuccessBody() { @Test void returnEndpointReturnsProtocolFailureBody() { EpayCompatNotifyService notifyService = mock(EpayCompatNotifyService.class); - when(notifyService.handleCallback(Map.of("trade_status", "TRADE_CLOSED"))) + when(notifyService.handleReturn(Map.of("trade_status", "TRADE_CLOSED"))) .thenReturn("fail"); EpayCompatController controller = controller(notifyService); @@ -35,6 +36,7 @@ void returnEndpointReturnsProtocolFailureBody() { 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) { 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 index 516fd73d5..12076a49d 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -66,6 +67,38 @@ void v2NotifyUrlIsEmptyWithoutPlatformPrivateKey() { 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) @@ -101,6 +134,57 @@ void duplicateSuccessfulCallbackReturnsSuccessWithoutSecondStateTransition() { "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); @@ -141,6 +225,13 @@ private static EpayCompatNotifyService service(PayOrderService payOrderService, 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"); 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 index 6d5424b2b..ade6466f7 100644 --- 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 @@ -213,6 +213,19 @@ void credentialResolverUsesAppSecretForV1AndExternalRsaKeysForV2() throws Except .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(); 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()); + } +} From 287382de6bfa59b15bd8e07cc7ad64ecb286fb89 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 27 Jul 2026 13:54:03 +0800 Subject: [PATCH 27/27] test: lock epay compatibility contracts --- .../compat/epay/EpayCompatNotifyService.java | 15 ++-- .../epay/EpayCompatNotifyServiceTest.java | 35 +++++++++ .../compat/epay/EpayContractFixtureTest.java | 75 +++++++++++++++++++ .../resources/epay/fixtures/v1-notify.json | 15 ++++ .../resources/epay/fixtures/v1-request.json | 16 ++++ .../resources/epay/fixtures/v1-response.json | 13 ++++ .../resources/epay/fixtures/v2-notify.json | 16 ++++ .../resources/epay/fixtures/v2-request.json | 19 +++++ .../resources/epay/fixtures/v2-response.json | 12 +++ 9 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 jeepay-payment/src/test/java/com/jeequan/jeepay/pay/compat/epay/EpayContractFixtureTest.java create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v1-notify.json create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v1-request.json create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v1-response.json create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v2-notify.json create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v2-request.json create mode 100644 jeepay-payment/src/test/resources/epay/fixtures/v2-response.json diff --git a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java index b28745cea..e1a6de20f 100644 --- a/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java +++ b/jeepay-payment/src/main/java/com/jeequan/jeepay/pay/compat/epay/EpayCompatNotifyService.java @@ -84,7 +84,7 @@ private Optional validateCallback(Map fields) { } String pid = required(fields, "pid"); String outTradeNo = required(fields, "out_trade_no"); - EpayProtocolVersion version = protocolVersion(fields.get("sign_type")); + EpayProtocolVersion callbackVersion = protocolVersion(fields.get("sign_type")); PayOrder payOrder = payOrderService.queryMchOrder(pid, null, outTradeNo); if (payOrder == null) { return Optional.empty(); @@ -93,6 +93,10 @@ private Optional validateCallback(Map fields) { if (metadata.isEmpty() || !matchesIdentity(fields, metadata.get(), payOrder)) { return Optional.empty(); } + EpayProtocolVersion expectedVersion = protocolVersion(metadata.get().version()); + if (callbackVersion != expectedVersion) { + return Optional.empty(); + } if (!matchesTradeNo(fields.get("trade_no"), metadata.get(), payOrder) || !matchesMoney(fields.get("money"), payOrder) || !safeEquals(fields.get("type"), metadata.get().type())) { @@ -101,11 +105,11 @@ private Optional validateCallback(Map fields) { String appId = hasText(metadata.get().appId()) ? metadata.get().appId() : properties.resolveAppId(pid); - EpayCredential credential = credentialResolver.resolveForVerification(pid, appId, version); - if (!verify(fields, credential, version)) { + EpayCredential credential = credentialResolver.resolveForVerification(pid, appId, expectedVersion); + if (!verify(fields, credential, expectedVersion)) { return Optional.empty(); } - return Optional.of(new CallbackContext(fields, payOrder, metadata.get(), version)); + return Optional.of(new CallbackContext(fields, payOrder, metadata.get())); } private String createCallbackUrl(PayOrder payOrder, boolean notify) { @@ -292,7 +296,6 @@ private static boolean hasText(String value) { private record CallbackContext(Map fields, PayOrder payOrder, - EpayMetadataValue metadata, - EpayProtocolVersion version) { + EpayMetadataValue metadata) { } } 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 index 12076a49d..d27c4b7cb 100644 --- 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 @@ -199,6 +199,41 @@ void callbackAmountMismatchReturnsFail() { "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() 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/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=" + } +}