Forráskód Böngészése

feat(统一支付): 上游客户端/响应解析/超时传输

alphaH 1 hete
szülő
commit
7e2652f6eb

+ 68 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/JdkUnifiedTransport.java

@@ -0,0 +1,68 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+
+import java.io.IOException;
+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;
+
+/**
+ * 基于 JDK {@link HttpClient} 的上游传输(spec §6.2)。
+ *
+ * <p>显式设置连接与读取超时;不复用项目里的 {@code RestTemplate}
+ * ({@code WebMvcConfig} 那个故意没有超时)。非 2xx 与 IO 异常统一包成
+ * {@link UnifiedApiException#CODE_TRANSPORT}。
+ */
+public class JdkUnifiedTransport implements UnifiedTransport {
+
+    private final HttpClient client;
+    private final int readTimeoutMs;
+
+    public JdkUnifiedTransport(UnifiedPayProperties props) {
+        this.client = HttpClient.newBuilder()
+                .connectTimeout(Duration.ofMillis(props.getConnectTimeoutMs()))
+                .build();
+        this.readTimeoutMs = props.getReadTimeoutMs();
+    }
+
+    @Override
+    public String post(String url, String jsonBody) {
+        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+                .header("Content-Type", "application/json;charset=UTF-8")
+                .timeout(Duration.ofMillis(readTimeoutMs))
+                .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8))
+                .build();
+        return send(request);
+    }
+
+    @Override
+    public String get(String url) {
+        HttpRequest request = HttpRequest.newBuilder(URI.create(url))
+                .timeout(Duration.ofMillis(readTimeoutMs))
+                .GET()
+                .build();
+        return send(request);
+    }
+
+    private String send(HttpRequest request) {
+        try {
+            HttpResponse<String> response = client.send(request,
+                    HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
+            if (response.statusCode() < 200 || response.statusCode() >= 300) {
+                // 只带状态码,不带响应体:上游错误页可能回显请求内容
+                throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT,
+                        "上游返回 HTTP " + response.statusCode());
+            }
+            return response.body();
+        } catch (IOException e) {
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游网络异常", e);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游请求被中断", e);
+        }
+    }
+}

+ 37 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedApiException.java

@@ -0,0 +1,37 @@
+package com.payment.platform.module.payment.unified.client;
+
+/**
+ * 统一支付上游调用失败。
+ *
+ * <p>负数码由本客户端产生:{@link #CODE_TRANSPORT} 网络/超时/非 2xx/报文不可解析,
+ * {@link #CODE_SIGN} 响应验签失败;非负数码是上游业务拒绝,原样带回上游 {@code code}。
+ */
+public class UnifiedApiException extends RuntimeException {
+
+    /** 传输层失败:网络异常、超时、上游非 2xx、响应报文不可解析。 */
+    public static final int CODE_TRANSPORT = -1;
+
+    /** 响应验签失败。 */
+    public static final int CODE_SIGN = -2;
+
+    private final int code;
+
+    public UnifiedApiException(int code, String message) {
+        super(message);
+        this.code = code;
+    }
+
+    public UnifiedApiException(int code, String message, Throwable cause) {
+        super(message, cause);
+        this.code = code;
+    }
+
+    public int getCode() {
+        return code;
+    }
+
+    /** 是否上游业务拒绝(非负 code);传输与验签失败返回 false。 */
+    public boolean isBusinessRejection() {
+        return code >= 0;
+    }
+}

+ 267 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedClient.java

