Jelajahi Sumber

fix(进件): 上游调用失败不再落 catch-all 500,改回防重试出口(R-7b)

create 的上游调用在 try 之外(try 从下一行才开始),它抛的 LeshouyingApiException
不在那条 409 护栏里;而全仓唯一的 advice(GlobalExceptionHandler)五个分支里没有
它,于是落兜底的 Exception 分支 ⇒ HTTP 500 + 业务码 500「服务器内部错误」。
500 的语义就是「重试」,重试一次就是再建一个上游商户(上游建号没有幂等键);
/commit 与 /upload-image 同理,都是一次无法撤销的重复副作用。

修法:在本控制器上加 @ExceptionHandler(LeshouyingApiException.class),照
LeshouyingOpenController 的形状,但不动全局 advice —— 那条口径只对进件这条链成立。
回 HTTP 200 + 业务码 502:业务失败在本仓的既有出口就是 HTTP 200 + 业务码,而调用方
(管理端 axios 拦截器)是按业务码取 msg 展示的,回 HTTP 502 反而会让这句防重试文案
到不了操作员眼前;业务码取 502 而非 500,是为了与兜底分支可分辨。

★ 文案两句,判据是 HTTP 语义而不是路径名单:本控制器六个端点里五个走上游,
其中 /areas 与 /mcc 是读(GET 被定义为 safe,重放无副作用 ⇒ 重试安全,回可重试口径),
/create /commit /upload-image 是写(超时或连接中断时我们无法知道上游那一次调用有没有
落地 ⇒ 只能给防重试的指令)。若改用「哪些路径是写」的名单去枚举,将来往本控制器加
端点就会漏,而漏的方向恰好是「新写端点拿到可重试文案」。

上游痕迹一个字都不进应答:e.getMessage() 逐字形如「乐收赢接口失败[HTTP_504]: …」,
既有上游名号又有上游返回码,只进日志(Global Constraint 1)。

两向变异实测(4 条,逐条读数见 .superpowers/sdd/2026-09-23-lsy-onboarding-B 的加记 86):
- RED 先行:两条新用例先跑,都红在 Status expected:<200> but was:<500>
- M-1 写腿文案换成 open 控制器那句(500/请稍后重试) ⇒ 1 红($.code 期望 502 得 500)
- M-2 把 e.getMessage() 拼进应答 ⇒ 1 红($.msg 不得含「乐收赢」那条)
- M-3 摘掉 GET 分支 ⇒ 1 红(areas 用例 $.msg 期望「请稍后重试」)
- M-4 无害改动(log.error→warn、去掉异常实参)⇒ 仍全绿
- 每次还原后 sha256 与基线 fdc4188b… 逐字节相同(47622 字节 / 669 LF / 0 CR)
  ★ M-3 第一遍是假绿:我"还原"时把 GET 分支又插了一遍(重复的是注释行也一样多),
    删的只是副本。判据是读源码发现分支还在,不是推断。

全量 mvn clean test = 647 绿(原 645 + 新增 2;49 个 surefire 报告文件全部解析到汇总行)。
alphaH 3 minggu lalu
induk
melakukan
5290d84405

+ 56 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -5,6 +5,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyMccItemDTO;
@@ -13,6 +14,7 @@ import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
 import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
+import jakarta.servlet.http.HttpServletRequest;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.RequiredArgsConstructor;
@@ -20,6 +22,7 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
@@ -510,6 +513,59 @@ public class LeshouyingOnboardingController {
         return Result.ok(onboardingService.mccList(level, parentId, StrUtil.blankToDefault(merchantType, null)));
     }
 
