Sfoglia il codice sorgente

feat(统一支付): 开放接口与渠道用户跳转

alphaH 1 settimana fa
parent
commit
8317c712ff

+ 139 - 0
java/src/main/java/com/payment/platform/module/payment/unified/controller/open/UnifiedOpenController.java

@@ -0,0 +1,139 @@
+package com.payment.platform.module.payment.unified.controller.open;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedChannelUserJumpCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderQueryRequest;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundQueryRequest;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedChannelUserJumpVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedRefundVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.service.UnifiedChannelUserService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 统一支付开放 API —— 下单、查单、退款、退款查询、渠道用户跳转。
+ *
+ * <p>认证走 {@code TenantApiKeyAuthFilter}(Authorization: ApiKey xxx + Signature 头),
+ * 认证成功后把 {@code openapi.tenantId} / {@code openapi.apiKey} 写进 request attribute,
+ * 本控制器只读这两个属性。租户号缺失即 401,且先于任何服务调用。
+ *
+ * <p>口径(R46):
+ * <ul>
+ *   <li>{@code order/create} 与 {@code refund} 直接复用内部命令对象作请求体;</li>
+ *   <li>查询按 {@code orderNo}/{@code refundNo} 优先,{@code mchNo} 保留但不参与谓词
+ *       —— API Key 是租户级凭证,同租户多商户不是越权边界;</li>
+ *   <li>查不到统一 404,不区分「不存在」与「跨租户」;</li>
+ *   <li>下单的 {@code clientIp} 只在调用方未传时补 {@code request.getRemoteAddr()};</li>
+ *   <li>跳转 URL 的 {@code redirectUrl} 只校验 scheme(浏览器跳转,允许内网)。</li>
+ * </ul>
+ *
+ * <p>开放视图用 {@code Unified*VO#open},不带内部主键 {@code id} 与租户归属。
+ */
+@Slf4j
+@Tag(name = "统一支付-开放API")
+@RestController
+@RequestMapping("/payment/openapi/unified")
+@RequiredArgsConstructor
+public class UnifiedOpenController {
+
+    private final UnifiedOrderService orderService;
+    private final UnifiedRefundService refundService;
+    private final UnifiedChannelUserService channelUserService;
+    private final HttpServletRequest request;
+
+    @PostMapping("/order/create")
+    @Operation(summary = "统一下单(API Key 认证)")
+    public Result<UnifiedOrderVO> orderCreate(@Valid @RequestBody UnifiedOrderCreateCommand cmd) {
+        Long tenantId = requireTenantId();
+        // 调用方未传客户端 IP 时用传输层来源;传了就不覆盖(可能是经过代理的真实用户 IP)
+        if (StrUtil.isBlank(cmd.getClientIp())) {
+            cmd.setClientIp(request.getRemoteAddr());
+        }
+        UnifiedOrderEntity order = orderService.create(cmd, tenantId, getOpenapiApiKey(),
+                UnifiedSource.OPENAPI);
+        return Result.ok(UnifiedOrderVO.open(order, orderService.refundableAmount(order)));
+    }
+
+    @PostMapping("/order/query")
+    @Operation(summary = "查询订单(API Key 认证)")
+    public Result<UnifiedOrderVO> orderQuery(@Valid @RequestBody UnifiedOrderQueryRequest query) {
+        Long tenantId = requireTenantId();
+        // orderNo 优先;mchNo 不参与谓词(R46)。两者都空/查不到都是同一个 404。
+        UnifiedOrderEntity order = StrUtil.isNotBlank(query.getOrderNo())
+                ? orderService.findByOrderNo(tenantId, query.getOrderNo())
+                : orderService.findByOutTradeNo(tenantId, query.getOutTradeNo());
+        if (order == null) {
+            throw new BusinessException(404, "订单不存在");
+        }
+        return Result.ok(UnifiedOrderVO.open(order, orderService.refundableAmount(order)));
+    }
+
+    @PostMapping("/refund")
+    @Operation(summary = "发起退款(API Key 认证)")
+    public Result<UnifiedRefundVO> refund(@Valid @RequestBody UnifiedRefundCreateCommand cmd) {
+        Long tenantId = requireTenantId();
+        UnifiedRefundEntity refund = refundService.createRefund(cmd, tenantId, getOpenapiApiKey(),
+                UnifiedSource.OPENAPI);
+        return Result.ok(UnifiedRefundVO.open(refund));
+    }
+
+    @PostMapping("/refund/query")
+    @Operation(summary = "查询退款(API Key 认证)")
+    public Result<UnifiedRefundVO> refundQuery(@Valid @RequestBody UnifiedRefundQueryRequest query) {
+        Long tenantId = requireTenantId();
+        UnifiedRefundEntity refund = StrUtil.isNotBlank(query.getRefundNo())
+                ? refundService.findByRefundNo(tenantId, query.getRefundNo())
+                : refundService.findByOutRefundNo(tenantId, query.getOutRefundNo());
+        if (refund == null) {
+            throw new BusinessException(404, "退款单不存在");
+        }
+        return Result.ok(UnifiedRefundVO.open(refund));
+    }
+
+    @PostMapping("/channel-user/jump-url")
+    @Operation(summary = "获取渠道用户跳转 URL(API Key 认证)")
+    public Result<UnifiedChannelUserJumpVO> channelUserJumpUrl(
+            @Valid @RequestBody UnifiedChannelUserJumpCommand cmd) {
+        Long tenantId = requireTenantId();
+        return Result.ok(channelUserService.build(tenantId, cmd));
+    }
+
+    // ---------- helpers ----------
+
+    /** 五个端点共用的认证后置守卫:没有租户上下文即 401,先于任何服务调用。 */
+    private Long requireTenantId() {
+        Long tenantId = getOpenapiTenantId();
+        if (tenantId == null) {
+            throw new BusinessException(401, "未通过 API Key 认证");
+        }
+        return tenantId;
+    }
+
+    private Long getOpenapiTenantId() {
+        Object tid = request.getAttribute("openapi.tenantId");
+        return tid instanceof Long ? (Long) tid : null;
+    }
+
+    private String getOpenapiApiKey() {
+        Object key = request.getAttribute("openapi.apiKey");
+        return key != null ? key.toString() : null;
+    }
+}

+ 27 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedChannelUserJumpCommand.java

@@ -0,0 +1,27 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+/**
+ * 开放 API「获取渠道用户 ID」跳转入参(spec §4.7)。
+ *
+ * <p>{@code redirectUrl} 是**浏览器**跳转目标,不是服务端出站地址:只校验
+ * scheme ∈ {http, https},不拒绝内网地址(企业内网回跳是合法场景,R46)。
+ */
+@Data
+public class UnifiedChannelUserJumpCommand {
+
+    /** 上游商户号;租户只有一条配置时可空(服务层解析)。 */
+    private String mchNo;
+
+    /** 授权完成后浏览器跳回的地址;必须是 http/https。 */
+    @NotBlank(message = "redirectUrl 不能为空")
+    private String redirectUrl;
+
+    /** 渠道标识;空则用商户配置的 {@code channelUserIfCode}。 */
+    private String ifCode;
+
+    /** 透传参数,原样带到上游。 */
+    private String extParam;
+}

+ 25 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedOrderQueryRequest.java

@@ -0,0 +1,25 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import lombok.Data;
+
+/**
+ * 开放 API 订单查询入参。
+ *
+ * <p>定位次序:{@code orderNo} 优先,其次 {@code outTradeNo};两者都空/查不到统一
+ * 404「订单不存在」(不区分「不存在」与「跨租户」,避免成为探针)。
+ *
+ * <p>★ {@code mchNo} 仅为兼容调用方既有报文保留,**不参与查询谓词**:API Key 是
+ * 租户级凭证,同一租户下的多个商户不是越权边界(R46)。
+ */
+@Data
+public class UnifiedOrderQueryRequest {
+
+    /** 上游商户号;保留字段,不参与查询。 */
+    private String mchNo;
+
+    /** 我方订单号;优先于 {@code outTradeNo}。 */
+    private String orderNo;
+
+    /** 调用方下单时的商户订单号。 */
+    private String outTradeNo;
+}

+ 25 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/req/UnifiedRefundQueryRequest.java

@@ -0,0 +1,25 @@
+package com.payment.platform.module.payment.unified.dto.req;
+
+import lombok.Data;
+
+/**
+ * 开放 API 退款查询入参。
+ *
+ * <p>定位次序:{@code refundNo} 优先,其次 {@code outRefundNo};两者都空/查不到统一
+ * 404「退款单不存在」(不区分「不存在」与「跨租户」)。
+ *
+ * <p>★ {@code mchNo} 仅为兼容调用方既有报文保留,**不参与查询谓词**(R46):
+ * API Key 是租户级凭证,同一租户下的多个商户不是越权边界。
+ */
+@Data
+public class UnifiedRefundQueryRequest {
+
+    /** 上游商户号;保留字段,不参与查询。 */
+    private String mchNo;
+
+    /** 我方退款单号;优先于 {@code outRefundNo}。 */
+    private String refundNo;
+
+    /** 调用方退款单号。 */
+    private String outRefundNo;
+}

+ 21 - 0
java/src/main/java/com/payment/platform/module/payment/unified/dto/vo/UnifiedChannelUserJumpVO.java

@@ -0,0 +1,21 @@
+package com.payment.platform.module.payment.unified.dto.vo;
+
+import lombok.Data;
+
+/**
+ * 渠道用户跳转 URL(开放 API 应答体)。
+ *
+ * <p>{@code url} 是含签名的完整上游地址;{@code appSecret} 只参与签名计算,
+ * 绝不出现在 URL 里。
+ */
+@Data
+public class UnifiedChannelUserJumpVO {
+
+    private String url;
+
+    public static UnifiedChannelUserJumpVO of(String url) {
+        UnifiedChannelUserJumpVO vo = new UnifiedChannelUserJumpVO();
+        vo.setUrl(url);
+        return vo;
+    }
+}

+ 80 - 0
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedChannelUserService.java

@@ -0,0 +1,80 @@
+package com.payment.platform.module.payment.unified.service;
+
+import cn.hutool.core.util.StrUtil;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedChannelUserJumpCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedChannelUserJumpVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.net.URI;
+import java.util.Locale;
+
+/**
+ * 「获取渠道用户 ID」跳转 URL(spec §4.7)。
+ *
+ * <p>只做三件事:校验 {@code redirectUrl} 形状、按租户解析商户(含停用闸)、
+ * 委托 {@link UnifiedClient} 拼出带签名的完整地址。
+ *
+ * <p>★ {@code redirectUrl} 是**浏览器**跳转目标,不是服务端出站回调:
+ * 这里只校验 scheme ∈ {http, https},**不**复用
+ * {@code UnifiedCallbackService#isAllowedCallbackUrl} —— 那一份拒绝内网地址,
+ * 用在这里会误伤企业内网回跳(R46)。
+ *
+ * <p>{@code appSecret} 只进入签名计算,本类不打印、不返回它。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class UnifiedChannelUserService {
+
+    private final UnifiedMerchantService merchantService;
+    private final UnifiedClient client;
+
+    /**
+     * @param tenantId 认证上下文给出的租户;必须非空(控制器已挡 401)
+     * @throws BusinessException 400 redirectUrl 非 http/https / 多条未指定 mch_no / 商户已停用;
+     *                           404 租户尚未配置商户
+     */
+    public UnifiedChannelUserJumpVO build(Long tenantId, UnifiedChannelUserJumpCommand cmd) {
+        if (cmd == null) {
+            throw new BusinessException(400, "请求不能为空");
+        }
+        String redirectUrl = cmd.getRedirectUrl();
+        if (!isHttpUrl(redirectUrl)) {
+            throw new BusinessException(400, "redirectUrl 必须是 http/https 地址");
+        }
+
+        // resolveForTenant 已含停用校验;显式再钉一次,防止未来实现分叉时静默放过停用商户
+        UnifiedMerchantEntity cfg = merchantService.resolveForTenant(tenantId, cmd.getMchNo());
+        merchantService.requireEnabled(cfg);
+
+        String ifCode = StrUtil.isNotBlank(cmd.getIfCode())
+                ? cmd.getIfCode()
+                : cfg.getChannelUserIfCode();
+        String url = client.buildChannelUserJumpUrl(cfg, ifCode, redirectUrl, cmd.getExtParam());
+        return UnifiedChannelUserJumpVO.of(url);
+    }
+
+    /** 只允许 http/https(大小写不敏感);空值、无 scheme、无法解析一律拒绝。 */
+    static boolean isHttpUrl(String url) {
+        if (StrUtil.isBlank(url)) {
+            return false;
+        }
+        URI uri;
+        try {
+            uri = URI.create(url.trim());
+        } catch (IllegalArgumentException e) {
+            return false;
+        }
+        String scheme = uri.getScheme();
+        if (scheme == null) {
+            return false;
+        }
+        scheme = scheme.toLowerCase(Locale.ROOT);
+        return "http".equals(scheme) || "https".equals(scheme);
+    }
+}