@@ -0,0 +1,267 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import lombok.extern.slf4j.Slf4j;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 统一支付上游客户端:JSON 组装 + 发送 + 响应验签/解析 + 错误分类(spec §4.2–§4.7)。
+ *
+ * <p>报文用独立的普通 {@link ObjectMapper}(camelCase)—— 全局 {@code JacksonConfig}
+ * 是 {@code SNAKE_CASE},绝不能用于上游。
+ *
+ * <p><b>不打印请求/响应报文</b>:报文含 {@code channelExtra} 与 {@code sign}。
+ * 错误日志只含接口名、{@code mchOrderNo}/{@code mchRefundNo}、上游 {@code code}/{@code msg}。
+ */
+@Slf4j
+public class UnifiedClient {
+
+    private static final String VERSION = "1.0";
+    private static final String SIGN_TYPE = "MD5";
+    /** spec §5:只支持小写 cny。 */
+    private static final String CURRENCY = "cny";
+
+    private static final String PATH_UNIFIED_ORDER = "/har-pay/api/pay/unifiedOrder";
+    private static final String PATH_QUERY_ORDER = "/har-pay/api/pay/query";
+    private static final String PATH_REFUND_ORDER = "/har-pay/api/refund/refundOrder";
+    private static final String PATH_QUERY_REFUND = "/har-pay/api/refund/query";
+    private static final String PATH_CHANNEL_USER_JUMP = "/har-pay/api/channelUserId/jump";
+
+    private static final DateTimeFormatter COMPACT_14 = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+    private final UnifiedPayProperties props;
+    private final UnifiedTransport transport;
+    private final ObjectMapper camelMapper = new ObjectMapper();
+
+    public UnifiedClient(UnifiedPayProperties props, UnifiedTransport transport) {
+        this.props = props;
+        this.transport = transport;
+        this.camelMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+    }
+
+    /** 统一下单。 */
+    public JsonNode unifiedOrder(UnifiedMerchantEntity cfg, UnifiedOrderPayload payload) {
+        Map<String, Object> body = buildOrderBody(cfg, payload, reqTime(props, OffsetDateTime.now()));
+        return post("unifiedOrder", PATH_UNIFIED_ORDER, body, cfg, payload.mchOrderNo(), null);
+    }
+
+    /** 查询订单。 */
+    public JsonNode queryOrder(UnifiedMerchantEntity cfg, String mchOrderNo) {
+        Map<String, Object> body = buildQueryBody(cfg, mchOrderNo, reqTime(props, OffsetDateTime.now()));
+        return post("queryOrder", PATH_QUERY_ORDER, body, cfg, mchOrderNo, null);
+    }
+
+    /** 统一退款;上游通知地址固定取 {@link UnifiedPayProperties#notifyRefundUrl()}。 */
+    public JsonNode refundOrder(UnifiedMerchantEntity cfg, String mchOrderNo, String mchRefundNo,
+                                long refundAmount, String refundReason) {
+        Map<String, Object> body = buildRefundBody(cfg, mchOrderNo, mchRefundNo, refundAmount,
+                refundReason, props.notifyRefundUrl(), reqTime(props, OffsetDateTime.now()));
+        return post("refundOrder", PATH_REFUND_ORDER, body, cfg, mchOrderNo, mchRefundNo);
+    }
+
+    /** 查询退款(文档没写,按 SDK 模型实现,spec §4.1)。 */
+    public JsonNode queryRefund(UnifiedMerchantEntity cfg, String mchRefundNo) {
+        Map<String, Object> body = buildRefundQueryBody(cfg, mchRefundNo, reqTime(props, OffsetDateTime.now()));
+        return post("queryRefund", PATH_QUERY_REFUND, body, cfg, null, mchRefundNo);
+    }
+
+    /**
+     * 生成「获取渠道用户 ID」的已签名跳转 URL(spec §4.7)。
+     *
+     * <p>签名对<b>编码前</b>的值计算;query 值按 UTF-8 URL 编码;
+     * {@code appSecret} 只参与签名,绝不出现在 URL 里。
+     */
+    public String buildChannelUserJumpUrl(UnifiedMerchantEntity cfg, String ifCode,
+                                          String redirectUrl, String extParam) {
+        Map<String, Object> params = new LinkedHashMap<>();
+        params.put("version", VERSION);
+        params.put("signType", SIGN_TYPE);
+        params.put("reqTime", reqTime(props, OffsetDateTime.now()));
+        putIfPresent(params, "mchNo", cfg.getMchNo());
+        putIfPresent(params, "appId", cfg.getAppId());
+        putIfPresent(params, "ifCode", ifCode);
+        putIfPresent(params, "redirectUrl", redirectUrl);
+        putIfPresent(params, "extParam", extParam);
+        attachSign(params, cfg);
+
+        StringBuilder url = new StringBuilder(trimmedBaseUrl())
+                .append(PATH_CHANNEL_USER_JUMP).append('?');
+        boolean first = true;
+        for (Map.Entry<String, Object> entry : params.entrySet()) {
+            if (!first) {
+                url.append('&');
+            }
+            first = false;
+            url.append(encode(entry.getKey()))
+                    .append('=')
+                    .append(encode(String.valueOf(entry.getValue())));
+        }
+        return url.toString();
+    }
+
+    // ---- 报文构造(包内可见,单测直接钉字段集) ----
+
+    static Map<String, Object> buildOrderBody(UnifiedMerchantEntity cfg, UnifiedOrderPayload payload,
+                                              String reqTime) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        commonFields(body, cfg, reqTime);
+        putIfPresent(body, "mchOrderNo", payload.mchOrderNo());
+        body.put("amount", payload.amount());
+        body.put("currency", CURRENCY);
+        putIfPresent(body, "clientIp", payload.clientIp());
+        putIfPresent(body, "subject", payload.subject());
+        putIfPresent(body, "body", payload.body());
+        putIfPresent(body, "wayCode", payload.wayCode());
+        putIfPresent(body, "channelExtra", payload.channelExtraJson());
+        putIfPresent(body, "notifyUrl", payload.notifyUrl());
+        putIfPresent(body, "returnUrl", payload.returnUrl());
+        putIfPresent(body, "expiredTime", payload.expiredTime());
+        putIfPresent(body, "extParam", payload.extParam());
+        attachSign(body, cfg);
+        return body;
+    }
+
+    static Map<String, Object> buildQueryBody(UnifiedMerchantEntity cfg, String mchOrderNo,
+                                              String reqTime) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        commonFields(body, cfg, reqTime);
+        putIfPresent(body, "mchOrderNo", mchOrderNo);
+        attachSign(body, cfg);
+        return body;
+    }
+
+    static Map<String, Object> buildRefundBody(UnifiedMerchantEntity cfg, String mchOrderNo,
+                                               String mchRefundNo, long refundAmount,
+                                               String refundReason, String notifyUrl, String reqTime) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        commonFields(body, cfg, reqTime);
+        putIfPresent(body, "mchOrderNo", mchOrderNo);
+        putIfPresent(body, "mchRefundNo", mchRefundNo);
+        body.put("refundAmount", refundAmount);
+        putIfPresent(body, "refundReason", refundReason);
+        body.put("currency", CURRENCY);
+        putIfPresent(body, "notifyUrl", notifyUrl);
+        attachSign(body, cfg);
+        return body;
+    }
+
+    static Map<String, Object> buildRefundQueryBody(UnifiedMerchantEntity cfg, String mchRefundNo,
+                                                    String reqTime) {
+        Map<String, Object> body = new LinkedHashMap<>();
+        commonFields(body, cfg, reqTime);
+        putIfPresent(body, "mchRefundNo", mchRefundNo);
+        attachSign(body, cfg);
+        return body;
+    }
+
+    /**
+     * 生成 {@code reqTime},按 {@code unified.pay.req-time-format} 切换
+     * {@code compact14}(默认)/ {@code epoch13} / {@code epoch10}(spec §4.2)。
+     * 未知取值按默认 {@code compact14} 处理。
+     */
+    static String reqTime(UnifiedPayProperties props, OffsetDateTime now) {
+        String format = props.getReqTimeFormat();
+        if (format == null) {
+            format = "compact14";
+        }
+        return switch (format) {
+            case "epoch13" -> String.valueOf(now.toInstant().toEpochMilli());
+            case "epoch10" -> String.valueOf(now.toInstant().getEpochSecond());
+            default -> now.format(COMPACT_14);
+        };
+    }
+
+    // ---- 发送与解析 ----
+
+    private JsonNode post(String api, String path, Map<String, Object> body,
+                          UnifiedMerchantEntity cfg, String mchOrderNo, String mchRefundNo) {
+        String raw;
+        try {
+            raw = transport.post(trimmedBaseUrl() + path, camelMapper.writeValueAsString(body));
+        } catch (JsonProcessingException e) {
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游请求序列化失败", e);
+        }
+
+        JsonNode root;
+        try {
+            root = camelMapper.readTree(raw);
+        } catch (JsonProcessingException e) {
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游响应不是合法 JSON", e);
+        }
+        if (root == null || !root.isObject()) {
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游响应不是 JSON 对象");
+        }
+
+        // data 可能是 JSON 字符串:必须先解包再验签(verifyData 对非对象按空参数集处理)
+        JsonNode data = UnifiedResponseParser.data(root);
+        String sign = textOrNull(root.get("sign"));
+        if (!UnifiedSigner.verifyData(data, sign, cfg.getAppSecret())) {
+            log.error("[统一支付] {} 响应验签失败 mchOrderNo={} mchRefundNo={}",
+                    api, mchOrderNo, mchRefundNo);
+            throw new UnifiedApiException(UnifiedApiException.CODE_SIGN, "上游响应验签失败");
+        }
+
+        JsonNode codeNode = root.get("code");
+        if (codeNode == null || codeNode.isNull()) {
+            log.error("[统一支付] {} 响应缺少 code mchOrderNo={} mchRefundNo={}",
+                    api, mchOrderNo, mchRefundNo);
+            throw new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "上游响应缺少 code");
+        }
+        int code = codeNode.asInt(UnifiedApiException.CODE_TRANSPORT);
+        if (code != 0 && code != 200) {
+            String msg = textOrNull(root.get("msg"));
+            log.error("[统一支付] {} 业务失败 mchOrderNo={} mchRefundNo={} code={} msg={}",
+                    api, mchOrderNo, mchRefundNo, code, msg);
+            throw new UnifiedApiException(code, msg);
+        }
+        return data;
+    }
+
+    // ---- 小工具 ----
+
+    private static void commonFields(Map<String, Object> body, UnifiedMerchantEntity cfg, String reqTime) {
+        body.put("version", VERSION);
+        body.put("signType", SIGN_TYPE);
+        body.put("reqTime", reqTime);
+        putIfPresent(body, "mchNo", cfg.getMchNo());
+        putIfPresent(body, "appId", cfg.getAppId());
+    }
+
+    private static void attachSign(Map<String, Object> body, UnifiedMerchantEntity cfg) {
+        body.put("sign", UnifiedSigner.sign(body, cfg.getAppSecret()));
+    }
+
+    private static void putIfPresent(Map<String, Object> body, String key, String value) {
+        if (value != null && !value.isBlank()) {
+            body.put(key, value);
+        }
+    }
+
+    private static String textOrNull(JsonNode node) {
+        return node == null || node.isNull() ? null : node.asText();
+    }
+
+    private static String encode(String value) {
+        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
+    }
+
+    private String trimmedBaseUrl() {
+        String base = props.getBaseUrl() == null ? "" : props.getBaseUrl();
+        int end = base.length();
+        while (end > 0 && base.charAt(end - 1) == '/') {
+            end--;
+        }
+        return base.substring(0, end);
+    }
+}