+    // ──────────────── 走上游失败时的出口(R-7b) ────────────────
+
+    /**
+     * 本控制器任一走上游的端点抛出 {@link LeshouyingApiException} 时的出口。
+     *
+     * <p><b>【为什么这条出口必须存在】</b>全仓唯一的 advice({@code GlobalExceptionHandler})
+     * 五个分支里**没有** {@link LeshouyingApiException} ⇒ 它落兜底的 {@code Exception} 分支,
+     * 得到 HTTP 500 + 业务码 500「服务器内部错误」—— 零信息,而 500 的语义就是「重试」。
+     * 对 {@code /create} 来说重试一次就是**再建一个上游商户**(上游建号没有幂等键);
+     * {@code /commit} 与 {@code /upload-image} 同理,都是一次无法撤销的重复副作用。
+     *
+     * <p>★ <b>为什么挂在控制器上、而不是加进 {@code GlobalExceptionHandler}</b>:那个 advice 是
+     * **全平台**的,而这条口径只对进件这条链成立 —— 同 {@code LeshouyingOpenController} 当年把三个
+     * {@code @ExceptionHandler} 挂在自己身上的两条理由(那里还记着「改用 {@code @ControllerAdvice}
+     * + {@code assignableTypes} 的话,多个 advice 之间只按 {@code @Order} 排序、不按匹配度,
+     * 是个静默的先后手问题」)。{@code ExceptionHandlerExceptionResolver} 先查 handler 自身的
+     * 处理器、再查 advice 缓存 ⇒ 本方法对**本控制器**的优先级是确定的。
+     *
+     * <p>★★ <b>两句文案,判据取 HTTP 语义而不是路径名单。</b>本控制器六个端点里五个走上游:
+     * {@code /create} 建号、{@code /commit} 提交、{@code /upload-image} 上传、
+     * {@code /areas} 与 {@code /mcc} 取参考数据(只有 {@code /page} 只读本地库)。
+     * 前三者是**写** —— 超时 / 连接中断时我们根本不知道上游那一次调用有没有落地,
+     * 所以文案不能断言「没成功」,只能给防重试的指令;方向是不对称的:多谨慎一次只是让管理员
+     * 多查一遍,乐观一次就是多一个上游商户。后两者是**读** —— GET 被定义为 safe
+     * (重放不产生副作用),重试是安全的,拿写腿那句会把一次「刷新下拉框」升级成一次人工工单。
+     * 若改用「哪些路径是写」的名单去枚举,将来往本控制器加端点就会漏 ——
+     * 而漏的方向恰好是「新写端点拿到可重试文案」,正是本方法要防的那件事。
+     *
+     * <p>★ <b>应答里零上游痕迹</b>(Global Constraint 1):{@code e.getMessage()} 逐字形如
+     * 「乐收赢接口失败[HTTP_504]: …」—— 既有上游名号又有上游返回码 ⇒ 它**只进日志**,
+     * 一个字都不进应答。这也正是不能照抄 {@code LeshouyingOpenController} 那句
+     * {@code Result.of(500, "处理未成功,请稍后重试")} 的原因:那句话对读端点成立、对写端点有害。
+     *
+     * <p>★ <b>回 HTTP 200 + 业务码 502,不是 HTTP 502</b>:本仓业务失败的既有出口就是
+     * HTTP 200 + 业务码({@code BusinessException} 那一支没有 {@code @ResponseStatus}),
+     * 而调用方(管理端 axios 拦截器)是按业务码取 {@code msg} 展示的 —— 回 HTTP 502
+     * 反而会让这句防重试文案到不了操作员眼前。业务码取 502 而不是 500:500 与兜底分支同码,
+     * 测不出「是不是真的换了出口」;而 502 在本模块已有一条既有含义({@code LsyOnboardingService}
+     * 里三处「上游成功了、但回的东西不能用」)。
+     */
+    @ExceptionHandler(LeshouyingApiException.class)
+    public Result<?> handleUpstreamFailure(LeshouyingApiException e, HttpServletRequest request) {
+        // message 已由 LeshouyingApiException 自己 sanitize(压平行终止符 + 逐个字段封顶)⇒ 进日志安全。
+        log.error("进件走上游调用失败 [{} {}]: {}", request.getMethod(), request.getRequestURI(),
+                e.getMessage(), e);
+        if ("GET".equals(request.getMethod())) {
+            // 读端点:重放无副作用 ⇒ 可以照既有「可重试」口径。
+            return Result.of(502, "数据获取未成功,请稍后重试");
+        }
+        // 写端点:上游那一次调用**可能已经生效** ⇒ 只能给防重试的指令。
+        return Result.of(502, "请求未能完成,无法确认是否已经生效(不得当作失败直接重试,请联系管理员核实)");
+    }
+
     // ──────────────── 服务端决定的值:只来自认证上下文 / 平台配置,不接受请求体指定 ────────────────
 
     /**

+ 83 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -12,6 +12,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.exception.GlobalExceptionHandler;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
@@ -66,6 +67,7 @@ import java.util.Set;
 import java.util.TreeSet;
 
 import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -285,6 +287,87 @@ class LeshouyingOnboardingControllerTest {
         }
     }
 
+    /**
+     * ★ <b>窗口的另一半边:上游调用**本身**失败时,同样不得逃逸成 catch-all 500。</b>
+     *
+     * <p>上面那条堵的是「上游已建号、本地落行失败」;本条堵的是它的反向半边 ——
+     * {@link LeshouyingApiException}(通信失败 / 上游 {@code return_code} 非成功 / 报文不可解析)
+     * 从 {@code createMerchant} 里抛出来的那一格。
+     *
+     * <p><b>【为什么这条出口必须存在】</b>上游调用在 {@code create} 方法体的中间那一行,
+     * 而下面那个 {@code try} 从**再下一行**才开始 ⇒ 它抛出的异常**不在**那条护栏里。
+     * 全仓唯一的 advice({@code GlobalExceptionHandler})五个分支里没有
+     * {@link LeshouyingApiException} ⇒ 它落兜底的 {@code Exception} 分支
+     * ⇒ <b>HTTP 500 + 业务码 500「服务器内部错误」</b>,零信息。而 500 的语义就是「重试」,
+     * 重试一次就是**再建一个上游商户**。
+     *
+     * <p>★★ <b>这一格比「上游已建号」那条更常发生、也更不可分辨。</b>超时 / 连接中断时我们
+     * **根本不知道**上游那次调用有没有落地(请求可能已经送达并建了号,只是应答没回来)。
+     * 所以文案不能断言「没建成功」,只能给**防重试**的指令 —— 方向是不对称的:
+     * 多谨慎一次只是让管理员多查一遍,乐观一次就是多一个上游商户。
+     *
+     * <p>★ <b>文案不得带上游痕迹</b>(Global Constraint 1):本例喂进去的那个异常的
+     * {@code getMessage()} 逐字是「乐收赢接口失败[HTTP_504]: gateway timeout」——
+     * 既有上游名号又有上游返回码 ⇒ 它只能进日志,不能进应答。下面两条 {@code not} 断言钉住这一点。
+     *
+     * <p>★ 读端点上的**正对照**见
+     * {@code areasEndpoint_upstreamCallFailure_saysRetryable_notTheWriteEndpointsAntiRetryCopy}:
+     * 同一个异常在 GET 腿上走的是另一句文案 —— 两处各自被钉住,才说明那条分支不是「怎么写都过」。
+     */
+    @Test
+    void createEndpoint_upstreamCallFailure_isNotCatchAll500_andForbidsBlindRetry() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any()))
+                .thenThrow(new LeshouyingApiException("HTTP_504", "gateway timeout", "{}"));
+
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                // 出口形态:本仓业务失败的既有出口是 HTTP 200 + 业务码(同上面那条 409 的用例)。
+                // 掉回 catch-all 的话这两个断言会一起红(HTTP 500 / 业务码 500)。
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(502))
+                .andExpect(jsonPath("$.msg", containsString("不得当作失败直接重试")))
+                // Global Constraint 1:上游名号与上游返回码都不得出现在应答里。
+                .andExpect(jsonPath("$.msg", not(containsString("乐收赢"))))
+                .andExpect(jsonPath("$.msg", not(containsString("HTTP_504"))));
+
+        // 上游失败 ⇒ 本地一行都不写(与上面那条「不得留半个行」同一条理由)。
+        verify(merchantService, never()).createOnboardingRow(any(), any(), any(), any());
+    }
+
+    /**
+     * ★ <b>写腿那句「不得当作失败直接重试」不得被搬到读腿上</b> —— 同一个异常的 GET 正对照。
+     *
+     * <p>【为什么要分开】本控制器的六个端点里,五个都走上游(见类 javadoc 的端点清单:
+     * {@code /create} 建号、{@code /commit} 提交、{@code /upload-image} 上传、
+     * {@code /areas} 与 {@code /mcc} 取上游参考数据;只有 {@code /page} 只读本地库)。
+     * 一个 handler 若对它们回同一句话,那么在 {@code /areas} 上它说的就是**假话**:
+     * 一个下拉框没加载出来,「无法确认本次操作是否已经生效」无从谈起,
+     * 而「联系管理员核实」会把一次刷新页面的操作升级成一次人工工单。
+     *
+     * <p>【判据取 HTTP 语义,不取路径白名单】GET 被定义为 **safe**(重放不产生副作用)
+     * ⇒ 读端点重试是安全的,可以照 {@code LeshouyingOpenController} 的既有「可重试」口径回。
+     * 若改用「哪些路径是写」的名单去枚举,将来往本控制器加端点就会漏 —— 而漏的方向恰好是
+     * 「新写端点拿到可重试文案」,正是本条要防的那件事。
+     *
+     * <p>两条断言缺一不可:{@code containsString("请稍后重试")} 只说明「是读腿文案」,
+     * {@code not(containsString("不得当作失败直接重试"))} 才说明「没拿到写腿那句」。
+     */
+    @Test
+    void areasEndpoint_upstreamCallFailure_saysRetryable_notTheWriteEndpointsAntiRetryCopy() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.areaList("2", "1"))
+                .thenThrow(new LeshouyingApiException("HTTP_504", "gateway timeout", "{}"));
+
+        mockMvc.perform(get(AREAS_PATH).param("level", "2").param("parentCode", "1"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(502))
+                .andExpect(jsonPath("$.msg", containsString("请稍后重试")))
+                .andExpect(jsonPath("$.msg", not(containsString("不得当作失败直接重试"))))
+                .andExpect(jsonPath("$.msg", not(containsString("乐收赢"))));
+    }
+
     /**
      * ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
      *