+ 447 - 0
java/src/test/java/com/payment/platform/module/payment/unified/controller/open/UnifiedOpenControllerTest.java

@@ -0,0 +1,447 @@
+package com.payment.platform.module.payment.unified.controller.open;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.config.JacksonConfig;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.common.response.Result;
+import com.payment.platform.core.security.TenantApiKeyAuthFilter;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedChannelUserJumpCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderQueryRequest;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundCreateCommand;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedRefundQueryRequest;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedChannelUserJumpVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedRefundVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
+import com.payment.platform.module.payment.unified.service.UnifiedChannelUserService;
+import com.payment.platform.module.payment.unified.service.UnifiedOrderService;
+import com.payment.platform.module.payment.unified.service.UnifiedRefundService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.function.Executable;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 统一支付开放 API 控制器(Task 15)。
+ *
+ * <p>直调方法,角色由 {@link MockHttpServletRequest} 上由认证 filter 注入的两个属性承担:
+ * {@code openapi.tenantId} 与 {@code openapi.apiKey}。本类只钉控制器自己的职责:
+ * <ol>
+ *   <li>租户上下文缺失时**先于任何服务调用**返 401;</li>
+ *   <li>下单时 {@code clientIp} 只在调用方没传时补 {@code request.getRemoteAddr()};</li>
+ *   <li>查询以 {@code orderNo}/{@code refundNo} 优先,且租户条件显式传给服务;</li>
+ *   <li>开放视图不暴露内部 {@code id}(序列化后按字段名断言,不做子串匹配);</li>
+ *   <li>5 条映射与 {@code TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS} 逐字一致 ——
+ *       两边都反射读真实注解/数组,不在测试里抄一份路径清单。</li>
+ * </ol>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedOpenControllerTest {
+
+    private static final String PREFIX = "/payment/openapi/unified";
+    /** 全局 mapper(与 Web 层同一个 {@link JacksonConfig}),用于钉「开放视图没有 id」。 */
+    private static final ObjectMapper GLOBAL_MAPPER = new JacksonConfig().objectMapper();
+
+    @Mock private UnifiedOrderService orderService;
+    @Mock private UnifiedRefundService refundService;
+    @Mock private UnifiedChannelUserService channelUserService;
+
+    // ────────────────────────────── 认证上下文 ──────────────────────────────
+
+    /**
+     * 五个端点共用同一道守卫:没有 {@code openapi.tenantId} 就 401,且一次服务都不许调。
+     *
+     * <p>逐个端点跑,确保将来某个方法漏掉守卫会立刻变红(而不是被别的方法代跑)。
+     */
+    @Test
+    void allFiveEndpoints_withoutOpenapiTenant_throw401BeforeAnyServiceCall() {
+        UnifiedOpenController controller = controller(request(null, null));
+
+        for (Executable call : List.<Executable>of(
+                () -> controller.orderCreate(createCmd("T1")),
+                () -> controller.orderQuery(orderQuery("UO1", null)),
+                () -> controller.refund(refundCmd()),
+                () -> controller.refundQuery(refundQuery("UR1", null)),
+                () -> controller.channelUserJumpUrl(jumpCmd()))) {
+            BusinessException ex = assertThrows(BusinessException.class, call);
+            assertEquals(401, ex.getCode(), "无 API Key 上下文时必须 401");
+        }
+
+        verifyNoInteractions(orderService, refundService, channelUserService);
+    }
+
+    // ────────────────────────────── 下单 ──────────────────────────────
+
+    @Test
+    void create_blankClientIp_filledFromRemoteAddr_andPassesTenantApiKeySource() {
+        MockHttpServletRequest request = request(2L, "key-2");
+        request.setRemoteAddr("203.0.113.9");
+        UnifiedOpenController controller = controller(request);
+
+        UnifiedOrderCreateCommand cmd = createCmd("T1");
+        cmd.setClientIp("   ");
+        UnifiedOrderEntity saved = order(11L, 2L, "UO1", "T1");
+        when(orderService.create(any(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI)))
+                .thenReturn(saved);
+        when(orderService.refundableAmount(saved)).thenReturn(900L);
+
+        Result<UnifiedOrderVO> result = controller.orderCreate(cmd);
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> captor =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(orderService).create(captor.capture(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI));
+        assertEquals("203.0.113.9", captor.getValue().getClientIp(),
+                "调用方未传 clientIp 时必须补 request.getRemoteAddr()");
+        assertEquals(0, result.getCode());
+        assertNotNull(result.getData());
+        assertEquals("UO1", result.getData().getOrderNo());
+        assertEquals(900L, result.getData().getRefundableAmount(),
+                "开放视图必须带服务层算出的可退金额");
+        assertNull(result.getData().getId(), "开放视图不得暴露内部主键");
+    }
+
+    @Test
+    void create_explicitClientIp_isNotOverwritten() {
+        MockHttpServletRequest request = request(2L, "key-2");
+        request.setRemoteAddr("203.0.113.9");
+        UnifiedOpenController controller = controller(request);
+
+        UnifiedOrderCreateCommand cmd = createCmd("T1");
+        cmd.setClientIp("198.51.100.7");
+        when(orderService.create(any(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI)))
+                .thenReturn(order(11L, 2L, "UO1", "T1"));
+
+        controller.orderCreate(cmd);
+
+        ArgumentCaptor<UnifiedOrderCreateCommand> captor =
+                ArgumentCaptor.forClass(UnifiedOrderCreateCommand.class);
+        verify(orderService).create(captor.capture(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI));
+        assertEquals("198.51.100.7", captor.getValue().getClientIp(),
+                "调用方已传 clientIp 时不得用 remoteAddr 覆盖");
+    }
+
+    // ────────────────────────────── 查单 ──────────────────────────────
+
+    @Test
+    void orderQuery_orderNoWins_overOutTradeNo() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        UnifiedOrderEntity row = order(11L, 2L, "UO1", "T1");
+        when(orderService.findByOrderNo(2L, "UO1")).thenReturn(row);
+        when(orderService.refundableAmount(row)).thenReturn(500L);
+
+        Result<UnifiedOrderVO> result = controller.orderQuery(orderQuery("UO1", "T1"));
+
+        verify(orderService).findByOrderNo(2L, "UO1");
+        verify(orderService, never()).findByOutTradeNo(any(), any());
+        assertEquals("UO1", result.getData().getOrderNo());
+        assertEquals(500L, result.getData().getRefundableAmount());
+    }
+
+    @Test
+    void orderQuery_fallsBackToOutTradeNo_whenOrderNoBlank() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        UnifiedOrderEntity row = order(11L, 2L, "UO1", "T1");
+        when(orderService.findByOutTradeNo(2L, "T1")).thenReturn(row);
+
+        Result<UnifiedOrderVO> result = controller.orderQuery(orderQuery("  ", "T1"));
+
+        verify(orderService).findByOutTradeNo(2L, "T1");
+        verify(orderService, never()).findByOrderNo(any(), any());
+        assertEquals("T1", result.getData().getOutTradeNo());
+    }
+
+    @Test
+    void orderQuery_unknown_is404_notDistinguishedByTenant() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        when(orderService.findByOrderNo(2L, "UO-NOPE")).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.orderQuery(orderQuery("UO-NOPE", null)));
+        assertEquals(404, ex.getCode());
+    }
+
+    // ────────────────────────────── 退款 ──────────────────────────────
+
+    @Test
+    void refund_callsCreateRefundWithOpenapiSource() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        UnifiedRefundCreateCommand cmd = refundCmd();
+        UnifiedRefundEntity refund = refund(31L, "UR1", "UO1");
+        when(refundService.createRefund(any(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI)))
+                .thenReturn(refund);
+
+        Result<UnifiedRefundVO> result = controller.refund(cmd);
+
+        ArgumentCaptor<UnifiedRefundCreateCommand> captor =
+                ArgumentCaptor.forClass(UnifiedRefundCreateCommand.class);
+        verify(refundService).createRefund(captor.capture(), eq(2L), eq("key-2"),
+                eq(UnifiedSource.OPENAPI));
+        assertSame(cmd, captor.getValue(), "请求体必须原样交给服务层校验");
+        assertEquals("UR1", result.getData().getRefundNo());
+        assertNull(result.getData().getId(), "开放视图不得暴露内部主键");
+    }
+
+    /**
+     * 缺 {@code outRefundNo} 由**服务层**校验({@code UnifiedRefundService#validateCreate}),
+     * 控制器不吞异常:这里用 mock 让服务抛 400,断言它原样到达调用方。
+     */
+    @Test
+    void refund_serviceRejects400_isPropagated() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        when(refundService.createRefund(any(), eq(2L), eq("key-2"), eq(UnifiedSource.OPENAPI)))
+                .thenThrow(new BusinessException(400, "out_refund_no 不能为空"));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.refund(refundCmd()));
+        assertEquals(400, ex.getCode());
+    }
+
+    // ────────────────────────────── 退款查询 ──────────────────────────────
+
+    @Test
+    void refundQuery_refundNoWins_overOutRefundNo() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        when(refundService.findByRefundNo(2L, "UR1")).thenReturn(refund(31L, "UR1", "UO1"));
+
+        Result<UnifiedRefundVO> result = controller.refundQuery(refundQuery("UR1", "RF1"));
+
+        verify(refundService).findByRefundNo(2L, "UR1");
+        verify(refundService, never()).findByOutRefundNo(any(), any());
+        assertEquals("UR1", result.getData().getRefundNo());
+        assertNull(result.getData().getId());
+    }
+
+    @Test
+    void refundQuery_fallsBackToOutRefundNo_whenRefundNoBlank() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        UnifiedRefundEntity row = refund(31L, "UR1", "UO1");
+        row.setOutRefundNo("RF1");
+        when(refundService.findByOutRefundNo(2L, "RF1")).thenReturn(row);
+
+        Result<UnifiedRefundVO> result = controller.refundQuery(refundQuery(null, "RF1"));
+
+        verify(refundService).findByOutRefundNo(2L, "RF1");
+        verify(refundService, never()).findByRefundNo(any(), any());
+        assertEquals("RF1", result.getData().getOutRefundNo());
+    }
+
+    @Test
+    void refundQuery_unknown_is404() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        when(refundService.findByRefundNo(2L, "UR-NOPE")).thenReturn(null);
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> controller.refundQuery(refundQuery("UR-NOPE", null)));
+        assertEquals(404, ex.getCode());
+    }
+
+    // ────────────────────────────── 渠道用户跳转 ──────────────────────────────
+
+    @Test
+    void channelUserJump_returnsUrlFromService() {
+        UnifiedOpenController controller = controller(request(2L, "key-2"));
+        UnifiedChannelUserJumpCommand cmd = jumpCmd();
+        UnifiedChannelUserJumpVO vo = new UnifiedChannelUserJumpVO();
+        vo.setUrl("https://gw.example.test/jump?sign=x");
+        when(channelUserService.build(2L, cmd)).thenReturn(vo);
+
+        Result<UnifiedChannelUserJumpVO> result = controller.channelUserJumpUrl(cmd);
+
+        verify(channelUserService).build(2L, cmd);
+        assertEquals("https://gw.example.test/jump?sign=x", result.getData().getUrl());
+    }
+
+    // ────────────────────────────── 开放视图 ──────────────────────────────
+
+    @Test
+    void openOrderView_serializedWithGlobalMapper_hasNoInternalId() throws Exception {
+        UnifiedOrderEntity entity = order(99001L, 7L, "UO-X", "T-X");
+
+        JsonNode node = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedOrderVO.open(entity, 500L)));
+
+        assertFalse(node.has("id"), "开放订单视图不得含内部主键 id,读数: " + node);
+        assertFalse(node.has("tenant_id"), "开放订单视图不得含租户归属,读数: " + node);
+        // 正对照:序列化确实发生了(否则「没有 id」可能只是空对象)
+        assertTrue(node.has("order_no"), "序列化结果缺 order_no,量具无效: " + node);
+        assertEquals(500L, node.get("refundable_amount").asLong());
+    }
+
+    @Test
+    void openRefundView_serializedWithGlobalMapper_hasNoInternalId() throws Exception {
+        UnifiedRefundEntity entity = refund(99002L, "UR-X", "UO-X");
+
+        JsonNode node = GLOBAL_MAPPER.readTree(
+                GLOBAL_MAPPER.writeValueAsString(UnifiedRefundVO.open(entity)));
+
+        assertFalse(node.has("id"), "开放退款视图不得含内部主键 id,读数: " + node);
+        // 正对照
+        assertTrue(node.has("refund_no"), "序列化结果缺 refund_no,量具无效: " + node);
+        assertEquals("UR-X", node.get("refund_no").asText());
+    }
+
+    // ────────────────────────────── 认证清单接线 ──────────────────────────────
+
+    /**
+     * 两边都从源码里读:控制器注解拼出的路径集合 vs filter 数组中 unified 子集。
+     * 任何一边漏改都会红,且不需要在测试里维护第三份路径清单。
+     */
+    @Test
+    void fiveMappings_matchOpenapiAccountPathsExactly() throws Exception {
+        String prefix = UnifiedOpenController.class.getAnnotation(RequestMapping.class).value()[0];
+        assertEquals(PREFIX, prefix);
+
+        Set<String> mapped = Arrays.stream(UnifiedOpenController.class.getDeclaredMethods())
+                .filter(m -> m.isAnnotationPresent(PostMapping.class))
+                .map(UnifiedOpenControllerTest::mappedPath)
+                .collect(Collectors.toSet());
+
+        assertEquals(Set.of(
+                        PREFIX + "/order/create",
+                        PREFIX + "/order/query",
+                        PREFIX + "/refund",
+                        PREFIX + "/refund/query",
+                        PREFIX + "/channel-user/jump-url"),
+                mapped,
+                "控制器映射与 Task 15 契约不符。读数: " + mapped);
+
+        List<String> paths = Arrays.asList(
+                readStringArray(TenantApiKeyAuthFilter.class, "OPENAPI_ACCOUNT_PATHS"));
+        Set<String> filtered = paths.stream()
+                .filter(p -> p.startsWith(PREFIX + "/"))
+                .collect(Collectors.toSet());
+        assertEquals(mapped, filtered,
+                "控制器映射与 OPENAPI_ACCOUNT_PATHS 的 unified 子集必须逐字一致。读数: " + filtered);
+        // 正对照:既有 alipay 端点必须仍在数组中,证明读到的是真数组
+        assertTrue(paths.contains("/payment/openapi/alipay/order/create"),
+                "既有 alipay 端点缺失,说明量具读错了数组。读数: " + paths);
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private UnifiedOpenController controller(MockHttpServletRequest request) {
+        return new UnifiedOpenController(orderService, refundService, channelUserService, request);
+    }
+
+    private static MockHttpServletRequest request(Long tenantId, String apiKey) {
+        MockHttpServletRequest request = new MockHttpServletRequest("POST", PREFIX + "/order/create");
+        request.setContextPath("/api/v1");
+        request.setServletPath(PREFIX + "/order/create");
+        request.setRequestURI("/api/v1" + PREFIX + "/order/create");
+        // 容器形状:servletPath ≠ requestURI,属性访问不受影响,但保持与线上一致
+        if (tenantId != null) {
+            request.setAttribute("openapi.tenantId", tenantId);
+        }
+        if (apiKey != null) {
+            request.setAttribute("openapi.apiKey", apiKey);
+        }
+        return request;
+    }
+
+    private static UnifiedOrderCreateCommand createCmd(String outTradeNo) {
+        UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
+        cmd.setAmount(100L);
+        cmd.setWayCode("QR_CASHIER");
+        cmd.setSubject("测试订单");
+        cmd.setOutTradeNo(outTradeNo);
+        return cmd;
+    }
+
+    private static UnifiedOrderQueryRequest orderQuery(String orderNo, String outTradeNo) {
+        UnifiedOrderQueryRequest query = new UnifiedOrderQueryRequest();
+        query.setOrderNo(orderNo);
+        query.setOutTradeNo(outTradeNo);
+        return query;
+    }
+
+    private static UnifiedRefundCreateCommand refundCmd() {
+        UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
+        cmd.setOrderNo("UO1");
+        cmd.setRefundAmount(100L);
+        cmd.setRefundReason("测试退款");
+        cmd.setOutRefundNo("RF1");
+        return cmd;
+    }
+
+    private static UnifiedRefundQueryRequest refundQuery(String refundNo, String outRefundNo) {
+        UnifiedRefundQueryRequest query = new UnifiedRefundQueryRequest();
+        query.setRefundNo(refundNo);
+        query.setOutRefundNo(outRefundNo);
+        return query;
+    }
+
+    private static UnifiedChannelUserJumpCommand jumpCmd() {
+        UnifiedChannelUserJumpCommand cmd = new UnifiedChannelUserJumpCommand();
+        cmd.setRedirectUrl("https://merchant.example.test/cb");
+        return cmd;
+    }
+
+    private static UnifiedOrderEntity order(Long id, Long tenantId, String orderNo,
+                                            String outTradeNo) {
+        UnifiedOrderEntity entity = new UnifiedOrderEntity();
+        entity.setId(id);
+        entity.setTenantId(tenantId);
+        entity.setOrderNo(orderNo);
+        entity.setOutTradeNo(outTradeNo);
+        entity.setMchNo("M001");
+        entity.setWayCode("QR_CASHIER");
+        entity.setAmount(1000L);
+        entity.setRefundedAmount(0L);
+        entity.setTradeStatus("2");
+        return entity;
+    }
+
+    private static UnifiedRefundEntity refund(Long id, String refundNo, String orderNo) {
+        UnifiedRefundEntity entity = new UnifiedRefundEntity();
+        entity.setId(id);
+        entity.setRefundNo(refundNo);
+        entity.setOutRefundNo("RF-" + refundNo);
+        entity.setOrderNo(orderNo);
+        entity.setMchNo("M001");
+        entity.setAmount(100L);
+        entity.setState("2");
+        return entity;
+    }
+
+    private static String mappedPath(Method method) {
+        String classPrefix = UnifiedOpenController.class.getAnnotation(RequestMapping.class).value()[0];
+        return classPrefix + method.getAnnotation(PostMapping.class).value()[0];
+    }
+
+    private static String[] readStringArray(Class<?> owner, String fieldName) throws Exception {
+        Field field = owner.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return (String[]) field.get(null);
+    }
+}