+ 30 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedOrderPayload.java

@@ -0,0 +1,30 @@
+package com.payment.platform.module.payment.unified.client;
+
+/**
+ * 下单入参(spec §4.1 / Task 4 brief)。
+ *
+ * @param mchOrderNo       商户订单号(我方 {@code order_no})
+ * @param amount           金额,单位分
+ * @param subject          标题
+ * @param body             描述;空时由服务层用 {@code subject} 顶上
+ * @param wayCode          支付方式
+ * @param channelExtraJson 上游要求的 {@code channelExtra} JSON <b>字符串</b>(不是对象)
+ * @param notifyUrl        上游订单通知地址
+ * @param returnUrl        前端回跳地址
+ * @param expiredTime      过期间隔(秒)的字符串形态
+ * @param clientIp         发起支付的客户端 IP
+ * @param extParam         透传参数
+ */
+public record UnifiedOrderPayload(
+        String mchOrderNo,
+        long amount,
+        String subject,
+        String body,
+        String wayCode,
+        String channelExtraJson,
+        String notifyUrl,
+        String returnUrl,
+        String expiredTime,
+        String clientIp,
+        String extParam) {
+}

+ 121 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedResponseParser.java

@@ -0,0 +1,121 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.NullNode;
+
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.util.Set;
+
+/**
+ * 上游响应解析(spec §4.3 / §4.5)。
+ *
+ * <p>纯函数,不依赖 Spring。{@link #data(JsonNode)} 统一了「data 是对象 / 是 JSON
+ * 字符串 / 缺失」三种形态 —— 调用方必须先解包再验签({@link UnifiedSigner#verifyData}
+ * 对非对象节点按空参数集处理)。
+ */
+public final class UnifiedResponseParser {
+
+    /** 上游状态枚举 0–6(spec §4.5);订单与退款共用。 */
+    private static final Set<String> VALID_STATES = Set.of("0", "1", "2", "3", "4", "5", "6");
+
+    private static final ZoneId SHANGHAI = ZoneId.of("Asia/Shanghai");
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private UnifiedResponseParser() {
+    }
+
+    /**
+     * 取响应 {@code data} 并统一成 {@link JsonNode}。
+     *
+     * <p>对象原样返回;JSON 字符串解包成对象;缺失/null/空串/非法 JSON 一律返回
+     * {@link NullNode},调用方无需判空。
+     */
+    public static JsonNode data(JsonNode root) {
+        if (root == null || !root.isObject()) {
+            return NullNode.getInstance();
+        }
+        JsonNode data = root.get("data");
+        if (data == null || data.isNull()) {
+            return NullNode.getInstance();
+        }
+        if (!data.isTextual()) {
+            return data;
+        }
+        String text = data.asText();
+        if (text.isBlank()) {
+            return NullNode.getInstance();
+        }
+        try {
+            return MAPPER.readTree(text);
+        } catch (JsonProcessingException e) {
+            return NullNode.getInstance();
+        }
+    }
+
+    /**
+     * 取订单/退款状态:{@code state} 优先,缺失或空白时回退 {@code orderState};
+     * 只有 0–6 视为有效值,其余返回 {@code null}(R11:未知状态不回退,当作没读到)。
+     */
+    public static String pickState(JsonNode data) {
+        if (data == null || !data.isObject()) {
+            return null;
+        }
+        String state = text(data.get("state"));
+        if (state != null) {
+            return VALID_STATES.contains(state) ? state : null;
+        }
+        String orderState = text(data.get("orderState"));
+        return orderState != null && VALID_STATES.contains(orderState) ? orderState : null;
+    }
+
+    /**
+     * 取 {@code payDataType} 原值(大小写不敏感的比较由调用方做,spec §4.3)。
+     * 缺失或空白返回 {@code null}。
+     */
+    public static String pickPayDataType(JsonNode data) {
+        if (data == null || !data.isObject()) {
+            return null;
+        }
+        return text(data.get("payDataType"));
+    }
+
+    /**
+     * 取毫秒时间戳字段;缺失/null/0/非数字一律返回 {@code null}。
+     */
+    public static Long epochMillis(JsonNode data, String field) {
+        if (data == null || !data.isObject()) {
+            return null;
+        }
+        JsonNode node = data.get(field);
+        if (node == null || node.isNull()) {
+            return null;
+        }
+        long value = node.asLong(0L);
+        return value == 0L ? null : value;
+    }
+
+    /** 固定按 Asia/Shanghai 转成 {@link OffsetDateTime},不依赖机器默认时区。 */
+    public static OffsetDateTime toShanghai(Long epochMillis) {
+        if (epochMillis == null) {
+            return null;
+        }
+        return Instant.ofEpochMilli(epochMillis).atZone(SHANGHAI).toOffsetDateTime();
+    }
+
+    private static String text(JsonNode node) {
+        if (node == null || node.isNull() || node.isContainerNode()) {
+            return null;
+        }
+        String value = node.asText();
+        if (value == null) {
+            return null;
+        }
+        value = value.trim();
+        return value.isEmpty() ? null : value;
+    }
+}

+ 15 - 0
java/src/main/java/com/payment/platform/module/payment/unified/client/UnifiedTransport.java

@@ -0,0 +1,15 @@
+package com.payment.platform.module.payment.unified.client;
+
+/**
+ * 上游 HTTP 传输抽象。
+ *
+ * <p>生产实现是 {@link JdkUnifiedTransport};单测用桩实现,不启动网络。
+ */
+public interface UnifiedTransport {
+
+    /** 发送 JSON POST,返回响应原文。 */
+    String post(String url, String jsonBody);
+
+    /** 发送 GET,返回响应原文。 */
+    String get(String url);
+}

+ 113 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/JdkUnifiedTransportTest.java

@@ -0,0 +1,113 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+
+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;
+
+/**
+ * JDK HttpClient 传输(spec §6.2 / Task 4 brief)。
+ *
+ * <p>用本机 {@link HttpServer},不依赖外网。
+ */
+class JdkUnifiedTransportTest {
+
+    @Test
+    void readTimeout_throwsTransportCode() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/slow", exchange -> {
+            sleepQuietly(300);
+            try {
+                respond(exchange, 200, "{\"code\":0}");
+            } catch (IOException ignored) {
+                // 客户端已超时断开,写响应失败是预期
+            }
+        });
+        server.start();
+        try {
+            UnifiedPayProperties props = new UnifiedPayProperties();
+            props.setConnectTimeoutMs(1000);
+            props.setReadTimeoutMs(100);
+            JdkUnifiedTransport transport = new JdkUnifiedTransport(props);
+
+            UnifiedApiException e = assertThrows(UnifiedApiException.class,
+                    () -> transport.post(base(server) + "/slow", "{}"));
+
+            assertEquals(UnifiedApiException.CODE_TRANSPORT, e.getCode());
+            assertFalse(e.isBusinessRejection());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void success_returnsResponseBodyVerbatim() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/ok", exchange -> respond(exchange, 200, "{\"code\":0,\"msg\":\"ok\"}"));
+        server.start();
+        try {
+            UnifiedPayProperties props = new UnifiedPayProperties();
+            props.setConnectTimeoutMs(1000);
+            props.setReadTimeoutMs(1000);
+            JdkUnifiedTransport transport = new JdkUnifiedTransport(props);
+
+            assertEquals("{\"code\":0,\"msg\":\"ok\"}",
+                    transport.post(base(server) + "/ok", "{\"a\":1}"));
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    @Test
+    void non2xx_throwsTransportCodeWithStatusButWithoutBody() throws Exception {
+        HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+        server.createContext("/boom", exchange -> respond(exchange, 502, "SENSITIVE-UPSTREAM-BODY"));
+        server.start();
+        try {
+            UnifiedPayProperties props = new UnifiedPayProperties();
+            props.setConnectTimeoutMs(1000);
+            props.setReadTimeoutMs(1000);
+            JdkUnifiedTransport transport = new JdkUnifiedTransport(props);
+
+            UnifiedApiException e = assertThrows(UnifiedApiException.class,
+                    () -> transport.post(base(server) + "/boom", "{}"));
+
+            assertEquals(UnifiedApiException.CODE_TRANSPORT, e.getCode());
+            assertTrue(e.getMessage().contains("502"), e.getMessage());
+            assertFalse(e.getMessage().contains("SENSITIVE-UPSTREAM-BODY"), e.getMessage());
+        } finally {
+            server.stop(0);
+        }
+    }
+
+    private static void respond(HttpExchange exchange, int status, String body) throws IOException {
+        try {
+            byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+            exchange.sendResponseHeaders(status, bytes.length);
+            exchange.getResponseBody().write(bytes);
+        } finally {
+            exchange.close();
+        }
+    }
+
+    private static void sleepQuietly(long millis) {
+        try {
+            Thread.sleep(millis);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    private static String base(HttpServer server) {
+        return "http://127.0.0.1:" + server.getAddress().getPort();
+    }
+}

+ 351 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedClientTest.java

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

+ 129 - 0
java/src/test/java/com/payment/platform/module/payment/unified/client/UnifiedResponseParserTest.java

@@ -0,0 +1,129 @@
+package com.payment.platform.module.payment.unified.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 上游响应解析(spec §4.3 / §4.5 / Task 4 brief)。
+ *
+ * <p>固定毫秒 {@code 1760000000000} 在 Asia/Shanghai 是 2025-10-09T16:53:20+08:00
+ * (brief 写的「2026-10-09」是笔误,毫秒值是 2025 年)。断言全部按上海时区取字段,
+ * 不依赖运行机器的默认时区。
+ */
+class UnifiedResponseParserTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    @Test
+    void data_objectIsReturnedAsIs() throws Exception {
+        JsonNode root = MAPPER.readTree("{\"code\":0,\"data\":{\"state\":\"2\"}}");
+        JsonNode data = UnifiedResponseParser.data(root);
+        assertTrue(data.isObject());
+        assertEquals("2", data.path("state").asText());
+    }
+
+    @Test
+    void data_jsonStringIsUnwrappedToObject() throws Exception {
+        JsonNode root = MAPPER.readTree(
+                "{\"code\":0,\"data\":\"{\\\"state\\\":\\\"2\\\",\\\"payOrderId\\\":\\\"P1\\\"}\"}");
+        JsonNode data = UnifiedResponseParser.data(root);
+        assertTrue(data.isObject());
+        assertEquals("2", data.path("state").asText());
+        assertEquals("P1", data.path("payOrderId").asText());
+    }
+
+    @Test
+    void data_missingNullOrEmpty_isNullNode() throws Exception {
+        assertTrue(UnifiedResponseParser.data(null).isNull());
+        assertTrue(UnifiedResponseParser.data(MAPPER.readTree("{\"code\":5}")).isNull());
+        assertTrue(UnifiedResponseParser.data(MAPPER.readTree("{\"code\":0,\"data\":null}")).isNull());
+        assertTrue(UnifiedResponseParser.data(MAPPER.readTree("{\"code\":0,\"data\":\"\"}")).isNull());
+        assertTrue(UnifiedResponseParser.data(MAPPER.readTree("{\"code\":0,\"data\":\"not-json\"}")).isNull());
+    }
+
+    @Test
+    void pickState_stateWinsOverOrderState() throws Exception {
+        assertEquals("2", UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"state\":\"2\",\"orderState\":\"1\"}")));
+    }
+
+    @Test
+    void pickState_fallsBackToOrderStateOnlyWhenStateAbsentOrBlank() throws Exception {
+        assertEquals("1", UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"orderState\":\"1\"}")));
+        assertEquals("6", UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"state\":\" \",\"orderState\":\"6\"}")));
+    }
+
+    @Test
+    void pickState_unknownStateReturnsNullWithoutFallback() throws Exception {
+        // R11:state 存在但非法时视为「没读到状态」,绝不回退 orderState
+        assertNull(UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"state\":\"99\",\"orderState\":\"2\"}")));
+        assertNull(UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"state\":\"7\"}")));
+    }
+
+    @Test
+    void pickState_acceptsZeroThroughSix() throws Exception {
+        for (int i = 0; i <= 6; i++) {
+            assertEquals(String.valueOf(i), UnifiedResponseParser.pickState(
+                    MAPPER.readTree("{\"state\":\"" + i + "\"}")));
+        }
+        assertEquals("6", UnifiedResponseParser.pickState(
+                MAPPER.readTree("{\"orderState\":6}")));
+    }
+
+    @Test
+    void pickState_nullOrEmptyDataReturnsNull() {
+        assertNull(UnifiedResponseParser.pickState(null));
+        assertNull(UnifiedResponseParser.pickState(MAPPER.createObjectNode()));
+    }
+
+    @Test
+    void pickPayDataType_returnsRawValue() throws Exception {
+        assertEquals("payurl", UnifiedResponseParser.pickPayDataType(
+                MAPPER.readTree("{\"payDataType\":\"payurl\"}")));
+        assertEquals("payUrl", UnifiedResponseParser.pickPayDataType(
+                MAPPER.readTree("{\"payDataType\":\"payUrl\"}")));
+        assertNull(UnifiedResponseParser.pickPayDataType(
+                MAPPER.readTree("{\"payDataType\":\"  \"}")));
+        assertNull(UnifiedResponseParser.pickPayDataType(MAPPER.readTree("{}")));
+    }
+
+    @Test
+    void epochMillis_zeroNullOrMissingIsNull() throws Exception {
+        assertNull(UnifiedResponseParser.epochMillis(
+                MAPPER.readTree("{\"successTime\":0}"), "successTime"));
+        assertNull(UnifiedResponseParser.epochMillis(
+                MAPPER.readTree("{}"), "successTime"));
+        assertNull(UnifiedResponseParser.epochMillis(
+                MAPPER.readTree("{\"successTime\":null}"), "successTime"));
+        assertEquals(Long.valueOf(1760000000000L), UnifiedResponseParser.epochMillis(
+                MAPPER.readTree("{\"successTime\":1760000000000}"), "successTime"));
+        // 上游可能把时间戳当字符串发
+        assertEquals(Long.valueOf(1760000000000L), UnifiedResponseParser.epochMillis(
+                MAPPER.readTree("{\"successTime\":\"1760000000000\"}"), "successTime"));
+    }
+
+    @Test
+    void toShanghai_alwaysUsesShanghaiZone() {
+        OffsetDateTime t = UnifiedResponseParser.toShanghai(1760000000000L);
+        assertEquals(2025, t.getYear());
+        assertEquals(10, t.getMonthValue());
+        assertEquals(9, t.getDayOfMonth());
+        assertEquals(16, t.getHour());
+        assertEquals(53, t.getMinute());
+        assertEquals(20, t.getSecond());
+        assertEquals(ZoneOffset.ofHours(8), t.getOffset());
+        assertNull(UnifiedResponseParser.toShanghai(null));
+    }
+}