+ 242 - 0
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedChannelUserServiceTest.java

@@ -0,0 +1,242 @@
+package com.payment.platform.module.payment.unified.service;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.common.exception.BusinessException;
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
+import com.payment.platform.module.payment.unified.client.UnifiedTransport;
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
+import com.payment.platform.module.payment.unified.dto.req.UnifiedChannelUserJumpCommand;
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedChannelUserJumpVO;
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
+import com.payment.platform.module.payment.unified.mapper.UnifiedMerchantMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+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;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * 渠道用户跳转 URL 服务(spec §4.7 / Task 15)。
+ *
+ * <p>这里用**真实的** {@link UnifiedMerchantService}(Mapper 为 mock)和**真实的**
+ * {@link UnifiedClient}(Transport 为「被调用即失败」的桩),因此:
+ * <ul>
+ *   <li>「多条未指定 → 400」「停用 → 400」是委托链真的跑出来的读数,不是桩替它抛的;</li>
+ *   <li>「URL 不含 appSecret」是真实签名实现产出的 URL 上量的,不是把测试自己拼的字符串再读一遍;</li>
+ *   <li>跳转 URL 生成不应发任何网络请求 —— 桩会炸。</li>
+ * </ul>
+ */
+@ExtendWith(MockitoExtension.class)
+class UnifiedChannelUserServiceTest {
+
+    private static final String BASE = "https://gw.example.test/gateway";
+    private static final String SECRET = "S3CRET-KEY-abc";
+    private static final String REDIRECT = "https://merchant.example.test/pay/cb";
+
+    @Mock private UnifiedMerchantMapper merchantMapper;
+    @Mock private UnifiedOrderMapper orderMapper;
+    @Mock private UnifiedRefundMapper refundMapper;
+
+    private UnifiedChannelUserService service;
+
+    @BeforeEach
+    void setUp() {
+        UnifiedMerchantService merchantService =
+                new UnifiedMerchantService(merchantMapper, orderMapper, refundMapper);
+        UnifiedPayProperties props = new UnifiedPayProperties();
+        props.setBaseUrl(BASE);
+        service = new UnifiedChannelUserService(merchantService,
+                new UnifiedClient(props, new NoNetworkTransport()));
+
+        // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper 可在无 Spring 上下文时求值
+        // (同款引导见 UnifiedMerchantServiceTest)。
+        MybatisConfiguration configuration = new MybatisConfiguration();
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""),
+                UnifiedMerchantEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""),
+                UnifiedOrderEntity.class);
+        TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""),
+                UnifiedRefundEntity.class);
+    }
+
+    // ────────────────────────────── redirectUrl 闸 ──────────────────────────────
+
+    @Test
+    void redirectUrl_notHttpOrHttps_is400_beforeAnyMerchantLookup() {
+        for (String bad : List.of(
+                "ftp://merchant.example.test/cb",
+                "javascript:alert(1)",
+                "merchant.example.test/cb",
+                "//merchant.example.test/cb",
+                "",
+                "   ")) {
+            BusinessException ex = assertThrows(BusinessException.class,
+                    () -> service.build(2L, cmd(bad, null)),
+                    "应拒绝 redirectUrl=" + bad);
+            assertEquals(400, ex.getCode(), "redirectUrl=" + bad + " 应返回 400");
+        }
+
+        BusinessException nullUrl = assertThrows(BusinessException.class,
+                () -> service.build(2L, cmd(null, null)));
+        assertEquals(400, nullUrl.getCode());
+
+        // 参数闸在查库之前:非法地址不得触发商户解析
+        verifyNoInteractions(merchantMapper, orderMapper, refundMapper);
+    }
+
+    @Test
+    void redirectUrl_uppercaseScheme_isAccepted() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
+
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd("HTTPS://merchant.example.test/cb", null));
+
+        assertNotNull(vo);
+        assertEquals("HTTPS://merchant.example.test/cb", params(vo.getUrl()).get("redirectUrl"));
+    }
+
+    // ────────────────────────────── 商户解析 ──────────────────────────────
+
+    @Test
+    void disabledMerchant_is400() {
+        when(merchantMapper.selectList(any()))
+                .thenReturn(List.of(merchant(11L, 2L, "M001", "1")));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.build(2L, cmd(REDIRECT, null)));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("停用"),
+                "应是「商户已停用」而不是别的 400,读数: " + ex.getMessage());
+    }
+
+    @Test
+    void multipleMerchants_withoutMchNo_is400_delegatedToResolveForTenant() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(
+                merchant(11L, 2L, "M001", "0"),
+                merchant(12L, 2L, "M002", "0")));
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.build(2L, cmd(REDIRECT, null)));
+
+        assertEquals(400, ex.getCode());
+        assertTrue(ex.getMessage().contains("mch_no"),
+                "应提示调用方指定 mch_no,读数: " + ex.getMessage());
+    }
+
+    // ────────────────────────────── ifCode 来源 ──────────────────────────────
+
+    @Test
+    void blankIfCode_usesMerchantConfig() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
+
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "  "));
+
+        Map<String, String> query = params(vo.getUrl());
+        assertEquals("fuioupay", query.get("ifCode"), "ifCode 空时应回落到 cfg.channelUserIfCode");
+        assertEquals("M001", query.get("mchNo"));
+        assertTrue(query.containsKey("sign"), "URL 必须带签名,读数: " + vo.getUrl());
+    }
+
+    @Test
+    void explicitIfCode_winsOverMerchantConfig() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
+
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, "wxpay"));
+
+        assertEquals("wxpay", params(vo.getUrl()).get("ifCode"));
+    }
+
+    // ────────────────────────────── URL 内容 ──────────────────────────────
+
+    @Test
+    void jumpUrl_isFullSignedUrl_andNeverLeaksAppSecret() {
+        when(merchantMapper.selectList(any())).thenReturn(List.of(merchant(11L, 2L, "M001", "0")));
+
+        UnifiedChannelUserJumpVO vo = service.build(2L, cmd(REDIRECT, null));
+
+        assertNotNull(vo.getUrl());
+        assertTrue(vo.getUrl().startsWith(BASE + "/har-pay/api/channelUserId/jump?"),
+                "应是完整的上游跳转地址,读数: " + vo.getUrl());
+        assertFalse(vo.getUrl().contains(SECRET),
+                "appSecret 只参与签名,绝不能出现在 URL 里");
+        Map<String, String> query = params(vo.getUrl());
+        assertEquals(REDIRECT, query.get("redirectUrl"), "redirectUrl 应可解码还原");
+        assertEquals("A1", query.get("appId"));
+        assertTrue(query.containsKey("sign"));
+    }
+
+    // ────────────────────────────── 工具 ──────────────────────────────
+
+    private static UnifiedChannelUserJumpCommand cmd(String redirectUrl, String ifCode) {
+        UnifiedChannelUserJumpCommand cmd = new UnifiedChannelUserJumpCommand();
+        cmd.setRedirectUrl(redirectUrl);
+        cmd.setIfCode(ifCode);
+        return cmd;
+    }
+
+    private static UnifiedMerchantEntity merchant(Long id, Long tenantId, String mchNo,
+                                                  String status) {
+        UnifiedMerchantEntity entity = new UnifiedMerchantEntity();
+        entity.setId(id);
+        entity.setTenantId(tenantId);
+        entity.setMchNo(mchNo);
+        entity.setAppId("A1");
+        entity.setAppSecret(SECRET);
+        entity.setMerchantName("商户" + mchNo);
+        entity.setChannelUserIfCode("fuioupay");
+        entity.setStatus(status);
+        return entity;
+    }
+
+    /** 按 UTF-8 解码 query 参数(跳转 URL 的值都由客户端 URL 编码)。 */
+    private static Map<String, String> params(String url) {
+        int question = url.indexOf('?');
+        assertTrue(question > 0, "URL 没有 query,读数: " + url);
+        Map<String, String> result = new LinkedHashMap<>();
+        for (String pair : url.substring(question + 1).split("&")) {
+            int equals = pair.indexOf('=');
+            if (equals >= 0) {
+                result.put(decode(pair.substring(0, equals)), decode(pair.substring(equals + 1)));
+            }
+        }
+        return result;
+    }
+
+    private static String decode(String value) {
+        return URLDecoder.decode(value, StandardCharsets.UTF_8);
+    }
+
+    /** 跳转 URL 只在本地拼装,任何出站调用都说明实现跑偏了。 */
+    private static final class NoNetworkTransport implements UnifiedTransport {
+        @Override
+        public String post(String url, String jsonBody) {
+            throw new AssertionError("生成跳转 URL 不应发起网络请求: " + url);
+        }
+
+        @Override
+        public String get(String url) {
+            throw new AssertionError("生成跳转 URL 不应发起网络请求: " + url);
+        }
+    }
+}