2026-09-21-leshouying-payment-channel.md 445 KB

乐收赢支付通道 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 把乐收赢接入为上游支付通道,并包装成下游支付 API 供平台租户使用,覆盖被扫、主扫、H5 三种支付场景与完整收款生命周期。

Architecture: 在 module/payment/leshouying/ 下新建自包含模块,沿用 HAR 模块已建立的分层(client / config / controller / dto / entity / enums / mapper / scheduler / service)。我们→乐收赢用 MD5 签名,乐收赢→我们用免登录回调入口 + 强制验签;下游→我们复用现有 TenantApiKeyAuthFilter + HMAC-SHA256,协议不改。状态机用条件更新(受影响行数判定)实现幂等,不依赖唯一约束。

Tech Stack: Java 21、Spring Boot 3.2.5、MyBatis-Plus 3.5.5、PostgreSQL、Hutool 5.8.34、JUnit 5 + Mockito、Vue 3 + Element Plus

Spec: docs/superpowers/specs/2026-09-21-leshouying-payment-channel-design.md

上游接口摘录: D:\project2\leshouying-api-docs.md(30 节,含完整目录树与逐页参数 —— 实现时查参数以此为准)

Global Constraints

  • 一行都不 commit。 全部改动只落工作区,HEAD 必须停在 dbe364c。每个任务末尾用 git status --short 确认,不执行任何 git add / git commit。
  • SQL 不执行。 V1.8__create_pay_lsy_tables.sql 与 lsy-menu.sql 只写文件,不对任何数据库执行(建表/写菜单不可逆)。
  • 验签不可跳过。 回调入口的 MD5 验签是唯一身份证明,不实现任何 skip / disable / 开关 / 测试后门。见 Task 12。
  • 迁移版本号占 V1.8。 当前最新已应用是 V1.7;HAR 的 V1.8 在 stash 里未执行,恢复时需改名 V1.9。
  • mvnw 已损坏(内容是 404: Not Found)。用 /c/Users/1/tools/apache-maven-3.9.16/bin/mvn,在 java/ 目录下执行。
  • 凡步骤写了 Expected: Tests run: N 的,命令不要加 -q。 -q 会把 surefire 的 Tests run: 计数行一起吞掉(stdout 为空、只剩 exit code 0),于是那一步自己写的 Expected: 无法被观察 —— 一个要求你核对数字的步骤,却用了看不见数字的命令。 (Task 3 评审实测指出;此处统一订正,不逐个改 17 条命令。)
  • 实体字段必须与建表列一致,一个字段都不能多。 MP 按实体生成 SQL 列清单: 实体有、表里没有的字段不编译报错、写入时也被 NOT_NULL 策略掩盖,只在读时抛 PostgreSQL 42703。LsyEntityMappingTest.everyEntityFieldHasAColumnInTheMigration 钉住它。
  • 实体继承 PaymentTenantBaseEntity,它已提供 id(ASSIGN_ID 雪花)、status、description、createdTime、updatedTime —— 不要重复声明这五个字段。
  • 金额一律 bigint,单位分。 上游所有金额字段都是"分"的字符串。
  • 上游时间格式 yyyyMMddHHmmss(14 位)。 项目全局时区已是 Asia/Shanghai。
  • 包路径 com.payment.platform.module.payment.leshouying,表名前缀 pay_lsy_。
  • client 包里的类不得*依赖*容器,但必须*可被*容器装配。 判据是两条:不注入任何 bean、 不调用任何容器 API(ApplicationContext / @Autowired / @Value 等);但要带 @Component, 因为 LsyOrderService(@Service)的构造器需要一个 LeshouyingResponseParser 参数, 而全计划没有任何 @Configuration / @Bean —— 所有 bean 都靠组件扫描装配。 删掉 @Component 会让上下文启动时抛 NoSuchBeanDefinitionException,注解是承重的。 (订正:本条原先被写成「纯函数层…不碰 Spring 容器」,措辞把「不依赖容器」和「不可被容器装配」 混成了一件事,导致一次评审误报 LeshouyingResponseParser 的 @Component 违规。 裁定:保留注解,改措辞。)
  • 下游回调报文沿用 OpenapiService.sendCallback 的表单格式(notify_id / timestamp / content)。
  • 乐收赢的 MD5 只用于「我们 → 乐收赢」这一段;下游面向用户的签名仍是现有 HMAC-SHA256,不改协议。
  • 值序列化契约(Task 5-10 构造 client.post 入参 map 时一律遵守)。 签名原文里每个值都走 String.valueOf(见 LeshouyingSigner),所以进 map 的每个值都必须是最终字符串形状, client.post 只负责「把调用方给的 map 原样签名」,在那里做类型转换只会让「签的」与「发的」 两处形状各自演化,最后对不上:
    • 日期一律 yyyyMMddHHmmss 字符串(14 位,与上游一致,全局时区 Asia/Shanghai)。 在拼 map 之前就格式化好。
    • LocalDateTime / Date 一律不得进 map —— String.valueOf 会得到 2026-09-21T10:00 或 Mon Sep 21 10:00:00 CST 2026,两种都不是上游要的格式, 而失败会推迟到上游回「参数不合法」,根因不可见。
    • 金额一律「分」的字符串(无小数点、无千分位);下游实体字段是 bigint 分。

(订正:本条原先只存在于 LeshouyingClient.post() 的 javadoc 里,计划和 brief 都没有 —— 实测 值序列化契约 一词在 brief 命中 0、计划正文命中 0、源码命中 1。于是 Task 4 的审查者 会在 diff 里看到一个 brief 从未提过的段落,而 Task 5-10 的实现者根本看不到这条约束。 提到此处是它的正确归属:它约束的是构造入参的调用方,不是 post 本身。 也正因为 task-brief 只抽任务正文、不含 Global Constraints(已三次独立实测), 在这里追加不会改变任何已生成的 brief。)


File Structure

新建(后端)

文件 职责
leshouying/client/LeshouyingSigner.java MD5 签名生成 + 入站验签。纯函数,无状态
leshouying/client/LeshouyingResponseParser.java 解析两套报文信封(v3/* 与 api/*)
leshouying/client/LeshouyingApiException.java 上游业务异常
leshouying/client/LeshouyingClient.java 上游 HTTP 调用:签名 → POST JSON → 解析 → 异常包装
leshouying/client/dto/*.java 上游请求/响应对象,按接口一进一出
leshouying/config/LeshouyingProperties.java inst_no / 机构密钥 / baseUrl / 各超时窗口
leshouying/enums/{LsyPayScene,LsyPayType,LsyOrderStatus,LsyRefundStatus,LsyNotifyStatus}.java 枚举
leshouying/entity/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Entity.java 4 张表实体
leshouying/mapper/*.java 4 个 Mapper
leshouying/service/LsyOrderService.java 三种下单 + 状态机条件更新
leshouying/service/LsyQueryService.java 交易查询
leshouying/service/LsyCancelService.java 撤销/关单(先查后撤)
leshouying/service/LsyRefundService.java 退款 + 退款查询
leshouying/service/LsyNotifyService.java 回调验签 + 幂等 + 状态回写
leshouying/service/LsyCallbackService.java 回调我们的下游(含持久化重试)
leshouying/scheduler/LsyOrderPollScheduler.java 被扫密轮询 + 超时撤销
leshouying/scheduler/LsyReconcileScheduler.java 主扫/H5 兜底对账 + 关单 + 下游回调重试
leshouying/controller/LeshouyingNotifyController.java 乐收赢回调入口(免登录)
leshouying/controller/LeshouyingOrderController.java 管理端订单
leshouying/controller/LeshouyingRefundController.java 管理端退款
leshouying/controller/LeshouyingMerchantController.java 管理端商户映射
leshouying/controller/open/LeshouyingOpenController.java 下游 7 个 OpenAPI 端点
resources/db/migration/V1.8__create_pay_lsy_tables.sql 建表(不执行)
resources/db/lsy-menu.sql 菜单权限(不执行)

修改(后端,三处必改 —— 漏改即静默故障)

文件 改什么 漏改后果
core/security/TenantApiKeyAuthFilter.java:92-96 7 个下游路径逐条加入 OPENAPI_ACCOUNT_PATHS path.equals(p) 精确匹配不命中 → 过滤器整体跳过 → 完全无鉴权
core/security/SecurityConfig.java:38-70 加入 /payment/leshouying/notify/** 回调被 Spring Security 挡成 403
core/tenant/TenantInnerInterceptor.java:53-76 4 张新表加入 CONDITIONAL_TABLES 无认证场景查询被追加 tenant_id=0 → 回调回写静默失效

新建(前端): frontend/src/api/module_payment/leshouying.ts、frontend/src/views/module_payment/leshouying/{order,refund,merchant}/index.vue


Task 1: LeshouyingSigner —— MD5 签名与验签

整个模块的安全基石。规则来自上游「接入指引」:按参数名 ASCII 升序,拼 k=v&k=v,尾部接 &key=<机构密钥>,MD5 取 32 位小写;null 不参与,空字符串参与。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSigner.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSignerTest.java

Interfaces:

  • Consumes: 无
  • Produces:

    • String LeshouyingSigner.sign(Map<String,Object> params, String key)
    • boolean LeshouyingSigner.verify(Map<String,Object> params, String key)
  • [ ] Step 1: 写失败的测试

创建 LeshouyingSignerTest.java:

package com.payment.platform.module.payment.leshouying.client;

import org.junit.jupiter.api.Test;

import java.util.LinkedHashMap;
import java.util.Map;

import static org.junit.jupiter.api.Assertions.*;

class LeshouyingSignerTest {

    private final LeshouyingSigner signer = new LeshouyingSigner();
    private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076";

    private Map<String, Object> params(Object... kv) {
        Map<String, Object> m = new LinkedHashMap<>();
        for (int i = 0; i < kv.length; i += 2) m.put((String) kv[i], kv[i + 1]);
        return m;
    }

    @Test
    void sign_matchesDocumentedRule_asciiSortedWithKeySuffix() {
        // 乱序传入 d,b,a,c,期望被排成 a,b,c,d 后拼接
        String actual = signer.sign(params("d", "1", "b", "1", "a", "1", "c", "1"), KEY);
        String raw = "a=1&b=1&c=1&d=1&key=" + KEY;
        String expected = cn.hutool.crypto.digest.DigestUtil.md5Hex(raw).toLowerCase();
        assertEquals(expected, actual);
        assertEquals(32, actual.length());
        assertEquals(actual.toLowerCase(), actual);
    }

    @Test
    void sign_matchesDocumentedParamSet() {
        // 上游文档 §「签名规则」第 2 步给出的字符串 A 实例,逐字抄录:
        //   auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004
        //   &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e
        //   &pay_type=400&total_amount=1
        // 文档只给了 A,没给最终 sign,所以这里断言的是「A 的拼装结果」——
        // 用 md5(A + "&key=" + KEY) 反推,验证排序与拼接格式与文档一致。
        Map<String, Object> p = params(
                "auth_code", "134573607383541818",
                "total_amount", "1",
                "mch_no", "Y52211090004",
                "pay_time", "20190101125959",
                "inst_no", "900075",
                "pay_type", "400",
                "pay_trace_no", "18588dbd1c6e4572b915a2b33fcdc62e");

        String docA = "auth_code=134573607383541818"
                + "&inst_no=900075"
                + "&mch_no=Y52211090004"
                + "&pay_time=20190101125959"
                + "&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e"
                + "&pay_type=400"
                + "&total_amount=1";

        assertEquals(cn.hutool.crypto.digest.DigestUtil
                .md5Hex(docA + "&key=" + KEY).toLowerCase(), signer.sign(p, KEY));
    }

    @Test
    void sign_nullValueExcluded() {
        String withNull = signer.sign(params("a", "1", "b", null), KEY);
        String without = signer.sign(params("a", "1"), KEY);
        assertEquals(without, withNull);
    }

    @Test
    void sign_emptyStringParticipates() {
        // 空字符串参与签名 → b= 出现在拼串里,与"b 不存在"结果不同
        String withEmpty = signer.sign(params("a", "1", "b", ""), KEY);
        String withoutB = signer.sign(params("a", "1"), KEY);
        assertNotEquals(withoutB, withEmpty);
        assertEquals(cn.hutool.crypto.digest.DigestUtil
                .md5Hex("a=1&b=&key=" + KEY).toLowerCase(), withEmpty);
    }

    @Test
    void sign_keyIsNotPartOfInputMap() {
        // sign() 与 verify() 对称地剥离 sign 键:带 sign 的 map 与不带的 map 必须算出
        // **同一个**签名。否则重签一份已带 sign 的参数(重试复用同一份 map、调用方就地
        // 改写)会把那个陈旧值算进原文,上游回报「签名错误」而根因不可见。
        String plain = signer.sign(params("a", "1"), KEY);
        Map<String, Object> withSign = params("a", "1", "sign", "deadbeef");
        assertEquals(plain, signer.sign(withSign, KEY));
    }

    @Test
    void sign_insensitiveToInsertionOrder() {
        assertEquals(signer.sign(params("a", "1", "b", "2", "c", "3"), KEY),
                     signer.sign(params("c", "3", "a", "1", "b", "2"), KEY));
    }

    @Test
    void verify_acceptsCorrectSignature() {
        Map<String, Object> p = params("inst_no", "900112", "mch_no", "Y52211090004", "total_amount", "1");
        p.put("sign", signer.sign(p, KEY));
        assertTrue(signer.verify(p, KEY));
    }

    @Test
    void verify_rejectsTamperedField() {
        Map<String, Object> p = params("inst_no", "900112", "total_amount", "1");
        p.put("sign", signer.sign(p, KEY));
        p.put("total_amount", "999999");   // 篡改金额
        assertFalse(signer.verify(p, KEY));
    }

    @Test
    void verify_rejectsWrongKey() {
        Map<String, Object> p = params("inst_no", "900112");
        p.put("sign", signer.sign(p, KEY));
        assertFalse(signer.verify(p, "wrong-key"));
    }

    @Test
    void verify_rejectsMissingOrBlankSign() {
        assertFalse(signer.verify(params("inst_no", "900112"), KEY));
        Map<String, Object> blank = params("inst_no", "900112");
        blank.put("sign", "");
        assertFalse(signer.verify(blank, KEY));
        Map<String, Object> nullSign = params("inst_no", "900112");
        nullSign.put("sign", null);
        assertFalse(signer.verify(nullSign, KEY));
    }

    @Test
    void verify_signIsCaseInsensitiveOnInput() {
        Map<String, Object> p = params("inst_no", "900112");
        p.put("sign", signer.sign(p, KEY).toUpperCase());
        assertTrue(signer.verify(p, KEY));
    }
}
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSignerTest
    

Expected: 编译失败 —— LeshouyingSigner 不存在。

  • [ ] Step 3: 实现 LeshouyingSigner

    package com.payment.platform.module.payment.leshouying.client;
    
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.crypto.digest.DigestUtil;
    import org.springframework.stereotype.Component;
    
    import java.nio.charset.StandardCharsets;
    import java.security.MessageDigest;
    import java.util.LinkedHashMap;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    /**
    * 乐收赢 MD5 签名器 —— 对应上游「接入指引」[196236523]
    *
    * 规则(逐字来自文档):
    *   1. 参数值为 null → 不参与;为空字符串 → 参与
    *   2. 参数名按 ASCII 码顺序(字典序)排序,以 k=v&k=v 拼接 → 字符串 A
    *   3. String B = A + "&key=" + 机构密钥
    *   4. sign = md5(B) → 32 位小写
    *   5. 验签:先取出 sign,剩余参数按同一规则重算并比对
    *
    * 文档给出的 A 的实例(见 LeshouyingSignerTest#sign_matchesDocumentedParamSet):
    *   auth_code=134573607383541818&inst_no=900075&mch_no=Y52211090004
    *   &pay_time=20190101125959&pay_trace_no=18588dbd1c6e4572b915a2b33fcdc62e
    *   &pay_type=400&total_amount=1
    *
    * 【安全约束】本类不提供任何跳过/禁用签名的入口。回调入口的验签是唯一身份证明。
    */
    @Component
    public class LeshouyingSigner {
    
    private static final String SIGN_FIELD = "sign";
    private static final String KEY_SUFFIX = "&key=";
    
    /**
     * 生成签名。
     * 与 verify() 对称:**先把 sign 键剥掉**再拼原文 —— 重签一份已带 sign 的参数
     * (重试复用同一份 map、调用方就地改写)时,不剥会把陈旧值算进去,
     * 上游回报「签名错误」而根因不可见。
     *
     * 【本方法一个字都不许打日志】—— 它是纯函数,两条路径都会走到它,而出站与入站
     * 的内容来源安全等级**完全不同**:
     *   1. `verify()` 会调它,而入站回调参数由**攻击者控制**:参数值里塞 CRLF
     *      就能伪造日志行,把审计轨迹搅乱(CWE-117)——支付纠纷取证时正好要用这份轨迹。
     *   2. 回调参数含 `auth_code`(付款码),那是一张能直接发起支付的凭据。
     *      本计划已明确决定「付款码(auth_code)刻意不落库 —— 一次性支付凭证等同卡号」,
     *      从日志侧门把它留下,等于把那条决策绕过去。
     *   3. 一旦有人图省事把 String B(含 `&key=<机构密钥>`)也记下来,就等于交出
     *      伪造回调的能力(CWE-532)。密钥同样只在这一个方法里出现。
     *
     * 出站方向的调试日志放 `LeshouyingClient.post()` —— 那里的参数由我们自己构造,
     * 既不接受外部输入,也清楚知道哪些字段该脱敏。见 Task 4。
     */
    public String sign(Map<String, Object> params, String key) {
        Map<String, Object> clean = new LinkedHashMap<>();
        if (params != null) {
            clean.putAll(params);
            clean.remove(SIGN_FIELD);
        }
        return DigestUtil.md5Hex(buildSource(clean) + KEY_SUFFIX + key).toLowerCase();
    }
    
    /**
     * 验签:从入参取出 sign,对其余参数重算并做常量时间比较。
     * 任一步不满足(缺 sign / 空 sign / 不匹配)都返回 false。
     */
    public boolean verify(Map<String, Object> params, String key) {
        if (params == null || params.isEmpty()) {
            return false;
        }
        Object signObj = params.get(SIGN_FIELD);
        if (signObj == null || StrUtil.isBlank(String.valueOf(signObj))) {
            return false;
        }
        String actual = String.valueOf(signObj).trim().toLowerCase();
    
        Map<String, Object> rest = new LinkedHashMap<>(params);
        rest.remove(SIGN_FIELD);
    
        String expected = sign(rest, key);
        return constantTimeEquals(expected, actual);
    }
    
    /** null 排除、空字符串保留、按 key 自然序(ASCII)升序 */
    private String buildSource(Map<String, Object> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }
        return params.entrySet().stream()
                .filter(e -> e.getKey() != null)
                .filter(e -> e.getValue() != null)
                .sorted(Map.Entry.comparingByKey())
                .map(e -> e.getKey() + "=" + e.getValue())
                .collect(Collectors.joining("&"));
    }
    
    /** 常量时间比较,避免按字符提前返回导致的时序侧信道 */
    private boolean constantTimeEquals(String a, String b) {
        if (a == null || b == null) {
            return false;
        }
        return MessageDigest.isEqual(
                a.getBytes(StandardCharsets.UTF_8),
                b.getBytes(StandardCharsets.UTF_8));
    }
    }
    
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSignerTest
    

Expected: Tests run: 11, Failures: 0, Errors: 0

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Expected: 两个文件显示为 ??(未跟踪)。不要 git add。


Task 2: LeshouyingResponseParser —— 两套报文信封

上游有两套返回结构,混用会导致状态判断错误。这是本项目已确认的坑(spec §2)。

体系 成功判定 业务结果字段
支付类 /v3/* return_code == "SUCCESS" result_code = PAY_SUCCESS / PAY_IN_PROCESS / PAY_FAIL
进件类 /api/* result_code == "SUCCESS" result_msg

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParser.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiException.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitive.java(报文脱敏。Task 4 出站日志与 Task 12 回调落库都要复用它,不要各写一份正则)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingApiExceptionTest.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingSensitiveTest.java

Interfaces:

  • Consumes: 无(纯字符串解析)
  • Produces:

    • Map<String,Object> LeshouyingResponseParser.parseV3(String json) —— 通信失败抛 LeshouyingApiException
    • Map<String,Object> LeshouyingResponseParser.parseApi(String json)
    • boolean LeshouyingResponseParser.isPaySuccess(Map<String,Object> resp)
    • boolean LeshouyingResponseParser.isPayInProcess(Map<String,Object> resp)
    • boolean LeshouyingResponseParser.isPayFail(Map<String,Object> resp) —— 只有显式 PAY_FAIL 才为 true。 调用方(Task 6/7/9)必须用它而非 !isPaySuccess(...) 判定失败:若上游某天回了 result_code: "SUCCESS" 这类 未预期的码,!isPaySuccess 会把成功当失败落库(钱已收、单记失败)。未知码应走「既不成功也不失败」的异常分支, 记日志 + 待人工核对,不得静默归类。 同一规则也管回调路径(Task 12),尽管它根本不调这三个谓词 —— 回调体不经 parser, LsyNotifyService 直接拿 Map 里的 result_code 比较。那里是同一个坑的另一半, 而且退款侧的后果更重(未知码落 FAIL 会放开重复退款的两道锁)。
    • String LeshouyingSensitive.mask(String body) —— 对外部来源的任意文本尽力脱敏(键层 + 值层两层)。 Task 12 用它处理回调 rawBody 与验签失败的 log.error,Task 2 自己用它处理解析失败的响应。
    • String LeshouyingSensitive.maskByKey(String text) —— 只按键名遮,不碰无关长数字。 Task 4 出站 trace 日志用它。判据:内容由我们构造 → maskByKey;内容来自外部 → mask。 反用有代价:出站用 mask 会把 out_trade_no 一起遮掉、日志失去诊断价值; 入站用 maskByKey 则丢掉值层,XML 标签与嵌套转义 JSON这 2 类会原样泄漏(实测)。
    • LeshouyingApiException extends RuntimeException,提供 getRawReturnCode() / getRawReturnMsg() / getRawJson() —— 三个都带 Raw:逐字回显上游原文,不可信,不要直接打日志(要打用 getMessage(),已 sanitize)。 getMessage() 里两个字段都被压平了换行(含 U+2028 / U+0085)并封顶 512 字符;\t 故意保留。 这四个行为每条都有断言(LeshouyingApiExceptionTest)—— 它是 CWE-117 控制,不是排版。
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.client;
    
    import ch.qos.logback.classic.Logger;
    import ch.qos.logback.classic.spi.ILoggingEvent;
    import ch.qos.logback.core.read.ListAppender;
    import cn.hutool.json.JSONObject;
    import org.junit.jupiter.api.Test;
    import org.slf4j.LoggerFactory;
    
    import java.util.Map;
    import java.util.stream.Collectors;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    class LeshouyingResponseParserTest {
    
    private final LeshouyingResponseParser parser = new LeshouyingResponseParser();
    
    @Test
    void parseV3_successEnvelope_returnsFields() {
        String json = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\","
                + "\"result_code\":\"PAY_SUCCESS\",\"trade_no\":\"200001102F46230306\","
                + "\"total_amount\":\"100\",\"sign\":\"abc\"}";
        Map<String, Object> r = parser.parseV3(json);
        assertEquals("SUCCESS", r.get("return_code"));
        assertEquals("PAY_SUCCESS", r.get("result_code"));
        assertEquals("200001102F46230306", r.get("trade_no"));
        assertEquals("100", r.get("total_amount"));
        // ★ 这一条不能省。此前 4 处 isPaySuccess 断言全是 assertFalse,
        //   于是一个「恒返回 false」的实现(每一笔成功支付都判为未成功)能通过全部用例。
        //   而 isPaySuccess 正是支付状态机将要分支的那个谓词。
        assertTrue(parser.isPaySuccess(r));
        assertFalse(parser.isPayInProcess(r));
        assertFalse(parser.isPayFail(r));
    }
    
    @Test
    void parseV3_payInProcess_isNotSuccessButNotException() {
        String json = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付中\","
                + "\"result_code\":\"PAY_IN_PROCESS\"}";
        Map<String, Object> r = parser.parseV3(json);
        assertFalse(parser.isPaySuccess(r));
        assertTrue(parser.isPayInProcess(r));
    }
    
    @Test
    void parseV3_communicationFailure_throws() {
        String json = "{\"return_code\":\"FAIL\",\"return_msg\":\"参数不合法\"}";
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> parser.parseV3(json));
        assertEquals("FAIL", ex.getRawReturnCode());
        assertEquals("参数不合法", ex.getRawReturnMsg());
        assertEquals(json, ex.getRawJson());
    }
    
    @Test
    void parseV3_payFail_isNotException_onlyBusinessFailure() {
        String json = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_FAIL\","
                + "\"return_msg\":\"余额不足\"}";
        Map<String, Object> r = parser.parseV3(json);
        assertFalse(parser.isPaySuccess(r));
        assertFalse(parser.isPayInProcess(r));
        assertEquals("PAY_FAIL", r.get("result_code"));
    }
    
    @Test
    void parseV3_nonJson_throws() {
        // 注意实际走的分支:Hutool 对 `<html>502</html>` **不抛**,而是解析成 {"html":502},
        // 于是 return_code 缺失。故必须断言 PARSE_ERROR —— 若只断言异常类型,
        // 一个把它错分类成 EMPTY 或 null 的回归照样能通过。
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> parser.parseV3("<html>502</html>"));
        assertEquals("PARSE_ERROR", ex.getRawReturnCode());
        assertEquals("<html>502</html>", ex.getRawJson());
    }
    
    @Test
    void parseV3_missingReturnCode_isParseErrorNotNullCode() {
        // 网关 HTML 错误页 / {} / 被截断后恰好可解析的片段,都会走到这里。
        // 若不单独判定,会得到「乐收赢接口失败[null]: null」—— 联调期最需要原文时恰好丢掉原文。
        for (String body : new String[]{"{\"html\":502}", "{}"}) {
            LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                    () -> parser.parseV3(body));
            assertEquals("PARSE_ERROR", ex.getRawReturnCode(), body);
            assertEquals(body, ex.getRawJson());
        }
    }
    
    @Test
    void parseApi_missingResultCode_isParseError() {
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> parser.parseApi("{\"html\":502}"));
        assertEquals("PARSE_ERROR", ex.getRawReturnCode());
    }
    
    @Test
    void parseV3_topLevelJsonNullBecomesJavaNull() {
        // 实测(NullProbe / NullProbe2 探针,非推理):
        //   · `parseObj("…null…").get(k)` 返回 `cn.hutool.json.JSONNull`,**不是** Java null,
        //     所以 `r.get("receipt_amount") == null` 为 false。
        //   · `String.valueOf(jsonNull)` 得到的是**字符串** "null"(长度 4,equals("null") 为真),
        //     `Long.parseLong` 喂它 → NumberFormatException: For input string: "null"。
        //     (注意分叉:`Convert.toStr(jsonNull)` 反而给 Java null,走的是 NPE 而不是 NFE。
        //      下游 payPatch 用的是 String.valueOf 那条路,所以是「炸」不是「空指针」。)
        Map<String, Object> r = parser.parseV3(
                "{\"return_code\":\"SUCCESS\",\"receipt_amount\":null}");
        assertNull(r.get("receipt_amount"));
    }
    
    @Test
    void parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives() {
        // ★ 这条是**给未来的读者的警告**,同时钉住当前的真实边界:归一化只走顶层
        //   (实现对 `obj.keySet()` 逐键替换,不递归)。
        //   实测(NullProbe):嵌套对象仍是 `JSONObject`,其内部取出的 null 仍是 `JSONNull`。
        //   当前所有消费方(Task 6 payPatch、Task 12 回调)只读顶层字段,所以够用;
        //   但**别以为这是递归的** —— 谁要开始读嵌套字段,必须先把这里改成递归或在这里补归一化。
        Map<String, Object> r = parser.parseV3(
                "{\"return_code\":\"SUCCESS\",\"data\":{\"outer\":null},\"arr\":[null]}");
        Object nested = r.get("data");
        assertNotNull(nested, "嵌套对象本身原样透传,不转 Map");
        assertInstanceOf(JSONObject.class, nested,
                "实测:嵌套仍是 JSONObject,其内的 null 仍是 JSONNull —— 顶层归一化够不着它");
        assertFalse(((JSONObject) nested).get("outer") == null,
                "嵌套里的 JSON null **没有**被归一化成 Java null,这是当前已接受的边界");
    }
    
    @Test
    void isPayFail_trueOnlyForExplicitPayFail() {
        // 只有显式 PAY_FAIL 才算失败。上游某天回了 result_code="SUCCESS"(未预期的码)时,
        // isPaySuccess 与 isPayFail 都必须为 false —— 调用方据此走「未知码」分支,
        // 而不是用 !isPaySuccess 把一笔成功支付记成失败。
        assertTrue(parser.isPayFail(Map.of("result_code", "PAY_FAIL")));
        assertFalse(parser.isPayFail(Map.of("result_code", "PAY_SUCCESS")));
        assertFalse(parser.isPayFail(Map.of("result_code", "SUCCESS")));
        assertFalse(parser.isPayFail(null));
        Map<String, Object> unknown = parser.parseV3(
                "{\"return_code\":\"SUCCESS\",\"result_code\":\"SUCCESS\"}");
        assertFalse(parser.isPaySuccess(unknown));
        assertFalse(parser.isPayFail(unknown));
    }
    
    @Test
    void parseApi_successEnvelope_usesResultCodeNotReturnCode() {
        // 进件类报文正常形态:没有 return_code,成功看 result_code。
        String json = "{\"result_code\":\"SUCCESS\",\"result_msg\":\"创建成功\","
                + "\"mch_no\":\"Y52211090004\"}";
        Map<String, Object> r = parser.parseApi(json);
        assertEquals("SUCCESS", r.get("result_code"));
        assertEquals("Y52211090004", r.get("mch_no"));
    
        // ★ 上面那段**区分不了**这条测试的名字 —— 报文里压根没有 return_code,
        //   所以「用 result_code 而不是 return_code」这个命题它一个字都没验证。
        //   真正能区分的用例:两个码**同时存在且互相矛盾**,且必须往「不抛」的方向走。
        //   如果哪天有人把 parseApi 改成读 return_code,这条会立刻变红。
        Map<String, Object> contradictory = parser.parseApi(
                "{\"return_code\":\"FAIL\",\"result_code\":\"SUCCESS\",\"mch_no\":\"Y52211090004\"}");
        assertEquals("SUCCESS", contradictory.get("result_code"),
                "存在矛盾的 return_code=FAIL 时,parseApi 必须只看 result_code 并放行");
    }
    
    @Test
    void parseApi_businessFailure_throws() {
        String json = "{\"result_code\":\"FAIL\",\"result_msg\":\"商户已存在\"}";
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> parser.parseApi(json));
        assertEquals("FAIL", ex.getRawReturnCode());
        assertEquals("商户已存在", ex.getRawReturnMsg());
    }
    
    @Test
    void isPaySuccess_handlesNullAndEmptyGracefully() {
        assertFalse(parser.isPaySuccess(null));
        assertFalse(parser.isPaySuccess(Map.of()));
        assertFalse(parser.isPayInProcess(null));
        assertFalse(parser.isPayInProcess(Map.of()));
    }
    
    // ── catch 分支(解析失败路径)。★ 下面这四条是解析器那一行
    //    `LeshouyingSensitive.mask(...)` 的**唯一**守卫。
    //
    //    补这四条之前,本类**没有任何输入能到达 catch** —— 上面所有畸形报文
    //    (`<html>502</html>`、`{}`)Hutool 都能解析成功,走的是「缺 return_code」那条。
    //    后果是:18 位付款码的遮挡、以及 initCause 的因果链,两个都没人守。
    //    实测(P6/P7 探针,直跑本类):这几个断言的值都是量出来的,不是推的。
    
    @Test
    void parseFailure_pathIsReachable_truncatedJsonWithAuthCode() {
        String json = "{\"auth_code\":\"134573607383541001\"";   // 截断,缺收尾 }
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> parser.parseV3(json));
        assertEquals("PARSE_ERROR", ex.getRawReturnCode());
        assertNotNull(ex.getCause(),
                "catch 分支必须 initCause —— 否则调用方程序化拿不到 Hutool 的根因");
        assertEquals(json, ex.getRawJson(), "原文要逐字留下(排障时唯一的线索)");
    }
    
    @Test
    void parseFailure_doesNotLeakAuthCodeIntoTheLog() {
        // ★ 这是**变异测试**:把那一行的 `LeshouyingSensitive.mask(` 去掉(只留 StrUtil.brief),
        //   本方法立刻变红。只断言「异常抛出了」是钉不住它的 —— 异常照抛,码照漏。
        //   实测(Probe7):不加本方法时,删掉 mask 后日志里就是完整的 18 位付款码。
        Logger logger = (Logger) LoggerFactory.getLogger(LeshouyingResponseParser.class);
        ListAppender<ILoggingEvent> appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);
        try {
            assertThrows(LeshouyingApiException.class,
                    () -> parser.parseV3("{\"auth_code\":\"134573607383541001\""));
            String logged = appender.list.stream()
                    .map(ILoggingEvent::getFormattedMessage)
                    .collect(Collectors.joining("\n"));
            assertFalse(logged.contains("134573607383541001"),
                    "18 位付款码不得出现在日志里;实际=" + logged);
            assertTrue(logged.contains("***"),
                    "但报文本身仍要留痕 —— 否则这条日志失去排障价值;实际=" + logged);
        } finally {
            logger.detachAppender(appender);   // 不摘会把后续测试的日志也吃进来
        }
    }
    
    @Test
    void twoParseErrorBranches_areDistinguishable_byCause() {
        // 两条分支产出的 rawReturnCode 与 rawJson **完全相同**,只有 cause 能分辨。
        // 不区分的话「走的到底是哪条」就没人知道 —— 这正是 catch 分支长期零覆盖却没被发现的原因。
        LeshouyingApiException missingCode = assertThrows(LeshouyingApiException.class,
                () -> parser.parseV3("<html>502</html>"));   // Hutool 解得动,但缺 return_code
        assertEquals("PARSE_ERROR", missingCode.getRawReturnCode());
        assertNull(missingCode.getCause(),
                "网关 HTML 走的是「缺 return_code」分支,没进过 JSON 解析,不该有 cause");
    
        LeshouyingApiException unparseable = assertThrows(LeshouyingApiException.class,
                () -> parser.parseV3("{\"a\":1"));        // Hutool 解不动
        assertEquals("PARSE_ERROR", unparseable.getRawReturnCode());
        assertNotNull(unparseable.getCause(), "解不动才有 cause —— 这条断言把两条分支分开");
    }
    
    @Test
    void blankBody_isClassifiedEmpty_notParseError() {
        // EMPTY 与 PARSE_ERROR 必须分得开:前者是「根本没拿到报文」,后者是「拿到了但不是 v3 响应」。
        // 混成一类会让重试策略失去判据。实测(P6):三者都归 EMPTY,且都不带 cause。
        for (String blank : new String[]{null, "", "   "}) {
            LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                    () -> parser.parseV3(blank));
            assertEquals("EMPTY", ex.getRawReturnCode(), "input=[" + blank + "]");
            assertNull(ex.getCause(), "空报文没进过 JSON 解析,不该有 cause");
        }
    }
    }
    

同一步的第二个文件 —— LeshouyingApiExceptionTest:

package com.payment.platform.module.payment.leshouying.client;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

/**
 * 这个类测的**不是**「异常能不能抛」,是**上游文本会不会伪造出日志行**(CWE-117)。
 * 上游文本被拼进 message,而 GlobalExceptionHandler 打栈时会带上 message ——
 * 所以这层压平是**安全控制**,不是美化排版。删掉 `sanitize` 里的任何一个 replace,
 * 下面就有断言变红。
 *
 * 断言成对写:「压掉了什么」与「原文还在不在」。只断言前者,会把
 * 「直接丢弃字段内容」也判成合格实现。
 */
class LeshouyingApiExceptionTest {

    @Test
    void message_flattensCrAndLf_soUpstreamTextCannotForgeALogLine() {
        LeshouyingApiException ex = new LeshouyingApiException(
                "FAIL", "第一行\r\n2026-09-21 10:00:00 ERROR 伪造的日志行", "{}");
        assertFalse(ex.getMessage().contains("\n"), "裸 \\n 会让上游文本变成一条新日志行");
        assertFalse(ex.getMessage().contains("\r"));
        assertTrue(ex.getMessage().contains("2026-09-21 10:00:00 ERROR 伪造的日志行"),
                "是**压平**不是删除:内容仍要能读到,只是不再换行");
    }

    @Test
    void message_flattensUnicodeLineSeparators_too() {
        // U+2028 / U+0085 在部分查看器(编辑器、日志检索界面)里也表现为换行。
        // ★ 这里两个字符必须写成**转义**(反斜杠 + u + 恰好四位十六进制),例如 \u2028 和 \u0085:
        //   贴真字符的话它们不可见,把 U+2028 误写成 U+0085(或顺手删掉)都不会被任何人看出来。
        //
        // 【别在注释里写占位式的转义记号】本行原来写的是「反斜杠 u X X X X」当作占位符,
        // 结果**整模块 test 编译失败**:javac 在词法分析**之前**就处理转义(JLS §3.3),
        // 注释里也不例外,而 XXXX 不是四位十六进制 → 非法的 Unicode 转义。
        // 实测(javac 21):反斜杠 u 后接四位十六进制在注释里编译通过;
        // 四位位置放非十六进制字符、或反斜杠 u 后面不接十六进制,都直接编译失败。
        // 所以:**本文件里凡是出现反斜杠加 u,后面必须紧跟恰好四位十六进制**,没有例外。
        LeshouyingApiException ex = new LeshouyingApiException("FAIL", "A\u2028B\u0085C", "{}");
        assertFalse(ex.getMessage().contains("\u2028"));
        assertFalse(ex.getMessage().contains("\u0085"));
        assertTrue(ex.getMessage().contains("A B C"), "两个都变成空格");
    }

    @Test
    void message_sanitizesReturnCode_notJustReturnMsg() {
        // ★ 只堵 return_msg 是可绕过的:两个字段来自同一份 JSON、同一条连接、进同一行日志。
        LeshouyingApiException ex = new LeshouyingApiException("FAIL\n伪造行", "正常", "{}");
        assertFalse(ex.getMessage().contains("\n"), "return_code 里的 \\n 同样能伪造日志行");
        assertTrue(ex.getMessage().contains("FAIL 伪造行"));
    }

    @Test
    void message_keepsTab_becauseItIsLegitimateContent() {
        // 故意不压 \t:它在正常报文里是合法内容,压掉只毁可读性;
        // 而在本项目 logback 的 `%msg%n` 下,它不构成伪造行。
        LeshouyingApiException ex = new LeshouyingApiException("FAIL", "a\tb", "{}");
        assertTrue(ex.getMessage().contains("a\tb"), "压掉 tab = 毁掉对齐过的上游报文,换来零安全收益");
    }

    @Test
    void message_isCapped_soAMalformedResponseCannotInflateTheLog() {
        // 不封顶的话,上游文本会经 GlobalExceptionHandler 原样进日志,长度由上游说了算。
        // 实测(P9 探针):单个 20000 字符字段 → message 长 **530**
        // (= 8 的前缀「乐收赢接口失败[」+ 4 的 "FAIL" + 3 的 "]: " + 512 + "...");
        // 两个字段各 20000 → **1041**,即一条日志行的上界。
        LeshouyingApiException ex = new LeshouyingApiException("FAIL", "x".repeat(20000), "{}");
        assertTrue(ex.getMessage().endsWith("..."), "封顶要留可见标记,不能静默截断");
        assertEquals(530, ex.getMessage().length(), "封顶后的精确长度(实测,不是量级估计)");
    }

    @Test
    void rawFields_keepTheOriginalBytes_includingTheDangerousOnes() {
        // ★ 成对断言的另一半:message 被压平了,但字段**保留原文** —— 排障要看上游到底回了什么。
        //   这也正是 getter 带 Raw 的原因:想打日志用 getMessage();
        //   用 getRaw* 就是在往日志里写不可信文本,那一步必须由调用方自己想清楚。
        String nastyCode = "FAIL\n伪造行";
        String nastyMsg = "第一行\r\n第二行";
        LeshouyingApiException ex = new LeshouyingApiException(nastyCode, nastyMsg, "{\"raw\":1}");
        assertEquals(nastyCode, ex.getRawReturnCode());
        assertEquals(nastyMsg, ex.getRawReturnMsg());
        assertEquals("{\"raw\":1}", ex.getRawJson());
        assertTrue(ex.getRawReturnMsg().contains("\n"), "字段保留原文,换行还在 —— 所以别直接打它");
    }

    @Test
    void nullFields_doNotBlowUp() {
        LeshouyingApiException ex = new LeshouyingApiException(null, null, null);
        assertTrue(ex.getMessage().contains("乐收赢接口失败[null]: null"));
        assertNull(ex.getRawReturnCode());
    }

    @Test
    void message_flattensParagraphSeparator_andVtFf_too() {
        // U+2029 是 U+2028 的**成对字符**(二者合起来才是「行/段分隔」这一对),
        // 只堵前一个等于只堵了一半。VT(U+000B)/ FF(U+000C)同理:Java 的日志读取方
        // 不会在它们上面分行(只认 CR/LF),但在部分编辑器 / 日志检索界面里仍表现为换行 ——
        // 也正是本类压平 U+2028 时给出的那条理由,对它们逐字成立。
        //
        // ★ 写法警告:下面这些字符**必须写成转义形式**(反斜杠 + u + 恰好四位十六进制),
        //   贴真字符的话它们不可见,被顺手删掉不会被任何人看出来。
        //   实测(javac 21):U+2028 / U+2029 / U+0085 / U+000B / U+000C 的转义形式在
        //   char 字面量与字符串里**都编译通过**。
        //   但 U+000A / U+000D **绝不能**写成转义形式:javac 在词法分析前就还原它们,
        //   会直接把源码行拆断、整模块编译失败。
        LeshouyingApiException ex = new LeshouyingApiException(
                "A\u2029B\u000BC\u000CD", "x", "{}");
        assertFalse(ex.getMessage().contains("\u2029"));
        assertFalse(ex.getMessage().contains("\u000B"));
        assertFalse(ex.getMessage().contains("\u000C"));
        assertTrue(ex.getMessage().contains("A B C D"), "三个都变成空格(实测 len=7)");
        assertTrue(ex.getRawReturnCode().contains("\u2029"),
                "成对断言的另一半:字段保留原文,只有 message 被压平");
    }

    @Test
    void noTwoArgConstructor_byDesign() {
        // 没有 (String, Throwable) 构造器是**故意的**,不是忘了写:
        // 它会绕过 sanitize(那层防线只长在 3 参路径上),并把调用方消息塞进 rawReturnMsg,
        // 破坏「三个 Raw 字段 = 逐字上游原文」的契约。
        // 用例名加这条断言一起把设计钉住 —— 否则将来「顺手补个方便的构造器」会无声重开 CWE-117。
        assertThrows(NoSuchMethodException.class, () ->
                LeshouyingApiException.class.getDeclaredConstructor(String.class, Throwable.class));
    }
}
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='LeshouyingResponseParserTest,LeshouyingApiExceptionTest'
    

Expected: 编译失败 —— LeshouyingApiException 与 LeshouyingResponseParser 都不存在 (两个测试类都要引它俩;LeshouyingSensitive 只被待实现的解析器依赖,不在测试的编译面上)。

  • [ ] Step 3: 实现 LeshouyingApiException

    package com.payment.platform.module.payment.leshouying.client;
    
    import lombok.Getter;
    
    /**
    * 乐收赢上游异常。
    * 两种触发场景:
    *   1. 通信/参数层失败(v3 的 return_code != SUCCESS,或 api 的 result_code != SUCCESS)
    *   2. 报文无法解析
    * 业务层失败(result_code = PAY_FAIL)**不抛此异常** —— 那是正常的业务结果,
    * 需要落库记录,不能当成异常处理。
    *
    * 【getter 为什么都带 Raw】三个字段都是上游报文的**逐字回显**,而上游文本是不可信输入。
    * `log.error("...{}", ex.getRawReturnMsg())` 等于把上游可控文本写进日志,与 message 那条路径
    * 同一类风险(CWE-117)。字段保留原文是对的(排障要看原样),所以把警告放进**名字**里:
    * 想打日志请用 `getMessage()`,它已经过 sanitize。命名与既有的 `getRawJson()` 一致。
    */
    @Getter
    public class LeshouyingApiException extends RuntimeException {
    
    /**
     * **每个字段**的长度上限 —— 不是 message 整体的上限。
     * 实测(Probe:两个字段各 20000 字符):`getMessage().length() == 1041`,即一条日志行的上界约 1KB。
     * 分解(对得上才写):`乐收赢接口失败[` 8 字符 + `]: ` 3 字符 = **11** 固定前缀;
     * 两个字段各封顶成 `512 + "..."` = 515,故 11 + 515 × 2 = **1041**。
     * 防护仍然成立(畸形响应撑不爆日志),但别把它当 512 —— 原措辞说的是 message,量的是字段。
     * ★ 写数字时把算式一起写出来:上一版这里写的是「18 前缀 + 512 + "..." × 2」,
     *   算式本身算不出 1041(=536),而它恰恰出现在一次「为了纠正不实数字」的改动里。
     *   光写结论的数字没人能当场验,写算式就一眼看得出对不对。
     */
    private static final int MSG_CAP = 512;
    
    private final String rawReturnCode;
    private final String rawReturnMsg;
    private final String rawJson;
    
    public LeshouyingApiException(String returnCode, String returnMsg, String rawJson) {
        // 上游文本(或中间盒文本)被拼进 message,而 GlobalExceptionHandler 的
        // log.error("系统异常 [{}]", uri, e) 打出栈时会带上 message —— 若其中含 \n,即可**伪造日志行**(CWE-117)。
        // 本项目在入站侧已明确防这一点(见 LeshouyingSigner 为何一个字都不打日志),出站侧不能例外。
        //
        // ★ 两个字段都要 sanitize,不能只做 return_msg:它们来自同一份 JSON、同一条连接,
        //   进的是同一行日志。只堵一个,换个字段就绕过(实测 return_code 里的裸 \n 原样进 getMessage)。
        //   同时封顶长度防日志膨胀。
        super("乐收赢接口失败[" + sanitize(returnCode) + "]: " + sanitize(returnMsg));
        this.rawReturnCode = returnCode;
        this.rawReturnMsg = returnMsg;
        this.rawJson = rawJson;
    }
    
    /**
     * 仅供拼 message 用:压平行终止符 + 封顶。对象上的三个字段**一律保留原文**。
     *
     * \r \n 是必须压的(CWE-117 的伪造成立与否就看这两个);\u2028 / \u2029 / \u0085
     * 在部分查看器里也表现为换行,一并压掉 —— **U+2029 是 U+2028 的成对字符**
     * (二者合起来才是「行/段分隔」这一对),只堵前一个等于只堵了一半。
     * VT(U+000B)与 FF(U+000C)同理:Java 的日志读取方不会在它们上面分行(只认 CR/LF),
     * 但在部分编辑器 / 日志检索界面里仍表现为换行 —— 也就是上面这条理由对它们逐字成立。
     * **故意不动 \t**:它在正常报文里是合法内容,压掉反而毁可读性,
     * 而在本项目的 logback `%msg%n` 下不构成伪造行。
     *
     * 【写法警告】上面这几个字符**必须写成转义形式**(反斜杠 + u + 恰好四位十六进制),
     * 不要贴真字符 —— 它们在多数编辑器里不可见,被当成普通空格顺手删掉就静默失去这层防护
     * (本 plan 第一次写就踩了)。
     * 反过来,**绝不能**把 U+000A / U+000D 也写成同样的转义形式:javac 在词法分析**之前**
     * 就还原它们(JLS §3.3),会直接把源代码行拆断、整模块编译失败。
     * 实测(javac 21):U+2028 / U+2029 / U+0085 / U+000B / U+000C 的转义形式在
     * char 字面量与字符串里**都编译通过**,可以放心写。
     */
    private static String sanitize(String msg) {
        if (msg == null) {
            return null;
        }
        String flat = msg.replace('\r', ' ')
                .replace('\n', ' ')
                .replace('\u2028', ' ')
                .replace('\u2029', ' ')
                .replace('\u0085', ' ')
                .replace('\u000B', ' ')
                .replace('\u000C', ' ');
        return flat.length() <= MSG_CAP ? flat : flat.substring(0, MSG_CAP) + "...";
    }
    
    // 故意**不提供** (String message, Throwable cause) 这个构造器。
    // 它会绕过 sanitize(那层防线只长在 3 参路径上),并把调用方消息塞进 rawReturnMsg,
    // 破坏「三个 Raw 字段 = 逐字上游原文」的契约 —— 将来 HTTP 层顺手写
    // `new LeshouyingApiException("HTTP 请求失败: " + responseBody, e)` 就把 CWE-117 的口子
    // 重新打开了(responseBody 里带 \r\n 就能伪造日志行)。
    // 需要带 cause 时:3 参构造器 + initCause(e)(见 LeshouyingResponseParser 的 catch 分支)。
    // 钉住它不被加回来的测试:noTwoArgConstructor_byDesign。
    }
    
  • [ ] Step 4: 写 LeshouyingSensitiveTest(先跑一遍确认失败)

这份语料不是想出来的,是量出来的。 把下面这 9 类写法逐一喂给被替换掉的那版正则 (("auth_code"\s*:\s*")[^"]*),数出 9 类全部漏网。下面每条断言对应一次实测。(这里的 9 数的是列出来的这 9 类写法,不是语料总数;原写的「14 种语料里漏 9 种」引用了一份未保存的语料) 任何一条变红,都说明脱敏覆盖被削弱了。

(数字更正:此处原写「12 种里 10 种漏网」,来源是审查报告里的可达性计数 「10 of 12 shapes reach the catch block」被误当成了泄漏计数 —— 两个不同的量。 已按原正则逐条实跑改正。)

package com.payment.platform.module.payment.leshouying.client;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class LeshouyingSensitiveTest {

    // ── A. 实测泄漏的 6 种形状:必须被遮蔽 ──────────────────────────────────
    // 共同点:这 6 种都是**旧正则** `("auth_code"\s*:\s*")[^"]*` 实测漏掉的形状
    // (旧正则共漏 9 种,另 3 种是 XML 标签、驼峰 AuthCode / authCode)。
    //
    // ★ 但别把这句话读成「靠值层兜住」—— 实测(Layer 探针):
    //   新键层**自己就能接住这 6 种里的 5 种**(大小写、单双引号、`=` 分隔、无引号值、空白分隔
    //   全在它的覆盖里)。本组里**只有 masks_nestedEscapedJson 是值层独撑**。
    //   值层不是这 6 条的兜底,它是这 6 条里**其中 1 条**(以及 C 组 XML 标签)的唯一防线。
    //   把归属写清楚,是因为「键层是兜底、值层是主」这句话会让人以为删掉某一层只是减损覆盖率,
    //   而实际是:删掉值层 = 这两种形状直接裸奔。

    @Test
    void masks_formEncoded() {
        assertEquals("auth_code=***&inst_no=900075",
                LeshouyingSensitive.mask("auth_code=134573607383541002&inst_no=900075"));
    }

    @Test
    void masks_upperCaseKey() {
        assertEquals("{\"AUTH_CODE\":\"***\"",
                LeshouyingSensitive.mask("{\"AUTH_CODE\":\"134573607383541005\""));
    }

    @Test
    void masks_singleQuotedKeyAndValue() {
        assertEquals("{'auth_code':'***'",
                LeshouyingSensitive.mask("{'auth_code':'134573607383541006'"));
    }

    @Test
    void masks_unquotedValue() {
        assertEquals("{\"auth_code\":***,\"x\":1",
                LeshouyingSensitive.mask("{\"auth_code\":134573607383541007,\"x\":1"));
    }

    @Test
    void masks_nestedEscapedJson() {
        // 键名前有转义反斜杠,键层够不着它;值层不看键,照样命中。
        assertEquals("{\"data\":\"{\\\"auth_code\\\":\\\"***\\\"}\"",
                LeshouyingSensitive.mask("{\"data\":\"{\\\"auth_code\\\":\\\"134573607383541008\\\"}\""));
    }

    @Test
    void masks_spaceSeparated() {
        assertEquals("inst_no=900075 auth_code=***",
                LeshouyingSensitive.mask("inst_no=900075 auth_code=134573607383541011"));
    }

    // ── B. 本来就遮住的 4 种:不得回退 ─────────────────────────────────────

    @Test
    void keepsMasking_truncatedJson() {
        // ★ 期望值**没有**收尾的引号 —— 输入里本来就没有。
        //   输入 `{"auth_code":"134573607383541001` 共 32 字符,值未闭合,
        //   任何「保留输入结构」的遮蔽都只能产出 32-18+3=17 字符的 `{"auth_code":"***`。
        //   写成 `{"auth_code":"***"`(多一个引号)是**断言了一个输入提供不了的字符**,必定失败。
        //   这条断言的要害是「18 位数字没了」,不是「引号配对」。
        assertEquals("{\"auth_code\":\"***",
                LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541001"));
    }

    @Test
    void keepsMasking_htmlWrapped() {
        assertEquals("<html>502<br>req={\"auth_code\":\"***\"}</html>",
                LeshouyingSensitive.mask("<html>502<br>req={\"auth_code\":\"134573607383541003\"}</html>"));
    }

    @Test
    void keepsMasking_whitespaceAroundColon() {
        assertEquals("{\"auth_code\" : \"***\" , \"x\":1",
                LeshouyingSensitive.mask("{\"auth_code\" : \"134573607383541009\" , \"x\":1"));
    }

    @Test
    void keepsMasking_unterminatedValue() {
        // 同 keepsMasking_truncatedJson:用例名自称 unterminatedValue,期望值就不该带收尾引号。
        assertEquals("{\"auth_code\":\"***",
                LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541010"));
    }

    // ── C. 仅在脱敏器层面成立(走不到日志路径)────────────────────────────
    // 这两种 Hutool 其实**能**解析成功,因此永远不会进 catch 分支、永远不会被记日志。
    // 在这里断言,是为了把「脱敏器本身的覆盖」和「实际被走到的路径」分开记录:
    // 前者是能力,后者才是风险敞口。

    @Test
    void maskerCovers_xmlTag() {
        assertEquals("<auth_code>***</auth_code>",
                LeshouyingSensitive.mask("<auth_code>134573607383541004</auth_code>"));
    }

    @Test
    void maskerCovers_trailingGarbage() {
        assertEquals("{\"auth_code\":\"***\"} trailing",
                LeshouyingSensitive.mask("{\"auth_code\":\"134573607383541012\"} trailing"));
    }

    // ── D. 出现多次要全遮(replaceAll 而非 replaceFirst)──────────────────

    @Test
    void masks_everyOccurrence() {
        String out = LeshouyingSensitive.mask(
                "{\"auth_code\":\"134573607383541013\",\"auth_code\":\"134573607383541014\"}");
        assertEquals("{\"auth_code\":\"***\",\"auth_code\":\"***\"}", out);
        assertFalse(out.contains("134573607383541014"), "只遮第一处就是漏");
    }

    // ── E. 诊断字段的存活与代价:这条测试是「成本」的书面记录 ─────────────
    // 值层按长度遮,不看字段名 —— 所以它会连**无关的长数字**一起遮掉。
    // 这是刻意接受的代价:本行的职责是看「回来的是 HTML 还是被截断的 JSON」,不是看具体字段值。

    @Test
    void masksLongDigits_evenWhenUnrelated_preservingShortAndAlphanumeric() {
        String body = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"支付成功\","
                + "\"result_code\":\"PAY_SUCCESS\",\"trade_no\":\"200001102F46230306\","
                + "\"receipt_amount\":\"100\",\"pay_time\":\"20260921143025\","
                + "\"out_trade_no\":\"134573607383541015\"}";
        String out = LeshouyingSensitive.mask(body);

        // 必须存活:这四样是排障时真正要读的
        assertTrue(out.contains("\"return_code\":\"SUCCESS\""));
        assertTrue(out.contains("\"return_msg\":\"支付成功\""));
        assertTrue(out.contains("\"result_code\":\"PAY_SUCCESS\""));
        assertTrue(out.contains("\"trade_no\":\"200001102F46230306\""), "字母数字混合的 trade_no 不受值层影响");
        assertTrue(out.contains("\"receipt_amount\":\"100\""), "短数字不受影响");

        // 被牺牲:14 位的 pay_time 与 12 位及以上的 out_trade_no 会一起被遮。
        // 这不是缺陷,是「宁可少看几个字段,不可留下一个付款码」的兑换。
        assertTrue(out.contains("\"pay_time\":\"***\""));
        assertTrue(out.contains("\"out_trade_no\":\"***\""));
    }

    @Test
    void valueLayer_thresholdBoundary_11Survives_12IsMasked() {
        // 阈值是**闭区间下界 12**(`\d{12,}`)。「12 位以上」在中文里既能读成 ≥12 也能读成 >12,
        // 所以边界必须写成可执行的断言,而不是留在注释里靠读者理解。
        // 用 return_msg 承载,避免键层插手 —— 这两条只考察值层。
        assertTrue(LeshouyingSensitive.mask("{\"return_msg\":\"12345678901\"}").contains("12345678901"),
                "11 位不该被遮(否则是在无谓地毁诊断信息)");
        assertFalse(LeshouyingSensitive.mask("{\"return_msg\":\"123456789012\"}").contains("123456789012"),
                "12 位必须被遮 —— 这是下界的另一侧");
    }

    @Test
    void valueLayer_isPrefixAgnostic_notAnchoredToThe10_15Range() {
        // ★ 这条测试是**设计决策的钉子**,不是覆盖率凑数。
        //   计划记录付款码「18 位、以 10-15 开头」,所以 `1[0-5]\d{16}` 看起来更精确。
        //   我们**故意不用**它:前缀锚点朝危险方向失败 —— 上游换号段就静默恢复泄漏。
        //   实测(Pin 探针)用 99 开头的码喂这两种形状:
        //     `\d{12,}`   → 遮住;`1[0-5]\d{16}` → **原样泄漏**。
        //   所以下面两条断言在有人把阈值改回前缀锚点时会**因为漏码而变红**,
        //   而不是因为「遮多了」这种可商量的代价变红 —— 这正是要钉住的方向。
        //
        //   两个 body 都选**值层独撑**的形状(键层够不着,实测见 Layer 探针),
        //   否则键层会把码遮掉,这条测试就退化成不区分任何东西的空断言。
        assertFalse(LeshouyingSensitive.mask("{\"data\":\"{\\\"auth_code\\\":\\\"994573607383541008\\\"}\"}")
                .contains("994573607383541008"), "嵌套转义 + 非 10-15 号段:只有值层能接住");
        assertFalse(LeshouyingSensitive.mask("<auth_code>994573607383541004</auth_code>")
                .contains("994573607383541004"), "XML 标签 + 非 10-15 号段:同上");
    }

    @Test
    void mask_null_isNull() {
        assertNull(LeshouyingSensitive.mask(null));
    }

    // ── F. 具名的、已接受的残留 ────────────────────────────────────────────
    // 值层对**键的写法**不变,但对**值的再编码**不变。下面几条**故意断言「没遮住」**:
    // 这不是认可泄漏,而是把边界写成可执行的记录 —— 谁要是哪天真的补上了,
    // 这条测试会变红,提醒他回来更新类注释里的残留清单,而不是默默改变了对外承诺。
    //
    // ★ 断言风格:残留用例**一律用 `contains`,不用 `assertEquals(body, mask(body))`**。
    //   因为键层即使遮不住值,也可能把 `auth_code=` 这段标签重写成 `auth_code=***`(值匹配为空串),
    //   输出与输入并不逐字节相等 —— 那不是「遮住了」,但等式断言会失败。
    //   要断言的是**敏感内容是否还在**,不是输出是否等于输入。

    @Test
    void covered_urlEncodedValue_whenKeySurvives() {
        // ★ 这一条原先被我错放进「残留」列表,实测它是**被覆盖**的:
        //   键层的值尾巴是 `[^"'&\s,}]*`,而 `%` **不在**排除集里,
        //   所以 `auth_code=` 之后的 `%31%33…%36` 被整段消费并替换。
        //   (值层确实看不见它 —— 没有 12 位连续数字 —— 但键层把它兜住了。我原先只想到值层。)
        String body = "auth_code=%31%33%34%35%37%33%36%30%37%33%38%33%35%34%31%30%31%36";
        assertEquals("auth_code=***", LeshouyingSensitive.mask(body));
    }

    @Test
    void covered_base64Value_whenKeySurvives() {
        // 上面那条残留用例的注释里**声称**过这个形态(`auth_code=MTM0NTcz…`,base64 值但键名还在)
        // 「键层能遮住」—— 但原先只有注释、没有断言。而「注释里声称覆盖了、实际没人守」
        // 正是本轮评审在别处抓出来的那类问题,同一把尺子在这里也适用。
        //
        // 实测(Probe8):`mask` 与 `maskByKey` 都输出 `auth_code=***`。
        // 兜住它的是**键层**(值尾巴 `[^"'&\s,}]*` 把整段 base64 吃掉);
        // 值层在这里是瞎的 —— 实测该串不匹配 \\d{12,}(base64 里没有 12 位连续数字)。
        assertEquals("auth_code=***",
                LeshouyingSensitive.mask("auth_code=MTM0NTczNjA3MzgzNTQxMDE2"));
    }

    // ── C. 键层「接住 7 类」这个数字的钉子 ───────────────────────────────────
    //   类注释里数出 `9 = 键层接住 7 + 只有值层接得住 2`。那 7 类里原本只有 5 类具名测过
    //   (masks_formEncoded / masks_upperCaseKey / masks_singleQuotedKeyAndValue /
    //   masks_unquotedValue / masks_spaceSeparated),**驼峰这 2 类没有断言** ——
    //   「注释里声称覆盖、实际没人守」与 base64 那条是同一类问题,同一把尺子量到底。

    @Test
    void keyLayer_catchesPascalCaseKey() {
        // 兜住它的是键层里那个 `(?i)`:`auth` 匹配 `Auth`、`_?` 匹配空、`code` 匹配 `Code`。
        // ★ 必须断言 `maskByKey` 而不是 `mask`:这一条的值是裸 18 位数字,
        //   值层 `\d{12,}` 也能命中它 —— 用 `mask` 断言时,键层的 `(?i)` 整个被删掉
        //   测试照样绿(改成值层在兜),那就钉不住这个数字。
        assertEquals("{\"AuthCode\":\"***\"}",
                LeshouyingSensitive.maskByKey("{\"AuthCode\":\"134573607383541020\"}"));
        // 两层都上时结果一致(值层在此无额外贡献)
        assertEquals("{\"AuthCode\":\"***\"}",
                LeshouyingSensitive.mask("{\"AuthCode\":\"134573607383541020\"}"));
    }

    @Test
    void keyLayer_catchesCamelCaseKey() {
        assertEquals("{\"authCode\":\"***\"}",
                LeshouyingSensitive.maskByKey("{\"authCode\":\"134573607383541021\"}"));
        assertEquals("{\"authCode\":\"***\"}",
                LeshouyingSensitive.mask("{\"authCode\":\"134573607383541021\"}"));
    }

    @Test
    void namedResidual_htmlEntityValue_survives() {
        // 真残留:`&` **在**键层的排除集里,所以 `auth_code=` 匹配到**空值**就停了,
        // 标签被重写成 `auth_code=***`,而实体编码的数字原样留在后面。
        // 这也正是这里**不能**用 assertEquals(body, ...) 的原因 —— 输出与输入并不逐字节相等。
        String out = LeshouyingSensitive.mask(
                "auth_code=&#49;&#51;&#52;&#53;&#55;&#51;&#54;&#48;&#55;&#51;&#56;&#51;");
        assertTrue(out.contains("&#49;&#51;&#52;&#53;&#55;&#51;&#54;&#48;&#55;&#51;&#56;&#51;"),
                "HTML 实体:值层认不出 &#49; 这种写法,键层被 & 截断 —— 真残留");
    }

    @Test
    void namedResidual_interiorSpaces_surviveInPart() {
        // 键层只吃掉了第一段(1345),后三段仍在。**部分遮蔽 ≠ 遮蔽。**
        String out = LeshouyingSensitive.mask("auth_code=1345 7360 7383 5418");
        assertTrue(out.startsWith("auth_code=***"),
                "标签本身会被重写(键层匹配到空串就停)—— 所以本节一律 contains,不用等式");
        assertTrue(out.contains("7360 7383 5418"), "分隔符把连续数字切成短片段,值层也接不住");
    }

    @Test
    void namedResidual_base64WithoutKeyContext_isNotMasked() {
        // 注意区分:`auth_code=MTM0NTcz...` 这种「键还在」的形态,键层**能**遮住。
        // 遮不住的是键名本身也被编码掉的情形,例如整包 base64。
        // 断言用 contains(本节风格):要问的是「码还在不在」,不是「输出等不等于输入」。
        String body = "{\"d\":\"MTM0NTczNjA3MzgzNTQxMDE2\"}";
        assertTrue(LeshouyingSensitive.mask(body).contains("MTM0NTczNjA3MzgzNTQxMDE2"),
                "键名被编码掉:键层无从下手,值层也认不出(base64 里没有 12 位连续数字)");
    }

    // ── G. 出站用 maskByKey:只按键名遮,不误伤我们自己的长数字 ─────────────

    @Test
    void maskByKey_masksAuthCode_butLeavesOurOwnLongNumbersAlone() {
        // 出站那一行 trace 日志的意义就是「这次请求发了什么参数」。
        // out_trade_no / pay_trace_no 是我们自己的对账号,遮掉它 = 删掉这条日志。
        String payload = "{auth_code=134573607383541001, out_trade_no=202609210000000123, "
                + "inst_no=900075, total_amount=100}";
        assertEquals("{auth_code=***, out_trade_no=202609210000000123, inst_no=900075, total_amount=100}",
                LeshouyingSensitive.maskByKey(payload));
    }

    @Test
    void maskByKey_isStrictlyWeakerThanMask() {
        // ★ 「严格更弱」必须**在同一个输入上给出两个不同的结论**才算证明。
        //   只断言 maskByKey 在某输入上恒等,证明不了它比 mask 弱 —— 那两条断言合起来
        //   在 mask 也是恒等的输入上同样成立,什么都没区分。
        //   这个 body 是实测挑的(Layer 探针):mask 遮得住(值层独撑),maskByKey 原样返回
        //   (键层够不着嵌套转义的键)。这才是两层分道扬镳的那一点。
        //   它同时是「别把出站的 maskByKey 挪到入站用」的哨兵:入站是任意报文形态,
        //   一旦改用 maskByKey,这条路径立刻退回裸奔。
        String body = "{\"data\":\"{\\\"auth_code\\\":\\\"134573607383541008\\\"}\"}";
        assertFalse(LeshouyingSensitive.mask(body).contains("134573607383541008"),
                "mask 必须遮住 —— 由值层完成,键层在这个形状上是瞎的");
        assertTrue(LeshouyingSensitive.maskByKey(body).contains("134573607383541008"),
                "maskByKey 遮不住 —— 这正是它只配用于「我们自己构造的报文」的原因");
    }
}

运行确认失败:

cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingSensitiveTest

Expected: 编译失败 —— LeshouyingSensitive 不存在。

  • [ ] Step 5: 实现 LeshouyingSensitive

    package com.payment.platform.module.payment.leshouying.client;
    
    import java.util.regex.Pattern;
    
    /**
    * 乐收赢报文脱敏 —— Task 2 / Task 4 / Task 12 共用同一份实现,不要各写一份正则。
    *
    * 【这不是安全边界】它是对**任意畸形文本**的尽力而为,**不保证完整**。
    * 任何人都不得据此认为「这里打日志 / 落库是安全的」。真正的边界是:
    * 付款码不进任何实体、不进任何结构化列(见建表脚本与实体的守卫测试)。
    *
    * 【为什么按「值的形态」而不是按字段名匹配】这条是实测结论,不是推理。
    * 被替换掉的那版正则 `("auth_code"\s*:\s*")[^"]*` 要求畸形文本里恰好命中 `"auth_code":` 这一种拼法。
    * 实测(把那版正则逐条喂给下面这 9 类写法):**9 类全部漏网** —— form-encoded、XML 标签、大写键、单引号、
    * 值无引号、双层转义嵌套、空格分隔、驼峰 `AuthCode` / `authCode`。
    * ★ 这里的「9」数的是**上面列出的这 9 类**,不是某份语料的总数。
    *   之前这里写的是「14 种形态语料里漏 9 种」,而那份语料**没有被保存下来**,
    *   导致该数字当场无法复现(控制方重构语料跑出来的是 11,两者并不矛盾,
    *   只是语料不同)。不可复现的数字**比没有数字更坏**,因为它看起来像证据。
    *   改成数**下面这九类**后,每一类都可当场用那版正则验。
    * 补完这几种还有 HTML 实体 / 键名编码 / 码内插分隔符,是必输的 whack-a-mole。
    * 而付款码的**形态**在改变写法时是不变量:键名可以换大小写、换引号、被嵌套转义,
    * 18 位数字仍然是 18 位数字。
    *
    * 【值层不是锦上添花,是唯一防线】上面那 9 类里,新键层能自己接住 7 类
    * (这 7 类现在**每一类都有具名测试**:5 类见 B 组,驼峰 2 类见 `keyLayer_catchesPascalCaseKey` /
    *  `keyLayer_catchesCamelCaseKey` —— 后者断言的是 `maskByKey`,不是 `mask`,因为这两条的值是裸长数字,
    *  用 `mask` 断言时删掉键层的 `(?i)` 测试照样绿,钉不住「键层接住」这个说法);
    * **剩下 2 种只有值层接得住**:XML 标签(`<auth_code>…`)、嵌套转义 JSON(`\"auth_code\":\"…`)。
    * 实测:把值层去掉,这两种立刻把 18 位付款码原样打出来。所以「两层都要」不是保守,
    * 是这两条路径的**唯一**拦截点。这 2 类各有一条具名测试兜着:`valueLayer_isPrefixAgnostic_notAnchoredToThe10_15Range`(XML 标签 + 非 10-15 号段)与 `maskByKey_isStrictlyWeakerThanMask`(嵌套转义)——**删掉值层,这两条立刻变红**。
    *
    * 【阈值为什么取长度而不锚定前缀】计划记录付款码是 18 位、以 10-15 开头。
    * 但 `1[0-5]\d{16}` 这类「前缀+长度」锚点会**朝危险方向失败**:上游一旦换号段或改位数,
    * 它静默恢复泄漏。长度阈值则朝安全方向退化(多遮几个无关长数字),代价只是这条诊断日志少几个字段值。
    *
    * 【代价(已实测,见 LeshouyingSensitiveTest)】值层不看字段名,所以 14 位的 pay_time、
    * 12 位及以上的 out_trade_no 会一起被遮;`trade_no`(字母数字混合)、`receipt_amount`(短)、
    * `return_code` / `return_msg` / `result_code` 不受影响。这是刻意的兑换:
    * 这条日志的职责是回答「回来的是 HTML 还是被截断的 JSON」,不是复现字段值。
    * 边界是**闭区间下界 12**:11 位不遮、12 位遮。这条边界有专属断言
    * (`valueLayer_thresholdBoundary_11Survives_12IsMasked`)——「12 位以上」在中文里
    * 既可读成 ≥12 也可读成 >12,而代码是前者。
    *
    * 【具名的、已接受的残留(勿当成已覆盖)】值层对「键的写法」不变,对「值的再编码」不变。
    * 下面三条是**实测确认遮不住**的(每条都有对应的断言测试):
    *   1. HTML 实体(&#49;&#51;…)—— `&` 在键层的排除集里,键层匹配到空值就停,
    *      所以标签会被重写成 `auth_code=***`,而实体编码的数字原样留在后面。
    *   2. 码内被插入分隔符(1345 7360 7383 5418)—— 连续数字被打断,值层接不住,只遮得住第一段。
    *   3. 键名本身也被编码掉的情形(如整包 base64)。
    *
    * 【几条**看起来**像残留、实测其实被覆盖的(别误加进上面的清单)】
    *   - `auth_code=%31%33%34…`:**能遮**。键层的值尾巴 `[^"'&\s,}]*` 不含 `%`,故整段被消费。
    *     (值层看不见它 —— 没有 12 位连续数字 —— 但键层兜住了。只想到值层就会误判成残留。)
    *   - `auth_code=MTM0NTcz…`(base64 但键还在):同上,**能遮**。只有键名也被编码才漏。
    *
    * 之所以接受:这些只能在「任意畸形文本」上才可能可靠识别,而那种识别本身不可靠。
    * 真正的防线始终是付款码不落结构化存储,不是这一层正则。
    */
    public final class LeshouyingSensitive {
    
    /** 值层(主):连续 12 位及以上数字(`{12,}` 是闭区间下界)。编码无关,故不受键名写法影响。 */
    private static final Pattern LONG_DIGITS = Pattern.compile("\\d{12,}");
    
    /**
     * 键层(兜底):覆盖规范 JSON 与最常见的写法变体 —— 大小写、单双引号、`:` 或 `=` 分隔、
     * 冒号两侧空白。挡住「非数字」的将来形态(值层只认数字)。保留键名本身,
     * 便于排障时仍能看懂报文结构。
     *
     * 【三条要知道的边界,别按直觉推广】
     *   1. **没有词边界**(实测,同 `Rx` 探针):加 `\b` 会挡掉 `_auth_code` 与 `xauth_code`
     *      (`_` 是词字符,前面没有边界),只有 `authcode` 还能命中 —— 即「加 `\b` 让匹配行为
     *      在 `authcode` 和 `auth_code` 之间变得不一致」。不加则三种都命中;
     *      代价是 `myauth_code=` 这种也会命中 —— 方向安全,接受。
     *   2. **`replaceAll("$1" + MASK)` 的风险在 `MASK`,不在 group 1**(实测,见 `Rx` 探针):
     *      `$1` 是**按引用**解析的,group 1 捕获到的文本原样拷进结果,永远不会被二次解析成
     *      替换语法 —— 输入里就算有 `$1`,输出里还是 `$1`。所以 group 1 的字符集与安全性无关。
     *      真正会被解析的是 `MASK` 拼在 `$1` 后面的那一段:
     *        · `MASK` 含反斜杠 → **静默吞掉**:实测用替换串「`$1` + 反斜杠 + 空格 + `x`」
     *          跑 `Pattern.compile("(a)").matcher("a")`,输出 **`a x`** —— 反斜杠没了,
     *          而 group 1 的内容与那个空格**都还在**。最危险的一种,因为它无声。
     *        · `MASK` 含 `$` → 抛 `IllegalArgumentException: Illegal group reference`(实测)。
     *        · `MASK` 以数字开头 → **安全**:Java 会回退到「最大的合法组号」,
     *          `"$1" + "2xx"` 实测输出 **`a2xx`**(组 1 = `a`,`2xx` 当字面量),不是组 12。
     *          只有组 1 捕到空串时输出才是 `2xx` —— 那是个特例,别当成通例。
     *      当前 `MASK = "***"`,不含 `$` 也不含 `\`,安全。**改 `MASK` 时必须重查这两条。**
     *   3. **尾巴 `[^"'&\s,}]*` 是「过度吃进」的**:它会把 `%31%33…`(URL 编码)整段吃掉。
     *      这正是我们要的 —— 见类注释里那条「看起来像残留、其实被覆盖」。
     *      代价是遇到 `auth_code=abc123def` 时会把整串非空白内容当值消费掉;
     *      在「值不含空白」的假设下这是对的,在含空白的畸形文本里会提前停(已被测试具名记录)。
     */
    private static final Pattern AUTH_CODE_KEY = Pattern.compile(
            "(?i)([\"']?auth_?code[\"']?\\s*[:=]\\s*[\"']?)[^\"'&\\s,}]*");
    
    private static final String MASK = "***";
    
    private LeshouyingSensitive() {
    }
    
    /**
     * 【用于我们**无法预知内容**的文本】解析失败的响应、入站回调整文。
     * 先按键名遮(保留键名,便于看懂报文结构),再按长数字遮(兜住所有键名变体)。
     * 两层都做,因为**两层的失效模式互相独立**:键层败于写法变体,值层败于值的再编码。
     */
    public static String mask(String body) {
        if (body == null) {
            return null;
        }
        return LONG_DIGITS.matcher(maskByKey(body)).replaceAll(MASK);
    }
    
    /**
     * 【只用于我们**自己构造**的报文】仅按键名遮,**不碰无关的长数字**。
     *
     * 为什么出站日志不能用 `mask()`:值层不看字段名,会把 `out_trade_no` / `pay_trace_no`
     * 这类**我们自己的、用来对账的**长数字一起遮掉。而出站那一行 trace 日志的全部意义
     * 就是回答「这次请求到底发了什么参数」—— 把参数遮掉,等于把这条日志删了。
     * 出站参数由本模块构造,键名叫什么我们**确定知道**,键层足够,不需要值层兜底。
     *
     * 判据一句话:**内容由我们构造 → `maskByKey`;内容来自外部 → `mask`。**
     * 反过来用会出问题:出站用 `mask` 日志失去诊断价值,入站用 `maskByKey` 则丢掉值层,**XML 标签**与**嵌套转义 JSON**这 2 类会原样泄漏
     * (实测:拿**11 形态语料**跑 `Probe3` —— 上面那 9 类 + HTML 实体 + 码内插空格 ——
     * `maskByKey` 漏 3、`mask` 漏 1,差集正是这 2 类。
     * ★ 语料必须点名:紧邻的上一段讲的是 **9 类**,若读者把「同一份语料」的先行词理解成那 9 类,
     *   就会读到「`mask` 漏 1」,与本类开头「`mask` 对 9 类漏 0」直接冲突。数字对不上不是矛盾,是语料不同 —— 所以才要写清是哪一份。)
     */
    public static String maskByKey(String text) {
        if (text == null) {
            return null;
        }
        return AUTH_CODE_KEY.matcher(text).replaceAll("$1" + MASK);
    }
    }
    
  • [ ] Step 6: 实现 LeshouyingResponseParser

    package com.payment.platform.module.payment.leshouying.client;
    
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.json.JSONNull;
    import cn.hutool.json.JSONObject;
    import cn.hutool.json.JSONUtil;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /**
    * 乐收赢两套报文信封解析器。
    *
    * 支付类 /v3/*:return_code(SUCCESS/FAIL) 表通信,result_code 表业务(PAY_SUCCESS/PAY_IN_PROCESS/PAY_FAIL)
    * 进件类 /api/*:result_code(SUCCESS/FAIL) 表成败,result_msg 表消息
    *
    * 混用两套判定会导致「支付中」被误判为失败或反之,必须分开。
    */
    @Slf4j
    @Component
    public class LeshouyingResponseParser {
    
    private static final String SUCCESS = "SUCCESS";
    private static final String PAY_SUCCESS = "PAY_SUCCESS";
    private static final String PAY_IN_PROCESS = "PAY_IN_PROCESS";
    private static final String PAY_FAIL = "PAY_FAIL";
    
    /** 解析 /v3/* 报文。通信层失败抛异常;业务层失败(PAY_FAIL)正常返回。 */
    public Map<String, Object> parseV3(String json) {
        Map<String, Object> map = toMap(json);
        String returnCode = str(map.get("return_code"));
        if (StrUtil.isBlank(returnCode)) {
            // /v3/* 报文必有 return_code。没有它,说明这根本不是乐收赢的 v3 响应
            // (网关 HTML 错误页、{}、被截断后恰好仍可解析的片段),而不是「上游回了 FAIL」。
            // 实测 Hutool 对 `<html>502</html>` **不抛**,而是解成 {"html":502},故这条分支是真实入口;
            // 若让它落进下面那条,会得到「乐收赢接口失败[null]: null」—— 既误导,又恰好丢掉了联调最需要的原文。
            throw new LeshouyingApiException("PARSE_ERROR",
                    "乐收赢返回报文缺少 return_code,非 v3 响应", json);
        }
        if (!SUCCESS.equals(returnCode)) {
            throw new LeshouyingApiException(returnCode,
                    str(map.get("return_msg")), json);
        }
        return map;
    }
    
    /**
     * 解析 /api/* 报文。非 SUCCESS 一律抛异常(进件类没有独立的"业务失败"语义)。
     *
     * 【本方法的边界:只认信封,不认字段】它验证的是「这是不是一个乐收赢的 /api/* 响应」
     * (看 `result_code` 在不在、是不是 SUCCESS),**不校验也不列举任何业务字段**。
     * 字段由调用方按需 `map.get(...)` 自取。
     *
     * 【为什么这样定】进件是**子项目 B**(spec §13),本期 A 阶段只用文档给的测试商户
     * `Y52211090004` 走通支付与收款生命周期,不去创建商户。进件的字段集在 B 才成立,
     * 而且**现在也定不下来**:spec §13 记着 B 有 7 条待确认项,其中 `reviewed_status`
     * 的值域在三处文档里互相冲突。所以这里**故意不写**字段白名单 ——
     * 写一份当下就自相矛盾的契约,比不写更糟:它会让后来的调用方以为漏掉的字段是
     * 「上游没有」,而不是「我们没查」。
     *
     * 这同时是**已知的范围限制**:本期 `parseApi` 的测试只覆盖信封行为
     * (缺码 / 失败码 / 矛盾的 return_code),覆盖不到任何字段语义。不是遗漏。
     */
    public Map<String, Object> parseApi(String json) {
        Map<String, Object> map = toMap(json);
        String resultCode = str(map.get("result_code"));
        if (StrUtil.isBlank(resultCode)) {
            // 与 parseV3 同理:缺 result_code 说明不是乐收赢的进件响应,不是「上游回了 FAIL」。
            throw new LeshouyingApiException("PARSE_ERROR",
                    "乐收赢返回报文缺少 result_code,非 api 响应", json);
        }
        if (!SUCCESS.equals(resultCode)) {
            throw new LeshouyingApiException(resultCode,
                    str(map.get("result_msg")), json);
        }
        return map;
    }
    
    public boolean isPaySuccess(Map<String, Object> resp) {
        return resp != null && PAY_SUCCESS.equals(str(resp.get("result_code")));
    }
    
    public boolean isPayInProcess(Map<String, Object> resp) {
        return resp != null && PAY_IN_PROCESS.equals(str(resp.get("result_code")));
    }
    
    /**
     * 只有**显式** PAY_FAIL 才算失败。
     *
     * 【为什么需要它】调用方若写 `isPaySuccess(...) ? SUCCESS : FAIL`,那么上游一旦回了
     * 未预期的 result_code(例如某端点回的是通用成功码 "SUCCESS" 而非 "PAY_SUCCESS"),
     * **一笔已经收到钱的支付会被落库为失败** —— 这正是本类存在的意义所要防的静默误判。
     * 三个谓词全为 false 时,调用方必须走「未知码」分支:记日志 + 待人工核对,不得静默归类。
     */
    public boolean isPayFail(Map<String, Object> resp) {
        return resp != null && PAY_FAIL.equals(str(resp.get("result_code")));
    }
    
    private Map<String, Object> toMap(String json) {
        if (StrUtil.isBlank(json)) {
            throw new LeshouyingApiException("EMPTY", "乐收赢返回空报文", json);
        }
        try {
            JSONObject obj = JSONUtil.parseObj(json);
            Map<String, Object> map = new LinkedHashMap<>();
            for (String k : obj.keySet()) {
                Object v = obj.get(k);
                // Hutool 把 JSON 的 null 解成 JSONNull 单例,**不是** Java null(实测)。
                // 不归一化的话 `resp.get(k) == null` 为 false,而 `String.valueOf(v)` 会得到
                // **字符串** "null"(实测 len=4)—— 下游 Task 6 的 payPatch 拿它去 parseLong
                // 会抛 NumberFormatException: For input string: "null"。统一归一成 Java null。
                //
                // 【边界:只归一层顶层,不递归】实测(NullProbe):嵌套对象原样透传为 JSONObject,
                // 其内部的 null 仍是 JSONNull,本循环够不着。当前消费方(payPatch、回调)只读顶层
                // 字段,所以够用;**谁要开始读嵌套字段,先回来把这里改成递归**。
                // 钉住这条边界的测试:parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives。
                map.put(k, v instanceof JSONNull ? null : v);
            }
            return map;
        } catch (Exception e) {
            // 解析失败时把原文记进日志,是排查的唯一线索(十有八九是网关 HTML 或截断 JSON)。
            // 必经 LeshouyingSensitive.mask —— 它是这条路径上**唯一**的脱敏控制。
            // 【不要把 StrUtil.brief 的长度上限当兜底】brief 是「头 + 尾」缩略,不是前缀截断:
            // 位于上限之后、靠近末尾的内容照样进日志(实测 len=633、码在 offset 615 → 仍被记录)。
            log.error("乐收赢报文解析失败, json={}", StrUtil.brief(LeshouyingSensitive.mask(json), 500), e);
            LeshouyingApiException ex = new LeshouyingApiException(
                    "PARSE_ERROR", "乐收赢返回报文无法解析为 JSON", json);
            ex.initCause(e);   // 否则 Hutool 的 JSONException 只活在上一行日志里,调用方程序化拿不到
            throw ex;
        }
    }
    
    private String str(Object o) {
        return o == null ? null : String.valueOf(o);
    }
    }
    
  • [ ] Step 7: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='LeshouyingResponseParserTest,LeshouyingApiExceptionTest,LeshouyingSensitiveTest'
    

Expected:

  • LeshouyingResponseParserTest → Tests run: 17, Failures: 0, Errors: 0
  • LeshouyingApiExceptionTest → Tests run: 9, Failures: 0, Errors: 0
  • LeshouyingSensitiveTest → Tests run: 26, Failures: 0, Errors: 0

模块级回归(Task 1 的 Signer 不得被碰坏):-Dtest='Leshouying*Test' → 17 + 9 + 26 + 11 = 63。

(计数要盯着看,数字对不上说明有测试被漏写。上面的数字是两轮修复之后的: 首轮交付 13 / 7 / 23(合计 54);修复轮一加 7 条 —— parser +4(catch 可达性与变异可杀性、 两条 PARSE_ERROR 分支可分辨、EMPTY 分类)、exception +2(新压字符的成对断言、 二参构造器不得回归)、sensitive +1(base64 键名保留形态);修复轮二再加 sensitive +2 (驼峰 AuthCode / authCode 两类 —— 它们此前是「注释里声称被键层接住、实际没人守」, 与 base64 那条同一类问题,同一把尺子量到底)。 17 = 13 + 4;9 = 7 + 2;26 = 23 + 1 + 2。 这些测试都是设计决策或安全控制的钉子(顶层归一化的边界、阈值下界、不锚定前缀、 message 的两字段压平 / U+2028+U+0085 / \t 保留 / 512 封顶 / 原文保留 / catch 的 mask 不可删), 不是覆盖率凑数:删掉它们代码照样全绿,但那时「为什么这么做」就只剩注释里的一句话了。 异常那 9 条尤其是 —— 删掉 sanitize 里任何一个 replace 都不会有任何东西变红。)

  • [ ] Step 8: 确认工作区状态(不提交)

    git status --short
    

Task 3: 数据库迁移 + 4 个实体 + 4 个 Mapper

Files:

  • Create: java/src/main/resources/db/migration/V1.8__create_pay_lsy_tables.sql(不执行)
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/enums/{LsyPayScene,LsyPayType,LsyOrderStatus,LsyRefundStatus,LsyNotifyStatus}.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/entity/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Entity.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/mapper/{LsyMerchant,LsyOrder,LsyRefund,LsyNotifyLog}Mapper.java

  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/entity/LsyEntityMappingTest.java

Interfaces:

  • Consumes: 无
  • Produces: 4 个实体类 + 4 个 Mapper 接口。字段名见下方 SQL 与实体定义,后续任务全部依赖这些 getter/setter 名。

  • [ ] Step 1: 写迁移脚本

创建 V1.8__create_pay_lsy_tables.sql:

-- 乐收赢支付通道 —— 建表脚本
-- 【注意】本脚本未经执行,需用户放行后手动执行或由 Flyway 自动迁移。
-- 版本号说明:当前最新已应用为 V1.7;HAR 模块的 V1.8 在 stash 中未执行,
--            其恢复时需改名为 V1.9,否则会因 outOfOrder=false 报错。

-- 企业 ↔ 乐收赢商户映射
CREATE TABLE IF NOT EXISTS pay_lsy_merchant (
    id              BIGINT       PRIMARY KEY,
    tenant_id       BIGINT       NOT NULL,
    enterprise_id   VARCHAR(64)  NOT NULL,
    mch_no          VARCHAR(32)  NOT NULL,
    shop_no         VARCHAR(32),
    merchant_name   VARCHAR(128),
    review_status   VARCHAR(32)  NOT NULL DEFAULT 'APPROVED',
    status          VARCHAR(32)  NOT NULL DEFAULT 'ENABLED',
    description     VARCHAR(255),
    created_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_merchant_tenant_ent
    ON pay_lsy_merchant (tenant_id, enterprise_id);
CREATE INDEX IF NOT EXISTS idx_lsy_merchant_mch_no ON pay_lsy_merchant (mch_no);
COMMENT ON TABLE pay_lsy_merchant IS '乐收赢商户映射(A阶段手工维护,B阶段进件自动写入)';

-- 支付订单
CREATE TABLE IF NOT EXISTS pay_lsy_order (
    id                      BIGINT       PRIMARY KEY,
    tenant_id               BIGINT       NOT NULL,
    enterprise_id           VARCHAR(64),
    mch_no                  VARCHAR(32)  NOT NULL,
    shop_no                 VARCHAR(32),

    out_trade_no            VARCHAR(64)  NOT NULL,
    api_key_id              BIGINT,
    notify_url              VARCHAR(512),
    attach                  VARCHAR(128),
    order_body              VARCHAR(128),

    pay_scene               VARCHAR(16)  NOT NULL,
    pay_type                VARCHAR(8),

    total_amount            BIGINT       NOT NULL DEFAULT 0,
    receipt_amount          BIGINT,
    discount_amount         BIGINT,
    refunded_amount         BIGINT       NOT NULL DEFAULT 0,

    pay_trace_no            VARCHAR(64)  NOT NULL,
    pay_time                VARCHAR(14),
    trade_no                VARCHAR(64),
    td_trade_no             VARCHAR(64),
    qd_trade_no             VARCHAR(64),
    user_id                 VARCHAR(64),

    code_url                TEXT,
    code_url_expire_time    TIMESTAMP WITH TIME ZONE,

    status                  VARCHAR(32)  NOT NULL,
    upstream_result_code    VARCHAR(32),
    upstream_result_msg     VARCHAR(255),

    query_count             INT          NOT NULL DEFAULT 0,
    next_query_time         TIMESTAMP WITH TIME ZONE,
    last_query_time         TIMESTAMP WITH TIME ZONE,

    notify_status           VARCHAR(16)  NOT NULL DEFAULT 'PENDING',
    notify_count            INT          NOT NULL DEFAULT 0,
    last_notify_time        TIMESTAMP WITH TIME ZONE,

    finish_time             TIMESTAMP WITH TIME ZONE,
    description             VARCHAR(255),
    created_time            TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_time            TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_order_out_trade_no ON pay_lsy_order (out_trade_no);
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_order_pay_trace_no ON pay_lsy_order (pay_trace_no);
CREATE INDEX IF NOT EXISTS idx_lsy_order_trade_no   ON pay_lsy_order (trade_no);
CREATE INDEX IF NOT EXISTS idx_lsy_order_poll       ON pay_lsy_order (status, pay_scene, next_query_time);
CREATE INDEX IF NOT EXISTS idx_lsy_order_tenant     ON pay_lsy_order (tenant_id, created_time DESC);
COMMENT ON TABLE pay_lsy_order IS '乐收赢支付订单';

-- 退款单
CREATE TABLE IF NOT EXISTS pay_lsy_refund (
    id                  BIGINT       PRIMARY KEY,
    tenant_id           BIGINT       NOT NULL,
    order_id            BIGINT       NOT NULL,
    out_trade_no        VARCHAR(64)  NOT NULL,
    out_refund_no       VARCHAR(64)  NOT NULL,
    refund_trace_no     VARCHAR(64)  NOT NULL,
    refund_no           VARCHAR(64),
    qd_refund_no        VARCHAR(64),
    refund_fee          BIGINT       NOT NULL DEFAULT 0,
    refund_settle       BIGINT,
    status              VARCHAR(32)  NOT NULL,
    upstream_result_code VARCHAR(32),
    upstream_result_msg  VARCHAR(255),
    -- 注意:上游退款接口里 pay_time 是"退款请求时间",原支付时间是 pay_trace_time
    refund_request_time VARCHAR(14),
    original_pay_time   VARCHAR(14),
    notify_status       VARCHAR(16)  NOT NULL DEFAULT 'PENDING',
    notify_count        INT          NOT NULL DEFAULT 0,
    last_notify_time    TIMESTAMP WITH TIME ZONE,
    finish_time         TIMESTAMP WITH TIME ZONE,
    description         VARCHAR(255),
    created_time        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_time        TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_out_refund_no ON pay_lsy_refund (out_refund_no);
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_trace_no      ON pay_lsy_refund (refund_trace_no);
CREATE INDEX IF NOT EXISTS idx_lsy_refund_order ON pay_lsy_refund (order_id);
-- ★ 部分唯一索引:同一订单最多只能有一笔"在途"退款(非终态)。
--   这是防重复退款的**结构性**防线,不是尽力而为的应用层检查。
--   为什么必须有:可退余额 remaining = total_amount - refunded_amount,而 refunded_amount
--   只在退款**成功后**才累加。所以在途期间 remaining 是虚高的 —— 一笔 100 元的单,
--   第一笔 100 元退款还在 REFUNDING 时,第二笔 100 元退款照样能通过应用层校验。
--   上游真扣两次,我们这边记一次。
--   PostgreSQL 部分唯一索引让第二笔 insert 直接违反约束,把并发窗口一并封死:
--   应用层的"先查后插"是读-判-写,两个并发请求可以同时通过;索引不会。
--   前提:pay_lsy_refund 是**状态机表**,没有逻辑删除列(本模块 DDL 全局无 deleted 列),
--   所以不存在"已软删的在途行永久占位"的问题。
CREATE UNIQUE INDEX IF NOT EXISTS uk_lsy_refund_one_pending
    ON pay_lsy_refund (order_id) WHERE status IN ('CREATED', 'REFUNDING');
COMMENT ON TABLE pay_lsy_refund IS '乐收赢退款单';

-- 回调审计(追加写,无唯一约束;幂等由状态机条件更新保证)
CREATE TABLE IF NOT EXISTS pay_lsy_notify_log (
    id              BIGINT       PRIMARY KEY,
    tenant_id       BIGINT,
    trade_type      VARCHAR(2),
    trade_no        VARCHAR(64),
    pay_trace_no    VARCHAR(64),
    raw_body        TEXT,
    verify_status   VARCHAR(16)  NOT NULL,
    process_status  VARCHAR(16),
    error_msg       VARCHAR(512),
    -- ★ 本表**不用** status(审计行的状态由 verify_status + process_status 表达),
    --   但这一列**必须存在**:LsyNotifyLogEntity 继承了 PaymentBaseEntity 的 status,
    --   而 MyBatis-Plus 按**实体**生成列清单 —— 缺这一列会让本表**任何读操作**抛
    --   PostgreSQL 42703 `column "status" does not exist`。
    --   写操作会「碰巧能过」(MP 默认 NOT_NULL 策略把 null 字段从 INSERT 列清单里剔掉),
    --   于是缺陷潜伏到第一次读才爆 —— 而下面专为读建了 idx_lsy_notify_trade_no。
    --   兄弟审计表 pay_alipay_notify_log 有 status;迁移里建的、有基类实体的表,本表是唯一缺它的。
    --   【注意】必须在本脚本**首次执行前**补上:CREATE TABLE IF NOT EXISTS
    --   会让执行之后对文件的任何修改静默失效(表已存在则整条语句跳过,不报错)。
    status          VARCHAR(32),
    description     VARCHAR(255),
    created_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
    updated_time    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_lsy_notify_trade_no ON pay_lsy_notify_log (trade_no, trade_type);
COMMENT ON TABLE pay_lsy_notify_log IS '乐收赢回调原始报文审计(追加写)';
  • Step 2: 写枚举

LsyPayScene.java:

package com.payment.platform.module.payment.leshouying.enums;

/** 支付场景。决定下单走向、关单方式、是否同步轮询。 */
public enum LsyPayScene {
    /** 付款码支付(B扫C)。上游 v3/pay。会返回 PAY_IN_PROCESS,需同步轮询。关单用 cancelorder。 */
    BARCODE,
    /** 聚合码支付(C扫B)。上游 v3/qrpay。不会返回 PAY_IN_PROCESS,不同步轮询。关单用 qrclose+code_url。 */
    QRCODE,
    /** H5 支付。上游 v3/h5pay。会返回 PAY_IN_PROCESS,但用户被跳转,不同步轮询。无已知关单接口。 */
    H5
}

LsyPayType.java:

package com.payment.platform.module.payment.leshouying.enums;

import cn.hutool.core.util.StrUtil;

/** 支付通道类型。 */
public enum LsyPayType {
    /** 自动识别。仅付款码支付支持。 */
    AUTO("000"),
    ALIPAY("300"),
    WECHAT("400");

    private final String code;

    LsyPayType(String code) { this.code = code; }

    public String getCode() { return code; }

    public static LsyPayType of(String code) {
        if (StrUtil.isBlank(code)) return AUTO;
        for (LsyPayType t : values()) {
            if (t.code.equals(code)) return t;
        }
        throw new IllegalArgumentException("不支持的支付类型: " + code);
    }

    /** H5 支付必须指定通道,不接受 AUTO */
    public static LsyPayType ofRequired(String code) {
        LsyPayType t = of(code);
        if (t == AUTO) {
            throw new IllegalArgumentException("该场景必须指定支付类型(300 支付宝 / 400 微信)");
        }
        return t;
    }
}

LsyOrderStatus.java:

package com.payment.platform.module.payment.leshouying.enums;

import java.util.Set;

/**
 * 订单状态。
 *
 * 条件更新的放行集合分成两组且互不重叠(spec §5.2):
 *   - 支付回写:CREATED / PAYING → 支付结果
 *   - 退款回写:SUCCESS / CANCELED / CLOSED / PARTIAL_REFUNDED → 退款结果
 * 共用一个集合会让「撤销后到达的退款回调」丢数据。
 */
public enum LsyOrderStatus {
    CREATED, PAYING, SUCCESS, FAIL, CANCELED, CLOSED, EXPIRED, PARTIAL_REFUNDED, REFUNDED;

    /** 允许被支付结果回写的来源状态 */
    public static final Set<String> PAY_WRITE_FROM = Set.of("CREATED", "PAYING");

    /** 允许被退款结果回写的来源状态 */
    public static final Set<String> REFUND_WRITE_FROM =
            Set.of("SUCCESS", "CANCELED", "CLOSED", "PARTIAL_REFUNDED");

    /**
     * 非终态订单 = 可能还欠一个结果的订单。
     * **使用者是 `LsyReconcileScheduler.expireStaleOrders` 的绝对过期兜底**(超出 absoluteExpireHours 仍未终态 → EXPIRED)。
     *
     * ★ 注意它**不是** `reconcilePendingOrders` 的扫描集合 —— 那个查询过滤的是 `status = 'PAYING'`
     *   (理由:只有已经送上游的单才谈得上「问上游要结果」)。
     *   这个区别很要紧:写下单路径的未知码分支时,若以为「留在 CREATED 会被对账轮询」就错了 ——
     *   收尾它的是**回调**(PAY_WRITE_FROM 放行 CREATED)与**这张表的绝对过期**,不是对账轮询。
     */
    public static final Set<String> PENDING = Set.of("CREATED", "PAYING");

    /**
     * 「我们已经认过这笔成功收款」的状态。
     *
     * 它与 {@link #PENDING} 合起来回答「回调的 0 行更新是什么意思」——
     * 两者把全部状态**划分**成三块:
     *   - ∈ {@code PENDING}            → 这笔款还能被写成功(正常在途);
     *   - ∈ {@code PAY_RECORDED}       → 款已记过,重复的成功回调只是重推(真·幂等);
     *   - 两者都不在                    → **终结了但从未认过成功**:FAIL / EXPIRED / CANCELED / CLOSED
     *                                     ⇒ 收到 PAY_SUCCESS 就是**账实不符**,必须人工核对。
     *
     * ★ 为什么是「PENDING 的补集再去掉本集合」,而不是**列举**那四个异常态:
     *   新增一个终态而忘了归类时,补集写法会**多报一次告警**(响的),
     *   列举写法会在同样情形下**静默漏掉**(不响的)。
     *   资金差异这件事上,多报一次可接受,漏一次不可接受 —— **故障方向选响的那一侧**。
     *
     * ★ 它**不是** {@link #REFUND_WRITE_FROM} 的别名:那两个集合语义不同。
     *   `REFUND_WRITE_FROM` 回答「**能不能**收退款结果」(含 CANCELED / CLOSED —— 撤销与关单也要能退款),
     *   本集合回答「**款有没有记过**」。取值不同、将来也会各自增删,**不要合并**。
     */
    public static final Set<String> PAY_RECORDED = Set.of("SUCCESS", "PARTIAL_REFUNDED", "REFUNDED");
}

LsyRefundStatus.java:

package com.payment.platform.module.payment.leshouying.enums;

import java.util.Set;

public enum LsyRefundStatus {
    CREATED, REFUNDING, SUCCESS, FAIL;

    /** 退款结果可回写的起始状态:本行只有从这里出发才能被写一次终态。 */
    public static final Set<String> PAY_WRITE_FROM = Set.of("CREATED", "REFUNDING");

    /**
     * 在途退款(非终态)。
     *
     * **使用者有两个**,改这个集合前先看这两处:
     *   1. `pay_lsy_refund` 上的部分唯一索引 `uk_lsy_refund_one_pending`
     *      (`WHERE status IN ('CREATED','REFUNDING')`)—— SQL 里是**字面量**,
     *      改这里必须同步改 V1.8 迁移脚本的索引谓词,否则应用层与数据库的"在途"定义会分叉;
     *   2. `LsyRefundService.createRefund` 的在途前置校验(防重复退款)。
     *
     * ★ 这两份副本(本常量 与 V1.8 索引谓词)由
     *   `LsyEntityMappingTest.refundPendingSetMatchesPartialUniqueIndexPredicate` 钉住,
     *   分叉即测试失败。这是真钱防线,不是文档约定。
     *
     * ★ 它和 `PAY_WRITE_FROM` 目前**取值相同但语义不同**,不要因此合并成一个常量:
     *   前者问"这笔退款还在进行中吗",后者问"现在还能不能写终态"。
     *   例如将来若允许从 FAIL 重试,PAY_WRITE_FROM 要加 FAIL 而 PENDING 不该加。
     *
     * ★ 当前**没有**退款侧的兜底对账任务(LsyReconcileScheduler 只扫订单表),
     *   所以一笔回调丢失的退款会永久停在 REFUNDING。见待确认清单。
     */
    public static final Set<String> PENDING = Set.of("CREATED", "REFUNDING");
}

LsyNotifyStatus.java:

package com.payment.platform.module.payment.leshouying.enums;

/** 下游回调推送状态 */
public enum LsyNotifyStatus {
    PENDING, SUCCESS, FAIL
}
  • Step 3: 写 4 个实体

LsyMerchantEntity.java:

package com.payment.platform.module.payment.leshouying.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import com.payment.platform.common.base.PaymentTenantBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;

/** 企业 ↔ 乐收赢商户映射。A 阶段管理端手工维护,B 阶段进件成功后自动写入。 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_lsy_merchant")
public class LsyMerchantEntity extends PaymentTenantBaseEntity {
    private String enterpriseId;
    private String mchNo;
    private String shopNo;
    private String merchantName;
    private String reviewStatus;
    // id / tenantId / status / description / createdTime / updatedTime 由基类提供
}

LsyOrderEntity.java:

package com.payment.platform.module.payment.leshouying.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import com.payment.platform.common.base.PaymentTenantBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.time.OffsetDateTime;

/**
 * 乐收赢支付订单。
 * 付款码(auth_code)刻意不落库 —— 一次性支付凭证等同卡号,存了也不能复用,对账靠 trade_no/pay_trace_no。
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_lsy_order")
public class LsyOrderEntity extends PaymentTenantBaseEntity {

    private String enterpriseId;
    private String mchNo;
    private String shopNo;

    private String outTradeNo;
    private Long apiKeyId;
    private String notifyUrl;
    private String attach;
    private String orderBody;

    private String payScene;
    private String payType;

    private Long totalAmount;
    private Long receiptAmount;
    private Long discountAmount;
    private Long refundedAmount;

    private String payTraceNo;
    private String payTime;
    private String tradeNo;
    private String tdTradeNo;
    private String qdTradeNo;
    private String userId;

    private String codeUrl;
    private OffsetDateTime codeUrlExpireTime;

    private String upstreamResultCode;
    private String upstreamResultMsg;

    private Integer queryCount;
    private OffsetDateTime nextQueryTime;
    private OffsetDateTime lastQueryTime;

    private String notifyStatus;
    private Integer notifyCount;
    private OffsetDateTime lastNotifyTime;

    private OffsetDateTime finishTime;
}

LsyRefundEntity.java:

package com.payment.platform.module.payment.leshouying.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import com.payment.platform.common.base.PaymentTenantBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;

import java.time.OffsetDateTime;

/**
 * 乐收赢退款单。
 * 注意上游命名陷阱:退款接口的 pay_time 是"退款请求时间",
 * 原支付时间是 pay_trace_time。此处落库为 refundRequestTime / originalPayTime,避免搞反。
 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_lsy_refund")
public class LsyRefundEntity extends PaymentTenantBaseEntity {

    private Long orderId;
    private String outTradeNo;
    private String outRefundNo;
    private String refundTraceNo;
    private String refundNo;
    private String qdRefundNo;
    private Long refundFee;
    private Long refundSettle;
    private String upstreamResultCode;
    private String upstreamResultMsg;
    private String refundRequestTime;
    private String originalPayTime;
    private String notifyStatus;
    private Integer notifyCount;
    private OffsetDateTime lastNotifyTime;
    private OffsetDateTime finishTime;
}

LsyNotifyLogEntity.java:

package com.payment.platform.module.payment.leshouying.entity;

import com.baomidou.mybatisplus.annotation.TableName;
import com.payment.platform.common.base.PaymentTenantBaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;

/** 乐收赢回调原始报文审计。追加写,无唯一约束。 */
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pay_lsy_notify_log")
public class LsyNotifyLogEntity extends PaymentTenantBaseEntity {
    private String tradeType;
    private String tradeNo;
    private String payTraceNo;
    private String rawBody;
    private String verifyStatus;
    private String processStatus;
    private String errorMsg;
}
  • Step 4: 写 4 个 Mapper

四个文件形状相同,但逐个写全(不要写「同理创建」—— 四份实体类型名各自不同, 而实现者可能只读到自己那一段,让它去推断映射关系就是让它猜):

文件 1/4 mapper/LsyOrderMapper.java:

package com.payment.platform.module.payment.leshouying.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LsyOrderMapper extends BaseMapper<LsyOrderEntity> {
}

文件 2/4 mapper/LsyMerchantMapper.java:

package com.payment.platform.module.payment.leshouying.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LsyMerchantMapper extends BaseMapper<LsyMerchantEntity> {
}

文件 3/4 mapper/LsyRefundMapper.java:

package com.payment.platform.module.payment.leshouying.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LsyRefundMapper extends BaseMapper<LsyRefundEntity> {
}

文件 4/4 mapper/LsyNotifyLogMapper.java:

package com.payment.platform.module.payment.leshouying.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface LsyNotifyLogMapper extends BaseMapper<LsyNotifyLogEntity> {
}

关于「4 张表要不要注册进 TenantInnerInterceptor.CONDITIONAL_TABLES」——要,但不在本任务做。 它归 Task 14 Step 5(「三个共享文件必改点」之一),且带一条会红的钉子 LeshouyingWiringTest.conditionalTables_containAllLeshouyingTables。 本任务只建表,不改任何既有文件。 原因:改动与它的钉子测试必须同处一个任务,否则「改了没测」或「测了没改」两头都看不出来; 而且这个失效只在运行时显现,Task 14 早于任何一次真实调用。 (评审若再提这一条,请看计划顶部预检表 core/tenant/TenantInnerInterceptor.java:53-76 一行 —— 已登记。)

  • [ ] Step 5: 写实体映射测试(防止注解写错)

    package com.payment.platform.module.payment.leshouying.entity;
    
    import com.baomidou.mybatisplus.annotation.TableName;
    import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
    import org.junit.jupiter.api.Test;
    
    import java.io.InputStream;
    import java.nio.charset.StandardCharsets;
    import java.util.LinkedHashSet;
    import java.util.Set;
    import java.util.regex.Matcher;
    import java.util.regex.Pattern;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    class LsyEntityMappingTest {
    
    private static final String MIGRATION = "db/migration/V1.8__create_pay_lsy_tables.sql";
    
    /**
     * **故意**不被实体映射的列(`表名.列名`)。**目前为空,且希望一直为空。**
     * 留这个集合是为了让「不映射某一列」必须是一次**被写下来**的决定,
     * 而不是一个没人注意到的默认状态。
     */
    private static final Set<String> UNMAPPED_COLUMNS = Set.of();
    
    @Test
    void allEntitiesDeclareExpectedTableName() {
        assertEquals("pay_lsy_merchant",
                LsyMerchantEntity.class.getAnnotation(TableName.class).value());
        assertEquals("pay_lsy_order",
                LsyOrderEntity.class.getAnnotation(TableName.class).value());
        assertEquals("pay_lsy_refund",
                LsyRefundEntity.class.getAnnotation(TableName.class).value());
        assertEquals("pay_lsy_notify_log",
                LsyNotifyLogEntity.class.getAnnotation(TableName.class).value());
    }
    
    @Test
    void orderEntity_hasNoAuthCodeField() {
        // 安全约束:付款码不落库
        for (var f : LsyOrderEntity.class.getDeclaredFields()) {
            assertNotEquals("authCode", f.getName(), "付款码不得落库");
        }
    }
    
    @Test
    void entitiesInheritAuditFieldsFromBase() throws Exception {
        var base = Class.forName("com.payment.platform.common.base.PaymentTenantBaseEntity");
        assertTrue(base.isAssignableFrom(LsyOrderEntity.class));
        assertTrue(base.isAssignableFrom(LsyRefundEntity.class));
        assertTrue(base.isAssignableFrom(LsyMerchantEntity.class));
        assertTrue(base.isAssignableFrom(LsyNotifyLogEntity.class));
    }
    
    /**
     * 实体字段 ⊆ 建表列 —— 这条是 C-1 那类缺陷的**机械**防线。
     *
     * 为什么必须有:MyBatis-Plus 按**实体**生成 SQL 列清单,不是按表。所以
     * 「实体上有的字段、表里没有这一列」既不会编译报错,也不会在**写**的时候报错
     * (MP 默认 NOT_NULL 策略把 null 字段从 INSERT 列清单里剔掉),
     * 只会在**读**的时候抛 PostgreSQL 42703 —— 而读往往发生在很久以后。
     *
     * `pay_lsy_notify_log` 就真的漏了基类带过来的 `status`,而 V1.8 偏偏给它建了
     * idx_lsy_notify_trade_no,说明读是按设计要发生的。这条测试让这种分叉当场变红,
     * 而不是等上线后第一次查审计日志才炸。
     *
     * 注意它与上面 `orderEntity_hasNoAuthCodeField` **互补、不可互替**:
     * 那条防的是「authCode 出现在实体里」,这条防的是「实体与 DDL 不同步」——
     * 若有人把 authCode 同时加进实体和 DDL,那条会红、这条不会;
     * 若有人漏了某一列,这条会红、那条不会。
     */
    @Test
    void everyEntityFieldHasAColumnInTheMigration() throws Exception {
        String sql = readMigration(MIGRATION).replaceAll("(?m)--[^\\n]*", "");
        for (var entity : new Class<?>[]{LsyMerchantEntity.class, LsyOrderEntity.class,
                                         LsyRefundEntity.class, LsyNotifyLogEntity.class}) {
            String table = entity.getAnnotation(TableName.class).value();
            Set<String> columns = tableColumns(sql, table);
    
            Set<String> mapped = new LinkedHashSet<>();
            for (Class<?> c = entity; c != null && c != Object.class; c = c.getSuperclass()) {
                for (var f : c.getDeclaredFields()) {
                    if (f.isSynthetic()
                            || java.lang.reflect.Modifier.isStatic(f.getModifiers())) {
                        continue;
                    }
                    mapped.add(camelToSnake(f.getName()));
                }
            }
    
            // 方向一:实体字段 ⊆ 建表列 —— C-1 漏的正是这个方向(实体有 status、表里没有)。
            for (String column : mapped) {
                assertTrue(columns.contains(column),
                        table + " 缺列 " + column + "。MP 按实体生成列清单:"
                                + "缺列会让**读**抛 42703,而写入因 NOT_NULL 策略跳过 null 字段而看似正常。");
            }
    
            // 方向二:建表列 ⊆ 实体字段 —— **反方向**的分叉,典型样本是「表里多出一列 auth_code」。
            // 这一半是必需的、不能被方向一替代:那种情况下实体侧完全干净(
            // orderEntity_hasNoAuthCodeField 绿),且 entity ⊆ DDL 照样成立(方向一也绿),
            // 只有这一半拦得住。
            for (String column : columns) {
                assertTrue(mapped.contains(column) || UNMAPPED_COLUMNS.contains(table + "." + column),
                        table + " 多出实体未映射的列 " + column + "。"
                                + "若确实要故意不映射它,请加进 UNMAPPED_COLUMNS 并写明理由 —— "
                                + "否则它可能就是绕过实体校验写进库的字段(auth_code 正是这个形态)。");
            }
        }
    }
    
    /** 抽出一张表的建表语句(到第一个分号为止)。 */
    private static String tableDdl(String sql, String table) {
        int at = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " (");
        assertTrue(at >= 0, "V1.8 里找不到建表语句: " + table);
        int end = sql.indexOf(';', at);
        assertTrue(end > at, table + " 的建表语句应以分号结束");
        return sql.substring(at, end);
    }
    
    /** 建表语句里的列名集合(每行第一个标识符;跳过表级约束)。 */
    private static Set<String> tableColumns(String sql, String table) {
        String ddl = tableDdl(sql, table);
        int open = ddl.indexOf('(');
        int close = ddl.lastIndexOf(')');
        assertTrue(open > 0 && close > open, "建表语句结构异常: " + table);
        Set<String> cols = new LinkedHashSet<>();
        for (String line : ddl.substring(open + 1, close).split(",")) {
            String t = line.trim();
            if (t.isEmpty()) {
                continue;
            }
            String head = t.split("\\s+")[0];
            if (head.equalsIgnoreCase("PRIMARY") || head.equalsIgnoreCase("CONSTRAINT")
                    || head.equalsIgnoreCase("UNIQUE") || head.equalsIgnoreCase("FOREIGN")
                    || head.equalsIgnoreCase("CHECK")) {
                continue;
            }
            cols.add(head);
        }
        return cols;
    }
    
    /** enterpriseId → enterprise_id */
    private static String camelToSnake(String s) {
        StringBuilder b = new StringBuilder();
        for (char ch : s.toCharArray()) {
            if (Character.isUpperCase(ch)) {
                b.append('_').append(Character.toLowerCase(ch));
            } else {
                b.append(ch);
            }
        }
        return b.toString();
    }
    
    /**
     * 钉住「在途退款」这个事实的两份副本:
     *   Java 侧 {@link LsyRefundStatus#PENDING} 与 SQL 侧部分唯一索引 uk_lsy_refund_one_pending 的谓词。
     *
     * 分叉的后果是真钱缺陷:应用层认「在途」而数据库不认(或反之),
     * 第二笔退款要么被应用层放过、要么绕过索引直奔上游扣款。
     * 所以这里不写「文档约定」,直接让分叉变成红灯。
     */
    @Test
    void refundPendingSetMatchesPartialUniqueIndexPredicate() throws Exception {
        String indexName = "uk_lsy_refund_one_pending";
        // 去掉 SQL 行注释,避免注释里出现的字面量污染下面的抽取
        String sql = readMigration(MIGRATION).replaceAll("(?m)--[^\\n]*", "");
    
        int at = sql.indexOf(indexName);
        assertTrue(at >= 0,
                "V1.8 必须存在部分唯一索引 " + indexName + " —— 它是防重复退款的结构性防线,不是可选项");
    
        int end = sql.indexOf(';', at);
        assertTrue(end > at, indexName + " 的定义应以分号结束");
        String ddl = sql.substring(at, end);
    
        assertTrue(ddl.contains("WHERE"),
                indexName + " 必须是**部分**唯一索引(带 WHERE 谓词);"
                        + "退化成全表唯一索引会变成「一个订单只能有一笔退款」,把部分退款一起禁掉。实际定义:" + ddl);
    
        Set<String> sqlLiterals = new LinkedHashSet<>();
        Matcher m = Pattern.compile("'([^']*)'").matcher(ddl);
        while (m.find()) {
            sqlLiterals.add(m.group(1));
        }
    
        assertEquals(LsyRefundStatus.PENDING, sqlLiterals,
                "V1.8 的 " + indexName + " 谓词与 LsyRefundStatus.PENDING 分叉了。"
                        + "二者是同一个事实的两份副本,改任一处必须同步改另一处。");
    }
    
    private static String readMigration(String path) throws Exception {
        try (InputStream in = LsyEntityMappingTest.class.getClassLoader().getResourceAsStream(path)) {
            assertNotNull(in, "找不到迁移脚本 " + path + "(应位于 src/main/resources 下)");
            return new String(in.readAllBytes(), StandardCharsets.UTF_8);
        }
    }
    }
    

上面第 4 个测试(refundPendingSetMatchesPartialUniqueIndexPredicate)是本计划早先漏写的, 补写进来是为了让「计划文本 = 交付物」保持逐字一致。 它守的是一条真钱不变量: 「在途退款」这个事实在两个地方各有一份副本 —— Java 的 LsyRefundStatus.PENDING 和 V1.8 里部分唯一索引 uk_lsy_refund_one_pending 的 WHERE 谓词。 两份副本分叉时不会有任何编译错误,只会在生产里变成重复退款,所以让分叉直接红灯。 (它读 SQL 文本、抽索引定义里的字面量集合,与 LsyRefundStatus.PENDING 全等比对。)

  • [ ] Step 6: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LsyEntityMappingTest
    

Expected: Tests run: 5, Failures: 0, Errors: 0

这里故意没有 -q。 -q 会把 surefire 的 Tests run: 计数行一起吞掉(只剩 exit code 0), 于是本步骤自己写的 Expected: 无法被观察 —— 一个要求你核对数字的步骤, 却用了看不见数字的命令。凡本计划中写了 Expected: Tests run: N 的步骤,命令都不要加 -q。

5 条的有效性已做变异验证(实测,非推断):

  • 把 V1.8 里索引谓词的 'REFUNDING' 改成 'REFUNDINGX' 后重跑 → Tests run: 5, Failures: 1, 只有 refundPendingSetMatchesPartialUniqueIndexPredicate 变红,另外 4 条保持绿 (说明钉子是精确定位、不是碰巧全红)。变异后已还原,文件 sha256 与变异前逐位一致。
  • 第 5 条 everyEntityFieldHasAColumnInTheMigration 是 C-1 的机械防线。它有两个方向, 两个方向都要做变异验证 —— 否则等于有一条断言从来没被执行过(写了不等于守住了):
    • 方向一(实体字段 ⊆ 建表列):在补上 V1.8 的 status 列之前它必红 —— pay_lsy_notify_log 实体侧 13 个字段、建表侧 12 列,缺的正是基类带来的 status; 补上之后四张表字段/列数是 11/11、37/37、22/22、13/13 全等。
    • 方向二(建表列 ⊆ 实体字段):临时往任一张表加一列 auth_code VARCHAR(64) → 必红;删掉即恢复绿。 两个方向都先红后绿、可复现,所以这条不是「永远绿的断言」。
  • 三条护栏的分工是互补、不可互替的(将来有人想删其中一条,请先回来看这段):

    • orderEntity_hasNoAuthCodeField —— authCode 出现在实体里(只看 entity 侧);
    • 方向一 —— 实体有、表里缺(C-1 的形态);
    • 方向二 —— 表里有、实体没有(auth_code 溜进 DDL 的形态)—— ★ 这种形态下前两条都是绿的,只有方向二拦得住。
  • [ ] Step 7: 确认工作区状态(不提交)

    git status --short
    

本任务应当只新增文件,不修改任何既有文件。 若 git diff --cached 非空,说明有人 git add 了 —— 立即回退。

SQL 文件到此为止 —— 不要对任何数据库执行。


Task 4: LeshouyingProperties + LeshouyingClient

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/config/LeshouyingProperties.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClient.java
  • Modify: java/src/main/resources/application.yml(新增 leshouying: 段)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingClientTest.java

Interfaces:

  • Consumes:
    • LeshouyingSigner.sign(Map,String), LeshouyingResponseParser.parseV3(String)
    • LeshouyingSensitive.maskByKey(String)(Task 2) —— 出站 trace 日志脱敏。用 maskByKey 不要用 mask(见 Task 2 Produces 的判据)。脱敏逻辑已在 Task 2 单测覆盖,本任务不要为它再写日志捕获测试(脆弱且重复)。
  • Produces:

    • Map<String,Object> LeshouyingClient.post(String path, Map<String,Object> bizParams) —— 自动补 inst_no、sign,POST JSON,返回解析后的 map(通信失败抛 LeshouyingApiException)
    • LeshouyingProperties.getInstNo() / getKey() / getBaseUrl() / getSyncPollTimes() / getSyncPollIntervalMs() / getBarcodePollMaxTimes() / getH5ExpireMinutes() / getDefaultQrExpireMinutes()
  • [ ] Step 1: 写配置类

    package com.payment.platform.module.payment.leshouying.config;
    
    import cn.hutool.core.util.StrUtil;
    import jakarta.annotation.PostConstruct;
    import lombok.Data;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
    * 乐收赢通道配置。
    * inst_no 与机构密钥是平台级的(我们是一个机构,各企业在其下开商户),
    * 不入库,避免机构密钥明文落库。
    */
    @Data
    @Component
    @ConfigurationProperties(prefix = "leshouying")
    public class LeshouyingProperties {
    
    /** 接口域名,测试 https://test.gdyfsk.com/yfpay,正式 https://open.gdyfsk.com/yfpay */
    private String baseUrl = "https://test.gdyfsk.com/yfpay";
    
    /** 机构号 */
    private String instNo;
    
    /** 机构密钥 */
    private String key;
    
    /** 后端回调地址(我们暴露给乐收赢的入口) */
    private String notifyUrl;
    
    /** 被扫同步轮询次数(用户定:3 次) */
    private int syncPollTimes = 3;
    
    /** 被扫同步轮询间隔毫秒(用户定:2 秒) */
    private long syncPollIntervalMs = 2000L;
    
    /** 被扫异步轮询最大次数(上游文档:15 次 × 5 秒 ≈ 80 秒) */
    private int barcodePollMaxTimes = 15;
    
    /** 被扫异步轮询间隔毫秒 */
    private long barcodePollIntervalMs = 5000L;
    
    /** 主扫聚合码默认有效期(分钟)。上游默认 3 天,我们收短以便本地状态收敛 */
    private int defaultQrExpireMinutes = 15;
    
    /** H5 订单未支付多久后本地置 EXPIRED(上游无已知关单接口) */
    private int h5ExpireMinutes = 30;
    
    /** 兜底对账:非终态订单创建多久后开始兜底轮询(秒) */
    private int reconcileDelaySeconds = 60;
    
    /** 任意非终态订单超过多久统一置 EXPIRED(小时) */
    private int absoluteExpireHours = 24;
    
    /** 下游回调最大重试次数 */
    private int downstreamNotifyMaxRetries = 8;
    
    /**
     * 启动即校验,缺配置就大声失败。
     *
     * 【为什么必须在这里炸,而不是等第一笔真实支付】签名器 {@code LeshouyingSigner} 是纯函数,
     * 对 {@code key == null} 它**不报错**,而是照常拼出 {@code ...&key=null} 再取 MD5 ——
     * 也就是签出一个「确定可预测」的值(谁都知道 key 字面量是 null),而失败会推迟到上游回
     * 「签名错误」那一刻。那时候人已经在排查一笔真实支付,而不是在看启动日志。
     * 校验放这里而不是放进签名器:签名器必须保持无状态纯函数(两条路径都走它,见其类注释)。
     *
     * 【notifyUrl 为什么也在必填里】它为空时 {@code post()} 会把这个 null 值**剔掉不上送**,
     * 上游于是用它的默认回调地址 —— 我们的回调**永远不会到达**,而且**没有任何报错**:
     * 订单停在非终态、只能靠 Task 16 的兜底轮询把状态捞回来。这是典型的静默失败,
     * 必须在启动时挡住。三个旋钮(轮询次数/间隔/过期时间)不校验:它们有合理默认值,
     * 键名打错也只是退回默认值,不会造成状态不收敛。
     *
     * 一次性列出**所有**缺失项:逐个抛会让人改一个重启一次,进件当天每轮重启都是分钟级。
     */
    @PostConstruct
    void validate() {
        List<String> missing = new ArrayList<>();
        if (StrUtil.isBlank(instNo)) {
            missing.add("leshouying.inst-no(环境变量 LESHOUYING_INST_NO)");
        }
        if (StrUtil.isBlank(key)) {
            missing.add("leshouying.key(环境变量 LESHOUYING_KEY)");
        }
        if (StrUtil.isBlank(baseUrl)) {
            missing.add("leshouying.base-url");
        }
        if (StrUtil.isBlank(notifyUrl)) {
            missing.add("leshouying.notify-url(环境变量 LESHOUYING_NOTIFY_URL)");
        }
        if (!missing.isEmpty()) {
            throw new IllegalStateException(
                    "乐收赢通道配置缺失,启动中止: " + String.join("; ", missing));
        }
    }
    }
    
  • [ ] Step 2: 在 application.yml 追加配置

在 java/src/main/resources/application.yml 末尾追加。

机构号与机构密钥都用「环境变量占位 + 测试值兜底」的写法(形如 变量名:默认值),理由可当场核实:

  • 这个文件被 git 跟踪(git ls-files 查得到),key 后面直接跟明文就等于把机构密钥提交进仓库。 同一文件里 alipay-public-key / app-id / app-private-key 都留着空值 "", 可见本仓库的既有意图正是不把通道密钥写进这个文件。
  • 这种写法两边都不牺牲:开发期不设任何环境变量也开箱即用(跑的就是文档给的测试机构), 进件后注入环境变量即可,不需要有人改这个文件、也就不需要提交正式密钥。
  • 同一个配置块里的 notify-url 已经是这个写法 —— 密钥留明文会让这个块自相矛盾。

    # 乐收赢支付通道
    leshouying:
    base-url: https://test.gdyfsk.com/yfpay
    # 默认值是**文档给的测试机构**,开发期开箱即用;正式机构号进件后由环境变量注入
    inst-no: ${LESHOUYING_INST_NO:900112}
    # 机构密钥同样用环境变量占位:本文件被 git 跟踪,写死正式密钥 = 把机构密钥提交进仓库
    key: ${LESHOUYING_KEY:1c4d83f0c3c24a7d9fcaa1fed2682076}
    notify-url: ${LESHOUYING_NOTIFY_URL:https://qcsj88888.com/api/v1/payment/leshouying/notify/pay}
    sync-poll-times: 3
    sync-poll-interval-ms: 2000
    barcode-poll-max-times: 15
    barcode-poll-interval-ms: 5000
    default-qr-expire-minutes: 15
    h5-expire-minutes: 30
    reconcile-delay-seconds: 60
    absolute-expire-hours: 24
    downstream-notify-max-retries: 8
    
  • [ ] Step 3: 写失败的测试

    package com.payment.platform.module.payment.leshouying.client;
    
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    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 org.springframework.http.HttpEntity;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.client.HttpServerErrorException;
    import org.springframework.web.client.ResourceAccessException;
    import org.springframework.web.client.RestTemplate;
    
    import java.nio.charset.StandardCharsets;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.when;
    
    @ExtendWith(MockitoExtension.class)
    class LeshouyingClientTest {
    
    @Mock private RestTemplate restTemplate;
    private LeshouyingProperties props;
    private LeshouyingClient client;
    
    @BeforeEach
    void setUp() {
        props = new LeshouyingProperties();
        props.setBaseUrl("https://test.gdyfsk.com/yfpay");
        props.setInstNo("900112");
        props.setKey("1c4d83f0c3c24a7d9fcaa1fed2682076");
        client = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
                new LeshouyingResponseParser(), props);
    }
    
    @Test
    void post_injectsInstNoAndSign_automatically() {
        String body = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\"}";
        when(restTemplate.postForEntity(eq("https://test.gdyfsk.com/yfpay/v3/pay"),
                any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>(body, HttpStatus.OK));
    
        Map<String, Object> resp = client.post("/v3/pay", Map.of("mch_no", "Y52211090004"));
    
        assertEquals("PAY_SUCCESS", resp.get("result_code"));
    
        // 验证请求体里确实补了 inst_no 与 sign,且 sign 可被同一密钥验回
        var captor = org.mockito.ArgumentCaptor.forClass(HttpEntity.class);
        org.mockito.Mockito.verify(restTemplate).postForEntity(
                eq("https://test.gdyfsk.com/yfpay/v3/pay"), captor.capture(), eq(String.class));
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = (Map<String, Object>) captor.getValue().getBody();
        assertEquals("900112", sent.get("inst_no"));
        assertNotNull(sent.get("sign"));
        assertTrue(new LeshouyingSigner().verify(sent, props.getKey()));
    }
    
    @Test
    void post_communicationFailure_throws() {
        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>(
                        "{\"return_code\":\"FAIL\",\"return_msg\":\"参数不合法\"}", HttpStatus.OK));
    
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> client.post("/v3/pay", Map.of("mch_no", "X")));
        assertEquals("参数不合法", ex.getRawReturnMsg());
    }
    
    @Test
    void post_networkException_wrappedAsApiException() {
        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
                .thenThrow(new ResourceAccessException("connect timed out"));
    
        assertThrows(LeshouyingApiException.class,
                () -> client.post("/v3/pay", Map.of("mch_no", "X")));
    }
    
    @Test
    void post_nullValueParams_areStrippedBeforeSending() {
        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>(
                        "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\"}", HttpStatus.OK));
    
        Map<String, Object> p = new java.util.LinkedHashMap<>();
        p.put("mch_no", "Y52211090004");
        p.put("shop_no", null);
        client.post("/v3/pay", p);
    
        var captor = org.mockito.ArgumentCaptor.forClass(HttpEntity.class);
        org.mockito.Mockito.verify(restTemplate).postForEntity(
                any(String.class), captor.capture(), eq(String.class));
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = (Map<String, Object>) captor.getValue().getBody();
        assertFalse(sent.containsKey("shop_no"));
    }
    
    /**
     * ★ 安全回归:上游 4xx/5xx 的**响应体不得**随异常对象外流。
     *
     * 为什么必须有:RestTemplate 默认的 DefaultResponseErrorHandler 对 4xx/5xx 是**抛**异常
     * (`HttpServerErrorException` / `HttpClientErrorException`,都是 `RestClientResponseException` 的子类),
     * 而不是返回 ResponseEntity;而这些异常的 `getMessage()` **内嵌完整响应体**。
     * 上游或 CDN 在错误页里回显我们的请求是常见行为 —— 而我们的请求含 `auth_code`,
     * 它是能**直接发起支付**的凭据。一旦它进了 message 或 cause,就会经
     * GlobalExceptionHandler 的 `log.error("系统异常 [{}]", uri, e)` 落进日志文件。
     *
     * 本用例断言**三处**都不含付款码:`rawReturnMsg`、`getMessage()`、`cause`。
     * 第三处是关键:只把 message 脱敏、却仍然 `initCause(e)`,body 会从 "Caused by:" 那行原样漏出去。
     * (`cause` 为 null 时 `String.valueOf` 得 "null",同样不含码 —— 两种情况都算过。)
     */
    @Test
    void post_upstreamErrorBody_neverRidesAlongInException() {
        String code = "134573607383541004";
        String echoBody = "{\"error\":\"bad gateway\",\"request\":{\"auth_code\":\"" + code
                + "\",\"mch_no\":\"Y52211090004\"}}";
        when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
                .thenThrow(HttpServerErrorException.create(HttpStatus.BAD_GATEWAY, "Bad Gateway",
                        HttpHeaders.EMPTY, echoBody.getBytes(StandardCharsets.UTF_8),
                        StandardCharsets.UTF_8));
    
        LeshouyingApiException ex = assertThrows(LeshouyingApiException.class,
                () -> client.post("/v3/pay", Map.of("mch_no", "Y52211090004", "auth_code", code)));
    
        // 4xx/5xx 必须归到 HTTP_xxx,不得退化成 NETWORK_ERROR(否则接住它的那个分支等于不存在)
        assertEquals("HTTP_502", ex.getRawReturnCode());
        assertFalse(String.valueOf(ex.getRawReturnMsg()).contains(code), "rawReturnMsg 泄漏付款码");
        assertFalse(String.valueOf(ex.getMessage()).contains(code), "getMessage 泄漏付款码");
        assertFalse(String.valueOf(ex.getCause()).contains(code), "cause 链泄漏付款码");
    }
    }
    
  • [ ] Step 4: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingClientTest
    

Expected: 编译失败 —— LeshouyingClient 不存在。

  • [ ] Step 5: 实现 LeshouyingClient

    package com.payment.platform.module.payment.leshouying.client;
    
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.HttpEntity;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.MediaType;
    import org.springframework.http.ResponseEntity;
    import org.springframework.stereotype.Component;
    import org.springframework.web.client.RestClientResponseException;
    import org.springframework.web.client.RestTemplate;
    
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /**
    * 乐收赢上游 HTTP 客户端。
    * 职责:补 inst_no → 签名 → POST JSON → 解析两套信封 → 异常包装。
    * 业务层失败(PAY_FAIL / PAY_IN_PROCESS)不抛异常,由调用方分支处理 —— 但**必须用
    * `isPaySuccess` / `isPayInProcess` / `isPayFail` 这三个谓词**,不得裸比较 `result_code` 字符串。
    *
    * ★ **禁止写 `!isPaySuccess(resp)`。** 上游某端点在 `/v3/pay` 回的是通用成功码 `SUCCESS`
    * 而非 `PAY_SUCCESS`(属未预期码)时,三个谓词**全为 false** —— 此时 `!isPaySuccess`
    * 会把一笔**已经收到钱**的支付判成失败落库,后续对账缺口或按失败单重收。
    * 未预期码必须走独立的未知码分支:**记日志 + 留在非终态 + 待人工核对**,
    * 既不写 SUCCESS 也不写 FAIL。
    * (三个谓词本身已被 LeshouyingResponseParserTest 钉住;这里钉的是**调用方的用法**。)
    */
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class LeshouyingClient {
    
    private final RestTemplate restTemplate;
    private final LeshouyingSigner signer;
    private final LeshouyingResponseParser parser;
    private final LeshouyingProperties props;
    
    /**
     * 调用 /v3/* 接口。
     * @param path     以 / 开头的路径,如 "/v3/pay"
     * @param bizParams 业务参数(不含 inst_no / sign,本方法自动补)
     */
    public Map<String, Object> post(String path, Map<String, Object> bizParams) {
        Map<String, Object> payload = new LinkedHashMap<>();
        if (bizParams != null) {
            // null 值不上送(与签名规则一致,避免产生歧义)
            bizParams.forEach((k, v) -> {
                if (v != null) {
                    payload.put(k, v);
                }
            });
        }
        payload.put("inst_no", props.getInstNo());
        payload.put("sign", signer.sign(payload, props.getKey()));
    
        // ★ 全模块唯一一处记录「签名原文」的地方,必须留在出站侧。
        //   入站路径(LeshouyingSigner.verify → sign)的参数由**攻击者控制**,
        //   在那里打日志会同时带来两个问题:参数值里塞 CRLF 可伪造日志行(CWE-117),
        //   以及 auth_code 被原样落盘。所以 LeshouyingSigner 里一个日志都没有。
        //   这里虽然参数是我们自己构造的,auth_code 仍要打码 ——
        //   它是能直接发起支付的凭据,与计划「auth_code 刻意不落库」的决策保持一致。
        //
        // ★ 用 maskByKey 而不是 mask:**判据是内容由谁构造**。本方法的报文由本模块拼,
        //   键名确定已知,键层足够;值层会连 out_trade_no / pay_trace_no 这些
        //   **我们自己的对账号**一起遮掉 —— 那等于把这条 trace 日志的唯一用途
        //   (回答「这次请求到底发了什么参数」)抹掉。
        //   也**不要**退回手写 `containsKey("auth_code")`:那是又一次「按键名匹配」,
        //   而且只在键名恰好拼作 auth_code 时有效,将来出现 authCode / AUTH_CODE 就静默失效。
        //   渲染成字符串再过一遍,是为了出站与入站共用**同一份**正则实现,而不是各写一份。
        if (log.isTraceEnabled()) {
            log.trace("乐收赢请求 {} 出站参数(已脱敏): {}",
                    path, LeshouyingSensitive.maskByKey(payload.toString()));
        }
    
        String url = props.getBaseUrl() + path;
        String json;
        try {
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            HttpEntity<Map<String, Object>> entity = new HttpEntity<>(payload, headers);
    
            long start = System.currentTimeMillis();
            ResponseEntity<String> resp = restTemplate.postForEntity(url, entity, String.class);
            long cost = System.currentTimeMillis() - start;
    
            log.info("乐收赢请求完成: path={}, status={}, cost={}ms", path, resp.getStatusCode(), cost);
    
            if (!resp.getStatusCode().is2xxSuccessful() || resp.getBody() == null) {
                throw new LeshouyingApiException("HTTP_" + resp.getStatusCode().value(),
                        "乐收赢返回非 2xx 或空响应", null);
            }
            json = resp.getBody();
        } catch (LeshouyingApiException e) {
            throw e;
        } catch (RestClientResponseException e) {
            // ★ 4xx/5xx **走这里**,不走上面那个 is2xxSuccessful() 分支 ——
            //   RestTemplate 默认的 DefaultResponseErrorHandler 对 400/403/429/502/503 一律 hasError=true,
            //   于是**抛异常**而不是返回 ResponseEntity(Task 4 实现时实测:400/403/429/502/503 全抛,200/302 不抛)。
            //   所以上面那行 "HTTP_" + resp.getStatusCode() 对 4xx/5xx 是**死代码**,
            //   真正兜住它们的是这里。若不显式接住,真实的 502/429/403 会全部退化成 NETWORK_ERROR。
            //
            // ★★ 响应体可能**回显我们的请求**(上游或 CDN 在错误页里带上原文),而我们的请求含 auth_code ——
            //   它是能**直接发起支付**的凭据。所以这个异常对象**一个字都不许带着响应体往外走**:
            //     - 不把 body 拼进 message(否则经 GlobalExceptionHandler 的
            //       `log.error("系统异常 [{}]", uri, e)` 落进栈日志)
            //     - **不 initCause(e)**(否则 body 从 "Caused by:" 那行原样漏出去,
            //       前面把 message 脱敏就白做了)
            //   要留证据就只留**已脱敏**的 body,且**只留在这一行日志里**:
            //   本模块自带判据是「内容由我们构造 → maskByKey;内容来自外部 → mask」,
            //   这里的内容来自上游 ⇒ 用 mask(值层兜底,能挡住 auth_code 的键名变体)。
            //   (Task 4 实现时实测:上游 502 回显请求体时,未脱敏的 message 里
            //     18 位付款码原样可见 `>>> message 含 18 位付款码: true`。)
            log.error("乐收赢请求失败: path={}, status={}, body(已脱敏)={}",
                    path, e.getStatusCode().value(),
                    LeshouyingSensitive.mask(e.getResponseBodyAsString()));
            throw new LeshouyingApiException("HTTP_" + e.getStatusCode().value(),
                    "乐收赢返回 HTTP " + e.getStatusCode().value(), null);
        } catch (Exception e) {
            // 走到这里的**没有响应体**(连接超时 / SSL 握手 / DNS 解析失败等),
            // message 与栈都可以原样留 —— 它们只含本机信息,不含上游回显。
            log.error("乐收赢请求异常: path={}, error={}", path, e.getMessage(), e);
            // 【不要用 (String, Throwable) 构造器 —— 它被 LeshouyingApiException 刻意取消了】
            // 该构造器会绕过 sanitize(那层防线只长在 3 参路径上),把调用方消息塞进 rawReturnMsg,
            // 破坏「三个 Raw 字段 = 逐字上游原文」的契约,并重开 CWE-117 的口子。
            // 需要带 cause 时用 3 参构造器 + initCause(与 LeshouyingResponseParser 的 catch 分支一致)。
            // initCause 不可省:否则连接超时/SSL 握手的原始异常只活在上一行日志里,调用方程序化拿不到。
            LeshouyingApiException ex = new LeshouyingApiException("NETWORK_ERROR",
                    "乐收赢接口调用失败: " + e.getMessage(), null);
            ex.initCause(e);
            throw ex;
        }
    
        return parser.parseV3(json);
    }
    
    /** 机构号,供调用方拼装查询流水号等场景使用 */
    public String instNo() {
        return props.getInstNo();
    }
    }
    
  • [ ] Step 6: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test -Dtest=LeshouyingClientTest
    

Expected: Tests run: 5, Failures: 0, Errors: 0

命令里没有 -q:-q 会把下面这行 Tests run: 计数一起吞掉,于是这个 Expected 无法被观察。 (Step 4 的 -q 保留 —— 它的 Expected 是"编译失败",没有计数行要核对。)

  • [ ] Step 7: 确认工作区状态(不提交)

    git status --short
    

Task 5: 订单状态机核心 —— 条件更新

幂等的实现基础。受影响行数 = 1 才触发下游回调(spec §5.2)。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(本任务只写状态流转部分)
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java(仅骨架:持有 LeshouyingClient 一个字段 + 构造器,本任务不加任何方法。Task 6 Step 4 再追加 queryOrder)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.java

Interfaces:

  • Consumes: LsyOrderEntity, LsyOrderMapper, LsyOrderStatus.PAY_WRITE_FROM / REFUND_WRITE_FROM, LeshouyingClient, LeshouyingResponseParser, LeshouyingProperties, LsyQueryService
  • Produces:
    • 构造器(后续所有任务统一按此 5 参签名构造,不要再改): LsyOrderService(LsyOrderMapper orderMapper, LeshouyingClient client, LeshouyingResponseParser parser, LeshouyingProperties props, LsyQueryService queryService)
    • boolean applyPayResult(LsyOrderEntity order, String newStatus, LsyOrderEntity patch)   patch 由调用方填好业务字段(trade_no / receipt_amount / td_trade_no …);   本方法补 status / finishTime / updatedTime 后做条件更新。   全模块唯一的支付状态写入口 —— 下单、回调、调度都走它,避免三份副本各自漂移。
    • boolean applyRefundResult(LsyOrderEntity order, long refundAmount, String newStatus)
    • boolean isTerminal(String status)

本任务一次性把 5 个 final 字段和构造器定下来(虽然本任务只用到 orderMapper), 这样 Task 6-8 往里加下单方法时不需要回头改 Task 5 的测试。

  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    import org.junit.jupiter.api.extension.ExtendWith;
    import org.mockito.ArgumentCaptor;
    import org.mockito.Mock;
    import org.mockito.junit.jupiter.MockitoExtension;
    
    import java.util.Set;
    import java.util.stream.Collectors;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyOrderServiceStateTest {
    
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LeshouyingClient client;
    @Mock private LsyQueryService queryService;
    private LsyOrderService service;
    private LeshouyingProperties props;
    
    @BeforeEach
    void setUp() {
        props = new LeshouyingProperties();
        // 构造器签名固定为 5 参 —— 后续任务新增下单方法时不要改动它
        service = new LsyOrderService(orderMapper, client,
                new LeshouyingResponseParser(), props, queryService);
    }
    
    private LsyOrderEntity order(String status) {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setStatus(status);
        o.setPayScene("BARCODE");
        o.setTotalAmount(100L);
        o.setRefundedAmount(0L);
        return o;
    }
    
    private LsyOrderEntity patch(String tradeNo) {
        LsyOrderEntity p = new LsyOrderEntity();
        p.setTradeNo(tradeNo);
        p.setUpstreamResultCode("PAY_SUCCESS");
        return p;
    }
    
    @Test
    void applyPayResult_affectedRowIsOne_returnsTrue_andBackfillsOrderInMemory() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
        LsyOrderEntity o = order("PAYING");
    
        assertTrue(service.applyPayResult(o, "SUCCESS", patch("T1")));
    
        // 内存里的对象也要被更新,否则调用方紧接着回调用到的是旧状态
        assertEquals("SUCCESS", o.getStatus());
        assertEquals("T1", o.getTradeNo());
        assertNotNull(o.getFinishTime());
    }
    
    @Test
    void applyPayResult_affectedRowIsZero_returnsFalse_duplicateNotification() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(0);
    
        assertFalse(service.applyPayResult(order("SUCCESS"), "SUCCESS", patch("T1")),
                "重复通知必须返回 false,调用方据此不重复回调下游");
    }
    
    @Test
    void applyPayResult_onlyAllowsCreatedAndPayingAsSource() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
        service.applyPayResult(order("CREATED"), "SUCCESS", patch("T1"));
    
        ArgumentCaptor<UpdateWrapper> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
        verify(orderMapper).update(any(), captor.capture());
        UpdateWrapper<?> w = captor.getValue();
    
        // ★ 断言必须分两半 —— MyBatis-Plus 的 `.in()` **不把值写进 SQL 文本**,
        //   而是塞进 paramNameValuePairs,SQL 里只留 `#{ew.paramNameValuePairs.MPGENVALn}` 占位符。
        //   Task 5 开工前实测(真探针跑在 MP 3.5.5 上,非推断):
        //     getSqlSegment()     = (id = #{ew.paramNameValuePairs.MPGENVAL1} AND status IN (#{...VAL2},#{...VAL3}))
        //     paramNameValuePairs = {MPGENVAL3=CREATED, MPGENVAL2=PAYING, MPGENVAL1=1}
        //     seg.contains("CREATED") = **false**
        //   所以 `sql.contains("CREATED")` 恒 false:两条 assertTrue 必失败,
        //   而 assertFalse 会**空洞通过**(放行集合是什么都过)。
        //   那种测试比没有更糟 —— 它给出"已覆盖"的假象。
        String sql = w.getSqlSegment();
        assertTrue(sql.contains("status IN"), "放行条件必须是 status 的 IN 集合,实测: " + sql);
    
        // 取值看 paramNameValuePairs。只取 String 值(Long 那个是 id)。
        // 用**恰好相等**而非 contains:contains 挡不住"多放行了一个状态"。
        Set<String> allowFrom = w.getParamNameValuePairs().values().stream()
                .filter(String.class::isInstance).map(String.class::cast)
                .collect(Collectors.toSet());
        assertEquals(Set.of("CREATED", "PAYING"), allowFrom,
                "支付回写的放行集合必须恰好是 CREATED/PAYING");
        assertFalse(allowFrom.contains("SUCCESS"),
                "支付回写不得以 SUCCESS 为来源,否则会覆盖退款状态");
    }
    
    @Test
    void applyPayResult_stampsStatusAndFinishTimeOntoPatch() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
        LsyOrderEntity p = patch("T1");
    
        service.applyPayResult(order("PAYING"), "FAIL", p);
    
        ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper).update(captor.capture(), any(UpdateWrapper.class));
        assertEquals("FAIL", captor.getValue().getStatus());
        assertNotNull(captor.getValue().getFinishTime());
        assertNotNull(captor.getValue().getUpdatedTime());
    }
    
    @Test
    void applyRefundResult_allowsSuccessCanceledClosedPartial() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
        service.applyRefundResult(order("CANCELED"), 100L, "REFUNDED");
    
        ArgumentCaptor<UpdateWrapper> captor = ArgumentCaptor.forClass(UpdateWrapper.class);
        verify(orderMapper).update(any(), captor.capture());
        UpdateWrapper<?> w = captor.getValue();
    
        // 同 applyPayResult 那条:值在 paramNameValuePairs 里,不在 SQL 文本里(实测见上)
        assertTrue(w.getSqlSegment().contains("status IN"));
    
        Set<String> allowFrom = w.getParamNameValuePairs().values().stream()
                .filter(String.class::isInstance).map(String.class::cast)
                .collect(Collectors.toSet());
        assertEquals(Set.of("SUCCESS", "CANCELED", "CLOSED", "PARTIAL_REFUNDED"), allowFrom,
                "撤销后到达的退款回调必须能回写,否则丢数据");
        assertFalse(allowFrom.contains("PAYING"), "退款不得把支付中的单改成退款态");
    }
    
    @Test
    void applyRefundResult_accumulatesRefundedAmount() {
        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
        LsyOrderEntity o = order("SUCCESS");
        o.setRefundedAmount(30L);   // 已退 30
    
        service.applyRefundResult(o, 20L, "PARTIAL_REFUNDED");
    
        ArgumentCaptor<LsyOrderEntity> captor = ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper).update(captor.capture(), any(UpdateWrapper.class));
        assertEquals(50L, captor.getValue().getRefundedAmount());
        assertEquals(50L, o.getRefundedAmount(), "内存对象同步累加");
    }
    
    @Test
    void isTerminal_recognizesAllTerminalStates() {
        assertTrue(service.isTerminal("SUCCESS"));
        assertTrue(service.isTerminal("FAIL"));
        assertTrue(service.isTerminal("CANCELED"));
        assertTrue(service.isTerminal("CLOSED"));
        assertTrue(service.isTerminal("EXPIRED"));
        assertTrue(service.isTerminal("REFUNDED"));
        assertFalse(service.isTerminal("CREATED"));
        assertFalse(service.isTerminal("PAYING"));
        assertFalse(service.isTerminal("PARTIAL_REFUNDED"));
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyOrderServiceStateTest
    

Expected: 编译失败。

  • Step 3: 实现状态流转

创建 LsyOrderService.java,本任务只写以下方法(下单方法在 Task 6-8 追加):

package com.payment.platform.module.payment.leshouying.service;

import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.time.OffsetDateTime;
import java.util.Set;

/**
 * 乐收赢订单服务 —— 状态机核心 + 三种下单入口(下单方法在 Task 6-8 追加)。
 *
 * 【幂等设计】所有状态流转走条件更新,用受影响行数判断是否真的流转:
 *   行数 = 1 → 真流转 → 调用方据此触发下游回调
 *   行数 = 0 → 重复通知 / 竞态 → 静默忽略,不重复回调
 * 不用唯一约束做幂等 —— 那会误伤「撤销后到达的退款回调」这类更正型通知。
 *
 * 【单一写入口】applyPayResult 是全模块唯一改支付状态的路径。
 * 下单、回调、两个调度器都调它,避免条件更新逻辑出现多份副本各自漂移。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class LsyOrderService {

    private final LsyOrderMapper orderMapper;
    private final LeshouyingClient client;
    private final LeshouyingResponseParser parser;
    private final LeshouyingProperties props;
    private final LsyQueryService queryService;

    private static final Set<String> TERMINAL = Set.of(
            "SUCCESS", "FAIL", "CANCELED", "CLOSED", "EXPIRED", "REFUNDED");

    /**
     * 支付结果回写。返回 true 表示真的发生了流转(调用方应触发下游回调)。
     *
     * @param patch 调用方填好的业务字段(trade_no / receipt_amount / td_trade_no / user_id …)。
     *              本方法会覆盖其中的 status / finishTime / updatedTime。
     */
    public boolean applyPayResult(LsyOrderEntity order, String newStatus, LsyOrderEntity patch) {
        if (patch == null) {
            patch = new LsyOrderEntity();
        }
        patch.setStatus(newStatus);
        patch.setFinishTime(OffsetDateTime.now());
        patch.setUpdatedTime(OffsetDateTime.now());

        UpdateWrapper<LsyOrderEntity> w = new UpdateWrapper<>();
        w.eq("id", order.getId())
         .in("status", LsyOrderStatus.PAY_WRITE_FROM);

        int rows = orderMapper.update(patch, w);
        if (rows == 1) {
            // 同步内存对象:调用方紧接着要拿新状态去回调下游
            order.setStatus(newStatus);
            order.setFinishTime(patch.getFinishTime());
            if (patch.getTradeNo() != null) {
                order.setTradeNo(patch.getTradeNo());
            }
            if (patch.getTdTradeNo() != null) {
                order.setTdTradeNo(patch.getTdTradeNo());
            }
            if (patch.getQdTradeNo() != null) {
                order.setQdTradeNo(patch.getQdTradeNo());
            }
            if (patch.getUserId() != null) {
                order.setUserId(patch.getUserId());
            }
            if (patch.getReceiptAmount() != null) {
                order.setReceiptAmount(patch.getReceiptAmount());
            }
            if (patch.getUpstreamResultCode() != null) {
                order.setUpstreamResultCode(patch.getUpstreamResultCode());
            }
            return true;
        }
        log.info("支付回写未命中(重复或状态不符): orderId={}, currentStatus={}, target={}",
                order.getId(), order.getStatus(), newStatus);
        return false;
    }

    /** 退款结果回写:累加 refundedAmount,更新状态。返回 true 表示真的流转。 */
    public boolean applyRefundResult(LsyOrderEntity order, long refundAmount, String newStatus) {
        long current = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount();
        long accumulated = current + refundAmount;

        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setStatus(newStatus);
        patch.setRefundedAmount(accumulated);
        patch.setUpdatedTime(OffsetDateTime.now());

        UpdateWrapper<LsyOrderEntity> w = new UpdateWrapper<>();
        w.eq("id", order.getId())
         .in("status", LsyOrderStatus.REFUND_WRITE_FROM);

        int rows = orderMapper.update(patch, w);
        if (rows == 1) {
            order.setStatus(newStatus);
            order.setRefundedAmount(accumulated);
            return true;
        }
        log.info("退款回写未命中: orderId={}, currentStatus={}", order.getId(), order.getStatus());
        return false;
    }

    public boolean isTerminal(String status) {
        return status != null && TERMINAL.contains(status);
    }
}

本任务只实现状态流转,client / parser / props / queryService 四个字段暂时只声明不使用 —— 这是刻意的:一次性锁定构造器签名,Task 6-8 加下单方法时就不必回头改本任务的测试。 编译不会因未使用字段报警(private final 由 Lombok 生成构造器,必然被"使用")。

  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyOrderServiceStateTest
    

Expected: Tests run: 7, Failures: 0, Errors: 0

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 6: 被扫下单(含同步轮询)

唯一需要同步轮询的场景。 用户就站在收银台前,要有即时反馈。

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java:1(追加 createBarcodeOrder)
  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryService.java(本任务只写 queryOrder)
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyPayResultDTO.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java

Interfaces:

  • Consumes: LeshouyingClient.post(String,Map), LeshouyingResponseParser.isPaySuccess/isPayInProcess/isPayFail, LsyOrderService.applyPayResult(...)
    • 三个谓词都要用:isPaySuccess → SUCCESS,isPayFail → FAIL,两者皆 false → 什么都不写。 Task 7/8 下单路径同理。!isPayInProcess 当失败用是错的,见 Task 2 Produces 的说明。
  • Produces:

    • LsyPayResultDTO LsyOrderService.createBarcodeOrder(LsyOrderEntity order, String authCode, String payType)
    • LsyPayResultDTO 字段:String outTradeNo、String status、String tradeNo、Long totalAmount、Long receiptAmount、String codeUrl、String message
    • Map<String,Object> LsyQueryService.queryOrder(LsyOrderEntity order)
    • LsyOrderEntity LsyOrderService.payPatch(Map<String,Object> resp, Long receiptFallback)   包内可见(非 private):Task 12 的回调报文与查询报文同形,直接复用它,不要自己再写一份映射。
    • String LsyOrderService.settleFromQuery(LsyOrderEntity order, Map<String,Object> resp)   "SUCCESS" / "FAIL" / null(支付中或条件更新未命中)。   全模块唯一解释「上游支付报文」的地方;Task 16 两个调度器、Task 7/8 都调它。   调用方见 null 时不得据此撤销订单 —— 它无法区分「仍支付中」和「已被回调抢先」,   要撤销必须另查一次交易(见 Task 16 Step 3 的复查逻辑)。
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.dto.LsyPayResultDTO;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    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.util.LinkedHashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyBarcodePayTest {
    
    @Mock private LeshouyingClient client;
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyQueryService queryService;
    private LsyOrderService orderService;
    private LeshouyingProperties props;
    
    @BeforeEach
    void setUp() {
        props = new LeshouyingProperties();
        props.setSyncPollTimes(3);
        props.setSyncPollIntervalMs(0);   // 测试里不真的睡
        // 构造器签名与 Task 5 一致(5 参),不要改
        orderService = new LsyOrderService(orderMapper, client,
                new LeshouyingResponseParser(), props, queryService);
    }
    
    private LsyOrderEntity order() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setOutTradeNo("OUT001");
        o.setMchNo("Y52211090004");
        o.setPayScene("BARCODE");
        o.setPayTraceNo("PT001");
        o.setPayTime("20260921120000");
        o.setTotalAmount(100L);
        o.setStatus("CREATED");
        return o;
    }
    
    private Map<String, Object> resp(String resultCode) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", resultCode);
        m.put("trade_no", "T001");
        m.put("total_amount", "100");
        return m;
    }
    
    @Test
    void barcode_paySuccess_returnsImmediately_withoutPolling() {
        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000");
    
        assertEquals("SUCCESS", r.getStatus());
        assertEquals("T001", r.getTradeNo());
        verify(client, times(1)).post(eq("/v3/pay"), anyMap());
        // ★ 必须打在 queryService 上:实现从不以 /v3/queryorder 路径调 client,
        //   写 client 的 never() 恒真 —— 那是空洞断言,给的是「已覆盖」的假象。
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void barcode_payFail_returnsFail() {
        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_FAIL"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000");
    
        assertEquals("FAIL", r.getStatus());
    }
    
    @Test
    void barcode_payInProcess_thenSuccessOnSecondSyncPoll() {
        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
        // 第 1 次查询仍支付中,第 2 次成功
        // ★ 桩必须打在 queryService 上,不是 client 上。
        //   同步轮询的实现(settleAfterPay)走的是 queryService.queryOrder(order),
        //   它内部才去调 client.post("/v3/queryorder")。client 是 @Mock ⇒ 那条边永不执行。
        //   Task 6 实现时实测:打在 client 上的桩永不兑现,Mockito 直接报
        //   「Wanted but not invoked … However, there was exactly 1 interaction with this mock」,
        //   而那 1 次是 /v3/pay。计划里 Task 9 / Task 16 的测试本来就全桩在 queryService 上,
        //   此处原为孤例。
        when(queryService.queryOrder(any()))
                .thenReturn(resp("PAY_IN_PROCESS"))
                .thenReturn(resp("PAY_SUCCESS"));
    
        LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000");
    
        assertEquals("SUCCESS", r.getStatus());
        verify(queryService, times(2)).queryOrder(any());
    }
    
    @Test
    void barcode_payInProcess_exhaustsSyncPolls_returnsPayingAndSetsNextQuery() {
        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_IN_PROCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createBarcodeOrder(order(), "134567890123456789", "000");
    
        assertEquals("PAYING", r.getStatus());
        verify(queryService, times(3)).queryOrder(any());  // syncPollTimes = 3
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper, atLeastOnce()).update(captor.capture(), any());
        LsyOrderEntity lastPatch = captor.getAllValues().get(captor.getAllValues().size() - 1);
        assertNotNull(lastPatch.getNextQueryTime(), "必须落 next_query_time 交给异步调度器");
    }
    
    @Test
    void barcode_sendsAuthCodeAndPayType_upstream() {
        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        orderService.createBarcodeOrder(order(), "134567890123456789", "400");
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/pay"), captor.capture());
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = captor.getValue();
        assertEquals("134567890123456789", sent.get("auth_code"));
        assertEquals("400", sent.get("pay_type"));
        assertEquals("Y52211090004", sent.get("mch_no"));
        assertEquals("PT001", sent.get("pay_trace_no"));
    }
    
    // ---- settleFromQuery 的契约(Task 16 两个调度器都依赖它) ----
    
    @Test
    void settleFromQuery_success_writesAndReturnsSuccess() {
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        assertEquals("SUCCESS", orderService.settleFromQuery(order(), resp("PAY_SUCCESS")));
    }
    
    @Test
    void settleFromQuery_conditionUpdateMissed_returnsNull() {
        // 回调抢先写了终态 → WHERE status IN ('CREATED','PAYING') 命中 0 行
        when(orderMapper.update(any(), any())).thenReturn(0);
    
        assertNull(orderService.settleFromQuery(order(), resp("PAY_SUCCESS")),
                "未命中必须返回 null,否则调度器会去撤销一张已付款的单");
    }
    
    @Test
    void settleFromQuery_inProcess_returnsNull_andDoesNotTouchDb() {
        assertNull(orderService.settleFromQuery(order(), resp("PAY_IN_PROCESS")));
        verify(orderMapper, never()).update(any(), any());
    }
    
    @Test
    void settleFromQuery_payFail_writesFail() {
        // 显式 PAY_FAIL 是**唯一**允许落 FAIL 的输入
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        assertEquals("FAIL", orderService.settleFromQuery(order(), resp("PAY_FAIL")));
    }
    
    @Test
    void settleFromQuery_unknownResultCode_doesNotWriteAnyState() {
        // ★ 本用例守的是「钱已收、单记失败」这个最坏方向。
        //   `resp("SUCCESS")` 造出的正是那个输入:return_code=SUCCESS 而 result_code 是通用成功码。
        //   原写法 `if (!isPayInProcess(resp)) → applyPayResult(FAIL)` 会把这样一笔
        //   **已经收到钱的支付**写成终态 FAIL —— 终态不会被回调纠正,也退出了对账集合。
        //   正确行为:三个谓词都不认 → 什么都不写,订单留在 CREATED/PAYING(= PENDING),
        //   下一轮 LsyReconcileScheduler 会拿同一个问题再问上游一次。
        assertNull(orderService.settleFromQuery(order(), resp("SUCCESS")),
                "未知码不得落终态,必须返回 null 让订单留在 PENDING 被对账捞起");
        verify(orderMapper, never()).update(any(), any());
    }
    
    @Test
    void payPatch_mapsAllUpstreamFields() {
        // 「上游报文 → 我们字段」的唯一映射点在这里。
        // 回调用的是同一个方法(包内可见),所以回调字段也由本用例覆盖。
        Map<String, Object> r = resp("PAY_SUCCESS");
        r.put("trade_no", "T001");
        r.put("td_trade_no", "TD001");
        r.put("qd_trade_no", "QD001");
        r.put("user_id", "2088xxxx");
        r.put("receipt_amount", "100");
        r.put("return_msg", "成功");
    
        LsyOrderEntity patch = orderService.payPatch(r, null);
    
        assertEquals("PAY_SUCCESS", patch.getUpstreamResultCode());
        assertEquals("成功", patch.getUpstreamResultMsg());
        assertEquals("T001", patch.getTradeNo());
        assertEquals("TD001", patch.getTdTradeNo());
        assertEquals("QD001", patch.getQdTradeNo());
        assertEquals("2088xxxx", patch.getUserId());
        assertEquals(100L, patch.getReceiptAmount());
    }
    
    @Test
    void payPatch_receiptAmountAbsent_fallsBackToOrderTotal() {
        // 同步轮询拿到 PAY_SUCCESS 时上游可能不带 receipt_amount,用订单金额兜底
        LsyOrderEntity patch = orderService.payPatch(resp("PAY_SUCCESS"), 100L);
        assertEquals(100L, patch.getReceiptAmount());
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyBarcodePayTest
    

Expected: 编译失败。

  • [ ] Step 3: 写 LsyPayResultDTO

    package com.payment.platform.module.payment.leshouying.dto;
    
    import lombok.Data;
    
    /** 下单结果(三种场景共用),返回给下游 OpenAPI 层 */
    @Data
    public class LsyPayResultDTO {
    private String outTradeNo;
    /** CREATED / PAYING / SUCCESS / FAIL */
    private String status;
    private String tradeNo;
    private Long totalAmount;
    private Long receiptAmount;
    /** 主扫/H5 场景的跳转或二维码链接 */
    private String codeUrl;
    private String message;
    }
    
  • [ ] Step 4: 写 LsyQueryService.queryOrder

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.IdUtil;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /** 交易查询。上游要求每次查询带唯一的 query_trace_no。 */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyQueryService {
    
    private final LeshouyingClient client;
    
    /**
     * 查交易。优先用 trade_no,没有则用 pay_trace_no + pay_time 组合。
     * 注意:判断交易结果以 result_code 为准,trade_state 仅供参考(上游文档明确说明)。
     */
    public Map<String, Object> queryOrder(LsyOrderEntity order) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        p.put("query_trace_no", IdUtil.fastSimpleUUID());
    
        if (order.getTradeNo() != null) {
            p.put("trade_no", order.getTradeNo());
        } else {
            p.put("pay_trace_no", order.getPayTraceNo());
            p.put("pay_time", order.getPayTime());
        }
        return client.post("/v3/queryorder", p);
    }
    }
    
  • [ ] Step 5: 把被扫下单方法追加到 LsyOrderService

不要改动 Task 5 定下的 5 个字段和构造器,只在类中追加常量和下列方法:

    private static final String TIME_FMT = "yyyyMMddHHmmss";

    /**
     * 被扫下单(B扫C)。
     * 流程:调 v3/pay → PAY_SUCCESS/PAY_FAIL 直接返回 → PAY_IN_PROCESS 则同步轮询 syncPollTimes 次。
     * 轮询耗尽仍支付中 → 落 next_query_time,交给 LsyOrderPollScheduler。
     */
    public LsyPayResultDTO createBarcodeOrder(LsyOrderEntity order, String authCode, String payType) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        p.put("pay_trace_no", order.getPayTraceNo());
        p.put("pay_time", order.getPayTime());
        p.put("total_amount", String.valueOf(order.getTotalAmount()));
        p.put("auth_code", authCode);
        p.put("pay_type", StrUtil.blankToDefault(payType, LsyPayType.AUTO.getCode()));
        if (order.getOrderBody() != null) {
            p.put("order_body", order.getOrderBody());
        }
        if (order.getAttach() != null) {
            p.put("attach", order.getAttach());
        }
        if (order.getNotifyUrl() != null) {
            p.put("notify_url", order.getNotifyUrl());
        }

        Map<String, Object> resp = client.post("/v3/pay", p);
        return settleAfterPay(order, resp, true);
    }

    /**
     * 把上游下单/查询响应落到订单上,必要时同步轮询。
     *
     * 【只给被扫用】全模块唯一调用点是 {@code createBarcodeOrder}(传 allowSyncPoll=true)。
     * 主扫/H5 **不得**走这里 —— 下单这一刻拿不到支付结果(主扫的码还没被扫、H5 是用户跳到上游页面自行付款),
     * 同步轮询只会白占请求线程。
     *
     * ★ 原注释「Task 7/8 复用(allowSyncPoll=false)」**不实**:Task 7 与 Task 8 都没复用它,
     *   allowSyncPoll=false 那条分支实际是死代码。签名保留不动(不得删参数、不得删该分支)。
     */
    private LsyPayResultDTO settleAfterPay(LsyOrderEntity order,
                                           Map<String, Object> resp,
                                           boolean allowSyncPoll) {
        String settled = settleFromQuery(order, resp);
        if (settled != null) {
            return toResult(order, "SUCCESS".equals(settled) ? "支付成功" : "支付失败");
        }

        // 支付中
        applyPayResult(order, "PAYING", payPatch(resp, null));
        if (!allowSyncPoll) {
            return toResult(order, "支付中");
        }

        for (int i = 0; i < props.getSyncPollTimes(); i++) {
            sleepQuietly(props.getSyncPollIntervalMs());
            Map<String, Object> q = queryService.queryOrder(order);
            String s = settleFromQuery(order, q);
            if (s != null) {
                return toResult(order, "SUCCESS".equals(s) ? "支付成功" : "支付失败");
            }
        }

        markNextQuery(order);
        return toResult(order, "支付中");
    }

    /**
     * 把一次上游查询/下单响应落库 —— 全模块唯一解释「上游支付报文」的地方。
     *
     * 被扫同步轮询、被扫密集轮询(LsyOrderPollScheduler)、主扫/H5 兜底对账
     * (LsyReconcileScheduler)三条路径全部调用本方法,不要再各自写一份分支。
     *
     * @return 流转后的终态 "SUCCESS"/"FAIL";仍是支付中、或条件更新未命中
     *         (说明已被回调先行写终态 = 重复)返回 null
     */
    public String settleFromQuery(LsyOrderEntity order, Map<String, Object> resp) {
        if (parser.isPaySuccess(resp)) {
            return applyPayResult(order, "SUCCESS", payPatch(resp, order.getTotalAmount()))
                    ? "SUCCESS" : null;
        }
        if (parser.isPayFail(resp)) {
            return applyPayResult(order, "FAIL", payPatch(resp, null)) ? "FAIL" : null;
        }
        if (!parser.isPayInProcess(resp)) {
            // ★ 三个谓词全 false = 上游回了个我们没见过的 result_code。
            //   这里**绝不能**落 FAIL。原写法 `if (!isPayInProcess) → FAIL` 就是这个陷阱:
            //   上游若回了通用成功码(如 "SUCCESS" 而非 "PAY_SUCCESS"),它会把
            //   **一笔已经收到钱的支付写成终态 FAIL** —— 用户付了款、我们的单是失败,
            //   而且终态不再进对账集合,回调若也回同一个码也纠正不了。这是最坏的失败方向。
            //
            //   正确做法是**什么都不写**:订单留在 CREATED/PAYING(= LsyOrderStatus.PENDING),
            //   于是调用方原有的重试机制会拿同一个问题再问上游一次 ——
            //   走对账路径的(PAYING 的 QRCODE/H5)由 LsyReconcileScheduler.reconcilePendingOrders 再查,
            //   走被扫轮询的由 LsyOrderPollScheduler 按 queryCount 再查(耗尽后自动撤销)。
            //   两条路都有界,不会无限重试。落 FAIL 反而把这两条路全切断。
            //   把原始码打出来 —— 未知码第一次出现时,这行日志是唯一的追溯线索。
            log.error("乐收赢返回未知 result_code,不落状态、留在 PENDING 待对账: outTradeNo={}, "
                            + "return_code={}, result_code={}, return_msg={}",
                    order.getOutTradeNo(), resp.get("return_code"),
                    resp.get("result_code"), resp.get("return_msg"));
        }
        return null;
    }

    /**
     * 把上游报文摊平成 patch 对象,交给 applyPayResult 统一落库。
     * 这是「上游字段 → 我们字段」的**唯一映射点**,别再在别处重复写一遍。
     *
     * 包内可见(非 private):LsyNotifyService 在同一包下,回调报文与查询报文
     * 字段名完全一致,直接复用它,不要自己再写一份。
     */
    LsyOrderEntity payPatch(Map<String, Object> resp, Long receiptFallback) {
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setUpstreamResultCode(str(resp.get("result_code")));
        patch.setUpstreamResultMsg(str(resp.get("return_msg")));
        patch.setTradeNo(str(resp.get("trade_no")));
        patch.setTdTradeNo(str(resp.get("td_trade_no")));
        patch.setQdTradeNo(str(resp.get("qd_trade_no")));
        patch.setUserId(str(resp.get("user_id")));
        patch.setReceiptAmount(parseAmount(resp.get("receipt_amount"), receiptFallback));
        // ★ fallback 必须传 null(不是 receiptFallback)—— 上游没返回 discount_amount 就是**未知**,
        //   不是「没有优惠」。写 0 等于替上游编一个它没说的结论;写订单总额更是无中生有。
        patch.setDiscountAmount(parseAmount(resp.get("discount_amount"), null));
        return patch;
    }

    /** 落 next_query_time,交给异步调度器接手 */
    void markNextQuery(LsyOrderEntity order) {
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setNextQueryTime(OffsetDateTime.now().plusSeconds(
                props.getBarcodePollIntervalMs() / 1000));
        patch.setUpdatedTime(OffsetDateTime.now());
        orderMapper.update(patch, new UpdateWrapper<LsyOrderEntity>()
                .eq("id", order.getId()).in("status", LsyOrderStatus.PAY_WRITE_FROM));
        order.setNextQueryTime(patch.getNextQueryTime());
    }

    private void sleepQuietly(long ms) {
        if (ms <= 0) {
            return;
        }
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private LsyPayResultDTO toResult(LsyOrderEntity order, String msg) {
        LsyPayResultDTO dto = new LsyPayResultDTO();
        dto.setOutTradeNo(order.getOutTradeNo());
        dto.setStatus(order.getStatus());
        dto.setTradeNo(order.getTradeNo());
        dto.setTotalAmount(order.getTotalAmount());
        dto.setReceiptAmount(order.getReceiptAmount());
        dto.setCodeUrl(order.getCodeUrl());
        dto.setMessage(msg);
        return dto;
    }

    private String str(Object o) {
        return o == null ? null : String.valueOf(o);
    }

    private Long parseAmount(Object v, Long fallback) {
        if (v == null) {
            return fallback;
        }
        try {
            return Long.parseLong(String.valueOf(v));
        } catch (NumberFormatException e) {
            return fallback;
        }
    }

LsyQueryService 已在 Task 5 注入完毕(无循环依赖:LsyQueryService 只依赖 LeshouyingClient,不依赖 LsyOrderService)。

类头部需要补 import:

import cn.hutool.core.util.StrUtil;
import com.payment.platform.module.payment.leshouying.dto.LsyPayResultDTO;
import com.payment.platform.module.payment.leshouying.enums.LsyPayType;
import java.util.LinkedHashMap;
import java.util.Map;
  • [ ] Step 6: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyBarcodePayTest
    

Expected: Tests run: 12, Failures: 0, Errors: 0 (5 个下单用例 + 5 个 settleFromQuery 契约用例 + 2 个 payPatch 映射用例)

  • [ ] Step 7: 回归 Task 5 的状态机测试

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test'
    

Expected: 全绿。构造器签名在 Task 5 就已锁定为 5 参,本任务不新增依赖,Task 5 的测试无需改动。

  • [ ] Step 8: 确认工作区状态(不提交)

    git status --short
    

Task 7: 主扫下单(聚合码 qrpay)

关键约束:qrpay 不返回 PAY_IN_PROCESS(spec §2.1),因此不做同步轮询。

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(追加 createQrOrder)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java

Interfaces:

  • Consumes: LeshouyingClient.post, LsyOrderService.settleAfterPay(本任务复用但 allowSyncPoll=false)
  • Produces:

    • LsyPayResultDTO LsyOrderService.createQrOrder(LsyOrderEntity order, Integer timeExpireMinutes)
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.dto.LsyPayResultDTO;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    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.util.LinkedHashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyQrPayTest {
    
    @Mock private LeshouyingClient client;
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyQueryService queryService;
    private LsyOrderService orderService;
    private LeshouyingProperties props;
    
    @BeforeEach
    void setUp() {
        props = new LeshouyingProperties();
        props.setDefaultQrExpireMinutes(15);
        // 构造器签名与 Task 5 一致(5 参)
        orderService = new LsyOrderService(orderMapper, client,
                new LeshouyingResponseParser(), props, queryService);
    }
    
    private LsyOrderEntity order() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(2L);
        o.setOutTradeNo("OUT002");
        o.setMchNo("Y52211090004");
        o.setPayScene("QRCODE");
        o.setPayTraceNo("PT002");
        o.setPayTime("20260921120000");
        o.setTotalAmount(500L);
        o.setStatus("CREATED");
        return o;
    }
    
    @Test
    void qr_success_returnsCodeUrl() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "PAY_SUCCESS");
        resp.put("code_url", "https://open.gdyfsk.com/yfpay/s/abc123");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createQrOrder(order(), null);
    
        // ★ 落 PAYING 不是 SUCCESS —— 下单成功 ≠ 收款成功(判据见 createQrOrder 的裁定注释)。
        assertEquals("PAYING", r.getStatus());
        assertEquals("https://open.gdyfsk.com/yfpay/s/abc123", r.getCodeUrl());
    }
    
    @Test
    void qr_neverSendsPayTypeParam() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "PAY_SUCCESS");
        resp.put("code_url", "https://x");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        orderService.createQrOrder(order(), null);
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/qrpay"), captor.capture());
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = captor.getValue();
        assertFalse(sent.containsKey("pay_type"),
                "聚合码自动识别通道,传 pay_type 会被上游拒绝");
    }
    
    @Test
    void qr_defaultExpireApplied_whenNotSpecified() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "PAY_SUCCESS");
        resp.put("code_url", "https://x");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        orderService.createQrOrder(order(), null);
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/qrpay"), captor.capture());
        // 不加 String.valueOf —— 那会把类型信息抹掉,实现若塞 Integer 15 也照样绿。
        assertEquals("15", captor.getValue().get("time_expire"));
    }
    
    @Test
    void qr_customExpire_isClampedToThreeDays() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "PAY_SUCCESS");
        resp.put("code_url", "https://x");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        orderService.createQrOrder(order(), 99999);   // 超过 4320 分钟上限
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/qrpay"), captor.capture());
        assertEquals("4320", captor.getValue().get("time_expire"));
    }
    
    @Test
    void qr_neverSyncPolls() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        // ★ 必须用**非终态**码。用 PAY_SUCCESS 时 settleFromQuery 立刻返回 SUCCESS、settleAfterPay 提前 return,
        //   轮询循环根本不可达 —— 下面的 verify(never()) 就成了恒真断言,去掉实现的任何分支都不会红。
        resp.put("result_code", "PAY_IN_PROCESS");
        resp.put("code_url", "https://x");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        // 不 stub orderMapper.update:STRICT_STUBS 下未被调用的 stub 会报 UnnecessaryStubbingException,
        // 而「一次都没被调用」正是本用例要断言的事实 —— 补一个桩反而会让用例红。
    
        orderService.createQrOrder(order(), null);
    
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void qr_payFail_returnsFail() {
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "PAY_FAIL");
        resp.put("return_msg", "商户未开通");
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createQrOrder(order(), null);
    
        assertEquals("FAIL", r.getStatus());
    }
    
    @Test
    void qr_unknownResultCode_doesNotWriteTerminalFail() {
        // 未知码 ≠ 失败。落 FAIL 会让订单退出 PENDING、不再被对账捞起,
        // 而这时我们并不知道上游到底建没建出这笔单。
        Map<String, Object> resp = new LinkedHashMap<>();
        resp.put("return_code", "SUCCESS");
        resp.put("result_code", "SUCCESS");   // 通用成功码,三个谓词都不认
        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
        // 注意:**不要**在这里 stub orderMapper.update —— MockitoExtension 默认 STRICT_STUBS,
        // 未被调用的 stub 会直接让用例报 UnnecessaryStubbingException。
        // 而「一次都没被调用」正是本用例要断言的事实。
    
        LsyPayResultDTO r = orderService.createQrOrder(order(), null);
    
        // 对调用方仍回「下单失败」—— 确实没拿到可用的 code_url,不能骗调用方
        assertEquals("下单失败", r.getMessage());
        // 但库里**不能**落终态
        verify(orderMapper, never()).update(any(), any());
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyQrPayTest
    

Expected: 编译失败 —— createQrOrder 不存在。

  • Step 3: 实现 createQrOrder

追加到 LsyOrderService:

    /** 上游硬限制:聚合码有效期最大 3 天 */
    private static final int QR_MAX_EXPIRE_MINUTES = 4320;

    /**
     * 主扫下单(聚合码,C扫B)。
     * 不做同步轮询 —— qrpay 只返回 PAY_SUCCESS/PAY_FAIL,没有 PAY_IN_PROCESS。
     * 支付结果靠回调 + LsyReconcileScheduler 兜底。
     */
    public LsyPayResultDTO createQrOrder(LsyOrderEntity order, Integer timeExpireMinutes) {
        int expire = timeExpireMinutes == null
                ? props.getDefaultQrExpireMinutes() : timeExpireMinutes;
        if (expire <= 0) {
            expire = props.getDefaultQrExpireMinutes();
        }
        expire = Math.min(expire, QR_MAX_EXPIRE_MINUTES);

        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        p.put("pay_trace_no", order.getPayTraceNo());
        p.put("pay_time", order.getPayTime());
        p.put("total_amount", String.valueOf(order.getTotalAmount()));
        p.put("time_expire", String.valueOf(expire));
        if (order.getOrderBody() != null) {
            p.put("order_body", order.getOrderBody());
        }
        if (order.getAttach() != null) {
            p.put("attach", order.getAttach());
        }
        if (order.getNotifyUrl() != null) {
            p.put("notify_url", order.getNotifyUrl());
        }
        // 注意:聚合码不传 pay_type,上游自动识别支付宝/微信

        Map<String, Object> resp = client.post("/v3/qrpay", p);

        if (parser.isPaySuccess(resp)) {
            String codeUrl = str(resp.get("code_url"));
            OffsetDateTime expireAt = OffsetDateTime.now().plusMinutes(expire);

            // code_url 不走 applyPayResult(那不是支付结果字段),先单独落库
            LsyOrderEntity urlPatch = new LsyOrderEntity();
            urlPatch.setCodeUrl(codeUrl);
            urlPatch.setCodeUrlExpireTime(expireAt);
            urlPatch.setUpdatedTime(OffsetDateTime.now());
            orderMapper.update(urlPatch, new UpdateWrapper<LsyOrderEntity>()
                    .eq("id", order.getId()).in("status", LsyOrderStatus.PAY_WRITE_FROM));

            order.setCodeUrl(codeUrl);
            order.setCodeUrlExpireTime(expireAt);

            // ★ receiptFallback 传 null(**下单路径**):上游没说的字段就是**未知**,不许替它编一个。
            //   传 order.getTotalAmount() 会在下游没回 receipt_amount 时把实收写成全额,
            //   而这一单此刻是 PAYING、用户一分钱都还没付 —— order/query 透给租户的就是自相矛盾的信息。
            //   (对比 settleFromQuery / createBarcodeOrder:那是**支付结果路径**,PAY_SUCCESS 就是收款成功,
            //    兜底成订单总额才对。判据只有一条:这条路径发起时,用户有没有可能已经付完款?)
            LsyOrderEntity payPatch = payPatch(resp, null);
            payPatch.setCodeUrl(codeUrl);
            // ★ 落 PAYING,**不是** SUCCESS。
            //   下单成功 ≠ 收款成功:/v3/qrpay 回的 PAY_SUCCESS 描述的是「下单请求被受理」,
            //   此刻码还没交到用户手上,不可能有任何支付发生。
            //   而 SUCCESS 是首个终态、PAY_WRITE_FROM = {CREATED, PAYING} 不含它 —— 写进去就再改不了:
            //   用户真付款后的回调走同一个 applyPayResult → 0 行 → changed==false
            //   → 不触发 callbackService.notifyOrder → 下游一条通知都收不到
            //   (retryFailed 只重试 notify_status='FAIL',本单该列恒为 NULL,永不被拾起),
            //   且 reconcilePendingOrders / expireStaleOrders 都过滤非终态,两条对账路也捞不到。
            //   PAYING 非终态,四处既有机制就地对上:① 在 PAY_WRITE_FROM 内 → 回调可写;
            //   ② PAYING+QRCODE 命中 reconcilePendingOrders 的两个过滤条件;③ PAYING ∈ PENDING → 绝对过期兜底;
            //   ④ closeExpiredQrOrders 首次真正生效(qrclose 关单)。
            //   【与 createBarcodeOrder 的区别】被扫写 SUCCESS 是对的:被扫是用户出示码、我们当场提交,
            //   上游同步返回的就是真实付款结果 → 可写终态。
            applyPayResult(order, "PAYING", payPatch);
            return toResult(order, "下单成功");
        }

        if (parser.isPayFail(resp)) {
            applyPayResult(order, "FAIL", payPatch(resp, null));
            return toResult(order, "下单失败");
        }

        // ★ 未知 result_code:**不落终态 FAIL**。
        //   我们并不确定上游到底有没有建出这笔单,而 FAIL 是终态、不可逆。
        //
        //   留在 CREATED 靠什么收尾(说准确,别想当然):
        //   - **主力是回调**:订单状态写入的条件更新放行 CREATED/PAYING(LsyOrderStatus.PAY_WRITE_FROM),
        //     所以一条迟到的支付回调仍然能把这一单推进到 SUCCESS。落 FAIL 就没这条路了。
        //   - **兜底是绝对过期**:LsyReconcileScheduler.expireStaleOrders 会把超过
        //     absoluteExpireHours 仍未终态的 CREATED/PAYING 强制收敛为 EXPIRED。
        //   - **注意:reconcilePendingOrders 捞不到它** —— 那个查询过滤 `status = 'PAYING'`,
        //     而这里留下的状态是 CREATED。所以这一单不靠「下一轮对账主动问上游」,
        //     而是靠回调 + 绝对过期两条路收尾。不要把它写成会被对账轮询。
        //
        //   对调用方仍回「下单失败」—— 我们确实没能给出可用的 code_url,不能骗调用方说成功了。
        //   局部的措辞不一致(调用方看到失败、库里还没终态)是有意为之:**外部看到的确定性
        //   优先于内部记账的整洁**,而内部那笔账由回调/绝对过期收尾。
        log.error("乐收赢聚合码下单返回未知 result_code,不落终态待对账: outTradeNo={}, "
                        + "return_code={}, result_code={}, return_msg={}",
                order.getOutTradeNo(), resp.get("return_code"),
                resp.get("result_code"), resp.get("return_msg"));
        return toResult(order, "下单失败");
    }

语义说明:主扫(聚合码)下单成功后订单状态置 PAYING(非终态),不是 SUCCESS。 判据只有一条:这条路径发起时,用户有没有可能已经把款付完? 主扫是用户拿码去扫 —— 下单这一刻码还没交到用户手上,付款不可能发生,所以只能落非终态。 写 SUCCESS 会让真实支付回调被条件更新吞掉、下游永远收不到通知 (完整推演见上面 createQrOrder 成功分支的裁定注释)。

toResult 返回的 message 仍用"下单成功"(status 取自订单,自然是 PAYING); 下游 order/query 以库中状态为准,用户付款前 receiptAmount 为 null。

这条语义必须对下游可见 —— 落点是 Task 15 的 LsyPayResultVO javadoc(已写明 「主扫与 H5 的下单成功落 PAYING(不是 SUCCESS)—— 下单成功 ≠ 收款成功」), 以及 Task 15 各端点方法的 javadoc。 本计划不产出独立的「下游接入文档」文件(18 个任务的 Files 段里没有任何 .md 交付物), 所以不要把这句话读成一个待建文档 —— 语义靠代码内的 javadoc 承载, 对接方要的说明由交付时另行提供。

  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyQrPayTest
    

Expected: Tests run: 7, Failures: 0, Errors: 0 (含 1 条 qr_unknownResultCode_doesNotWriteTerminalFail —— 它守的是「未知码不得落终态」)

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 8: H5 下单(h5pay)

pay_type 必填(300/400),与聚合码相反;会返回 PAY_IN_PROCESS,但用户被跳转到支付 App,不做同步轮询。

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java(追加 createH5Order)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java

Interfaces:

  • Consumes: LeshouyingClient.post, LsyOrderService.settleAfterPay
  • Produces: LsyPayResultDTO LsyOrderService.createH5Order(LsyOrderEntity order, String payType)

  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.dto.LsyPayResultDTO;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    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.util.LinkedHashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyH5PayTest {
    
    @Mock private LeshouyingClient client;
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyQueryService queryService;
    private LsyOrderService orderService;
    
    @BeforeEach
    void setUp() {
        LeshouyingProperties props = new LeshouyingProperties();
        props.setSyncPollTimes(3);
        props.setSyncPollIntervalMs(0);
        // 构造器签名与 Task 5 一致(5 参)
        orderService = new LsyOrderService(orderMapper, client,
                new LeshouyingResponseParser(), props, queryService);
    }
    
    private LsyOrderEntity order() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(3L);
        o.setOutTradeNo("OUT003");
        o.setMchNo("Y52211090004");
        o.setPayScene("H5");
        o.setPayTraceNo("PT003");
        o.setPayTime("20260921120000");
        o.setTotalAmount(200L);
        o.setStatus("CREATED");
        return o;
    }
    
    private Map<String, Object> resp(String rc) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", rc);
        m.put("code_url", "https://pay.congmingpay.com/pay/scene/h5tominiapp_new.html?order_id=x");
        return m;
    }
    
    @Test
    void h5_payTypeIsRequired() {
        assertThrows(IllegalArgumentException.class,
                () -> orderService.createH5Order(order(), null));
        assertThrows(IllegalArgumentException.class,
                () -> orderService.createH5Order(order(), "000"),
                "H5 不接受自动识别,必须明确 300/400");
    }
    
    @Test
    void h5_success_returnsCodeUrl_immediately() {
        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createH5Order(order(), "400");
    
        // ★ 同主扫:H5 下单成功落 PAYING(用户还没被跳转,付款还没发生)。
        assertEquals("PAYING", r.getStatus());
        assertNotNull(r.getCodeUrl());
    }
    
    @Test
    void h5_payInProcess_doesNotSyncPoll() {
        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createH5Order(order(), "400");
    
        assertEquals("PAYING", r.getStatus());
        assertNotNull(r.getCodeUrl(), "支付中也必须把 code_url 给出去,否则用户无法跳转");
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void h5_sendsRequiredPayType() {
        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        orderService.createH5Order(order(), "300");
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/h5pay"), captor.capture());
        assertEquals("300", captor.getValue().get("pay_type"));   // 不加 String.valueOf:那会抹掉类型,塞 Integer 也照样绿
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = captor.getValue();
        assertEquals("200", sent.get("total_amount"), "金额必须是「分」的字符串,不是 Long");
        assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象");
    }
    
    @Test
    void h5_unknownResultCode_doesNotWriteTerminalFail() {
        // H5 的未知码比聚合码更危险:用户可能**已经**被跳到支付页并完成了付款,
        // 落终态 FAIL 会让这笔真实支付无处落地,且退出对账集合、不再被纠正。
        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("SUCCESS"));
        // 不 stub orderMapper.update:STRICT_STUBS 下未调用的 stub 会报 UnnecessaryStubbingException,
        // 而「一次都没被调用」正是本用例要断言的事实。
    
        LsyPayResultDTO r = orderService.createH5Order(order(), "300");
    
        assertEquals("下单失败", r.getMessage());   // 对调用方不撒谎
        verify(orderMapper, never()).update(any(), any());   // 但库里不得落终态
    }
    
    @Test
    void h5_payFail_writesFail() {
        // 与上一条恰好相反:上游**明确**说这笔单没建成(PAY_FAIL),此时落终态是安全的 ——
        // 没有可用的 code_url 交给用户,用户不可能已经付款,不存在「真实支付无处落地」的风险。
        // 同门 `barcode_payFail_returnsFail` / `qr_payFail_returnsFail` 都已有这条断言,
        // H5 是三种下单场景里**唯一漏掉 PAY_FAIL 终态断言**的
        //(H5 的 `applyPayResult(order, "FAIL", ...)` 在改动前全仓库零覆盖)。
        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_FAIL"));
        when(orderMapper.update(any(), any())).thenReturn(1);
    
        LsyPayResultDTO r = orderService.createH5Order(order(), "400");
    
        assertEquals("FAIL", r.getStatus());
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyH5PayTest
    

Expected: 编译失败。

  • Step 3: 实现 createH5Order

追加到 LsyOrderService:

    /**
     * H5 下单(移动端浏览器唤起微信/支付宝)。
     *
     * 与聚合码的关键差异:
     *   1. pay_type 必填(300 支付宝 / 400 微信),不支持自动识别(与聚合码相反)
     *      —— 上游 H5 还接受 600 云闪付 / 900 龙支付,但本模块**按 spec §2 不接入**
     *      (spec「不做」表:龙支付 / 云闪付「无场景」),故 `ofRequired` 会拒绝这两个码。
     *      这是**故意的**,不是漏配:见到 `不支持的支付类型: 600` 不要当成 bug 去补枚举。
     *   2. 会返回 PAY_IN_PROCESS
     *   3. 但用户会被跳转到支付 App,停留时长不可控 → 不做同步轮询
     *
     * 上游「H5支付」目录下无独立关单接口,本类不为 H5 提供关单路径(见 spec §2.3)。
     */
    public LsyPayResultDTO createH5Order(LsyOrderEntity order, String payType) {
        LsyPayType type = LsyPayType.ofRequired(payType);   // AUTO 会被拒绝

        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        p.put("pay_type", type.getCode());
        p.put("pay_trace_no", order.getPayTraceNo());
        p.put("pay_time", order.getPayTime());
        p.put("total_amount", String.valueOf(order.getTotalAmount()));
        if (order.getOrderBody() != null) {
            p.put("order_body", order.getOrderBody());
        }
        if (order.getAttach() != null) {
            p.put("attach", order.getAttach());
        }
        if (order.getNotifyUrl() != null) {
            p.put("notify_url", order.getNotifyUrl());
        }

        Map<String, Object> resp = client.post("/v3/h5pay", p);

        if (parser.isPaySuccess(resp)) {
            stageCodeUrl(order, resp, type.getCode());

            // ★ 同主扫:**下单路径**一律传 null。H5 这一单此刻用户连支付页都还没跳过去,
            //   上游没回 receipt_amount 时兜底成订单总额,就是把「一分钱没付」写成「实收全额」。
            LsyOrderEntity patch = payPatch(resp, null);
            patch.setCodeUrl(order.getCodeUrl());
            // ★ 同主扫:落 PAYING。H5 的下单成功只表示「上游受理了这次下单请求」,
            //   用户此刻还没被跳转到支付页 —— 与 isPayInProcess 分支落同一种状态才自洽。
            applyPayResult(order, "PAYING", patch);
            return toResult(order, "下单成功");
        }

        if (parser.isPayInProcess(resp)) {
            // 支付中也必须回传 code_url —— 用户还没跳转,不给链接就等于下单失败
            stageCodeUrl(order, resp, type.getCode());

            LsyOrderEntity patch = payPatch(resp, null);
            patch.setCodeUrl(order.getCodeUrl());
            applyPayResult(order, "PAYING", patch);
            return toResult(order, "支付中");
        }

        if (!parser.isPayFail(resp)) {
            // ★ 未知 result_code —— 同 createQrOrder:不落终态 FAIL,靠**回调**或
            //   **absoluteExpireHours 绝对过期**收尾(不是靠对账轮询,那个查询只捞 PAYING)。
            //   H5 尤其危险:用户可能已经被跳到支付页了,落 FAIL 会让一笔真实支付无处落地。
            log.error("乐收赢 H5 下单返回未知 result_code,不落终态待对账: outTradeNo={}, "
                            + "return_code={}, result_code={}, return_msg={}",
                    order.getOutTradeNo(), resp.get("return_code"),
                    resp.get("result_code"), resp.get("return_msg"));
            return toResult(order, "下单失败");
        }

        applyPayResult(order, "FAIL", payPatch(resp, null));
        return toResult(order, "下单失败");
    }

    /** 把 code_url / pay_type 落库并同步内存对象。H5 两个分支共用。 */
    private void stageCodeUrl(LsyOrderEntity order, Map<String, Object> resp, String payType) {
        String codeUrl = str(resp.get("code_url"));

        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setCodeUrl(codeUrl);
        patch.setPayType(payType);
        patch.setUpdatedTime(OffsetDateTime.now());
        orderMapper.update(patch, new UpdateWrapper<LsyOrderEntity>()
                .eq("id", order.getId()));

        order.setCodeUrl(codeUrl);
        order.setPayType(payType);
    }
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyH5PayTest
    

Expected: Tests run: 6, Failures: 0, Errors: 0 (含 h5_unknownResultCode_doesNotWriteTerminalFail 与 h5_payFail_writesFail —— 这两条必须成对: 前者证明未知码不写终态,后者证明明确失败要写终态。只留任一条,另一条都挡不住 「两个分支写反了」这种改动。)

  • [ ] Step 5: 跑一遍本模块全部测试

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test'
    
  • [ ] Step 6: 确认工作区状态(不提交)

    git status --short
    

Task 9: 撤销 / 关单(先查后撤)

本任务的核心是规避 spec §2.4 的陷阱:cancelorder 对已支付的单会退款给用户,但响应无法区分"关单"与"退款"。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java

Interfaces:

  • Consumes: LsyQueryService.queryOrder, LeshouyingClient.post, LeshouyingResponseParser.isPaySuccess/isPayFail/isPayInProcess
    • 前置查询的作用是判断能不能安全撤销,所以三个谓词都要用: 已支付 → 转退款;支付中 → 拒绝撤销;未知码 → 拒绝撤销;只有显式 PAY_FAIL 才继续走撤销。 撤销是不可逆资金动作,失败方向朝「不执行」倾斜。
  • Produces:

    • String LsyCancelService.closeOrder(LsyOrderEntity order) —— 返回目标状态字符串(CANCELED / CLOSED / EXPIRED / SUCCESS)
    • Long LsyCancelService.cancelTraceNo() / 无
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    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.util.LinkedHashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyCancelServiceTest {
    
    @Mock private LeshouyingClient client;
    @Mock private LsyQueryService queryService;
    private LsyCancelService service;
    
    @BeforeEach
    void setUp() {
        service = new LsyCancelService(client, queryService, new LeshouyingResponseParser());
    }
    
    private LsyOrderEntity barcode() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setMchNo("Y52211090004");
        o.setPayScene("BARCODE");
        o.setPayTraceNo("PT001");
        o.setPayTime("20260921120000");
        o.setStatus("PAYING");
        return o;
    }
    
    private LsyOrderEntity qrcode() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(2L);
        o.setMchNo("Y52211090004");
        o.setPayScene("QRCODE");
        o.setCodeUrl("https://open.gdyfsk.com/yfpay/s/abc");
        o.setStatus("PAYING");
        return o;
    }
    
    private Map<String, Object> resp(String resultCode) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", resultCode);
        return m;
    }
    
    @Test
    void barcode_alreadyPaid_skipsCancelOrder_returnsSuccess() {
        // 查到已支付 → 不调 cancelorder,交由退款流程处理
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_SUCCESS"));
    
        String target = service.closeOrder(barcode());
    
        assertEquals("SUCCESS", target, "已支付的单不能记成关单,必须留给退款流程");
        verify(client, never()).post(eq("/v3/cancelorder"), anyMap());
    }
    
    @Test
    void barcode_notPaid_callsCancelOrder_returnsCanceled() {
        // ★ 查询桩必须是 `PAY_FAIL`,**不能是** `PAY_IN_PROCESS`。
        //   本用例的名字(notPaid)说明它想表达的是「查到没付款 → 走撤销」,
        //   而「没付款」在 `cancelBarcodeOrder` 里的判据是 `isPayFail` 为真 —— 只有显式 PAY_FAIL。
        //   `PAY_IN_PROCESS` 走的是上面那条「支付中 → 拒绝撤销」分支,压根到不了 cancelorder。
        //   (历史:本用例的桩是 `PAY_IN_PROCESS`,那是「不是已支付就直接撤」那版写法的遗留;
        //     代码改成三谓词分派之后没同步,于是它与 `barcode_payInProcess_refusesToCancel`
        //     成了**同样输入、相反断言**的一对 —— 必有一条红。)
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL"));
        when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
    
        String target = service.closeOrder(barcode());
    
        assertEquals("CANCELED", target);
    }
    
    @Test
    void barcode_cancelFails_returnsNull_meaningDoNotChangeStatus() {
        // ★ 同上:桩必须是 `PAY_FAIL`,否则走不到 cancelorder。
        //   桩成 `PAY_IN_PROCESS` 时 `assertNull` 会**因为错误的原因通过** —— 断言绿了,
        //   但 `when(client.post(...))` 一次都没被调用,strict stubs 下这一整个用例报
        //   `UnnecessaryStubbingException`;即便关掉 strictness,它也变成一条什么都没验的测试。
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL"));
        when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_FAIL"));
    
        String target = service.closeOrder(barcode());
    
        assertNull(target, "撤销失败时不应改状态,留给下次重试");
    }
    
    @Test
    void barcode_queryFails_doesNotCallCancel() {
        when(queryService.queryOrder(any()))
                .thenThrow(new RuntimeException("网络超时"));
    
        assertThrows(RuntimeException.class, () -> service.closeOrder(barcode()));
        verify(client, never()).post(eq("/v3/cancelorder"), anyMap());
    }
    
    @Test
    void barcode_payInProcess_refusesToCancel() {
        // 用户可能正在输密码。撤销是**不可逆**的资金动作,
        // 「这次没撤成让运维重试」远轻于「撤掉一笔正在扣款的单」。
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_IN_PROCESS"));
    
        assertNull(service.closeOrder(barcode()), "支付中不得撤销");
        verify(client, never()).post(eq("/v3/cancelorder"), anyMap());
    }
    
    @Test
    void barcode_unknownResultCode_refusesToCancel() {
        // ★ 原写法是「不是已支付 → 直接撤」,未知码会一路走到撤销。
        //   而我们恰恰**不知道这笔付没付** —— 猜错的方向是不可逆的,所以要朝不执行的方向失败。
        when(queryService.queryOrder(any())).thenReturn(resp("SUCCESS"));
    
        assertNull(service.closeOrder(barcode()), "未知码下不知付没付,拒绝撤销");
        verify(client, never()).post(eq("/v3/cancelorder"), anyMap());
    }
    
    @Test
    void barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess() {
        // ★ 这条钉的是一个**决定**,不是一段逻辑。
        //   上游文档里 `/v3/cancelorder` 与 `/v3/closeorder` 的**正文**把成功码写成 `PAY_SUCEESS`
        //   (少一个 S),而同一份文档在 `/v3/trade/*` 的示例里用小写 `success`、
        //   在别处用 `PAY_SUCCESS` —— 文档**没有**解决这个拼写冲突,我们无法从文档判定
        //   `PAY_SUCEESS` 是笔误还是真实返回值。
        //
        //   决定:**不猜**,不把它加进 parser 的词表。它按未知码处理 ⇒ 撤销未确认 ⇒ 不改状态。
        //   为什么这样选:两种误判的代价不对称 —— 猜它是真实值,会让一个可疑拼写进入正常路径
        //   (若它其实是笔误,我们就永远看不到「真的没撤成」);猜它是笔误,代价只是撤销多走人工核对,
        //   而**钱没动**。撤销/关单是**不可逆**动作,不可逆那一侧要更保守。
        //
        //   代价明说:若上游真回这个拼写,每笔撤销都会被我们记成「没撤成」。所以
        //   `cancelBarcodeOrder` / `closeQrOrder` 的失败日志里**带上了 result_code** ——
        //   联调第一笔撤销就能一眼看出是不是这个拼写。那之后再改,是**有意**地改。
        when(queryService.queryOrder(any())).thenReturn(resp("PAY_FAIL"));
        when(client.post(eq("/v3/cancelorder"), anyMap())).thenReturn(resp("PAY_SUCEESS"));
    
        assertNull(service.closeOrder(barcode()),
                "PAY_SUCEESS 是未确认的文档拼写,不得当作撤销成功");
    }
    
    @Test
    void qrcode_usesQrcloseWithCodeUrl_notTradeNo() {
        when(client.post(eq("/v3/qrclose"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
    
        String target = service.closeOrder(qrcode());
    
        assertEquals("CLOSED", target);
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/qrclose"), captor.capture());
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = captor.getValue();
        assertEquals("https://open.gdyfsk.com/yfpay/s/abc", sent.get("code_url"));
        assertFalse(sent.containsKey("trade_no"), "qrclose 入参是 code_url,不是 trade_no");
        verify(client, never()).post(eq("/v3/closeorder"), anyMap());
    }
    
    @Test
    void qrcode_neverQueriesFirst_noAmbiguityInQrclose() {
        when(client.post(eq("/v3/qrclose"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
    
        service.closeOrder(qrcode());
    
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void h5_hasNoCloseInterface_returnsExpiredLocally() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(3L);
        o.setPayScene("H5");
        o.setStatus("PAYING");
    
        String target = service.closeOrder(o);
    
        assertEquals("EXPIRED", target);
        verify(client, never()).post(anyString(), anyMap());
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void qrcode_missingCodeUrl_returnsExpiredWithoutCallingUpstream() {
        LsyOrderEntity o = qrcode();
        o.setCodeUrl(null);
    
        assertEquals("EXPIRED", service.closeOrder(o));
        verify(client, never()).post(eq("/v3/qrclose"), anyMap());
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCancelServiceTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyCancelService

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.IdUtil;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /**
    * 撤销 / 关单。
    *
    * 【核心设计:先查后撤】上游 cancelorder 对已支付的单会把资金退还给用户,
    * 但返回码只有 PAY_SUCCESS/PAY_FAIL,**无法区分"关单"还是"退款"**。
    * 直接调会导致本地状态记错,因此:
    *   查到已支付 → 不调 cancelorder,返回 SUCCESS,交由退款流程处理
    *   查到未支付 → 调 cancelorder 关单 → CANCELED
    *
    * 竞态兜底:若查询时还在支付中、调撤销的瞬间用户支付成功,上游会退款并发退款回调,
    *         届时由回调把状态从 CANCELED 更正为 REFUNDED。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyCancelService {
    
    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    
    private final LeshouyingClient client;
    private final LsyQueryService queryService;
    private final LeshouyingResponseParser parser;
    
    /**
     * 关闭订单。
     * @return 目标状态;返回 null 表示"本次不改状态"(上游拒绝,留给下次重试)
     */
    public String closeOrder(LsyOrderEntity order) {
        String scene = order.getPayScene();
        if ("H5".equals(scene)) {
            // 上游 H5 目录下无独立关单接口,closeorder 正文也未提 H5。
            // v1 只做本地收敛,不动上游(见 spec §2.3 / §14 待确认项)。
            log.info("H5 订单无已知关单接口,本地收敛为 EXPIRED: outTradeNo={}", order.getOutTradeNo());
            return "EXPIRED";
        }
        if ("QRCODE".equals(scene)) {
            return closeQrOrder(order);
        }
        return cancelBarcodeOrder(order);
    }
    
    /** 聚合码关单:入参是 code_url,不是 trade_no */
    private String closeQrOrder(LsyOrderEntity order) {
        if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) {
            log.warn("聚合码订单缺少 code_url,无法关单,本地收敛: outTradeNo={}", order.getOutTradeNo());
            return "EXPIRED";
        }
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        p.put("code_url", order.getCodeUrl());
    
        Map<String, Object> resp = client.post("/v3/qrclose", p);
        if (parser.isPaySuccess(resp)) {
            return "CLOSED";
        }
        // ★ 必须把 result_code 一起打出来,不能只打 return_msg。
        //   上游文档里 `/v3/qrclose`(以及 closeorder / cancelorder)**正文把成功写成 `PAY_SUCEESS`**
        //   —— 少一个 S。若那不是笔误而是真实返回值,`isPaySuccess` 对它为 false,
        //   于是关单**实际上成功了**,我们却在这里记一条「失败」,
        //   而 `return_msg` 大概率是「成功」二字 —— 运维看到「关单失败: msg=成功」将无从判断。
        //   带上 result_code,这一条日志自己就回答了「是不是那个拼写」。
        //   ★ 不要为了这个去给 parser 加 `PAY_SUCEESS`:我们无法判定它是笔误还是真实值,
        //     而两种误判不对称 —— 猜错成真实值会让一个可疑拼写进入正常路径;
        //     猜成笔误只是让关单多走人工核对。**留到联调第一笔关单看日志定案。**
        log.warn("聚合码关单失败: outTradeNo={}, result_code={}, return_msg={}",
                order.getOutTradeNo(), resp.get("result_code"), resp.get("return_msg"));
        return null;
    }
    
    /** 被扫撤销:先查后撤 */
    private String cancelBarcodeOrder(LsyOrderEntity order) {
        Map<String, Object> q = queryService.queryOrder(order);
        if (parser.isPaySuccess(q)) {
            // 已支付 → 撤销会变成退款,语义完全不同,不在本方法处理
            log.info("撤销前查询发现已支付,转退款流程: outTradeNo={}", order.getOutTradeNo());
            return "SUCCESS";
        }
        // ★ 撤销是**资金动作**,所以这里的失败方向要选对:
        //   原写法是「不是已支付 → 直接撤」,于是 `PAY_IN_PROCESS`(用户正在输密码)和
        //   未知 result_code(我们根本不知道付没付)都会一路走到撤销。
        //   撤掉一笔已付款的单,比「这次没撤成、让运维重试一次」严重得多 ——
        //   所以这两种情况都**拒绝撤销**,朝不执行的方向失败。
        //   查不到/撤销被拒都是**可重试**的;撤销成功是**不可逆**的。不可逆的那一侧要更保守。
        if (parser.isPayInProcess(q)) {
            log.warn("撤销前查询显示支付中,暂不撤销(可能正在扣款): outTradeNo={}", order.getOutTradeNo());
            return null;
        }
        if (!parser.isPayFail(q)) {
            log.error("撤销前查询返回未知 result_code,不知付没付,拒绝撤销待人工核对: outTradeNo={}, "
                            + "return_code={}, result_code={}",
                    order.getOutTradeNo(), q.get("return_code"), q.get("result_code"));
            return null;
        }
    
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        p.put("cancel_trace_no", IdUtil.fastSimpleUUID());
        p.put("cancel_time", LocalDateTime.now().format(FMT));
        if (order.getTradeNo() != null) {
            p.put("trade_no", order.getTradeNo());
        } else {
            p.put("pay_trace_no", order.getPayTraceNo());
            p.put("pay_time", order.getPayTime());
        }
    
        Map<String, Object> resp = client.post("/v3/cancelorder", p);
        if (parser.isPaySuccess(resp)) {
            return "CANCELED";
        }
        // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)。
        //   不带 result_code 的话,「撤销实际成功、我们记成失败」将表现为
        //   「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
        log.warn("撤销失败: outTradeNo={}, result_code={}, return_msg={}",
                order.getOutTradeNo(), resp.get("result_code"), resp.get("return_msg"));
        return null;
    }
    }
    
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCancelServiceTest
    

Expected: Tests run: 11, Failures: 0, Errors: 0 (含 barcode_payInProcess_refusesToCancel、barcode_unknownResultCode_refusesToCancel、 barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess 三条「拒绝撤销」用例 —— 它们守的是不可逆动作的失败方向:撤销/关单一旦执行就无法回退, 所以在「不知道能不能撤」时必须朝不执行的方向失败。 ★ barcode_cancelFails_returnsNull_meaningDoNotChangeStatus 与 barcode_notPaid_callsCancelOrder_returnsCanceled 的查询桩必须都是 resp("PAY_FAIL"): 只有显式 PAY_FAIL 才代表「查到没付款」。这两个桩原先都是 PAY_IN_PROCESS, 与 barcode_payInProcess_refusesToCancel 构成同样输入、相反断言的一对,必有一条红。)

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 10: 退款与退款查询

字段语义陷阱:上游退款接口的 pay_time 是退款请求时间,原支付时间是 pay_trace_time。

★ 本任务的真正难点不是字段映射,是防重复退款

可退余额的唯一定义是 remaining = total_amount - refunded_amount,而 refunded_amount 只在退款成功后才累加。这中间有一个必然存在的窗口,窗口里的 remaining 是虚高的。 一笔 100 元的单,第一笔 100 元退款还在途时,第二笔 100 元退款从余额校验看完全合法。 上游会真扣两次钱,我们这边只记一笔。这不是"极端情况",是默认路径上的洞。

三道锁,缺一不可,改任何一处前先读全三条:

# 锁 挡的是
1 uk_lsy_refund_one_pending 部分唯一索引(Task 3 DDL) 并发双发:两请求同时通过应用层检查时,第二条 insert 直接违反约束
2 createRefund 的在途前置校验(读 LsyRefundStatus.PENDING) 串行双发:上一笔还在 REFUNDING 时再来一笔,给出人话错误
3 同步成功立即收敛订单侧(settleOrderAfterRefund) 事后双发:不收敛则 refunded_amount 永远不涨,锁 1/2 也拦不住下一轮

锁 1 与锁 2 都依赖"在途"的定义一致,而索引里的状态集是 SQL 字面量、 应用侧读的是 LsyRefundStatus.PENDING —— 改一处必须改两处。 锁 3 为什么必须存在,见 createRefund 里那段 ★ 注释,一句话: 同步成功的退款行直接是终态,回调到达时会被当成重复回调丢弃, 于是订单侧的累加永远不会发生。

已知残留(不修,记录在案):应用层的读-判-写不是原子的,锁 2 单独用挡不住并发; 兜底靠锁 1。若将来去掉锁 1,锁 2 必须改成真正的行锁或 SELECT ... FOR UPDATE。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

Interfaces:

  • Consumes: LeshouyingClient.post, LeshouyingResponseParser, LsyRefundMapper(含 selectList / insert / update), LsyRefundStatus.PENDING, LsyOrderService.applyRefundResult
  • Produces:

    • LsyRefundEntity LsyRefundService.createRefund(LsyOrderEntity order, String outRefundNo, long refundFee) —— 同步成功时会连同订单侧一起收敛,调用方不要再自己累加 refundedAmount
    • Map<String,Object> LsyRefundService.queryRefund(LsyRefundEntity refund)
    • boolean LsyRefundService.settleOrderAfterRefund(LsyOrderEntity order, long refundAmount) —— 退款成功后订单侧的唯一收敛入口(累加 + 推导 REFUNDED/PARTIAL_REFUNDED)。Task 12 也调它, 不要在 handleRefundNotify 里另算一份状态。
    • boolean LsyRefundService.applyNotifyResult(LsyRefundEntity refund, String resultCode, String resultMsg, String newStatus) —— 退款行的唯一状态写入口,条件更新受影响行数 = 1 才返回 true。这是 Task 12 的幂等闸门, 没有它重复回调会把 refunded_amount 重复累加。
    • LsyRefundEntity LsyRefundService.requireByOutRefundNo(Long tenantId, String outRefundNo) —— Task 15 Step 5 才追加(不在本任务写)。下游 OpenAPI 专用,首参必须带 tenantId。
    • 构造器(4 参):LsyRefundService(LeshouyingClient, LeshouyingResponseParser, LsyRefundMapper, LsyOrderService)
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    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.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyRefundServiceTest {
    
    @Mock private LeshouyingClient client;
    @Mock private LsyRefundMapper refundMapper;
    @Mock private LsyOrderService orderService;
    private LsyRefundService service;
    
    @BeforeEach
    void setUp() {
        service = new LsyRefundService(client, new LeshouyingResponseParser(), refundMapper, orderService);
    }
    
    private LsyOrderEntity order() {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setOutTradeNo("OUT001");
        o.setMchNo("Y52211090004");
        o.setPayTraceNo("PT001");
        o.setPayTime("20260921120000");
        o.setTradeNo("T001");
        o.setTotalAmount(100L);
        o.setRefundedAmount(0L);
        o.setStatus("SUCCESS");
        return o;
    }
    
    private Map<String, Object> resp(String rc) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", rc);
        m.put("refund_no", "R001");
        m.put("refund_settle", "100");
        return m;
    }
    
    @Test
    void refund_rejectsAmountExceedingRemaining() {
        LsyOrderEntity o = order();
        o.setRefundedAmount(80L);   // 剩余 20
    
        assertThrows(IllegalArgumentException.class,
                () -> service.createRefund(o, "RF001", 30L));
        verify(client, never()).post(anyString(), anyMap());
    }
    
    @Test
    void refund_rejectsNonPositiveAmount() {
        assertThrows(IllegalArgumentException.class,
                () -> service.createRefund(order(), "RF001", 0L));
    }
    
    @Test
    void refund_rejectsOrderNotInSuccessState() {
        LsyOrderEntity o = order();
        o.setStatus("PAYING");
        assertThrows(IllegalStateException.class,
                () -> service.createRefund(o, "RF001", 10L));
    }
    
    @Test
    void refund_mapsTimeFieldsCorrectly_notConfused() {
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
    
        service.createRefund(order(), "RF001", 100L);
    
        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
        verify(client).post(eq("/v3/refundorder"), captor.capture());
        @SuppressWarnings("unchecked")
        Map<String, Object> sent = captor.getValue();
    
        assertEquals("100", sent.get("refund_fee"));
        // 上游 pay_time = 退款请求时间(本次),pay_trace_time = 原支付时间
        assertNotNull(sent.get("pay_time"));
        assertEquals("20260921120000", sent.get("pay_trace_time"),
                "pay_trace_time 必须传原支付时间,传反会导致上游找不到原单");
    }
    
    @Test
    void refund_success_statusIsSuccess() {
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
    
        LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
    
        assertEquals("SUCCESS", r.getStatus());
        assertEquals("R001", r.getRefundNo());
    }
    
    @Test
    void refund_insertsAsRefundingFirst_neverDirectlyTerminal() {
        // 必须先进 REFUNDING 再收敛:insert 成 SUCCESS 会让本行落在
        // uk_lsy_refund_one_pending 的谓词之外,并发第二笔退款撞不上约束。
        // 这个断言就是在钉住"互斥窗口必须真的存在"。
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
    
        service.createRefund(order(), "RF001", 100L);
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper).insert(captor.capture());
        assertEquals("REFUNDING", captor.getValue().getStatus(),
                "插入时必须是非终态,否则部分唯一索引护不住并发窗口");
    }
    
    @Test
    void refund_syncSuccess_settlesOrderSideImmediately() {
        // ★ 这条是防"钱退了两遍"的核心回归。
        //   同步成功若不在本地就累加 refunded_amount,回调到达时本行已是 SUCCESS、
        //   不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行 → handleRefundNotify
        //   判为重复回调 → refunded_amount 永不累加 → remaining 永远虚高 → 可以再退一次。
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
    
        service.createRefund(order(), "RF001", 100L);
    
        verify(orderService).applyRefundResult(any(), eq(100L), eq("REFUNDED"));
    }
    
    @Test
    void refund_rejectsWhenAnotherRefundIsPending() {
        // 在途退款挡住新退款:refunded_amount 只在退款成功后才累加,
        // 所以在途期间 remaining 是虚高的,金额校验挡不住重复退款。
        LsyRefundEntity inFlight = new LsyRefundEntity();
        inFlight.setId(9L);
        inFlight.setStatus("REFUNDING");
        when(refundMapper.selectList(any())).thenReturn(List.of(inFlight));
    
        IllegalStateException ex = assertThrows(IllegalStateException.class,
                () -> service.createRefund(order(), "RF002", 100L));
        assertTrue(ex.getMessage().contains("正在处理中"));
        verify(client, never()).post(anyString(), anyMap());
    }
    
    @Test
    void refund_pendingGuard_passesWhenPreviousRefundIsTerminal() {
        // 对照组:上一笔已终态(SUCCESS/FAIL)不得挡路,否则退款只能退一次。
        LsyRefundEntity settled = new LsyRefundEntity();
        settled.setId(9L);
        settled.setStatus("SUCCESS");
        when(refundMapper.selectList(any())).thenReturn(List.of(settled));
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
    
        LsyRefundEntity r = service.createRefund(order(), "RF002", 30L);
    
        assertEquals("REFUNDING", r.getStatus());
    }
    
    @Test
    void refund_payInProcess_statusIsRefunding() {
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
    
        LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
    
        assertEquals("REFUNDING", r.getStatus());
    }
    
    @Test
    void refund_persistsEntityWithOutRefundNo() {
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        when(refundMapper.insert(any())).thenReturn(1);
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
    
        service.createRefund(order(), "RF001", 50L);
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper).insert(captor.capture());
        LsyRefundEntity saved = captor.getValue();
        assertEquals("RF001", saved.getOutRefundNo());
        assertEquals("OUT001", saved.getOutTradeNo());
        assertEquals(50L, saved.getRefundFee());
        assertNotNull(saved.getRefundTraceNo());
    }
    
    // ---- applyNotifyResult:退款行的幂等闸门(Task 12 依赖) ----
    
    @Test
    void applyNotifyResult_transitionApplied_returnsTrue() {
        LsyRefundEntity refund = refund("REFUNDING");
        when(refundMapper.update(any(), any())).thenReturn(1);
    
        assertTrue(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS"));
        assertEquals("SUCCESS", refund.getStatus());
    }
    
    @Test
    void applyNotifyResult_duplicateCallback_returnsFalse() {
        // 同一笔退款的回调重复到达 → status 已不在 {CREATED, REFUNDING} → 命中 0 行
        LsyRefundEntity refund = refund("SUCCESS");
        when(refundMapper.update(any(), any())).thenReturn(0);
    
        assertFalse(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS"),
                "重复回调必须返回 false,否则订单侧会重复累加 refunded_amount");
    }
    
    private LsyRefundEntity refund(String status) {
        LsyRefundEntity r = new LsyRefundEntity();
        r.setId(9L);
        r.setOutRefundNo("RF001");
        r.setRefundFee(30L);
        r.setStatus(status);
        return r;
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyRefundServiceTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyRefundService

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.IdUtil;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.Set;
    
    /**
    * 退款与退款查询。
    *
    * 【字段陷阱】上游退款接口的 pay_time 是"退款请求时间",
    * 原支付时间是 pay_trace_time。传反会导致上游找不到原单。
    * 本类内部统一用 refundRequestTime / originalPayTime 命名,绝不混用。
    *
    * 【为什么本类**不加** `@Transactional`】别"顺手补上",那会让情况更糟:
    * `createRefund` 中间有一次不可撤销的上游调用(`/v3/refundorder`,钱真的出去了),
    * 而本地写库是它的**后置记账**。若包在事务里,之后任何一步失败回滚,
    * 结果就是「上游钱已退、本地无记录」—— 记录消失比记录不准危险得多。
    * 所以本地写入只做两件事:条件更新保证幂等,失败就 log.error 让人看见。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyRefundService {
    
    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    private static final Set<String> REFUNDABLE = Set.of("SUCCESS", "PARTIAL_REFUNDED");
    
    private final LeshouyingClient client;
    private final LeshouyingResponseParser parser;
    private final LsyRefundMapper refundMapper;
    /**
     * 只为退款成功后的订单侧收敛({@link #settleOrderAfterRefund})而依赖。
     * 无循环依赖:LsyOrderService 不认识 LsyRefundService。
     */
    private final LsyOrderService orderService;
    
    /**
     * 申请退款。支持部分退款。
     * @throws IllegalArgumentException 金额非法
     * @throws IllegalStateException    订单状态不允许退款
     */
    public LsyRefundEntity createRefund(LsyOrderEntity order, String outRefundNo, long refundFee) {
        if (refundFee <= 0) {
            throw new IllegalArgumentException("退款金额必须大于 0");
        }
        if (!REFUNDABLE.contains(order.getStatus())) {
            throw new IllegalStateException("订单当前状态不可退款: " + order.getStatus());
        }
        // ★ 在途退款前置校验。为什么必须有:
        //   remaining 来自 refunded_amount,而 refunded_amount 只在退款**成功后**才累加。
        //   在途期间它还是旧值 → remaining 虚高 → 一笔在途 100 元的单还能再退 100 元。
        //   下面的金额校验挡不住这一点,因为从它的输入看余额确实够。
        //   用 selectList + LIMIT 1 而不是 selectCount:后者返 Long,Mockito 默认 null,
        //   测试里 unboxing 直接 NPE;selectList 默认空 List,没有这个坑。
        List<LsyRefundEntity> pending = refundMapper.selectList(new LambdaQueryWrapper<LsyRefundEntity>()
                .eq(LsyRefundEntity::getOrderId, order.getId())
                .in(LsyRefundEntity::getStatus, LsyRefundStatus.PENDING)
                .last("LIMIT 1"));
        if (!pending.isEmpty()) {
            throw new IllegalStateException(
                    "该订单有退款正在处理中,请等结果返回后再发起: orderId=" + order.getId());
        }
    
        long refunded = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount();
        long remaining = order.getTotalAmount() - refunded;
        if (refundFee > remaining) {
            throw new IllegalArgumentException(
                    "退款金额超过可退余额: 申请 " + refundFee + ", 可退 " + remaining);
        }
    
        String refundTraceNo = IdUtil.fastSimpleUUID();
        String requestTime = LocalDateTime.now().format(FMT);
    
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("mch_no", order.getMchNo());
        if (order.getShopNo() != null) {
            p.put("shop_no", order.getShopNo());
        }
        if (order.getTradeNo() != null) {
            p.put("trade_no", order.getTradeNo());
        } else {
            p.put("pay_trace_no", order.getPayTraceNo());
        }
        p.put("refund_trace_no", refundTraceNo);
        p.put("refund_fee", String.valueOf(refundFee));
        // 注意:此处的 pay_time 是退款请求时间;原支付时间走 pay_trace_time
        p.put("pay_time", requestTime);
        p.put("pay_trace_time", order.getPayTime());
    
        Map<String, Object> resp = client.post("/v3/refundorder", p);
    
        LsyRefundEntity entity = new LsyRefundEntity();
        entity.setTenantId(order.getTenantId());
        entity.setOrderId(order.getId());
        entity.setOutTradeNo(order.getOutTradeNo());
        entity.setOutRefundNo(outRefundNo);
        entity.setRefundTraceNo(refundTraceNo);
        entity.setRefundFee(refundFee);
        entity.setRefundRequestTime(requestTime);
        entity.setOriginalPayTime(order.getPayTime());
        entity.setRefundNo(str(resp.get("refund_no")));
        entity.setQdRefundNo(str(resp.get("qd_refund_no")));
        entity.setRefundSettle(parseLong(resp.get("refund_settle")));
        entity.setUpstreamResultCode(str(resp.get("result_code")));
        entity.setUpstreamResultMsg(str(resp.get("return_msg")));
        entity.setNotifyStatus("PENDING");
        entity.setNotifyCount(0);
        // 这里刻意保留二元三元式,与 Task 6/7/8 的处理不同 —— 理由是失败方向不同:
        //   未知 result_code 落到 REFUNDING,而 REFUNDING 属于 LsyRefundStatus.PENDING
        //   (非终态),所以我们既没有对下游宣称「退款成功」,也没有宣称「退款失败」,
        //   只是把它留在待收敛状态 + upstreamResultCode 存了原始码可追溯。
        //   没有「钱已退、单记失败」这种最坏方向,因此不需要像支付侧那样拦住未知码。
        //   (已知的次生问题:若这是个永远不出现的未知码,退款会停在 REFUNDING。
        //    这属于「需要人工/对账收敛」,比误报失败轻,已记入待确认清单。)
        //
        // ★ 一律先落非终态,同步成功也不能直接 insert 成 SUCCESS。两个理由:
        //   1. uk_lsy_refund_one_pending 的互斥窗口只覆盖 {CREATED, REFUNDING}。
        //      直接写 SUCCESS 的行不在索引谓词里,并发第二笔退款插进来不会撞约束。
        //   2. 退款行的状态只有一个写入口(applyNotifyResult),同步路径也不该例外。
        entity.setStatus("REFUNDING");
        refundMapper.insert(entity);
    
        // ★ 同步成功必须在这里就把订单侧收敛完,不能等回调:
        //   回调到达时本行已是 SUCCESS,不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行
        //   → handleRefundNotify 判为重复回调直接返回 → refunded_amount 永不累加。
        //   后果不是"少记一笔",而是 remaining 永远虚高:同一张单可以被反复退款。
        //   所以同步路径自己收敛,走的是与异步回调完全相同的那个方法。
        if (parser.isPaySuccess(resp)) {
            // 行刚插进去、状态必然是 REFUNDING,所以这里返回 false 只可能是本地写库失败。
            // 那会让本行卡在 REFUNDING 并**永久挡住这张单的后续退款**(在途校验 + 部分唯一索引都认它),
            // 而它不会有退款对账来捞 —— 必须让人看见,不能静默。
            if (!applyNotifyResult(entity, str(resp.get("result_code")),
                    str(resp.get("return_msg")), "SUCCESS")) {
                log.error("退款同步成功但状态回写失败, 本行将卡在 REFUNDING 且再无兜底, 需人工处理: "
                                + "outRefundNo={}, refundTraceNo={}", outRefundNo, refundTraceNo);
            }
            if (!settleOrderAfterRefund(order, refundFee)) {
                // 不抛异常:上游的钱确实已经退了,抛出去等于对调用方谎报失败。
                // 只记账 —— 订单侧没动,需要人工/对账收敛。
                log.error("退款同步成功但订单侧收敛未命中(状态不符或并发), 需人工核对: "
                                + "orderId={}, orderStatus={}, outRefundNo={}, refundFee={}",
                        order.getId(), order.getStatus(), outRefundNo, refundFee);
            }
        }
        return entity;
    }
    
    /**
     * 退款成功后的订单侧收敛 —— 累加 refundedAmount 并推导订单新状态。
     *
     * **同步成功(本类 createRefund)与异步回调(LsyNotifyService.handleRefundNotify)
     * 都必须走这里。** 两处各写一份 `(refunded + fee) >= total ? REFUNDED : PARTIAL_REFUNDED`
     * 是注定要漂移的:这个式子是"退满了没有"的唯一定义,只该有一个副本。
     *
     * @return true = 本次真的流转了;false = 订单状态不允许回写(并发或上游先动了)
     */
    public boolean settleOrderAfterRefund(LsyOrderEntity order, long refundAmount) {
        long current = order.getRefundedAmount() == null ? 0L : order.getRefundedAmount();
        String newStatus = (current + refundAmount) >= order.getTotalAmount()
                ? "REFUNDED" : "PARTIAL_REFUNDED";
        return orderService.applyRefundResult(order, refundAmount, newStatus);
    }
    
    /** 退款查询。同上:pay_time 是退款请求时间。 */
    public Map<String, Object> queryRefund(LsyRefundEntity refund) {
        Map<String, Object> p = new LinkedHashMap<>();
        p.put("query_trace_no", IdUtil.fastSimpleUUID());
        if (refund.getRefundNo() != null) {
            p.put("refund_no", refund.getRefundNo());
        } else {
            p.put("refund_trace_no", refund.getRefundTraceNo());
            p.put("refund_time", refund.getRefundRequestTime());
        }
        return client.post("/v3/queryrefund", p);
    }
    
    /**
     * 退款结果回写 —— **退款行的唯一状态写入口**,条件更新,受影响行数 = 1 才算真的流转。
     *
     * ★ 为什么幂等闸门必须落在退款行而不是订单行:
     *   订单侧的放行集合是 {SUCCESS, CANCELED, CLOSED, PARTIAL_REFUNDED},
     *   而 PARTIAL_REFUNDED **必须**保持可写(否则第二笔部分退款就落不了库)。
     *   于是同一笔退款的回调重复到达时,订单侧条件更新照样命中 →
     *   refunded_amount 被**重复累加**,几轮之后一张 100 元的单会被算成"退满"。
     *   把闸门放在退款行上:CREATE/REFUNDING → SUCCESS/FAIL 只可能发生一次,
     *   重复回调命中 0 行,调用方据此跳过订单侧累加。
     *
     * @return true = 本次真的流转了;false = 重复回调或状态不符
     */
    public boolean applyNotifyResult(LsyRefundEntity refund, String resultCode,
                                     String resultMsg, String newStatus) {
        LsyRefundEntity patch = new LsyRefundEntity();
        patch.setStatus(newStatus);
        patch.setUpstreamResultCode(resultCode);
        patch.setUpstreamResultMsg(resultMsg);
        patch.setFinishTime(OffsetDateTime.now());
        patch.setUpdatedTime(OffsetDateTime.now());
    
        UpdateWrapper<LsyRefundEntity> w = new UpdateWrapper<>();
        w.eq("id", refund.getId()).in("status", LsyRefundStatus.PAY_WRITE_FROM);
        if (refundMapper.update(patch, w) != 1) {
            log.info("退款回写未命中(重复回调): outRefundNo={}, currentStatus={}",
                    refund.getOutRefundNo(), refund.getStatus());
            return false;
        }
        refund.setStatus(newStatus);
        refund.setUpstreamResultCode(resultCode);
        refund.setUpstreamResultMsg(resultMsg);
        return true;
    }
    
    private String str(Object o) {
        return o == null ? null : String.valueOf(o);
    }
    
    private Long parseLong(Object o) {
        if (o == null) {
            return null;
        }
        try {
            return Long.parseLong(String.valueOf(o));
        } catch (NumberFormatException e) {
            return null;
        }
    }
    }
    

类头部需要补的 import:

import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
import java.time.OffsetDateTime;
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyRefundServiceTest
    

Expected: Tests run: 13, Failures: 0, Errors: 0 (7 个原有用例 + applyNotifyResult 的「流转成功返回 true」与「重复回调返回 false」两个

  • 本轮新增 4 个:refund_insertsAsRefundingFirst_neverDirectlyTerminal、 refund_syncSuccess_settlesOrderSideImmediately、refund_rejectsWhenAnotherRefundIsPending、 refund_pendingGuard_passesWhenPreviousRefundIsTerminal。 它们是防重复退款的回归,不能因为"难写"省掉 —— 见本节顶部的 ★ 说明)

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 11: 调用方 —— 上游商户号解析

三种下单都需要把企业映射成 mch_no。本任务很小但为 Task 12 铺路,单独成一个测试周期。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

Interfaces:

  • Consumes: LsyMerchantMapper
  • Produces:

    • LsyMerchantEntity LsyMerchantService.requireByEnterprise(Long tenantId, String enterpriseId)
    • LsyMerchantEntity LsyMerchantService.requireByMchNo(String mchNo)
    • PageResult<LsyMerchantEntity> LsyMerchantService.page(int pageNo, int pageSize, String keyword)
    • LsyMerchantEntity LsyMerchantService.save(LsyMerchantEntity entity)
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.common.exception.BusinessException;
    import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
    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 static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.when;
    
    @ExtendWith(MockitoExtension.class)
    class LsyMerchantServiceTest {
    
    @Mock private LsyMerchantMapper merchantMapper;
    private LsyMerchantService service;
    
    @BeforeEach
    void setUp() {
        service = new LsyMerchantService(merchantMapper);
    }
    
    @Test
    void requireByEnterprise_found_returnsEntity() {
        LsyMerchantEntity m = new LsyMerchantEntity();
        m.setMchNo("Y52211090004");
        when(merchantMapper.selectOne(any())).thenReturn(m);
    
        assertEquals("Y52211090004", service.requireByEnterprise(1L, "ENT1").getMchNo());
    }
    
    @Test
    void requireByEnterprise_missing_throws404() {
        when(merchantMapper.selectOne(any())).thenReturn(null);
    
        BusinessException ex = assertThrows(BusinessException.class,
                () -> service.requireByEnterprise(1L, "ENT1"));
        assertEquals(404, ex.getCode());
    }
    
    @Test
    void requireByEnterprise_blankEnterpriseId_throws() {
        assertThrows(BusinessException.class, () -> service.requireByEnterprise(1L, "  "));
    }
    
    @Test
    void requireByMchNo_missing_throws404() {
        when(merchantMapper.selectOne(any())).thenReturn(null);
        assertThrows(BusinessException.class, () -> service.requireByMchNo("NOT_EXIST"));
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyMerchantServiceTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyMerchantService

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.StrUtil;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.payment.platform.common.exception.BusinessException;
    import com.payment.platform.common.response.PageResult;
    import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    import org.springframework.transaction.annotation.Transactional;
    
    import java.util.stream.Collectors;
    
    /**
    * 企业 ↔ 乐收赢商户映射服务。
    * A 阶段由管理端手工维护(测试商户 Y52211090004);
    * B 阶段(进件)成功后自动写入,reviewStatus 接乐收赢审核通知。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyMerchantService {
    
    private final LsyMerchantMapper merchantMapper;
    
    public LsyMerchantEntity requireByEnterprise(Long tenantId, String enterpriseId) {
        if (StrUtil.isBlank(enterpriseId)) {
            throw new BusinessException(400, "enterprise_id 不能为空");
        }
        LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
                .eq(LsyMerchantEntity::getTenantId, tenantId)
                .eq(LsyMerchantEntity::getEnterpriseId, enterpriseId)
                .last("LIMIT 1"));
        if (m == null) {
            throw new BusinessException(404,
                    "该企业未配置乐收赢商户号,请先在「乐收赢商户映射」中配置: " + enterpriseId);
        }
        return m;
    }
    
    public LsyMerchantEntity requireByMchNo(String mchNo) {
        if (StrUtil.isBlank(mchNo)) {
            throw new BusinessException(400, "mch_no 不能为空");
        }
        LsyMerchantEntity m = merchantMapper.selectOne(new LambdaQueryWrapper<LsyMerchantEntity>()
                .eq(LsyMerchantEntity::getMchNo, mchNo)
                .last("LIMIT 1"));
        if (m == null) {
            throw new BusinessException(404, "乐收赢商户号不存在: " + mchNo);
        }
        return m;
    }
    
    public PageResult<LsyMerchantEntity> page(int pageNo, int pageSize, String keyword) {
        Page<LsyMerchantEntity> page = new Page<>(pageNo, pageSize);
        LambdaQueryWrapper<LsyMerchantEntity> w = new LambdaQueryWrapper<>();
        if (StrUtil.isNotBlank(keyword)) {
            w.and(q -> q.like(LsyMerchantEntity::getMchNo, keyword)
                    .or().like(LsyMerchantEntity::getMerchantName, keyword)
                    .or().like(LsyMerchantEntity::getEnterpriseId, keyword));
        }
        w.orderByDesc(LsyMerchantEntity::getCreatedTime);
        Page<LsyMerchantEntity> r = merchantMapper.selectPage(page, w);
        return PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords());
    }
    
    @Transactional
    public LsyMerchantEntity save(LsyMerchantEntity entity) {
        if (StrUtil.isBlank(entity.getMchNo())) {
            throw new BusinessException(400, "乐收赢商户号不能为空");
        }
        if (StrUtil.isBlank(entity.getEnterpriseId())) {
            throw new BusinessException(400, "企业 ID 不能为空");
        }
        if (StrUtil.isBlank(entity.getReviewStatus())) {
            entity.setReviewStatus("APPROVED");   // A 阶段手工维护,默认视为已审核
        }
        if (StrUtil.isBlank(entity.getStatus())) {
            entity.setStatus("ENABLED");
        }
        if (entity.getId() == null) {
            merchantMapper.insert(entity);
        } else {
            merchantMapper.updateById(entity);
        }
        return merchantMapper.selectById(entity.getId());
    }
    
    @Transactional
    public void delete(Long id) {
        if (merchantMapper.selectById(id) == null) {
            throw new BusinessException(404, "商户映射不存在");
        }
        merchantMapper.deleteById(id);
    }
    }
    
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyMerchantServiceTest
    

Expected: Tests run: 4, Failures: 0, Errors: 0

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 12: 回调接收 —— 验签 + 幂等 + 状态回写

本任务是安全边界。入口免登录,MD5 验签是唯一身份证明。

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingNotifyController.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.java

Interfaces:

  • Consumes:
    • LeshouyingSigner.verify(params, key)
    • LeshouyingSensitive.mask(String)(Task 2) —— 落 raw_body 与 log.error 出参报文时都要过它。 用 mask 不要用 maskByKey(与 Task 4 出站日志的选择相反,判据见 Task 2 Produces): 回调报文来自外部,键名的写法我们控制不了,只按键名匹配实测漏 9 种形态; mask 多遮的那点噪音(14 位 pay_time 之类)在这里无所谓,漏掉付款码代价大得多。 不要在本类里另写一份正则。
    • LsyOrderService.payPatch(Map,Long)(Task 6 的包内可见方法)
    • LsyOrderService.applyPayResult(order, newStatus, patch)
    • LsyRefundService.applyNotifyResult(refund, resultCode, resultMsg, newStatus)(Task 10)
    • LsyRefundService.settleOrderAfterRefund(order, refundAmount)(Task 10) —— 退款成功后订单侧的收敛只走这个。不要在 handleRefundNotify 里自己算 (refunded + fee) >= total ? REFUNDED : PARTIAL_REFUNDED:同步成功那条路径 (LsyRefundService.createRefund)要得出同一个答案,两份副本必然漂移。
    • LsyNotifyLogMapper / LsyOrderMapper / LsyRefundMapper / LsyCallbackService
  • Produces:
    • 构造器(8 参):LsyNotifyService(LeshouyingSigner, LeshouyingProperties, LsyOrderMapper, LsyRefundMapper, LsyNotifyLogMapper, LsyCallbackService, LsyOrderService, LsyRefundService)
    • String LsyNotifyService.handlePayNotify(Map<String,Object> params, String rawBody) —— 返回应答 JSON 字符串
    • String LsyNotifyService.handleRefundNotify(Map<String,Object> params, String rawBody)

本类不做字段映射:支付回调直接复用 LsyOrderService.payPatch(同包,无需 public)。 两份 payPatch 会各自漂移,字段映射只能有一处。 报文怎么解释、状态怎么写,全部下沉到 LsyOrderService / LsyRefundService; 本类只负责:验签 → 找单 → 委派 → 触发下游回调 → 记审计。

★ raw_body 必须脱敏(本任务的安全要点,别漏)

本计划从 Task 3 起就守着一条纪律:付款码 auth_code 是能直接发起支付的一次性凭据,等同卡号,不落任何持久层。 这条纪律在结构化存储上是成立的 —— auth_code 不出现在任何实体字段里,也不出现在 V1.8 建表脚本里。 但 pay_lsy_notify_log.raw_body TEXT 是唯一一条例外通道:它把整包回调整文原样落库, 而回调整文里有什么字段,不由我们决定。审计表的存在恰恰让它成了一个无人在场的入口 —— 没有实体字段、没有 DDL 约束、没有校验,谁都不会在 review 时想起它。 日志也是持久层,所以本任务里 raw_body、log.error 两处都要过 LeshouyingSensitive.mask。

三条硬约束:

  1. 只脱敏字符串,绝不改 params map。 signer.verify(params, key) 吃的是原文; 动了 map 就是改坏验签 —— 而验签是绝不能跳过的。
  2. 先 mask 再 StrUtil.brief,顺序不能反。 brief 是「头 + 尾」缩略而非前缀截断, 一个 18 位付款码跨在截断点上会被切成两段 6 位,值层要求连续 12 位、两段都接不住 —— 等于原样泄漏。
  3. 不要删 raw_body 这一列。 审计价值是真的(报文结构、return_code、trade_no 都在), 被遮的只有长数字。因噎废食地删列是过度反应。

对应测试:logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields,已完整写在 Step 1 里。

⚠️ 这里原先只放了一段带省略号(// ……按正常路径让 handler 走到 logNotify 并落库)的草稿, 并且写着「Step 1 里必须有」—— 但 Step 1 里从来没有这条测试:草稿不可编译,谁也没把它搬进去。 结果是一条承重的安全约束(付款码不得进审计表)只剩下一段注释,没有任何执行点。 现已补为可运行的真实测试,并顺带修掉了草稿里的一个错:草稿用 verify(notifyLogMapper).insert(...),而走完全程的回调会写两行审计(RECEIVED + APPLIED), times(1) 的默认语义会因「调用次数过多」直接红 —— 真实版用 atLeastOnce() + getAllValues() 断言「每一行」都干净。

教训(记在本模块):注释里写「必须有」而正文里没有,等于没有; 计划自身的这类「草稿 + 省略号」是最容易被当成已完成的东西。

  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    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.util.LinkedHashMap;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyNotifyServiceTest {
    
    private static final String KEY = "1c4d83f0c3c24a7d9fcaa1fed2682076";
    
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyRefundMapper refundMapper;
    @Mock private LsyNotifyLogMapper notifyLogMapper;
    
    @Mock private LsyOrderService orderService;
    @Mock private LsyRefundService refundService;
    
    private LeshouyingSigner signer;
    private LsyNotifyService service;
    private LsyCallbackService callbackService;
    
    @BeforeEach
    void setUp() {
        signer = new LeshouyingSigner();
        LeshouyingProperties props = new LeshouyingProperties();
        props.setKey(KEY);
        callbackService = mock(LsyCallbackService.class);
        service = new LsyNotifyService(signer, props, orderMapper, refundMapper,
                notifyLogMapper, callbackService, orderService, refundService);
    }
    
    private Map<String, Object> signed(Map<String, Object> body) {
        Map<String, Object> m = new LinkedHashMap<>(body);
        m.put("sign", signer.sign(m, KEY));
        return m;
    }
    
    private Map<String, Object> payBody(String resultCode) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", resultCode);
        m.put("pay_trace_no", "PT001");
        m.put("trade_no", "T001");
        m.put("trade_type", "1");
        m.put("total_amount", "100");
        m.put("mch_no", "Y52211090004");
        return m;
    }
    
    private LsyOrderEntity order(String status) {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setTenantId(1L);
        o.setOutTradeNo("OUT001");
        o.setPayTraceNo("PT001");
        o.setPayScene("BARCODE");
        o.setTotalAmount(100L);
        o.setRefundedAmount(0L);
        o.setStatus(status);
        return o;
    }
    
    @Test
    void badSignature_isRejected_businessStateUntouched() {
        Map<String, Object> body = payBody("PAY_SUCCESS");
        body.put("sign", "0000000000000000000000000000dead");   // 伪造签名
    
        String resp = service.handlePayNotify(body, "{}");
    
        assertTrue(resp.contains("FAIL"), "验签失败必须返回非 SUCCESS");
        verify(orderService, never()).applyPayResult(any(), any(), any());
        verify(callbackService, never()).notifyOrder(any());
    
        // 必须留审计记录
        var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
        verify(notifyLogMapper).insert(captor.capture());
        assertEquals("FAIL", captor.getValue().getVerifyStatus());
    }
    
    @Test
    void missingSignature_isRejected() {
        Map<String, Object> body = payBody("PAY_SUCCESS");   // 无 sign
    
        String resp = service.handlePayNotify(body, "{}");
    
        assertTrue(resp.contains("FAIL"));
        verify(orderService, never()).applyPayResult(any(), any(), any());
    }
    
    @Test
    void validSignature_paySuccess_updatesOrderAndNotifiesDownstream() {
        when(orderMapper.selectOne(any())).thenReturn(order("PAYING"));
        when(orderService.applyPayResult(any(), any(), any())).thenReturn(true);
    
        String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}");
    
        assertTrue(resp.contains("SUCCESS"));
        verify(orderService).applyPayResult(any(), eq("SUCCESS"), any());
        verify(callbackService).notifyOrder(any());
    }
    
    @Test
    void duplicateNotification_doesNotNotifyDownstreamTwice() {
        when(orderMapper.selectOne(any())).thenReturn(order("SUCCESS"));
        when(orderService.applyPayResult(any(), any(), any())).thenReturn(false);  // 未命中 = 重复
    
        String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}");
    
        assertTrue(resp.contains("SUCCESS"), "重复通知仍要应答成功,否则上游会一直重推");
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void unknownOrder_stillAcksSuccess_toAvoidInfiniteRetry() {
        when(orderMapper.selectOne(any())).thenReturn(null);
    
        String resp = service.handlePayNotify(signed(payBody("PAY_SUCCESS")), "{}");
    
        assertTrue(resp.contains("SUCCESS"),
                "查不到订单也要应答成功并告警,不能让上游无限重推");
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void payFail_updatesOrderToFail() {
        when(orderMapper.selectOne(any())).thenReturn(order("PAYING"));
        when(orderService.applyPayResult(any(), any(), any())).thenReturn(true);
    
        service.handlePayNotify(signed(payBody("PAY_FAIL")), "{}");
    
        verify(orderService).applyPayResult(any(), eq("FAIL"), any());
    }
    
    @Test
    void payNotify_unknownResultCode_doesNotWriteTerminalStatus() {
        // ★ 未知码既不是成功也不是失败。落 FAIL 会让订单退出 PENDING,
        //   而 LsyReconcileScheduler.reconcilePendingOrders 只扫 status = 'PAYING',
        //   这笔已收到钱的支付就再也不会被对账捞起 —— 缺口固化。
        //   用 "SUCCESS"(通用成功码)作未知码:它是真实可能出现的值(上游某端点即回此值),
        //   且三个业务谓词对它全 false。
        when(orderMapper.selectOne(any())).thenReturn(order("PAYING"));
    
        service.handlePayNotify(signed(payBody("SUCCESS")), "{}");
    
        // 不写任何终态、不通知下游;但仍应答上游 SUCCESS(免无限重推)
        verify(orderService, never()).applyPayResult(any(), any(), any());
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void duplicateRefundCallback_doesNotDoubleCountRefundedAmount() {
        // 幂等闸门在退款行上:applyNotifyResult 返回 false ⇒ 订单侧不得再累加
        LsyRefundEntity refund = new LsyRefundEntity();
        refund.setId(9L);
        refund.setOrderId(1L);
        refund.setRefundNo("R001");
        refund.setOutRefundNo("RF001");
        refund.setRefundFee(30L);
        refund.setStatus("SUCCESS");                 // 已被上一次回调写成终态
        when(refundMapper.selectOne(any())).thenReturn(refund);
        when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(false);
    
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("return_code", "SUCCESS");
        body.put("result_code", "TRADE_REFUND");
        body.put("refund_no", "R001");
        body.put("trade_no", "T001");
        body.put("mch_no", "Y52211090004");
    
        service.handleRefundNotify(signed(body), "{}");
    
        verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void firstRefundCallback_appliesAndNotifiesDownstream() {
        LsyRefundEntity refund = new LsyRefundEntity();
        refund.setId(9L);
        refund.setOrderId(1L);
        refund.setRefundNo("R001");
        refund.setOutRefundNo("RF001");
        refund.setRefundFee(30L);
        refund.setStatus("REFUNDING");
        when(refundMapper.selectOne(any())).thenReturn(refund);
        when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(true);
        when(orderMapper.selectById(1L)).thenReturn(order("SUCCESS"));
        when(refundService.settleOrderAfterRefund(any(), anyLong())).thenReturn(true);
    
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("return_code", "SUCCESS");
        body.put("result_code", "TRADE_REFUND");
        body.put("refund_no", "R001");
        body.put("trade_no", "T001");
        body.put("mch_no", "Y52211090004");
    
        service.handleRefundNotify(signed(body), "{}");
    
        // 订单侧收敛不在这里算状态 —— 交给 LsyRefundService.settleOrderAfterRefund,
        // 同步成功路径调的是同一个方法。这里只验证"调了、传对了金额"。
        verify(refundService).settleOrderAfterRefund(any(), eq(30L));
        verify(callbackService).notifyOrder(any());
    }
    
    @Test
    void refundNotify_unknownResultCode_doesNotWriteTerminalFail() {
        // ★ 未知码落 FAIL 会同时放开两道防重复退款的锁:
        //   FAIL 不在 LsyRefundStatus.PENDING 里 ⇒ 部分唯一索引 uk_lsy_refund_one_pending
        //   与 createRefund 的在途校验都放行 ⇒ 可以再退一次 = 重复退款。
        //   退款侧没有兜底对账任务,这个错误没有自动恢复路径。
        //   用 "SUCCESS" 作未知码:它是真实可能出现的值(与本模块其它端点的一致词表),
        //   且不等同于退款侧的两个合法码。
        LsyRefundEntity refund = new LsyRefundEntity();
        refund.setId(9L);
        refund.setOrderId(1L);
        refund.setRefundNo("R001");
        refund.setOutRefundNo("RF001");
        refund.setRefundFee(30L);
        refund.setStatus("REFUNDING");
        when(refundMapper.selectOne(any())).thenReturn(refund);
    
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("return_code", "SUCCESS");
        body.put("result_code", "SUCCESS");
        body.put("refund_no", "R001");
        body.put("trade_no", "T001");
        body.put("mch_no", "Y52211090004");
    
        String resp = service.handleRefundNotify(signed(body), "{}");
    
        // 不落终态(退款行留在 REFUNDING)、不动订单、不通知下游;但仍应答上游 SUCCESS
        // (OK 是 LsyNotifyService 的 private 常量,测试类访问不到,故写字面量)
        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
        verify(refundService, never()).applyNotifyResult(any(), any(), any(), any());
        verify(refundService, never()).settleOrderAfterRefund(any(), anyLong());
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void signIsExcludedFromVerification_recomputeSucceeds() {
        Map<String, Object> body = signed(payBody("PAY_SUCCESS"));
        assertTrue(signer.verify(body, KEY));
        // 篡改 amount 后验签必须失败
        body.put("total_amount", "999999");
        assertFalse(signer.verify(body, KEY));
    }
    
    @Test
    void logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields() {
        // 回调报文里**故意带上** auth_code —— 不是因为我们知道上游会回显(未确认),
        // 而是因为「上游不会回显」是个我们控制不了的假设,防线不能建在它上面。
        // 这条测试是「**写入侧真的调了脱敏器**」的唯一执行点:
        // Task 2 覆盖的是脱敏器本身,覆盖不了 LsyNotifyService 有没有把它接上 ——
        // 把 logNotify 里的 LeshouyingSensitive.mask(...) 去掉,Task 2 的 26 条照样全绿。
        String rawBody = "{\"return_code\":\"SUCCESS\",\"result_code\":\"PAY_SUCCESS\","
                + "\"trade_no\":\"200001102F46230306\",\"auth_code\":\"134573607383541001\","
                + "\"pay_trace_no\":\"T20260921001\"}";
        Map<String, Object> params = signed(payBody("PAY_SUCCESS"));
        params.put("pay_trace_no", "T20260921001");
        params.put("trade_no", "200001102F46230306");
        when(orderMapper.selectOne(any())).thenReturn(order("PAYING"));
        when(orderService.applyPayResult(any(), any(), any())).thenReturn(true);
    
        service.handlePayNotify(params, rawBody);
    
        // ★ 一条走完全程的支付回调会写**两行**审计(RECEIVED + APPLIED)。
        //   所以这里**不能**写 `verify(notifyLogMapper).insert(captor.capture())` ——
        //   它等价于 times(1),会因为「调用次数过多」直接红,于是有人会去把断言改成 times(2),
        //   而真正要断言的是「**每一行**都不含付款码」。用 atLeastOnce + getAllValues 才是对的:
        //   漏遮任何一条都会被抓到,将来增多写入点也不会把测试写死。
        var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
        assertFalse(captor.getAllValues().isEmpty());
    
        for (LsyNotifyLogEntity row : captor.getAllValues()) {
            String stored = row.getRawBody();
            assertNotNull(stored);
            assertFalse(stored.contains("134573607383541001"), "付款码不得落库");
            assertTrue(stored.contains("***"), "长数字必须被遮成 ***");
        }
        // 审计价值靠保留:trade_no 这类排障字段不能被一起遮掉
        assertTrue(captor.getAllValues().stream()
                        .anyMatch(r -> r.getRawBody().contains("200001102F46230306")),
                "trade_no 必须留着,否则审计表失去意义");
    }
    
    @Test
    void refundNotify_unknownRefundNo_stillAcksSuccess_andLeavesAuditRow() {
        // 「上游说退款回调来了,我们这边却没有这笔退款单」是最需要留痕的一类事件:
        // 它意味着要么上游退了别人家的钱,要么我们的退款单没落库。
        // 只写 log.error 会随日志轮转消失,事后无从查证,所以必须落审计行。
        when(refundMapper.selectOne(any())).thenReturn(null);
    
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("return_code", "SUCCESS");
        body.put("result_code", "TRADE_REFUND");
        body.put("refund_no", "R_NOT_EXIST");
        body.put("mch_no", "Y52211090004");
    
        String resp = service.handleRefundNotify(signed(body), "{}");
    
        // 仍应答 SUCCESS:与支付侧 ORDER_NOT_FOUND 同一取舍,免得上游无限重推
        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
        assertTrue(captor.getAllValues().stream()
                        .anyMatch(r -> "REFUND_NOT_FOUND".equals(r.getProcessStatus())),
                "找不到退款单必须留下审计行,否则事后无从查证");
    
        // ★ 这条同时钉住一个**裁定**:归属不明的回调,tenant_id 必须是 null ——
        //   不是 0、也不是随便挑一个租户。pay_lsy_notify_log.tenant_id 可空正是为它留的。
        //   (为什么不写 0:拦截器对无认证上下文返回的正是 LongValue(0),
        //     若真写成 0,管理端按 tenant_id=<本租户> 查时照样查不到,且与"真的属于 0 号租户"无法区分。)
        assertTrue(captor.getAllValues().stream().allMatch(r -> r.getTenantId() == null),
                "归属不明的回调不得被赋予任何租户");
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyNotifyServiceTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyNotifyService

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.StrUtil;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingSigner;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
    import com.payment.platform.module.payment.leshouying.enums.LsyRefundStatus;
    import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
    import com.payment.platform.module.payment.leshouying.client.LeshouyingSensitive;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Service;
    
    import java.util.Map;
    
    /**
    * 乐收赢回调处理。
    *
    * 【安全边界】入口免登录,MD5 验签是唯一身份证明。
    * 本类不提供任何跳过/禁用验签的路径。验签不通过 → 不写任何业务状态。
    *
    * 【幂等】靠 LsyOrderService 的条件更新受影响行数判定,
    * 不用唯一约束 —— 那会误伤「撤销后到达的退款回调」。
    *
    * 【应答】必须返回 {"return_code":"SUCCESS","return_msg":"成功"},
    * 否则乐收赢会持续重推。查不到订单也应答成功(记 CRITICAL 告警),
    * 避免无效报文把上游拖入无限重试。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyNotifyService {
    
    private static final String OK = "{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}";
    private static final String BAD = "{\"return_code\":\"FAIL\",\"return_msg\":\"验签失败\"}";
    
    private final LeshouyingSigner signer;
    private final LeshouyingProperties props;
    private final LsyOrderMapper orderMapper;
    private final LsyRefundMapper refundMapper;
    private final LsyNotifyLogMapper notifyLogMapper;
    private final LsyCallbackService callbackService;
    private final LsyOrderService orderService;
    private final LsyRefundService refundService;
    
    public String handlePayNotify(Map<String, Object> params, String rawBody) {
        if (!signer.verify(params, props.getKey())) {
            // ★ 顺序是「先脱敏、后 brief」,不能反。
            //   StrUtil.brief 是「头 + 尾」缩略(不是前缀截断),一个 18 位付款码若正好跨在截断点上,
            //   会被切成两段 6 位数字 —— 值层要求连续 12 位,两段都接不住,等于原样泄漏。
            //   先 brief 再遮 = 把完整凭据拆成遮不住的小块。先遮再 brief 才安全。
            // ★ 顺序的第二段:最外层还要过 flatten(CWE-117 压行终止符),不能反。
            //   brief 只做「头+尾」缩略、mask 只遮长数字(其自身 javadoc 逐字:「mask 只遮长数字」),
            //   **两者都不压换行** ⇒ 未验签报文里的 \n 会原样进日志,伪造出一条不存在的日志行。
            //   flatten 必须在**最外层**:它若不压,里面两步都挡不住行终止符;
            //   而 flatten 声明**不封顶长度**,所以 brief 的 500 上限要在它**里面**。
            log.error("乐收赢交易回调验签失败! payTraceNo={}, raw={}",
                    LsyLogSanitizer.flatten(params.get("pay_trace_no")),
                    LsyLogSanitizer.flatten(StrUtil.brief(LeshouyingSensitive.mask(rawBody), 500)));
            logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败");
            return BAD;
        }
        // 此刻还没查订单,归属未知 → tenantId 传 null(审计行只对超管可见,见 logNotify 的 javadoc)
        logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
    
        String payTraceNo = str(params.get("pay_trace_no"));
        LsyOrderEntity order = orderMapper.selectOne(new LambdaQueryWrapper<LsyOrderEntity>()
                .eq(LsyOrderEntity::getPayTraceNo, payTraceNo)
                .last("LIMIT 1"));
        if (order == null) {
            log.error("乐收赢回调找不到订单,应答成功避免无限重推: payTraceNo={}", payTraceNo);
            logNotify(params, rawBody, null, "SUCCESS", "ORDER_NOT_FOUND", "订单不存在");
            return OK;
        }
    
        String resultCode = str(params.get("result_code"));
        // ★ 必须三分支。**不能**写成 "PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL":
        //   那个写法把未知码判成 FAIL,与 Task 6/7/8/9 的同步路径**自相矛盾**
        //   (那三处都写明「未知码既不成功也不失败,不写终态」),而且后果是真钱方向 ——
        //   把一笔**已经收到钱**的支付写成终态 FAIL,订单就退出了 PENDING,
        //   而 LsyReconcileScheduler.reconcilePendingOrders 只扫 status = 'PAYING',
        //   于是这笔再也不会被对账捞起,缺口就此固化。这正是本模块开头点名要防的那件事。
        //
        //   为什么不能假设「回调只会回这两个值」:同一份上游文档里,
        //   cancelorder / closeorder 的正文把成功写成 `PAY_SUCEESS`(少一个 S),
        //   /v3/trade/* 的返回示例用的又是**小写** `success`。
        //   文档明确没有解决这个拼写冲突,所以「上游不会回别的值」是个不该依赖的假设。
        String newStatus;
        if ("PAY_SUCCESS".equals(resultCode)) {
            newStatus = "SUCCESS";
        } else if ("PAY_FAIL".equals(resultCode)) {
            newStatus = "FAIL";
        } else {
            newStatus = null;
        }
        if (newStatus == null) {
            // 上游报文原文一律过 flatten(CWE-117):return_msg 可能把调用方传的
            // out_trade_no 原样回显,所以它不只是「上游的话」,也间接是**不可信文本**。
            log.error("乐收赢支付回调返回未知 result_code,不写终态、订单留在 PENDING 待对账: "
                            + "payTraceNo={}, return_code={}, result_code={}, return_msg={}",
                    payTraceNo, LsyLogSanitizer.flatten(params.get("return_code")),
                    LsyLogSanitizer.flatten(resultCode),
                    LsyLogSanitizer.flatten(params.get("return_msg")));
            // 仍应答 SUCCESS:免得上游无限重推(与上面 ORDER_NOT_FOUND 同一取舍)。
            // 兜底不是靠重推,是靠订单留在 PAYING 被对账轮询捞起。
            logNotify(params, rawBody, order.getTenantId(), "SUCCESS", "UNKNOWN_RESULT_CODE", null);
            return OK;
        }
        // 状态写入口在 LsyOrderService,本类不自己拼条件更新
        // payPatch 是 LsyOrderService 的包内方法 —— 回调报文与查询报文同形,复用同一份映射
        boolean changed = orderService.applyPayResult(
                order, newStatus, orderService.payPatch(params, null));
        // ── 0 行更新有两种**截然不同**的含义,必须分开,不能都叫 DUPLICATE ──────────
        //   ① 本单**已认过这笔成功收款**(状态 ∈ LsyOrderStatus.PAY_RECORDED:
        //      SUCCESS / PARTIAL_REFUNDED / REFUNDED),上游重推 → 真·重复,幂等正常工作。
        //      ★ 「已退款」也属于①:款确实记过,重复的成功回调只是重推,不是差异。
        //   ② 本单落在**其余终态**(EXPIRED / CLOSED / CANCELED / FAIL)而上游说
        //      「支付成功」→ 这**不是**重复,是**账实不符**:钱在上游收了,我们账上
        //      却写着「已过期 / 已关闭」,且这笔单已退出全部对账集合。
        //
        //   ② 的根因见 spec §14 待确认第 1 项:H5 **没有关单接口**,本地置 EXPIRED 时
        //   上游链接**仍然有效**(spec 风险表已记),用户 30 分钟后照样能付成功。
        //   把 ② 记成 DUPLICATE 的后果是:**唯一能证明这笔差异存在的证据,被记成了
        //   一条正常的重复回调**,与 spec 风险表承诺的「EXPIRED 状态可追溯」直接矛盾。
        //
        //   v1 只**暴露**、不自动纠正:受控的 EXPIRED→SUCCESS 纠正通道要等联调确认
        //   「上游对已过期订单的回调语义」后再定(§14 第 1 项)。在此之前,
        //   基于未经确认的假设去自动改写资金终态,比不纠正更危险。
        //
        //   判据 = PENDING 的补集 **去掉「已认过成功收款」的状态**(LsyOrderStatus.PAY_RECORDED)。
        //   ★ 不能只用 PENDING 的补集:那样 SUCCESS / PARTIAL_REFUNDED / REFUNDED 也会命中 ——
        //     而这三个状态**恰恰是「款已记过」**,上游重推成功回调时它们三个的合取项全为真
        //     ⇒ **正常的重复回调被误报成「账实不符」**,顶层告警每次重推都响,
        //     真差异来的时候没人看(告警疲劳)。
        //   为什么用「补集再去掉」而不是**列举**那四个异常态:见 LsyOrderStatus.PAY_RECORDED 的 javadoc
        //     (新增终态忘了归类时,补集写法多报一次响的告警,列举写法静默漏掉 —— 选响的那一侧)。
        boolean latePaymentAfterTerminal = !changed
                && "SUCCESS".equals(newStatus)
                && !LsyOrderStatus.PENDING.contains(order.getStatus())
                && !LsyOrderStatus.PAY_RECORDED.contains(order.getStatus());
        if (latePaymentAfterTerminal) {
            log.error("★ 账实不符:上游回调称支付成功,但本单已落终态 {},本次回写被拒;"
                            + "钱可能已在上游收取,需人工核对!outTradeNo={}, tradeNo={}",
                    order.getStatus(), order.getOutTradeNo(),
                    LsyLogSanitizer.flatten(params.get("trade_no")));
        }
        if (changed) {
            callbackService.notifyOrder(order);
        }
        logNotify(params, rawBody, order.getTenantId(), "SUCCESS",
                changed ? "APPLIED"
                        : (latePaymentAfterTerminal ? "LATE_PAYMENT_AFTER_TERMINAL" : "DUPLICATE"),
                null);
        return OK;
    }
    
    public String handleRefundNotify(Map<String, Object> params, String rawBody) {
        if (!signer.verify(params, props.getKey())) {
            // 与支付侧同一条顺序纪律:mask(仅遮长数字)→ brief(仅限长 500)→ flatten(压行终止符,最外层)。
            // 前两步都不压换行,缺了 flatten 这一层,未验签报文里的 \n 就能伪造日志行。
            log.error("乐收赢退款回调验签失败! raw={}",
                    LsyLogSanitizer.flatten(StrUtil.brief(LeshouyingSensitive.mask(rawBody), 500)));
            logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败");
            return BAD;
        }
        // 此刻还没查退款单,归属未知 → tenantId 传 null
        logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
    
        String refundNo = str(params.get("refund_no"));
        LsyRefundEntity refund = refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
                .eq(LsyRefundEntity::getRefundNo, refundNo)
                .last("LIMIT 1"));
        if (refund == null) {
            log.error("乐收赢退款回调找不到退款单: refundNo={}", refundNo);
            // 与支付侧 ORDER_NOT_FOUND 对齐,补上审计行:
            // 「上游说退款回调来了,我们这边没有这笔退款单」是最需要留痕的一类事件,
            // 只写 log.error 会随日志轮转消失,事后无从查证。
            logNotify(params, rawBody, null, "SUCCESS", "REFUND_NOT_FOUND", "退款单不存在");
            return OK;
        }
    
        String resultCode = str(params.get("result_code"));
        // ★ 退款回调是**另一套词表**:TRADE_REFUND / TRADE_REFUND_FAIL,
        //   不是支付侧的 PAY_SUCCESS / PAY_FAIL(实测:上游「退款异步回调」页)。
        //   同样必须三分支。写成二元的 "TRADE_REFUND".equals(rc) ? SUCCESS : FAIL,
        //   未知码就落 FAIL —— 而 FAIL **不在** LsyRefundStatus.PENDING 里,
        //   于是一笔其实**已经退成功**的退款会同时绕过两道防重复退款的锁:
        //     (a) 部分唯一索引 uk_lsy_refund_one_pending(WHERE status IN ('CREATED','REFUNDING'))
        //     (b) LsyRefundService.createRefund 的在途前置校验
        //   → 可以再退一次 = **重复退款,真钱**。
        //   而且退款侧**没有**兜底对账任务(LsyReconcileScheduler 只扫订单表),
        //   这个错误没有任何自动恢复路径。
        //   留在 REFUNDING 则三道锁继续生效,代价是这笔要人工核对 —— 方向是对的:
        //   「卡住等人工」远好于「静默判失败后放行重退」。
        String outcome;
        if ("TRADE_REFUND".equals(resultCode)) {
            outcome = "SUCCESS";
        } else if ("TRADE_REFUND_FAIL".equals(resultCode)) {
            outcome = "FAIL";
        } else {
            outcome = null;
        }
        if (outcome == null) {
            log.error("乐收赢退款回调返回未知 result_code,不落终态、留在 REFUNDING 待人工核对: "
                            + "refundNo={}, return_code={}, result_code={}, return_msg={}",
                    refundNo, LsyLogSanitizer.flatten(params.get("return_code")),
                    LsyLogSanitizer.flatten(resultCode),
                    LsyLogSanitizer.flatten(params.get("return_msg")));
            logNotify(params, rawBody, refund.getTenantId(), "SUCCESS", "UNKNOWN_RESULT_CODE", null);
            return OK;
        }
        boolean success = "SUCCESS".equals(outcome);
    
        // ★ 幂等闸门落在退款行上(applyNotifyResult 的条件更新),不是订单行。
        //   PARTIAL_REFUNDED 必须保持可写,所以订单侧的条件更新挡不住重复回调 ——
        //   同一笔退款重推两次会把 refunded_amount 累加两次。
        boolean applied = refundService.applyNotifyResult(
                refund, resultCode, str(params.get("return_msg")), outcome);
        if (!applied) {
            // ── 0 行更新有两种**截然不同**的含义,与支付侧的 ①② **同构**(见 handlePayNotify)──
            //   ① 本行已记过这笔退款成功(状态 = SUCCESS)→ 真·重复,幂等正常工作。
            //   ② 上游称退款**成功**,而我们记的是 **FAIL** → 这**不是**重复,是**账实不符**:
            //      钱可能已经退给用户,我们账上却写着「退款失败」。
            //      退款侧**没有任何兜底对账**(LsyReconcileScheduler 只扫订单表,见 LsyRefundStatus 的 javadoc),
            //      把 ② 记成 DUPLICATE 的后果比支付侧更重:**审计表里连一个能描述这种情形的取值都不会出现**,
            //      事后人工翻审计也翻不出来。
            //   ★ 可达性在**本类内**闭合:`TRADE_REFUND_FAIL` 分支(outcome="FAIL")就把本行写成 FAIL,
            //     之后到达的成功回调必然命中这里。
            //   判据与支付侧同形 —— 用**补集**而不是列举 FAIL:新增终态忘了归类时会**多报一次响的告警**,
            //   而不是静默漏掉。(此处没有再抽一个具名常量:退款侧「已记过成功」只有 SUCCESS 一个取值,
            //   单元素集合是噪音,而补集写法本身已经给出了「响的那一侧」这个性质。)
            //   `refund.getStatus()` 读的是**更新前**的状态 —— applyNotifyResult 的 return false 在 setter 之前。
            boolean lateRefundAfterTerminal = success
                    && !LsyRefundStatus.PENDING.contains(refund.getStatus())
                    && !"SUCCESS".equals(refund.getStatus());
            if (lateRefundAfterTerminal) {
                log.error("★ 账实不符:上游回调称退款成功,但本行已落终态 {},本次回写被拒;"
                                + "钱可能已退给用户,需人工核对!outRefundNo={}, refundNo={}",
                        refund.getStatus(), refund.getOutRefundNo(),
                        LsyLogSanitizer.flatten(refundNo));
            } else {
                log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refundNo));
            }
            logNotify(params, rawBody, refund.getTenantId(), "SUCCESS",
                    lateRefundAfterTerminal ? "LATE_REFUND_AFTER_TERMINAL" : "DUPLICATE", null);
            return OK;
        }
    
        if (success) {
            LsyOrderEntity order = orderMapper.selectById(refund.getOrderId());
            if (order != null) {
                long fee = refund.getRefundFee() == null ? 0L : refund.getRefundFee();
                // 状态推导不在这里算 —— 同步成功的另一条路径(LsyRefundService.createRefund)
                // 也要得出同一个 REFUNDED/PARTIAL_REFUNDED。两份副本迟早漂移,
                // 而"退满了没有"算错的后果是可退余额算错,也就是能多退钱。
                if (refundService.settleOrderAfterRefund(order, fee)) {
                    callbackService.notifyOrder(order);
                }
            }
        }
        logNotify(params, rawBody, refund.getTenantId(), "SUCCESS", "APPLIED", null);
        return OK;
    }
    
    /**
     * 写一条回调审计行。
     *
     * @param tenantId **这笔回调能归属到哪个租户**;归属不明时传 null
     *                 (验签失败 / 找不到订单 / 退款单不存在 —— 都属于平台级事件,本就不属于任何租户)。
     *
     * ★ 为什么必须**显式**传,而不能指望自动填充:
     *   `AutoFillMetaObjectHandler.insertFill` 只在 SecurityContext 里**有认证用户**时才填 `tenantId`,
     *   而回调是免登录入口(SecurityConfig 白名单)—— 不显式传就恒为 null。
     *   而 `pay_lsy_notify_log` 在 `TenantInnerInterceptor.CONDITIONAL_TABLES` 里,
     *   管理端(**已认证**)查这张表时会被追加 `tenant_id = <本租户>`(该文件 `:117-119`),
     *   于是所有 `tenant_id` 为 null 的回调行**一行都查不出来** ——
     *   Task 17 的 `/{outTradeNo}/notify-logs` 会**永远返回空列表**:接口看着是活的,其实读不到东西。
     *   这类「不报错、只是空」的失效正是本模块反复要防的形态,所以在写入侧一次性修掉。
     *
     * ★ null 行只对超管可见是有意为之:`TenantInnerInterceptor.ignoreTable` 对超管直接 `return true`
     *   (该文件 `:101-105`),平台运营能查到全部归属不明的事件,普通租户查不到 —— 正是想要的语义。
     */
    private void logNotify(Map<String, Object> params, String rawBody, Long tenantId,
                           String verifyStatus, String processStatus, String errorMsg) {
        try {
            LsyNotifyLogEntity logEntity = new LsyNotifyLogEntity();
            logEntity.setTenantId(tenantId);
            logEntity.setTradeType(str(params.get("trade_type")));
            logEntity.setTradeNo(str(params.get("trade_no")));
            logEntity.setPayTraceNo(str(params.get("pay_trace_no")));
            // ★ 只脱敏**字符串**,绝不能碰上面的 params map —— signer.verify(params, key)
            //   要的是原文,动了 map 就等于把验签改坏(而验签是绝不能跳过的)。
            // ★ raw_body 是 pay_lsy_* 里**唯一**能让任意字段进库而不在实体、DDL、校验里留下痕迹的通道:
            //   auth_code 在全部实体字段和建表脚本里都不存在,所以结构化存储是干净的 ——
            //   干净的代价就是这条通道没人替它把关。回调报文将来回显什么字段不由我们决定,
            //   所以脱敏放在写入口,而不是靠「上游应该不会回显」的假设。
            // ★ 审计价值靠保留——保留的是报文结构、return_code/result_msg、trade_no 这些排障要读的字段;
            //   被遮掉的只有长数字。不要因为「遮了就不完整」而删掉 raw_body 这一列。
            logEntity.setRawBody(LeshouyingSensitive.mask(rawBody));
            logEntity.setVerifyStatus(verifyStatus);
            logEntity.setProcessStatus(processStatus);
            logEntity.setErrorMsg(errorMsg);
            notifyLogMapper.insert(logEntity);
        } catch (Exception e) {
            log.error("回调审计日志写入失败", e);
        }
    }
    
    private String str(Object o) {
        return o == null ? null : String.valueOf(o);
    }
    }
    
  • [ ] Step 4: 实现 LeshouyingNotifyController

    package com.payment.platform.module.payment.leshouying.controller;
    
    import com.payment.platform.module.payment.leshouying.service.LsyNotifyService;
    import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.MediaType;
    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;
    
    import java.util.Map;
    
    /**
    * 乐收赢回调入口 —— 免登录(须加入 SecurityConfig 白名单)。
    *
    * 上游约定:POST + Content-Type: application/json,参数含 sign。
    * 必须应答 {"return_code":"SUCCESS","return_msg":"成功"},否则上游持续重推。
    *
    * 【安全】入口无认证,签名是唯一身份证明。不提供任何跳过验签的路径。
    */
    @Slf4j
    @RestController
    @RequestMapping("/payment/leshouying/notify")
    @RequiredArgsConstructor
    public class LeshouyingNotifyController {
    
    private final LsyNotifyService notifyService;
    
    @PostMapping(value = "/pay", produces = MediaType.APPLICATION_JSON_VALUE)
    public String payNotify(@RequestBody Map<String, Object> body) {
        // ★ 本入口**免登录且验签尚未发生** ⇒ 这两个实参是完全不可信的,必须过 flatten(CWE-117)。
        //   攻击者连签名都不用伪造,直接 POST 一个带 \n 的 pay_trace_no 就能往日志里插行。
        log.info("收到乐收赢交易回调: payTraceNo={}, resultCode={}",
                LsyLogSanitizer.flatten(body.get("pay_trace_no")),
                LsyLogSanitizer.flatten(body.get("result_code")));
        return notifyService.handlePayNotify(body, toJson(body));
    }
    
    @PostMapping(value = "/refund", produces = MediaType.APPLICATION_JSON_VALUE)
    public String refundNotify(@RequestBody Map<String, Object> body) {
        // 同 payNotify:免登录 + 未验签 ⇒ 同样不可信,同样 flatten。
        log.info("收到乐收赢退款回调: refundNo={}, resultCode={}",
                LsyLogSanitizer.flatten(body.get("refund_no")),
                LsyLogSanitizer.flatten(body.get("result_code")));
        return notifyService.handleRefundNotify(body, toJson(body));
    }
    
    private String toJson(Map<String, Object> body) {
        try {
            return cn.hutool.json.JSONUtil.toJsonStr(body);
        } catch (Exception e) {
            return String.valueOf(body);
        }
    }
    }
    
  • [ ] Step 5: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyNotifyServiceTest
    

Expected: Tests run: 13, Failures: 0, Errors: 0

(13 = 基础 9 条 + payNotify_unknownResultCode_doesNotWriteTerminalStatus + refundNotify_unknownResultCode_doesNotWriteTerminalFail + logNotify_masksAuthCodeInRawBody_butKeepsDiagnosticFields + refundNotify_unknownRefundNo_stillAcksSuccess_andLeavesAuditRow。

中间那两条不是覆盖率凑数,各自是某条规则的唯一执行点:

  • 两条 unknown-code:回调体不经 parser,result_code 是在本类里裸比较的, 任何 grep 都看不见 x ? A : B 这种形态,只有测试能挡住它被改回二元分派。
  • 脱敏那条:Task 2 覆盖的是脱敏器本身,覆盖不了 LsyNotifyService 有没有把脱敏器接上。 去掉 logNotify 里的 LeshouyingSensitive.mask(...),Task 2 的 26 条照样全绿 —— 而 pay_lsy_notify_log.raw_body 是本模块唯一能把任意字段写进库的通道。
  • 找不到退款单那条:钉住新增的 REFUND_NOT_FOUND 审计行,且同时钉住 「归属不明的回调 tenant_id 必须是 null」这个裁定。)

  • [ ] Step 6: 确认工作区状态(不提交)

    git status --short
    

Task 13: 下游回调推送 LsyCallbackService

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackService.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCallbackServiceTest.java

Interfaces:

  • Consumes: LsyOrderMapper, LsyRefundMapper, OpenConfMapper, TenantApiKeyMapper, RestTemplate, LeshouyingProperties(构造器共 6 参)
  • Produces:

    • void LsyCallbackService.notifyOrder(LsyOrderEntity order)
    • void LsyCallbackService.notifyRefund(LsyRefundEntity refund)
    • void LsyCallbackService.retryFailed() —— 供对账调度器调用
  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.service;
    
    import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
    import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
    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 org.springframework.http.HttpEntity;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    import org.springframework.web.client.RestTemplate;
    
    import static org.mockito.ArgumentMatchers.*;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsyCallbackServiceTest {
    
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyRefundMapper refundMapper;
    @Mock private TenantApiKeyMapper apiKeyMapper;
    @Mock private OpenConfMapper confMapper;
    @Mock private RestTemplate restTemplate;
    
    private LsyCallbackService service;
    
    @BeforeEach
    void setUp() {
        LeshouyingProperties props = new LeshouyingProperties();
        props.setDownstreamNotifyMaxRetries(8);
        service = new LsyCallbackService(orderMapper, refundMapper, apiKeyMapper,
                confMapper, restTemplate, props);
    }
    
    private LsyOrderEntity order(String notifyUrl) {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setTenantId(1L);
        o.setOutTradeNo("OUT001");
        o.setStatus("SUCCESS");
        o.setTotalAmount(100L);
        o.setNotifyStatus("PENDING");
        o.setNotifyCount(0);
        o.setNotifyUrl(notifyUrl);
        return o;
    }
    
    @Test
    void notifyOrder_usesUrlSnapshotFromOrder_notCurrentConfig() {
        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
    
        service.notifyOrder(order("https://merchant.example.com/cb"));
    
        verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"),
                any(HttpEntity.class), eq(String.class));
        verify(apiKeyMapper, never()).selectOne(any());
    }
    
    @Test
    void notifyOrder_success_setsNotifyStatusSuccess() {
        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
    
        service.notifyOrder(order("https://merchant.example.com/cb"));
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper).updateById(captor.capture());
        org.junit.jupiter.api.Assertions.assertEquals("SUCCESS", captor.getValue().getNotifyStatus());
    }
    
    @Test
    void notifyOrder_fallsBackToApiKeyReturnUrl_whenOrderSnapshotMissing() {
        TenantApiKeyEntity key = new TenantApiKeyEntity();
        key.setReturnUrl("https://fallback.example.com/cb");
        when(apiKeyMapper.selectById(2L)).thenReturn(key);
        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
                .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
    
        LsyOrderEntity o = order(null);
        o.setApiKeyId(2L);
        service.notifyOrder(o);
    
        verify(restTemplate).postForEntity(eq("https://fallback.example.com/cb"),
                any(HttpEntity.class), eq(String.class));
    }
    
    @Test
    void notifyOrder_noUrlAnywhere_recordsFailWithoutThrowing() {
        LsyOrderEntity o = order(null);
        service.notifyOrder(o);   // 不应抛异常
    
        verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
    }
    
    @Test
    void notifyOrder_httpError_setsFailStatus_andIncrementsCount() {
        when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
                .thenThrow(new RuntimeException("connection refused"));
    
        service.notifyOrder(order("https://merchant.example.com/cb"));
    
        var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper).updateById(captor.capture());
        org.junit.jupiter.api.Assertions.assertEquals("FAIL", captor.getValue().getNotifyStatus());
        org.junit.jupiter.api.Assertions.assertEquals(1, captor.getValue().getNotifyCount());
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCallbackServiceTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyCallbackService

    package com.payment.platform.module.payment.leshouying.service;
    
    import cn.hutool.core.util.StrUtil;
    import cn.hutool.json.JSONUtil;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.common.utils.SnowflakeIdGenerator;
    import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
    import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
    import com.payment.platform.module.payment.openapi.entity.OpenConfEntity;
    import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.http.HttpEntity;
    import org.springframework.http.HttpHeaders;
    import org.springframework.http.MediaType;
    import org.springframework.http.ResponseEntity;
    import org.springframework.stereotype.Service;
    import org.springframework.util.LinkedMultiValueMap;
    import org.springframework.util.MultiValueMap;
    import org.springframework.web.client.RestTemplate;
    
    import java.time.OffsetDateTime;
    import java.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    
    /**
    * 下游回调推送。
    *
    * 报文格式沿用现有 OpenapiService.sendCallback(表单 POST: notify_id/timestamp/content)。
    * 差异:本类**持久化推送状态**,失败由 LsyReconcileScheduler 重试,
    * 而不是像现有实现那样失败即丢。
    *
    * 回调地址优先级:订单快照的 notify_url → api_key.return_url → open_conf.return_url。
    * 优先快照是为了避免商户改配置导致历史订单回调漂移。
    */
    @Slf4j
    @Service
    @RequiredArgsConstructor
    public class LsyCallbackService {
    
    private final LsyOrderMapper orderMapper;
    private final LsyRefundMapper refundMapper;
    private final TenantApiKeyMapper apiKeyMapper;
    private final OpenConfMapper confMapper;
    private final RestTemplate restTemplate;
    private final LeshouyingProperties props;
    
    public void notifyOrder(LsyOrderEntity order) {
        String url = resolveUrl(order.getNotifyUrl(), order.getApiKeyId(), order.getTenantId());
        if (StrUtil.isBlank(url)) {
            log.warn("下游回调地址缺失,跳过: outTradeNo={}", order.getOutTradeNo());
            return;
        }
    
        Map<String, Object> content = new LinkedHashMap<>();
        content.put("out_trade_no", order.getOutTradeNo());
        content.put("trade_no", order.getTradeNo());
        content.put("status", order.getStatus());
        content.put("total_amount", order.getTotalAmount());
        content.put("receipt_amount", order.getReceiptAmount());
        content.put("refunded_amount", order.getRefundedAmount());
        content.put("pay_scene", order.getPayScene());
        content.put("attach", order.getAttach());
    
        boolean ok = push(url, content, "订单回调");
        recordOrderNotifyResult(order, ok);
    }
    
    public void notifyRefund(LsyRefundEntity refund) {
        String url = resolveUrl(null, null, refund.getTenantId());
        if (StrUtil.isBlank(url)) {
            return;
        }
        Map<String, Object> content = new LinkedHashMap<>();
        content.put("out_trade_no", refund.getOutTradeNo());
        content.put("out_refund_no", refund.getOutRefundNo());
        content.put("refund_no", refund.getRefundNo());
        content.put("status", refund.getStatus());
        content.put("refund_fee", refund.getRefundFee());
    
        boolean ok = push(url, content, "退款回调");
        LsyRefundEntity patch = new LsyRefundEntity();
        patch.setId(refund.getId());
        patch.setNotifyStatus(ok ? "SUCCESS" : "FAIL");
        patch.setNotifyCount((refund.getNotifyCount() == null ? 0 : refund.getNotifyCount()) + 1);
        patch.setLastNotifyTime(OffsetDateTime.now());
        refundMapper.updateById(patch);
    }
    
    /** 供对账调度器重试所有推送失败的回调 */
    public void retryFailed() {
        List<LsyOrderEntity> orders = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        .eq(LsyOrderEntity::getNotifyStatus, "FAIL")
                        .lt(LsyOrderEntity::getNotifyCount, props.getDownstreamNotifyMaxRetries())
                        .orderByAsc(LsyOrderEntity::getLastNotifyTime)
                        .last("LIMIT 50"));
        for (LsyOrderEntity o : orders) {
            try {
                notifyOrder(o);
            } catch (Exception e) {
                log.error("下游回调重试异常: outTradeNo={}", o.getOutTradeNo(), e);
            }
        }
    
        List<LsyRefundEntity> refunds = refundMapper.selectList(
                new LambdaQueryWrapper<LsyRefundEntity>()
                        .eq(LsyRefundEntity::getNotifyStatus, "FAIL")
                        .lt(LsyRefundEntity::getNotifyCount, props.getDownstreamNotifyMaxRetries())
                        .orderByAsc(LsyRefundEntity::getLastNotifyTime)
                        .last("LIMIT 50"));
        for (LsyRefundEntity r : refunds) {
            try {
                notifyRefund(r);
            } catch (Exception e) {
                log.error("下游退款回调重试异常: outRefundNo={}", r.getOutRefundNo(), e);
            }
        }
    }
    
    /** 地址优先级:订单快照 → API Key → 开放配置 */
    private String resolveUrl(String snapshotUrl, Long apiKeyId, Long tenantId) {
        if (StrUtil.isNotBlank(snapshotUrl)) {
            return snapshotUrl;
        }
        if (apiKeyId != null) {
            TenantApiKeyEntity key = apiKeyMapper.selectById(apiKeyId);
            if (key != null && StrUtil.isNotBlank(key.getReturnUrl())) {
                return key.getReturnUrl();
            }
        }
        if (tenantId != null) {
            OpenConfEntity conf = confMapper.selectOne(new LambdaQueryWrapper<OpenConfEntity>()
                    .eq(OpenConfEntity::getTenantId, tenantId)
                    .orderByDesc(OpenConfEntity::getUpdatedTime)
                    .last("LIMIT 1"));
            if (conf != null && StrUtil.isNotBlank(conf.getReturnUrl())) {
                return conf.getReturnUrl();
            }
        }
        return null;
    }
    
    /** 单次推送,不重试(重试交给调度器,避免占用回调线程) */
    private boolean push(String url, Map<String, Object> content, String label) {
        try {
            MultiValueMap<String, Object> form = new LinkedMultiValueMap<>();
            form.add("notify_id", "n" + SnowflakeIdGenerator.nextIdStr());
            form.add("timestamp", String.valueOf(System.currentTimeMillis()));
            form.add("content", JSONUtil.toJsonStr(content));
    
            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
            ResponseEntity<String> resp = restTemplate.postForEntity(
                    url, new HttpEntity<>(form, headers), String.class);
            boolean ok = resp.getStatusCode().is2xxSuccessful();
            log.info("{}推送{}: url={}, status={}", label, ok ? "成功" : "失败",
                    url, resp.getStatusCode());
            return ok;
        } catch (Exception e) {
            log.warn("{}推送异常: url={}, error={}", label, url, e.getMessage());
            return false;
        }
    }
    
    private void recordOrderNotifyResult(LsyOrderEntity order, boolean ok) {
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setId(order.getId());
        patch.setNotifyStatus(ok ? "SUCCESS" : "FAIL");
        patch.setNotifyCount((order.getNotifyCount() == null ? 0 : order.getNotifyCount()) + 1);
        patch.setLastNotifyTime(OffsetDateTime.now());
        orderMapper.updateById(patch);
    
        order.setNotifyStatus(patch.getNotifyStatus());
        order.setNotifyCount(patch.getNotifyCount());
    }
    }
    
  • [ ] Step 4: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsyCallbackServiceTest
    

Expected: Tests run: 5, Failures: 0, Errors: 0

  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 14: 三个共享文件必改点

漏改任意一处都会造成静默故障。 这是本任务存在的唯一理由。

Files:

  • Modify: java/src/main/java/com/payment/platform/core/security/TenantApiKeyAuthFilter.java:92-96
  • Modify: java/src/main/java/com/payment/platform/core/security/SecurityConfig.java:38-70
  • Modify: java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java:53-76
  • Test: java/src/test/java/com/payment/platform/core/security/LeshouyingWiringTest.java

Interfaces:

  • Consumes: 无
  • Produces: 无(纯配置改动)

  • [ ] Step 1: 写失败的测试

    package com.payment.platform.core.security;
    
    import com.payment.platform.core.tenant.TenantInnerInterceptor;
    import org.junit.jupiter.api.Test;
    import org.springframework.mock.web.MockHttpServletRequest;
    
    import java.lang.reflect.Field;
    import java.util.Set;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    /**
    * 防止三处共享文件漏改的守卫测试。
    * 漏改的后果分别是:完全无鉴权 / 回调 403 / 回调回写静默失效。
    */
    class LeshouyingWiringTest {
    
    private static final String[] OPENAPI_PATHS = {
            "/payment/openapi/leshouying/barcode/pay",
            "/payment/openapi/leshouying/qrcode/create",
            "/payment/openapi/leshouying/h5/create",
            "/payment/openapi/leshouying/order/query",
            "/payment/openapi/leshouying/order/close",
            "/payment/openapi/leshouying/refund",
            "/payment/openapi/leshouying/refund/query",
    };
    
    @Test
    void openapiPaths_areInFilterWhitelist_otherwiseNoAuthAtAll() throws Exception {
        Field f = TenantApiKeyAuthFilter.class.getDeclaredField("OPENAPI_ACCOUNT_PATHS");
        f.setAccessible(true);
        String[] paths = (String[]) f.get(null);
    
        for (String expected : OPENAPI_PATHS) {
            assertTrue(java.util.Arrays.asList(paths).contains(expected),
                    "路径未加入 OPENAPI_ACCOUNT_PATHS 会导致过滤器整体跳过 → 完全无鉴权: " + expected);
        }
    }
    
    @Test
    void shouldNotFilter_returnsFalseForLeshouyingPaths() throws Exception {
        TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
        for (String path : OPENAPI_PATHS) {
            MockHttpServletRequest req = new MockHttpServletRequest("POST", path);
            req.setServletPath(path);
            assertFalse(filter.shouldNotFilter(req),
                    "shouldNotFilter 必须返回 false(=需要过滤): " + path);
        }
    }
    
    @Test
    void notifyPath_isNotFilteredByApiKeyFilter_butStillWhitelistedInSecurityConfig() throws Exception {
        TenantApiKeyAuthFilter filter = new TenantApiKeyAuthFilter(null, null, null);
        MockHttpServletRequest req = new MockHttpServletRequest(
                "POST", "/payment/leshouying/notify/pay");
        req.setServletPath("/payment/leshouying/notify/pay");
        assertTrue(filter.shouldNotFilter(req), "回调入口不走 ApiKey 认证,走 MD5 验签");
    
        // SecurityConfig 白名单必须放行,否则被挡成 403
        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
        f.setAccessible(true);
        String[] whiteList = (String[]) f.get(null);
        assertTrue(java.util.Arrays.asList(whiteList).contains("/payment/leshouying/notify/**"),
                "回调地址未加入 SecurityConfig.WHITE_LIST 会被 Spring Security 挡成 403");
    }
    
    @Test
    void conditionalTables_containAllLeshouyingTables() throws Exception {
        Field f = TenantInnerInterceptor.class.getDeclaredField("CONDITIONAL_TABLES");
        f.setAccessible(true);
        @SuppressWarnings("unchecked")
        Set<String> tables = (Set<String>) f.get(null);
    
        for (String t : new String[]{"pay_lsy_order", "pay_lsy_refund",
                                     "pay_lsy_merchant", "pay_lsy_notify_log"}) {
            assertTrue(tables.contains(t),
                    "未加入 CONDITIONAL_TABLES → 无认证场景查询被追加 tenant_id=0 → 回调回写静默失效: " + t);
        }
    }
    }
    
  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingWiringTest
    

Expected: 4 个测试全部 FAIL。

  • Step 3: 改 TenantApiKeyAuthFilter

把 OPENAPI_ACCOUNT_PATHS(第 92-96 行)改为:

    /** 仅对 OpenAPI 账户接口生效 (对应 Python OpenapiRouter 中使用 TenantApiKeyAuth 的路由) */
    private static final String[] OPENAPI_ACCOUNT_PATHS = {
            "/payment/openapi/account/transfer",
            "/payment/openapi/account/transfer/query",
            "/payment/openapi/account/balance/query",
            // 乐收赢支付通道 —— 必须逐条列出。
            // shouldNotFilter 用的是 path.equals(p) 精确匹配,不支持通配符;
            // 漏列一条 = 该路径过滤器整体跳过 = 完全无鉴权。
            "/payment/openapi/leshouying/barcode/pay",
            "/payment/openapi/leshouying/qrcode/create",
            "/payment/openapi/leshouying/h5/create",
            "/payment/openapi/leshouying/order/query",
            "/payment/openapi/leshouying/order/close",
            "/payment/openapi/leshouying/refund",
            "/payment/openapi/leshouying/refund/query",
    };
  • Step 4: 改 SecurityConfig

在 WHITE_LIST 中 /payment/aplipay/auth 之后插入:

            // 乐收赢回调入口(免登录;身份由 MD5 验签保证,不通过则不写任何业务状态)
            "/payment/leshouying/notify/**",
  • Step 5: 改 TenantInnerInterceptor

pay_batch_authorize 是当前最后一项、末尾没有逗号。把那一行改成下面这样(先补逗号,再追加四项):

            "pay_batch_authorize",   // 制单授权(授权签约通知 + OAuth openid 回调均无认证上下文;
                                     //   无认证时按 out_biz_no(雪花ID全局唯一)反查,无跨租户风险;
                                     //   此前不在条件表导致通知回写查询恒带 tenant_id=0 而失效,
                                     //   实际靠前端 queryAuthorize 兜底 — 加入后通知回写真正生效)
            "pay_lsy_order",         // 乐收赢订单(回调/调度无认证上下文;同类表漏加会导致回写静默失效)
            "pay_lsy_refund",        // 乐收赢退款单(同上)
            "pay_lsy_merchant",      // 乐收赢商户映射(回调按 mch_no 反查)
            "pay_lsy_notify_log"     // 乐收赢回调审计(回调入口无认证上下文)
    );
  • [ ] Step 6: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingWiringTest
    

Expected: Tests run: 4, Failures: 0, Errors: 0

  • [ ] Step 7: 跑全量测试确认没有破坏既有功能

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test
    

Expected: 全绿。若有既有测试因 OPENAPI_ACCOUNT_PATHS 长度变化而失败,同步更新其断言。

  • [ ] Step 8: 确认工作区状态(不提交)

    git status --short
    

Task 15: 下游 OpenAPI 控制器(7 端点)

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/dto/*.java(7 个请求 DTO + 1 个响应 VO。响应 VO 只有 LsyPayResultVO 一个;orderQuery / orderClose / refund / refundQuery 四个端点返回 Result<Map<String,Object>>,不为它们建 VO)
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/util/LsyBizNoPattern.java(Step 1 的 @Pattern 唯一字符集来源,与 LsyLogSanitizer 逐字一致)
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java
  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java(Step 5 追加租户限定的 requireByOutRefundNo)
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

Interfaces:

  • Consumes: LsyMerchantService.requireByEnterprise, LsyOrderService.create{Barcode,Qr,H5}Order, LsyQueryService.queryOrder, LsyCancelService.closeOrder, LsyRefundService.createRefund/queryRefund, LsyCallbackService.notifyOrder
  • Produces:
    • 7 个 POST 端点,路径见 Task 14 的 OPENAPI_PATHS
    • 构造器(7 参):LeshouyingOpenController(LsyOrderService, LsyQueryService, LsyCancelService, LsyRefundService, LsyMerchantService, LsyOrderMapper, HttpServletRequest) —— HttpServletRequest 是构造注入的请求作用域代理,写法与既有 OpenapiController 一致
    • 私有查找方法一律以 Long tenantId 作首参:findExisting(Long, String)、requireOrder(Long, String, String)
    • LsyRefundService.requireByOutRefundNo(Long tenantId, String outRefundNo)

★ 本任务的核心安全约束:显式租户隔离

★★ 2026-09-21 lead 就地更正:原文「ApiKey 请求 = 无认证上下文」是假的(实测 TenantApiKeyAuthFilter:222-231 确实放了带 tenantId 的 LoginUser,拦截器会正常追加) 但显式条件仍必须写 —— 它是第二道防线,唯一真正需要它的场景是「某路径漏加进白名单 ⇒ 无认证对象 ⇒ 拦截器整条跳过」。所以下游每个按业务单号的查找都必须自己拼 .eq(LsyOrderEntity::getTenantId, tenantId),且 tenantId 只能来自 request.getAttribute("openapi.tenantId"),不得读请求体里的租户字段;漏掉即跨租户越权。

  • Step 1: 写请求/响应 DTO

★ 租户可控的业务单号一律在入口加 @Pattern(本步 7 个 DTO 共 9 个字段)

为什么在入口堵,而不是在每个日志位点套 flatten:outTradeNo / outRefundNo 会进 log.warn / log.error(关单、撤销、下单失败等位点,Task 9 的 撤销失败 就是一处)。 下游租户可控 = 请求体里可以直接塞换行符,把一条日志伪造成多条(CWE-117)。 模块里已有 LsyLogSanitizer.flatten,但它只在部分位点被调用 (LsyOrderService:438 调用它的理由正是「租户可控」)。只在部分位点堵 = 换个位点就绕过。 堵住 7 个 @Valid @RequestBody 端点,即一次覆盖全部日志位点 —— 这一点是本计划对 Task 9 的 B-1/B-3 书面承诺的前提,漏掉本步那两条承诺就不成立。

字符集不许另起一套:必须与 leshouying/util/LsyLogSanitizer(模块内唯一一份 CWE-117 字符集) 逐字一致 —— \r、\n、U+2028、U+2029、U+0085、VT(U+000B)、FF(U+000C)。 该 javadoc 已经写明它自己有两处拷贝并强调「改动其一必须同步另一处」, 所以本步不再新增 9 份拷贝:正则与 message 都从 LsyBizNoPattern 取。

正则用 \x{...} 写,绝不用 \uXXXX:LsyLogSanitizer 的 javadoc 明确警告 —— javac 在词法分析之前就还原 \uXXXX(JLS §3.3),源码行会被直接拆断、整模块编译失败。 写成 "\\x{000A}"(Java 字符串)→ 正则收到 \x{000A},完全绕开这条规则。

上界 {0,64} 的两点理由:① 与列定义对齐(out_trade_no VARCHAR(64), 超长从「DB 报错 → 500」变成「入口 400」);② 用 {0,64} 而非 {1,64}, 是因为 LsyOrderQueryDTO / LsyRefundQueryDTO 的字段选填,空串必须放行 (必填由既有的 @NotBlank 管,两者不冲突)。

LsyBizNoPattern.java(先建它,下面 7 个 DTO 都要引用):

package com.payment.platform.module.payment.leshouying.util;

/**
 * 租户可控业务单号(`out_trade_no` / `out_refund_no`)的**入口**校验字符集。
 *
 * 【与 {@link LsyLogSanitizer} 的关系 —— 这是一份**引用**,不是拷贝】
 * 本类只声明「哪些字符不许出现」,与 `LsyLogSanitizer.flatten` 压掉的集合**逐字一致**:
 * \r、\n、U+2028、U+2029、U+0085、VT(U+000B)、FF(U+000C)。
 * 那 7 个字符为什么是这 7 个,理由全在 `LsyLogSanitizer` 的 javadoc 里,此处不重复。
 * **改一处必须改另一处** —— 这正是 `@Pattern` 不内联正则、而要从这里取常量的原因。
 *
 * 【为什么写成 `\x{...}` 而不是 `\uXXXX`】
 * javac 在词法分析**之前**就还原 `\uXXXX`(JLS §3.3)。源码里直接写 `\u000A`
 * 会把源码行拆断、整模块编译失败;`\x{000A}` 是**正则引擎**层面的转义,
 * 不经过 javac 那条规则。
 *
 * 【上界 64】与 `pay_lsy_order.out_trade_no VARCHAR(64)` / `pay_lsy_refund` 两列一致。
 * 用 `{0,64}` 而非 `{1,64}`:两个查询 DTO 的字段是选填的,空串要放行。
 */
public final class LsyBizNoPattern {

    /** 不含任何行终止符 / 控制字符,长度 0-64。供 `@Pattern(regexp = ...)` 用。 */
    public static final String REGEXP =
            "^[^\\x{000A}\\x{000D}\\x{0085}\\x{2028}\\x{2029}\\x{000B}\\x{000C}]{0,64}$";

    /** 与 {@link #REGEXP} 配套的提示语;各字段自行前缀字段名,如 `"out_trade_no " + MESSAGE`。 */
    public static final String MESSAGE = "不能超过 64 位,且不得含换行符或控制字符";

    private LsyBizNoPattern() {
    }
}

LsyBarcodePayDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import lombok.Data;

@Data
public class LsyBarcodePayDTO {
    @NotBlank(message = "out_trade_no 不能为空")
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
    @NotBlank(message = "enterprise_id 不能为空")
    private String enterpriseId;
    /** 用户付款码,18 位纯数字,以 10-15 开头 */
    @NotBlank(message = "auth_code 不能为空")
    private String authCode;
    /** 金额,单位分 */
    @NotNull @Positive(message = "total_amount 必须大于 0")
    private Long totalAmount;
    /**
     * 000 自动识别 / 300 支付宝 / 400 微信,默认 000。
     *
     * ★ **选填**(null 由 `createBarcodeOrder` 里的 `StrUtil.blankToDefault(payType, AUTO)` 兜成 000),
     *   所以这里**不加** `@NotBlank`。但 `@Pattern` 要加 —— 理由同 `LsyH5CreateDTO.payType`:
     *   不支持的码会让 `LsyPayType.ofRequired` 抛 `IllegalArgumentException`,
     *   而 `GlobalExceptionHandler` 没有这个异常的映射,会落到兜底 → **HTTP 500** 而不是 400。
     *   `@Pattern` 对 **null 视为通过**,正好与「选填」一致,不会把默认值这条路堵死。
     */
    @Pattern(regexp = "^(000|300|400)$", message = "pay_type 只支持 000 自动识别 / 300 支付宝 / 400 微信")
    private String payType;
    private String orderBody;
    private String attach;
    private String notifyUrl;
}

LsyQrCreateDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import lombok.Data;

@Data
public class LsyQrCreateDTO {
    @NotBlank
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
    @NotBlank private String enterpriseId;
    @NotNull @Positive private Long totalAmount;
    private String orderBody;
    private String attach;
    private String notifyUrl;
    /** 有效期(分钟),默认 15,上限 4320(3天,上游硬限制) */
    private Integer timeExpire;
}

LsyH5CreateDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import lombok.Data;

@Data
public class LsyH5CreateDTO {
    @NotBlank
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
    @NotBlank private String enterpriseId;
    @NotNull @Positive private Long totalAmount;
    /**
     * 必填:300 支付宝 / 400 微信。H5 不支持自动识别。
     *
     * ★ `@Pattern` 不是装饰,是**错误码的归属**:`LsyPayType.ofRequired` 对不支持的码抛
     *   `IllegalArgumentException`,而 `GlobalExceptionHandler` **没有** `IllegalArgumentException`
     *   的映射(只有 `BusinessException` / `AccessDeniedException` / 参数校验 / 兜底 `Exception`),
     *   于是它会落到 `:53` 的兜底 → **HTTP 500「服务器内部错误」**。
     *   调用方传个 `pay_type=600` 或拼错一个字母,就得到 500 —— 既误导调用方(像是我们崩了),
     *   又会把告警噪音灌进 5xx 指标,把真正的服务端故障埋掉。
     *   用 `@Pattern` 拦在入口,走的是既有 `:35` 的 `MethodArgumentNotValidException` → **400**,
     *   与其它参数校验同一个出口、同一套 message 风格。
     */
    @NotBlank(message = "pay_type 必填(300 支付宝 / 400 微信)")
    @Pattern(regexp = "^(300|400)$", message = "pay_type 只支持 300 支付宝 / 400 微信")
    private String payType;
    private String orderBody;
    private String attach;
    private String notifyUrl;
}

LsyOrderQueryDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

@Data
public class LsyOrderQueryDTO {
    /** 与 tradeNo 二选一 */
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
    private String tradeNo;
}

LsyOrderCloseDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

@Data
public class LsyOrderCloseDTO {
    @NotBlank
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
}

LsyRefundDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import lombok.Data;

@Data
public class LsyRefundDTO {
    @NotBlank
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
    @NotBlank
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_refund_no " + LsyBizNoPattern.MESSAGE)
    private String outRefundNo;
    @NotNull @Positive private Long refundFee;
}

LsyRefundQueryDTO.java:

package com.payment.platform.module.payment.leshouying.dto;

import com.payment.platform.module.payment.leshouying.util.LsyBizNoPattern;
import jakarta.validation.constraints.Pattern;
import lombok.Data;

@Data
public class LsyRefundQueryDTO {
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_refund_no " + LsyBizNoPattern.MESSAGE)
    private String outRefundNo;
    @Pattern(regexp = LsyBizNoPattern.REGEXP, message = "out_trade_no " + LsyBizNoPattern.MESSAGE)
    private String outTradeNo;
}

LsyPayResultVO.java(下单统一响应):

package com.payment.platform.module.payment.leshouying.dto;

import lombok.Data;

@Data
public class LsyPayResultVO {
    private String outTradeNo;
    /**
     * CREATED / PAYING / SUCCESS / FAIL / CANCELED / CLOSED / EXPIRED / PARTIAL_REFUNDED / REFUNDED。
     *
     * ★ 主扫与 H5 的**下单**成功落 `PAYING`(不是 SUCCESS)—— 下单成功 ≠ 收款成功;
     *   只有被扫(用户当场出示付款码、我们当场提交)的 PAY_SUCCESS 才是真实支付结果,落 SUCCESS。
     * ★ 主扫/H5 下单响应里的 `receiptAmount` 在用户付款前为 **null** —— 上游没给就是未知,不替它编数字。
     */
    private String status;
    private String tradeNo;
    private Long totalAmount;
    private Long receiptAmount;
    /** 主扫:二维码链接;H5:跳转链接。被扫为空 */
    private String codeUrl;
    private String message;
}
  • [ ] Step 2: 写失败的测试

    package com.payment.platform.module.payment.leshouying.controller.open;
    
    import com.payment.platform.module.payment.leshouying.dto.*;
    import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.service.*;
    import com.payment.platform.common.exception.BusinessException;
    import jakarta.validation.Validation;
    import jakarta.validation.Validator;
    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 org.springframework.mock.web.MockHttpServletRequest;
    
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.*;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LeshouyingOpenControllerTest {
    
    private static final Long TENANT = 7L;
    
    @Mock private LsyOrderService orderService;
    @Mock private LsyQueryService queryService;
    @Mock private LsyCancelService cancelService;
    @Mock private LsyRefundService refundService;
    @Mock private LsyMerchantService merchantService;
    @Mock private LsyOrderMapper orderMapper;
    
    private MockHttpServletRequest request;
    private LeshouyingOpenController controller;
    
    @BeforeEach
    void setUp() {
        request = new MockHttpServletRequest();
        request.setAttribute("openapi.tenantId", TENANT);   // 模拟 TenantApiKeyAuthFilter 注入
        request.setAttribute("openapi.apiKeyId", 10L);
        controller = new LeshouyingOpenController(orderService, queryService,
                cancelService, refundService, merchantService, orderMapper, request);
    }
    
    private LsyMerchantEntity merchant() {
        LsyMerchantEntity m = new LsyMerchantEntity();
        m.setMchNo("Y52211090004");
        return m;
    }
    
    private LsyPayResultDTO result(String status) {
        LsyPayResultDTO d = new LsyPayResultDTO();
        d.setOutTradeNo("OUT001");
        d.setStatus(status);
        d.setTotalAmount(100L);
        return d;
    }
    
    @Test
    void barcodePay_mapsDtoToEntityAndReturnsResult() {
        when(merchantService.requireByEnterprise(any(), eq("ENT1"))).thenReturn(merchant());
        when(orderService.createBarcodeOrder(any(), eq("134567890123456789"), eq("000")))
                .thenReturn(result("SUCCESS"));
    
        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
        dto.setOutTradeNo("OUT001");
        dto.setEnterpriseId("ENT1");
        dto.setAuthCode("134567890123456789");
        dto.setTotalAmount(100L);
    
        LsyPayResultVO vo = controller.barcodePay(dto);
    
        assertEquals("SUCCESS", vo.getStatus());
        assertEquals("OUT001", vo.getOutTradeNo());
    }
    
    @Test
    void barcodePay_duplicateOutTradeNo_returnsExistingWithoutNewUpstreamCall() {
        LsyOrderEntity existing = new LsyOrderEntity();
        existing.setOutTradeNo("OUT001");
        existing.setStatus("SUCCESS");
        existing.setTradeNo("T001");
        existing.setTotalAmount(100L);
        when(orderMapper.selectOne(any())).thenReturn(existing);
    
        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
        dto.setOutTradeNo("OUT001");
        dto.setEnterpriseId("ENT1");
        dto.setAuthCode("134567890123456789");
        dto.setTotalAmount(100L);
    
        LsyPayResultVO vo = controller.barcodePay(dto);
    
        assertEquals("SUCCESS", vo.getStatus());
        verify(orderService, never()).createBarcodeOrder(any(), any(), any());
    }
    
    @Test
    void h5Create_passesPayTypeThrough() {
        when(merchantService.requireByEnterprise(any(), any())).thenReturn(merchant());
        when(orderService.createH5Order(any(), eq("400"))).thenReturn(result("SUCCESS"));
    
        LsyH5CreateDTO dto = new LsyH5CreateDTO();
        dto.setOutTradeNo("OUT002");
        dto.setEnterpriseId("ENT1");
        dto.setTotalAmount(200L);
        dto.setPayType("400");
    
        controller.h5Create(dto);
    
        verify(orderService).createH5Order(any(), eq("400"));
    }
    
    @Test
    void barcodePay_missingTenantAttribute_throws401() {
        MockHttpServletRequest anon = new MockHttpServletRequest();
        LeshouyingOpenController c = new LeshouyingOpenController(orderService,
                queryService, cancelService, refundService, merchantService,
                orderMapper, anon);
    
        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
        dto.setOutTradeNo("OUT001");
        dto.setEnterpriseId("ENT1");
        dto.setAuthCode("134567890123456789");
        dto.setTotalAmount(100L);
    
        assertThrows(BusinessException.class, () -> c.barcodePay(dto));
    }
    
    @Test
    void orderQuery_orderNotVisibleInThisTenant_throws404WithoutHittingUpstream() {
        // 别的租户的单 / 不存在的单,在本租户视角下都必须是 404,
        // 且不得拿着别人的单号去查上游(那本身就是一次越权信息探测)。
        when(orderMapper.selectOne(any())).thenReturn(null);
    
        LsyOrderQueryDTO dto = new LsyOrderQueryDTO();
        dto.setOutTradeNo("OUT_OF_OTHER_TENANT");
    
        BusinessException e = assertThrows(BusinessException.class,
                () -> controller.orderQuery(dto));
        assertEquals(404, e.getCode());
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void orderClose_orderNotVisibleInThisTenant_throws404WithoutClosingAnything() {
        when(orderMapper.selectOne(any())).thenReturn(null);
    
        LsyOrderCloseDTO dto = new LsyOrderCloseDTO();
        dto.setOutTradeNo("OUT_OF_OTHER_TENANT");
    
        assertThrows(BusinessException.class, () -> controller.orderClose(dto));
        verify(cancelService, never()).closeOrder(any());
    }
    
    // ── DTO 校验:本类其它测试**全都证明不了** `@Pattern` 挂上去了 ────────────────
    //   上面每条都是 `controller.barcodePay(dto)` 这样的**普通方法直调**,
    //   Spring 的参数校验压根不参与;把 `@Pattern` 整行删掉,上面 6 条照样全绿。
    //   所以要用 `Validation.buildDefaultValidatorFactory()` 直接跑注解本身 ——
    //   不启 Spring 上下文,与 `AlipayBatchPayServiceTest` 里
    //   `batchCreateDTO_transferSceneNameBlank_failsValidation` 同一套写法
    //   (那条已实测通过,Hibernate Validator 8.0.1.Final 在单测 classpath 上)。
    
    @Test
    void h5CreateDTO_unsupportedPayType_failsValidation() {
        // H5 不支持自动识别(000)。放行的话 `LsyPayType.ofRequired("000")` 抛
        // IllegalArgumentException,而 GlobalExceptionHandler 没有它的映射
        // ⇒ 落到兜底的 Exception ⇒ HTTP 500 而不是 400。
        LsyH5CreateDTO dto = new LsyH5CreateDTO();
        dto.setOutTradeNo("OUT002");
        dto.setEnterpriseId("ENT1");
        dto.setTotalAmount(200L);
        dto.setPayType("000");
    
        assertTrue(validator().validate(dto).stream()
                        .anyMatch(v -> "payType".equals(v.getPropertyPath().toString())),
                "H5 的 pay_type=000 应被 @Pattern 拦下(否则是 500 不是 400)");
    }
    
    @Test
    void barcodeDTO_unsupportedPayType_failsValidation() {
        // barcode 侧的白名单是 000|300|400,乱填的码同样要停在 400。
        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
        dto.setOutTradeNo("OUT001");
        dto.setEnterpriseId("ENT1");
        dto.setAuthCode("134567890123456789");
        dto.setTotalAmount(100L);
        dto.setPayType("999");
    
        assertTrue(validator().validate(dto).stream()
                        .anyMatch(v -> "payType".equals(v.getPropertyPath().toString())),
                "pay_type=999 应被 @Pattern 拦下");
    }
    
    @Test
    void barcodeDTO_nullPayType_passesValidation() {
        // ★ 这条钉的是一个**取舍**,不是一段逻辑:barcode 的 pay_type 是选填
        //   (null ⇒ `StrUtil.blankToDefault(payType, AUTO)` 兜成 000),所以那里
        //   **故意不加** `@NotBlank`。`@Pattern` 对 null 视为通过,两条正好兼容。
        //   哪天有人「顺手补个 @NotBlank」,默认 000 的路径就被堵死了 —— 这条会红。
        //   断言的是「payType 上无违规」而**不是** `violations.isEmpty()`:
        //   DTO 里还有别的 @NotBlank 字段,这条只关心 payType。
        LsyBarcodePayDTO dto = new LsyBarcodePayDTO();
        dto.setOutTradeNo("OUT001");
        dto.setEnterpriseId("ENT1");
        dto.setAuthCode("134567890123456789");
        dto.setTotalAmount(100L);
        dto.setPayType(null);
    
        assertTrue(validator().validate(dto).stream()
                        .noneMatch(v -> "payType".equals(v.getPropertyPath().toString())),
                "pay_type=null(选填)不得被判违规,否则默认 000 的路径被堵死");
    }
    
    private static Validator validator() {
        return Validation.buildDefaultValidatorFactory().getValidator();
    }
    }
    
    // ⚠️ 说明:`tenant_id` 条件是否真的拼进了 WHERE 子句,**无法用 Mockito 单测证明**。
    //
    // ★ 但**理由不是**「`getSqlSegment()` 不可用」—— 那样说与本计划的 Task 5 自相矛盾:
    //   Task 5 的两条 `LsyOrderServiceStateTest` 用例正是在**纯 Mockito 单测**里调
    //   `getSqlSegment()` 并期望通过。Task 5 预检时用真探针在 MP 3.5.5 上实测过:
    //   用**字符串列名**构造的 `UpdateWrapper`,`getSqlSegment()` 直接返回可用 SQL 段
    //   (`(id = #{ew.paramNameValuePairs.MPGENVAL1} AND status IN (...))`),**不需要 TableInfo**。
    //   (需要 TableInfo 的是走 `lambda()` 的写法 —— 那条路要把 `SFunction` 反解成列名。
    //    此处**未实测**,不作为结论使用,写在这里只是防止有人再把它当成「所有 wrapper 都炸」。)
    //
    //   真正的原因是:**`tenant_id` 这个条件根本不在控制器构造的 wrapper 里。**
    //   它由 `TenantInnerInterceptor` 在 SQL 真正下发的最后一刻追加,而 Mockito 里的 mapper
    //   是个 mock,MyBatis 的插件链路**一步都不执行**。所以单测能断言的只有
    //   「控制器自己拼了什么」,断言不到「拦截器加了什么」—— 而后者才是这条防线的主体。
    //   换句话说:即使把 wrapper 断言写得完美,它证明的也**不是**你想证明的那件事。
    //
    // 因此这条防线靠两件事守:(1) `findExisting`/`requireOrder`/`requireByOutRefundNo`
    // 的 `tenantId` 是**必传第一个参数**,漏传编译不过;(2) Task 18 之后在真实
    // Spring 上下文里跑一次 `/order/query`,把 SQL 日志打开确认 WHERE 里有 tenant_id。
    // 已登记在文末「交付后仍需联调确认」。
    
  • [ ] Step 3: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingOpenControllerTest
    

Expected: 编译失败。

  • [ ] Step 4: 实现 LeshouyingOpenController

    package com.payment.platform.module.payment.leshouying.controller.open;
    
    import cn.hutool.core.util.IdUtil;
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.common.exception.BusinessException;
    import com.payment.platform.common.response.Result;
    import com.payment.platform.module.payment.leshouying.dto.*;
    import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.service.*;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.validation.Valid;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.web.bind.annotation.*;
    
    import java.time.LocalDateTime;
    import java.time.format.DateTimeFormatter;
    import java.util.LinkedHashMap;
    import java.util.Map;
    
    /**
    * 乐收赢下游 OpenAPI —— 供平台租户调用。
    *
    * 认证:TenantApiKeyAuthFilter(Authorization: ApiKey + Signature HMAC-SHA256)。
    *      ★ 这 7 个路径逐条列于 TenantApiKeyAuthFilter.OPENAPI_ACCOUNT_PATHS(清单一致性);
    *        认证判定按**前缀**(OPENAPI_PREFIXES),**不读**本数组 ⇒ 漏列不会失去认证。
    *
    * 租户身份从 request attribute 取(由 filter 注入),不信任请求体里的 tenant_id。
    */
    @Slf4j
    @RestController
    @RequestMapping("/payment/openapi/leshouying")
    @RequiredArgsConstructor
    public class LeshouyingOpenController {
    
    private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
    
    private final LsyOrderService orderService;
    private final LsyQueryService queryService;
    private final LsyCancelService cancelService;
    private final LsyRefundService refundService;
    private final LsyMerchantService merchantService;
    private final LsyOrderMapper orderMapper;
    /** 请求作用域代理 —— 与既有 OpenapiController 同一写法,从 attribute 取认证上下文 */
    private final HttpServletRequest request;
    
    @PostMapping("/barcode/pay")
    public LsyPayResultVO barcodePay(@Valid @RequestBody LsyBarcodePayDTO dto) {
        Long tenantId = tenantId();
    
        LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo());
        if (existing != null) {
            return toVo(existing, "订单已存在,返回原结果");
        }
    
        LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                apiKeyId(), "BARCODE", "000");
        order.setEnterpriseId(dto.getEnterpriseId());
        order.setTotalAmount(dto.getTotalAmount());
        order.setOrderBody(dto.getOrderBody());
        order.setAttach(dto.getAttach());
        order.setNotifyUrl(dto.getNotifyUrl());
    
        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
                tenantId, dto.getEnterpriseId());
        order.setMchNo(merchant.getMchNo());
        order.setShopNo(merchant.getShopNo());
    
        // 注意:auth_code 不写进订单实体(不落库),只透传给上游
        return toVo(orderService.createBarcodeOrder(
                order, dto.getAuthCode(), dto.getPayType()), null);
    }
    
    @PostMapping("/qrcode/create")
    public LsyPayResultVO qrcodeCreate(@Valid @RequestBody LsyQrCreateDTO dto) {
        Long tenantId = tenantId();
    
        LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo());
        if (existing != null) {
            return toVo(existing, "订单已存在,返回原结果");
        }
    
        LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                apiKeyId(), "QRCODE", null);
        order.setEnterpriseId(dto.getEnterpriseId());
        order.setTotalAmount(dto.getTotalAmount());
        order.setOrderBody(dto.getOrderBody());
        order.setAttach(dto.getAttach());
        order.setNotifyUrl(dto.getNotifyUrl());
    
        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
                tenantId, dto.getEnterpriseId());
        order.setMchNo(merchant.getMchNo());
        order.setShopNo(merchant.getShopNo());
    
        return toVo(orderService.createQrOrder(order, dto.getTimeExpire()), null);
    }
    
    @PostMapping("/h5/create")
    public LsyPayResultVO h5Create(@Valid @RequestBody LsyH5CreateDTO dto) {
        Long tenantId = tenantId();
    
        LsyOrderEntity existing = findExisting(tenantId, dto.getOutTradeNo());
        if (existing != null) {
            return toVo(existing, "订单已存在,返回原结果");
        }
    
        LsyOrderEntity order = newOrder(dto.getOutTradeNo(), tenantId,
                apiKeyId(), "H5", dto.getPayType());
        order.setEnterpriseId(dto.getEnterpriseId());
        order.setTotalAmount(dto.getTotalAmount());
        order.setOrderBody(dto.getOrderBody());
        order.setAttach(dto.getAttach());
        order.setNotifyUrl(dto.getNotifyUrl());
    
        LsyMerchantEntity merchant = merchantService.requireByEnterprise(
                tenantId, dto.getEnterpriseId());
        order.setMchNo(merchant.getMchNo());
        order.setShopNo(merchant.getShopNo());
    
        return toVo(orderService.createH5Order(order, dto.getPayType()), null);
    }
    
    @PostMapping("/order/query")
    public Result<Map<String, Object>> orderQuery(@Valid @RequestBody LsyOrderQueryDTO dto) {
        Long tenantId = tenantId();
        LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), dto.getTradeNo());
        Map<String, Object> upstream = queryService.queryOrder(order);
        // 本地状态为准(回调可能比查询更早到达);上游报文一并返回便于对账
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("local_status", order.getStatus());
        data.put("trade_no", order.getTradeNo());
        data.put("upstream", upstream);
        return Result.ok(data);
    }
    
    /**
     * 撤销/关单 —— 按 pay_scene 内部路由。
     * 被扫走 cancelorder(先查后撤:已支付则转退款);主扫走 qrclose;H5 无上游关单。
     */
    @PostMapping("/order/close")
    public Result<Map<String, Object>> orderClose(@Valid @RequestBody LsyOrderCloseDTO dto) {
        Long tenantId = tenantId();
        LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), null);
        String target = cancelService.closeOrder(order);
        if (target == null) {
            return Result.fail(500, "上游拒绝撤销/关单,请稍后重试");
        }
        Map<String, Object> data = new LinkedHashMap<>();
        if ("SUCCESS".equals(target)) {
            data.put("status", order.getStatus());
            data.put("message", "该订单已支付,已转退款流程,请调用退款接口");
            return Result.ok(data);
        }
        order.setStatus(target);
        orderMapper.updateById(order);
        data.put("status", target);
        return Result.ok(data);
    }
    
    @PostMapping("/refund")
    public Result<Map<String, Object>> refund(@Valid @RequestBody LsyRefundDTO dto) {
        Long tenantId = tenantId();
        LsyOrderEntity order = requireOrder(tenantId, dto.getOutTradeNo(), null);
        LsyRefundEntity refund = refundService.createRefund(
                order, dto.getOutRefundNo(), dto.getRefundFee());
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("out_refund_no", refund.getOutRefundNo());
        data.put("refund_no", refund.getRefundNo());
        data.put("status", refund.getStatus());
        return Result.ok(data);
    }
    
    @PostMapping("/refund/query")
    public Result<Map<String, Object>> refundQuery(@Valid @RequestBody LsyRefundQueryDTO dto) {
        Long tenantId = tenantId();
        LsyRefundEntity refund = refundService.requireByOutRefundNo(tenantId, dto.getOutRefundNo());
        Map<String, Object> upstream = refundService.queryRefund(refund);
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("local_status", refund.getStatus());
        data.put("upstream", upstream);
        return Result.ok(data);
    }
    
    // ==================== 私有 ====================
    
    /** tenantId 是租户边界的唯一来源:不信任请求体里的任何 tenants 字段。 */
    private Long tenantId() {
        Object v = request.getAttribute("openapi.tenantId");
        if (!(v instanceof Long)) {
            throw new BusinessException(401, "缺少 API Key 认证上下文");
        }
        return (Long) v;
    }
    
    private Long apiKeyId() {
        Object v = request.getAttribute("openapi.apiKeyId");
        return v instanceof Long ? (Long) v : null;
    }
    
    private LsyOrderEntity newOrder(String outTradeNo, Long tenantId, Long apiKeyId,
                                    String scene, String payType) {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setTenantId(tenantId);
        o.setApiKeyId(apiKeyId);
        o.setOutTradeNo(outTradeNo);
        o.setPayScene(scene);
        o.setPayType(payType);
        o.setPayTraceNo(IdUtil.fastSimpleUUID());
        o.setPayTime(LocalDateTime.now().format(FMT));
        o.setStatus("CREATED");
        o.setRefundedAmount(0L);
        o.setQueryCount(0);
        o.setNotifyStatus("PENDING");
        o.setNotifyCount(0);
        orderMapper.insert(o);
        return o;
    }
    
    /**
     * 幂等查找:同 out_trade_no 重复下单返回原结果而非新建。
     * ★ 必须带 tenantId —— 不能只靠租户拦截器:它只在 filter 白名单覆盖到本路径时才生效,
     *   漏配即整条跳过;漏掉这一条会让租户 B 拿到租户 A 的订单。
     */
    private LsyOrderEntity findExisting(Long tenantId, String outTradeNo) {
        if (outTradeNo == null) {
            return null;
        }
        return orderMapper.selectOne(new LambdaQueryWrapper<LsyOrderEntity>()
                .eq(LsyOrderEntity::getTenantId, tenantId)
                .eq(LsyOrderEntity::getOutTradeNo, outTradeNo)
                .last("LIMIT 1"));
    }
    
    /** ★ 同样必须带 tenantId:否则可查/可关别的租户的订单。 */
    private LsyOrderEntity requireOrder(Long tenantId, String outTradeNo, String tradeNo) {
        LambdaQueryWrapper<LsyOrderEntity> w = new LambdaQueryWrapper<>();
        w.eq(LsyOrderEntity::getTenantId, tenantId);
        if (outTradeNo != null) {
            w.eq(LsyOrderEntity::getOutTradeNo, outTradeNo);
        } else if (tradeNo != null) {
            w.eq(LsyOrderEntity::getTradeNo, tradeNo);
        } else {
            throw new BusinessException(400, "out_trade_no 与 trade_no 至少提供一个");
        }
        LsyOrderEntity o = orderMapper.selectOne(w.last("LIMIT 1"));
        if (o == null) {
            throw new BusinessException(404, "订单不存在");
        }
        return o;
    }
    
    private LsyPayResultVO toVo(LsyOrderEntity order, String msg) {
        LsyPayResultVO vo = new LsyPayResultVO();
        vo.setOutTradeNo(order.getOutTradeNo());
        vo.setStatus(order.getStatus());
        vo.setTradeNo(order.getTradeNo());
        vo.setTotalAmount(order.getTotalAmount());
        vo.setReceiptAmount(order.getReceiptAmount());
        vo.setCodeUrl(order.getCodeUrl());
        vo.setMessage(msg);
        return vo;
    }
    
    private LsyPayResultVO toVo(LsyPayResultDTO dto, String msg) {
        LsyPayResultVO vo = new LsyPayResultVO();
        vo.setOutTradeNo(dto.getOutTradeNo());
        vo.setStatus(dto.getStatus());
        vo.setTradeNo(dto.getTradeNo());
        vo.setTotalAmount(dto.getTotalAmount());
        vo.setReceiptAmount(dto.getReceiptAmount());
        vo.setCodeUrl(dto.getCodeUrl());
        vo.setMessage(msg == null ? dto.getMessage() : msg);
        return vo;
    }
    }
    
  • [ ] Step 5: 给 LsyRefundService 补 requireByOutRefundNo

在 LsyRefundService 中追加(LsyRefundMapper 已在构造注入)。 tenantId 是必传参数,不是可选项 —— 见下面「跨租户隔离」说明:

    /** 下游 OpenAPI 专用:必须带 tenantId 限定,否则会读到别的租户的退款单。 */
    public LsyRefundEntity requireByOutRefundNo(Long tenantId, String outRefundNo) {
        if (cn.hutool.core.util.StrUtil.isBlank(outRefundNo)) {
            throw new com.payment.platform.common.exception.BusinessException(400, "out_refund_no 不能为空");
        }
        LsyRefundEntity r = refundMapper.selectOne(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LsyRefundEntity>()
                        .eq(LsyRefundEntity::getTenantId, tenantId)
                        .eq(LsyRefundEntity::getOutRefundNo, outRefundNo)
                        .last("LIMIT 1"));
        if (r == null) {
            throw new com.payment.platform.common.exception.BusinessException(404, "退款单不存在");
        }
        return r;
    }

★ 跨租户隔离:本控制器不依赖 TenantInnerInterceptor,必须自己带租户条件

★★ 2026-09-21 由 lead 就地更正(原文的机理写错了,实测推翻)。原文见下方引用块。

实测事实(三条独立证据):

  1. TenantApiKeyAuthFilter:222-231 确实往 SecurityContextHolder 放了一个 带 tenantId 的 LoginUser(第 219-221 行的注释还专门写明「principal 必须是 LoginUser (带 tenantId)而非裸 Long」,理由是 pay_account 等既有表要靠它取到正确租户)。 ⇒ ApiKey 请求有认证上下文,不是「无认证」。
  2. TenantInnerInterceptor 的判别式是 auth.getPrincipal() instanceof LoginUser(:151、:168)。 命中该分支且 tenantId != null ⇒ ignoreTable 返回 false ⇒ 照常追加 AND tenant_id = <该 ApiKey 的租户>。
  3. 测试 TenantInnerInterceptorTest.ignoreTable_authenticatedTenantUser_stillFiltersLsyTables 用 tenantId=7L 的 LoginUser 断言四张 pay_lsy_* 表全部返回 false —— 钉住的就是这个分支。

⇒ 显式租户条件不是「唯一防线」,是「第二道防线」;但它仍然必须写,理由有三条:

  1. 拦截器的保护以「filter 已经跑过」为前提。 若某条路径漏加进 OPENAPI_ACCOUNT_PATHS (正是 Task 14 要防的那个静默故障),该请求就没有认证对象 ⇒ ignoreTable 走无认证分支 返回 true ⇒ 整条跳过租户过滤。此时显式条件是唯一的兜底。 (该情形下 openapi.tenantId 属性也为 null ⇒ 控制器应 401,两重保护。)
  2. 该行为依赖一个非常窄的分支(principal instanceof LoginUser)。 任何对 filter principal 类型的改动(例如图省事塞个裸 Long)都会静默改变它。
  3. 与既有 OpenapiService 的做法一致(w.eq(OpenTransferEntity::getTenantId, tenantId))。

⇒ 硬要求不变:所有下游按业务单号的查询,一律显式 tenantId(request) + .eq(getTenantId, ...)。 与既有做法一致,且是上面第 1 条的兜底。

唯一的例外是 LsyMerchantService.requireByMchNo —— 它只服务回调路径(真正的无认证上下文, 手上只有 mch_no,没有 tenant_id),mch_no 在上游全局唯一,用它反查是设计如此。 (2026-09-21 实测更正:该方法当前零生产调用点,grep -rn "requireByMchNo|getByMchNo" src 只命中定义、其单测、以及 TenantInnerInterceptor:117 的注释。此处写的是将来的用法约束。)

原文(**机理部分是错的,保留供追溯**) ~~4 张 `pay_lsy_*` 表加进了 `CONDITIONAL_TABLES`,含义是「**无认证上下文时跳过租户过滤**」。 而 OpenAPI 请求走的是 `TenantApiKeyAuthFilter`,它**不往 SecurityContext 里放认证对象** —— 所以在 `TenantInnerInterceptor.getTenantId()` 眼里这是「无认证」, `ignoreTable` 返回 `true`,**`tenant_id` 条件根本不会被追加**。 后果:租户 B 用租户 A 已用过的 `out_trade_no` 调 `/order/query`、`/order/close`、`/refund/query`, 就能读到甚至操作 A 的订单/退款单 —— 跨租户越权。~~
  • [ ] Step 6: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingOpenControllerTest
    

Expected: Tests run: 9, Failures: 0, Errors: 0

(9 = 控制器 6 条 + DTO 校验 3 条。后 3 条不是覆盖率凑数:另外 6 条全是方法直调, @Valid 不参与,把两个 DTO 上的 @Pattern 整行删掉它们照样全绿 —— 也就是说这 3 条是「错误的 pay_type 返回 400 而不是 500」这条契约的唯一执行点。)

  • [ ] Step 7: 确认工作区状态(不提交)

    git status --short
    

Task 16: 两个调度任务

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyOrderPollScheduler.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

Interfaces:

  • Consumes: LsyOrderMapper, LsyQueryService, LsyCancelService, LsyOrderService.settleFromQuery(Task 6 已定义), LsyCallbackService, LeshouyingProperties
  • Produces:
    • void LsyOrderPollScheduler.pollBarcodeOrders()
    • void LsyReconcileScheduler.reconcile()

两个调度器都不注入 LeshouyingResponseParser:报文怎么解释是 LsyOrderService.settleFromQuery 的职责。调度器只决定「何时查、查几次、耗尽后怎么办」。

  • [ ] Step 1: 写失败的测试

    package com.payment.platform.module.payment.leshouying.scheduler;
    
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.service.*;
    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.util.LinkedHashMap;
    import java.util.List;
    import java.util.Map;
    
    import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.junit.jupiter.api.Assertions.assertNotNull;
    import static org.mockito.ArgumentMatchers.any;
    import static org.mockito.ArgumentMatchers.eq;
    import static org.mockito.Mockito.*;
    
    @ExtendWith(MockitoExtension.class)
    class LsySchedulerTest {
    
    @Mock private LsyOrderMapper orderMapper;
    @Mock private LsyQueryService queryService;
    @Mock private LsyCancelService cancelService;
    @Mock private LsyCallbackService callbackService;
    @Mock private LsyOrderService orderService;
    
    private LeshouyingProperties props;
    
    @BeforeEach
    void setUp() {
        props = new LeshouyingProperties();
        props.setBarcodePollMaxTimes(15);
        props.setBarcodePollIntervalMs(0);
    }
    
    private LsyOrderEntity paying(int queryCount) {
        LsyOrderEntity o = new LsyOrderEntity();
        o.setId(1L);
        o.setOutTradeNo("OUT001");
        o.setPayScene("BARCODE");
        o.setStatus("PAYING");
        o.setQueryCount(queryCount);
        o.setMchNo("Y52211090004");
        return o;
    }
    
    private Map<String, Object> resp(String rc) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("return_code", "SUCCESS");
        m.put("result_code", rc);
        return m;
    }
    
    @Test
    void pollBarcode_singleFailure_doesNotInterruptOthers() {
        LsyOrderEntity o1 = paying(0);
        LsyOrderEntity o2 = paying(0);
        o2.setId(2L);
        when(orderMapper.selectList(any())).thenReturn(List.of(o1, o2));
        when(queryService.queryOrder(o1)).thenThrow(new RuntimeException("网络超时"));
        when(queryService.queryOrder(o2)).thenReturn(resp("PAY_SUCCESS"));
        when(orderService.settleFromQuery(eq(o2), any())).thenReturn("SUCCESS");
    
        LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler(
                orderMapper, queryService, cancelService, callbackService,
                orderService, props);
    
        assertDoesNotThrow(scheduler::pollBarcodeOrders);
        verify(queryService).queryOrder(o2);
    }
    
    @Test
    void pollBarcode_success_updatesAndNotifiesDownstream() {
        LsyOrderEntity o = paying(0);
        when(orderMapper.selectList(any())).thenReturn(List.of(o));
        when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS"));
        when(orderService.settleFromQuery(eq(o), any())).thenReturn("SUCCESS");
    
        new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
                callbackService, orderService, props)
                .pollBarcodeOrders();
    
        verify(callbackService).notifyOrder(o);
    }
    
    @Test
    void pollBarcode_stillPaying_doesNotNotify_andIncrementsQueryCount() {
        LsyOrderEntity o = paying(0);
        when(orderMapper.selectList(any())).thenReturn(List.of(o));
        when(queryService.queryOrder(o)).thenReturn(resp("PAY_IN_PROCESS"));
        when(orderService.settleFromQuery(eq(o), any())).thenReturn(null);   // 未流转
    
        new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
                callbackService, orderService, props)
                .pollBarcodeOrders();
    
        verify(callbackService, never()).notifyOrder(any());
        var captor = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
        verify(orderMapper).updateById(captor.capture());
        assertEquals(1, captor.getValue().getQueryCount());
        assertNotNull(captor.getValue().getNextQueryTime());
    }
    
    @Test
    void pollBarcode_exhausted_triggersAutoCancel() {
        LsyOrderEntity o = paying(15);   // 已达上限
        when(orderMapper.selectList(any())).thenReturn(List.of(o));
        when(queryService.queryOrder(o)).thenReturn(resp("PAY_IN_PROCESS"));
        when(orderService.settleFromQuery(eq(o), any())).thenReturn(null);   // 仍未流转
        when(orderMapper.selectById(1L)).thenReturn(paying(15));             // 复查:仍是 PAYING
        when(cancelService.closeOrder(o)).thenReturn("CANCELED");
    
        new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
                callbackService, orderService, props)
                .pollBarcodeOrders();
    
        verify(cancelService).closeOrder(o);
        verify(callbackService).notifyOrder(o);
    }
    
    @Test
    void pollBarcode_exhaustedButCallbackAlreadyWon_doesNotCancel() {
        LsyOrderEntity o = paying(15);
        when(orderMapper.selectList(any())).thenReturn(List.of(o));
        when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS"));
        // 回调抢先写了 SUCCESS → 条件更新命中 0 行 → settleFromQuery 返回 null
        when(orderService.settleFromQuery(eq(o), any())).thenReturn(null);
    
        LsyOrderEntity won = paying(15);
        won.setStatus("SUCCESS");                    // DB 里已是终态
        when(orderMapper.selectById(1L)).thenReturn(won);
    
        new LsyOrderPollScheduler(orderMapper, queryService, cancelService,
                callbackService, orderService, props)
                .pollBarcodeOrders();
    
        verify(cancelService, never()).closeOrder(any());
        verify(callbackService, never()).notifyOrder(any());
    }
    
    @Test
    void pollBarcode_emptyList_doesNothing() {
        when(orderMapper.selectList(any())).thenReturn(List.of());
    
        LsyOrderPollScheduler scheduler = new LsyOrderPollScheduler(
                orderMapper, queryService, cancelService, callbackService,
                orderService, props);
        scheduler.pollBarcodeOrders();
    
        verify(queryService, never()).queryOrder(any());
    }
    
    @Test
    void reconcile_canceledTargetNull_doesNotOverwriteStatus() {
        LsyOrderEntity o = paying(0);
        o.setPayScene("QRCODE");
        o.setCodeUrl("https://x");
        when(cancelService.closeOrder(o)).thenReturn(null);
    
        LsyReconcileScheduler scheduler = new LsyReconcileScheduler(
                orderMapper, queryService, cancelService, callbackService,
                orderService, props);
    
        assertDoesNotThrow(() -> scheduler.applyCloseResult(o));
        verify(orderMapper, never()).updateById(any());
    }
    
    @Test
    void reconcile_pendingOrder_settlesViaOrderService() {
        LsyOrderEntity o = paying(0);
        o.setPayScene("QRCODE");
        when(orderMapper.selectList(any())).thenReturn(List.of(o));
        when(queryService.queryOrder(o)).thenReturn(resp("PAY_SUCCESS"));
        when(orderService.settleFromQuery(eq(o), any())).thenReturn("SUCCESS");
    
        LsyReconcileScheduler scheduler = new LsyReconcileScheduler(
                orderMapper, queryService, cancelService, callbackService,
                orderService, props);
        scheduler.reconcilePendingOrders();
    
        verify(callbackService).notifyOrder(o);
    }
    }
    

Expected: Tests run: 8, Failures: 0, Errors: 0

  • [ ] Step 2: 运行测试确认失败

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsySchedulerTest
    

Expected: 编译失败。

  • [ ] Step 3: 实现 LsyOrderPollScheduler

    package com.payment.platform.module.payment.leshouying.scheduler;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.service.LsyCallbackService;
    import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
    import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
    import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import java.time.OffsetDateTime;
    import java.util.List;
    import java.util.Map;
    
    /**
    * 被扫订单密集轮询 + 超时撤销。
    *
    * 上游规定的节奏是「5 秒后开始,15 次,间隔 5 秒,最长 ~80 秒」。
    * 我们的同步 3 次(6 秒)叠加在前,是用户体验优化,量级一致。
    *
    * 【不做状态判断,只做调度】上游报文怎么解释、状态怎么落库,全部交给
    * LsyOrderService.settleFromQuery —— 它是全模块唯一的状态写入口。
    * 本类只负责「什么时候查」「查多少次」「查完要不要撤销」。
    *
    * 单条异常 catch 记日志不中断 —— try/catch 必须放在**循环内**(放循环外会让第一笔失败吃掉整批)。
    * 既有同形状参考 `BatchStatusPollScheduler.pollPendingBatches`,但本类**刻意**有一处不同:
    * 那里打 `e.getMessage()`,本类把 `e` 整个交给日志。理由是堆栈 —— 本模块的失败多为上游协议异常,
    * `cause`(如 JSONException)是分辨走哪条分支的唯一线索(见 Task 2 的两条 PARSE_ERROR 分支)。
    * 这么写是安全的,依据是已核实的事实:`LeshouyingApiException` **不重写 `toString()`**,
    * 因此 `log.error(..., e)` 只打印经过 sanitize 的 `getMessage()` 加栈帧,
    * `rawJson` / `rawReturnMsg` 不会随栈泄漏。
    * ★ 反过来说:**谁给该异常加一个把 Raw 字段拼进 `toString()` 的重写,
    *   本行以及全模块所有 `log.error(..., e)` 站点会一起变成泄漏点。** 别加。
    */
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class LsyOrderPollScheduler {
    
    private final LsyOrderMapper orderMapper;
    private final LsyQueryService queryService;
    private final LsyCancelService cancelService;
    private final LsyCallbackService callbackService;
    private final LsyOrderService orderService;
    private final LeshouyingProperties props;
    
    @Scheduled(fixedDelay = 30 * 1000)
    public void pollBarcodeOrders() {
        List<LsyOrderEntity> orders = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        .eq(LsyOrderEntity::getStatus, "PAYING")
                        .eq(LsyOrderEntity::getPayScene, "BARCODE")
                        .le(LsyOrderEntity::getNextQueryTime, OffsetDateTime.now())
                        .orderByAsc(LsyOrderEntity::getNextQueryTime)
                        .last("LIMIT 100"));
        if (orders.isEmpty()) {
            return;
        }
        log.info("被扫订单轮询: {} 条", orders.size());
    
        for (LsyOrderEntity order : orders) {
            try {
                pollOne(order);
            } catch (Exception e) {
                log.error("被扫订单轮询异常: outTradeNo={}", order.getOutTradeNo(), e);
            }
        }
    }
    
    void pollOne(LsyOrderEntity order) {
        int count = order.getQueryCount() == null ? 0 : order.getQueryCount();
    
        Map<String, Object> resp = queryService.queryOrder(order);
    
        // 已终态:settleFromQuery 返回非 null
        String settled = orderService.settleFromQuery(order, resp);
        if (settled != null) {
            log.info("被扫订单轮询确认结果: outTradeNo={}, status={}",
                    order.getOutTradeNo(), settled);
            callbackService.notifyOrder(order);
            return;
        }
    
        count++;
        if (count >= props.getBarcodePollMaxTimes()) {
            // 【撤销前必须复查】settleFromQuery 返回 null 有两种含义:
            //   (a) 上游仍是支付中   (b) 回调已抢先写了终态,条件更新命中 0 行
            // 内存里的 order 在 (b) 情况下仍是 PAYING,据此撤销会去关一张已付款的单。
            // 复查一次 DB 状态,非 PAYING 就放弃撤销。
            LsyOrderEntity fresh = orderMapper.selectById(order.getId());
            if (fresh == null || !"PAYING".equals(fresh.getStatus())) {
                log.info("轮询耗尽但订单已非 PAYING,跳过撤销: outTradeNo={}, status={}",
                        order.getOutTradeNo(), fresh == null ? "null" : fresh.getStatus());
                return;
            }
            log.warn("被扫订单轮询耗尽({}次),触发自动撤销: outTradeNo={}",
                    count, order.getOutTradeNo());
            String target = cancelService.closeOrder(order);
            if (target != null) {
                LsyOrderEntity patch = new LsyOrderEntity();
                patch.setId(order.getId());
                patch.setStatus(target);
                patch.setUpdatedTime(OffsetDateTime.now());
                orderMapper.updateById(patch);
                order.setStatus(target);
                callbackService.notifyOrder(order);
            }
            return;
        }
    
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setId(order.getId());
        patch.setQueryCount(count);
        patch.setLastQueryTime(OffsetDateTime.now());
        patch.setNextQueryTime(OffsetDateTime.now()
                .plusSeconds(props.getBarcodePollIntervalMs() / 1000));
        patch.setUpdatedTime(OffsetDateTime.now());
        orderMapper.updateById(patch);
    }
    }
    
  • [ ] Step 4: 实现 LsyReconcileScheduler

    package com.payment.platform.module.payment.leshouying.scheduler;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
    import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
    import com.payment.platform.module.payment.leshouying.enums.LsyOrderStatus;
    import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
    import com.payment.platform.module.payment.leshouying.service.LsyCallbackService;
    import com.payment.platform.module.payment.leshouying.service.LsyCancelService;
    import com.payment.platform.module.payment.leshouying.service.LsyOrderService;
    import com.payment.platform.module.payment.leshouying.service.LsyQueryService;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.scheduling.annotation.Scheduled;
    import org.springframework.stereotype.Component;
    
    import java.time.OffsetDateTime;
    import java.util.List;
    import java.util.Map;
    
    /**
    * 主扫/H5 兜底对账 + 超时关单 + 下游回调重试。
    *
    * 为什么不给主扫/H5 排密集轮询:回调是主路径,上游自己会重试回调。
    * 轮询只为兜底「回调彻底丢失」,低频扫表足够;给每个订单排密集轮询会压垮调度器。
    *
    * 【不做状态判断】查询结果怎么解释、状态怎么落库,全部交给
    * LsyOrderService.settleFromQuery。四条子任务里只有「兜底对账」会改支付状态,
    * 走同一个入口;关单/过期收敛是本地状态收敛,不经过上游报文解析。
    */
    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class LsyReconcileScheduler {
    
    private final LsyOrderMapper orderMapper;
    private final LsyQueryService queryService;
    private final LsyCancelService cancelService;
    private final LsyCallbackService callbackService;
    private final LsyOrderService orderService;
    private final LeshouyingProperties props;
    
    @Scheduled(fixedDelay = 5 * 60 * 1000)
    public void reconcile() {
        try {
            reconcilePendingOrders();
        } catch (Exception e) {
            log.error("对账任务异常", e);
        }
        try {
            closeExpiredQrOrders();
        } catch (Exception e) {
            log.error("聚合码关单任务异常", e);
        }
        try {
            expireStaleOrders();
        } catch (Exception e) {
            log.error("过期收敛任务异常", e);
        }
        try {
            callbackService.retryFailed();
        } catch (Exception e) {
            log.error("下游回调重试任务异常", e);
        }
    }
    
    /** 兜底:非终态且已超过 reconcileDelaySeconds 的主扫/H5 订单,查一次上游 */
    void reconcilePendingOrders() {
        OffsetDateTime threshold = OffsetDateTime.now()
                .minusSeconds(props.getReconcileDelaySeconds());
        List<LsyOrderEntity> orders = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        .eq(LsyOrderEntity::getStatus, "PAYING")
                        .in(LsyOrderEntity::getPayScene, List.of("QRCODE", "H5"))
                        .le(LsyOrderEntity::getCreatedTime, threshold)
                        .last("LIMIT 100"));
    
        for (LsyOrderEntity order : orders) {
            try {
                Map<String, Object> resp = queryService.queryOrder(order);
                String settled = orderService.settleFromQuery(order, resp);
                if (settled != null) {
                    log.info("对账兜底确认结果: outTradeNo={}, status={}",
                            order.getOutTradeNo(), settled);
                    callbackService.notifyOrder(order);
                }
            } catch (Exception e) {
                log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e);
            }
        }
    }
    
    /** 聚合码到期 → 调 v3/qrclose 关单 */
    void closeExpiredQrOrders() {
        List<LsyOrderEntity> orders = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        .eq(LsyOrderEntity::getStatus, "PAYING")
                        .eq(LsyOrderEntity::getPayScene, "QRCODE")
                        .isNotNull(LsyOrderEntity::getCodeUrl)
                        .le(LsyOrderEntity::getCodeUrlExpireTime, OffsetDateTime.now())
                        .last("LIMIT 100"));
    
        for (LsyOrderEntity order : orders) {
            try {
                applyCloseResult(order);
            } catch (Exception e) {
                log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
            }
        }
    }
    
    /** H5 与任意超长挂起的订单 → 本地收敛为 EXPIRED */
    void expireStaleOrders() {
        OffsetDateTime h5Threshold = OffsetDateTime.now()
                .minusMinutes(props.getH5ExpireMinutes());
        List<LsyOrderEntity> h5Orders = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        .eq(LsyOrderEntity::getStatus, "PAYING")
                        .eq(LsyOrderEntity::getPayScene, "H5")
                        .le(LsyOrderEntity::getCreatedTime, h5Threshold)
                        .last("LIMIT 100"));
        for (LsyOrderEntity order : h5Orders) {
            markExpired(order);
        }
    
        OffsetDateTime absolute = OffsetDateTime.now()
                .minusHours(props.getAbsoluteExpireHours());
        List<LsyOrderEntity> stale = orderMapper.selectList(
                new LambdaQueryWrapper<LsyOrderEntity>()
                        // 用常量而不是字面量 List.of("CREATED","PAYING"):
                        // 这正是 LsyOrderStatus.PENDING 的定义,写死会让两边将来各改各的。
                        .in(LsyOrderEntity::getStatus, LsyOrderStatus.PENDING)
                        .le(LsyOrderEntity::getCreatedTime, absolute)
                        .last("LIMIT 100"));
        for (LsyOrderEntity order : stale) {
            log.warn("订单超过 {} 小时未终态,强制收敛为 EXPIRED: outTradeNo={}",
                    props.getAbsoluteExpireHours(), order.getOutTradeNo());
            markExpired(order);
        }
    }
    
    /** 包成独立方法便于单测:target 为 null 时不得覆盖状态 */
    void applyCloseResult(LsyOrderEntity order) {
        String target = cancelService.closeOrder(order);
        if (target == null) {
            log.info("关单未成功,保持原状态等待下次重试: outTradeNo={}", order.getOutTradeNo());
            return;
        }
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setId(order.getId());
        patch.setStatus(target);
        patch.setUpdatedTime(OffsetDateTime.now());
        orderMapper.updateById(patch);
        order.setStatus(target);
        callbackService.notifyOrder(order);
    }
    
    private void markExpired(LsyOrderEntity order) {
        LsyOrderEntity patch = new LsyOrderEntity();
        patch.setId(order.getId());
        patch.setStatus("EXPIRED");
        patch.setFinishTime(OffsetDateTime.now());
        patch.setUpdatedTime(OffsetDateTime.now());
        orderMapper.updateById(patch);
        order.setStatus("EXPIRED");
        callbackService.notifyOrder(order);
    }
    }
    

注意 markExpired 与 applyCloseResult 用 updateById 而非条件更新:它们是 本地状态收敛(上游根本没成交),不参与支付幂等放行集合;且调用前已由查询条件 限定为 status = 'PAYING'。真正需要防重复的是上游报文的回写,那条路径 全部收在 LsyOrderService.applyPayResult。

  • [ ] Step 5: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LsySchedulerTest
    

Expected: Tests run: 8, Failures: 0, Errors: 0

  • [ ] Step 6: 跑本模块全部测试

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest='Lsy*Test,Leshouying*Test'
    
  • [ ] Step 7: 确认工作区状态(不提交)

    git status --short
    

Task 17: 管理端控制器

Files:

  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOrderController.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingRefundController.java
  • Create: java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingMerchantController.java
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingAdminWiringTest.java

Interfaces:

  • Consumes: LsyOrderMapper, LsyRefundMapper, LsyNotifyLogMapper, LsyMerchantService
  • Produces: 管理端 REST 端点(走 JWT 认证,不在 ApiKey 白名单里)

本任务不做 test-first。 三个控制器是纯 CRUD 适配层——分页条件拼装 + 转发给 Service/Mapper, 没有分支逻辑,给它写 mock 测试只能测出「我调了我自己」,是负价值。 业务逻辑的测试在 Task 11(LsyMerchantServiceTest)已经覆盖。 这里唯一值得守的是一个安全边界:管理端路径绝不能混进 ApiKey 白名单, 否则任何持 API Key 的租户都能读到全平台订单。所以下面的测试守的是这条线。

  • Step 1: 实现三个管理端控制器

LeshouyingMerchantController.java:

package com.payment.platform.module.payment.leshouying.controller;

import com.payment.platform.common.response.PageResult;
import com.payment.platform.common.response.Result;
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

/**
 * 乐收赢商户映射管理 —— 走 JWT 认证(不在 ApiKey 白名单)。
 * A 阶段联调必需:不配 mch_no 就无法下单。
 */
@RestController
@RequestMapping("/payment/leshouying/merchant")
@RequiredArgsConstructor
public class LeshouyingMerchantController {

    private final LsyMerchantService merchantService;

    @GetMapping("/page")
    public Result<PageResult<LsyMerchantEntity>> page(
            @RequestParam(defaultValue = "1") int pageNo,
            @RequestParam(defaultValue = "20") int pageSize,
            @RequestParam(required = false) String keyword) {
        return Result.ok(merchantService.page(pageNo, pageSize, keyword));
    }

    @PostMapping
    public Result<LsyMerchantEntity> save(@RequestBody LsyMerchantEntity entity) {
        return Result.ok(merchantService.save(entity));
    }

    @DeleteMapping("/{id}")
    public Result<Void> delete(@PathVariable Long id) {
        merchantService.delete(id);
        return Result.ok();
    }
}

LeshouyingOrderController.java:

package com.payment.platform.module.payment.leshouying.controller;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.module.payment.leshouying.entity.LsyNotifyLogEntity;
import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
import com.payment.platform.module.payment.leshouying.mapper.LsyNotifyLogMapper;
import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/** 乐收赢订单管理 —— 走 JWT 认证 */
@RestController
@RequestMapping("/payment/leshouying/order")
@RequiredArgsConstructor
public class LeshouyingOrderController {

    private final LsyOrderMapper orderMapper;
    private final LsyNotifyLogMapper notifyLogMapper;

    @GetMapping("/page")
    public Result<PageResult<LsyOrderEntity>> page(
            @RequestParam(defaultValue = "1") int pageNo,
            @RequestParam(defaultValue = "20") int pageSize,
            @RequestParam(required = false) String status,
            @RequestParam(required = false) String payScene,
            @RequestParam(required = false) String keyword) {
        Page<LsyOrderEntity> page = new Page<>(pageNo, pageSize);
        LambdaQueryWrapper<LsyOrderEntity> w = new LambdaQueryWrapper<>();
        if (StrUtil.isNotBlank(status)) {
            w.eq(LsyOrderEntity::getStatus, status);
        }
        if (StrUtil.isNotBlank(payScene)) {
            w.eq(LsyOrderEntity::getPayScene, payScene);
        }
        if (StrUtil.isNotBlank(keyword)) {
            w.and(q -> q.like(LsyOrderEntity::getOutTradeNo, keyword)
                    .or().like(LsyOrderEntity::getTradeNo, keyword)
                    .or().like(LsyOrderEntity::getPayTraceNo, keyword));
        }
        w.orderByDesc(LsyOrderEntity::getCreatedTime);
        Page<LsyOrderEntity> r = orderMapper.selectPage(page, w);
        return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
    }

    /** 详情含回调审计轨迹,便于对账排查 */
    @GetMapping("/{outTradeNo}")
    public Result<LsyOrderEntity> detail(@PathVariable String outTradeNo) {
        LsyOrderEntity o = requireOrder(outTradeNo);
        return Result.ok(o);
    }

    /**
     * 某订单的回调审计轨迹。
     *
     * ★ 必须**先按 outTradeNo 取到本租户可见的订单**,再用它的 payTraceNo 去查审计表。
     *   不能反过来「直接拿入参 payTraceNo 查日志」—— 那等于把租户边界交给调用方传参:
     *   传一个别的租户的 payTraceNo 就能读到别人的回调报文(报文里有 trade_no、金额)。
     *   经订单中转之后,越权查询在第一步就变成 404 了。
     *
     * ★ 这里也修掉一个静默失效:原写法把 `outTradeNo` 这个 `@PathVariable` **完全没用上**
     *   (只用了可选的 `payTraceNo`),于是 `/notify-logs` 对任何订单都返回同样的
     *   「本租户最近 200 条」—— 路径参数被无声忽略,前端却会以为拿到的是这笔单的轨迹。
     */
    @GetMapping("/{outTradeNo}/notify-logs")
    public Result<List<LsyNotifyLogEntity>> notifyLogs(@PathVariable String outTradeNo) {
        LsyOrderEntity order = requireOrder(outTradeNo);

        LambdaQueryWrapper<LsyNotifyLogEntity> w = new LambdaQueryWrapper<>();
        w.eq(LsyNotifyLogEntity::getPayTraceNo, order.getPayTraceNo());
        w.orderByDesc(LsyNotifyLogEntity::getCreatedTime).last("LIMIT 200");
        return Result.ok(notifyLogMapper.selectList(w));
    }

    /**
     * 取本租户可见的订单,取不到一律 404。
     * ★ 与 Task 15 的 OpenAPI 侧同一条纪律:**绝不把「不存在」和「不属于你」区分开** ——
     *   区分开就等于提供了一个探测别人订单号是否存在的接口。
     */
    private LsyOrderEntity requireOrder(String outTradeNo) {
        LsyOrderEntity o = orderMapper.selectOne(new LambdaQueryWrapper<LsyOrderEntity>()
                .eq(LsyOrderEntity::getOutTradeNo, outTradeNo).last("LIMIT 1"));
        if (o == null) {
            throw new BusinessException(404, "订单不存在");
        }
        return o;
    }
}

LeshouyingRefundController.java:

package com.payment.platform.module.payment.leshouying.controller;

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.payment.platform.common.response.PageResult;
import com.payment.platform.common.response.Result;
import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

/** 乐收赢退款管理 —— 走 JWT 认证 */
@RestController
@RequestMapping("/payment/leshouying/refund")
@RequiredArgsConstructor
public class LeshouyingRefundController {

    private final LsyRefundMapper refundMapper;

    @GetMapping("/page")
    public Result<PageResult<LsyRefundEntity>> page(
            @RequestParam(defaultValue = "1") int pageNo,
            @RequestParam(defaultValue = "20") int pageSize,
            @RequestParam(required = false) String status,
            @RequestParam(required = false) String keyword) {
        Page<LsyRefundEntity> page = new Page<>(pageNo, pageSize);
        LambdaQueryWrapper<LsyRefundEntity> w = new LambdaQueryWrapper<>();
        if (StrUtil.isNotBlank(status)) {
            w.eq(LsyRefundEntity::getStatus, status);
        }
        if (StrUtil.isNotBlank(keyword)) {
            w.and(q -> q.like(LsyRefundEntity::getOutRefundNo, keyword)
                    .or().like(LsyRefundEntity::getOutTradeNo, keyword)
                    .or().like(LsyRefundEntity::getRefundNo, keyword));
        }
        w.orderByDesc(LsyRefundEntity::getCreatedTime);
        Page<LsyRefundEntity> r = refundMapper.selectPage(page, w);
        return Result.ok(PageResult.of(pageNo, pageSize, r.getTotal(), r.getRecords()));
    }

    @GetMapping("/{outRefundNo}")
    public Result<LsyRefundEntity> detail(@PathVariable String outRefundNo) {
        return Result.ok(refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
                .eq(LsyRefundEntity::getOutRefundNo, outRefundNo).last("LIMIT 1")));
    }
}

三个控制器的 Result 工厂必须用 Result.ok(...)(成功码 0)。 Result 类没有 success() / error() 方法 —— 写错会直接编译失败。

  • [ ] Step 2: 写安全边界守卫测试

    package com.payment.platform.module.payment.leshouying.controller;
    
    import com.payment.platform.core.security.TenantApiKeyAuthFilter;
    import com.payment.platform.core.security.SecurityConfig;
    import org.junit.jupiter.api.Test;
    
    import java.lang.reflect.Field;
    import java.util.Arrays;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    /**
    * 管理端安全边界守卫。
    *
    * 管理端接口(/payment/leshouying/**)必须走 JWT 认证。
    * 一旦被误加进 ApiKey 白名单或 SecurityConfig 白名单,任何持 API Key 的租户
    * 就能读到全平台订单/退款数据 —— 这是静默的越权,不会报错,只会漏数据。
    */
    class LeshouyingAdminWiringTest {
    
    private static final String[] ADMIN_PATHS = {
            "/payment/leshouying/order/page",
            "/payment/leshouying/order/{outTradeNo}",
            "/payment/leshouying/refund/page",
            "/payment/leshouying/refund/{outRefundNo}",
            "/payment/leshouying/merchant/page",
            "/payment/leshouying/merchant",
    };
    
    @Test
    void adminPaths_mustNotBeInApiKeyFilterWhitelist() throws Exception {
        Field f = TenantApiKeyAuthFilter.class.getDeclaredField("OPENAPI_ACCOUNT_PATHS");
        f.setAccessible(true);
        String[] paths = (String[]) f.get(null);
    
        for (String admin : ADMIN_PATHS) {
            assertFalse(Arrays.asList(paths).contains(admin),
                    "管理端路径不得进入 ApiKey 认证白名单,否则租户可越权读全平台数据: " + admin);
        }
    }
    
    @Test
    void onlyNotifyPaths_areWhitelistedInSecurityConfig() throws Exception {
        Field f = SecurityConfig.class.getDeclaredField("WHITE_LIST");
        f.setAccessible(true);
        String[] whiteList = (String[]) f.get(null);
    
        // 回调入口放行是必须的(无登录态,靠 MD5 验签)
        assertTrue(Arrays.asList(whiteList).contains("/payment/leshouying/notify/**"));
    
        // 管理端路径绝不能出现在白名单里
        for (String admin : ADMIN_PATHS) {
            assertFalse(Arrays.asList(whiteList).contains(admin),
                    "管理端路径不得放行: " + admin);
        }
        // 也不能用通配符把整个 leshouying 放行
        assertFalse(Arrays.asList(whiteList).contains("/payment/leshouying/**"),
                "整段放行会让管理端接口失去认证");
    }
    
    @Test
    void notifyPath_usesTheSameRequestMappingPrefixAsAdmin() {
        // 防止有人把回调入口挪到 /payment/openapi/leshouying/ 下 —— 那会被 ApiKey 过滤器拦截,
        // 而上游乐收赢不会带 ApiKey 头,回调全部 401
        String notifyPath = "/payment/leshouying/notify/pay";
        assertFalse(notifyPath.startsWith("/payment/openapi/"),
                "回调入口不得放在 openapi 前缀下,否则被 ApiKey 过滤器拦截");
    }
    }
    
  • [ ] Step 3: 运行测试确认全绿

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test -Dtest=LeshouyingAdminWiringTest
    

Expected: Tests run: 3, Failures: 0, Errors: 0 (需要 Task 14 已完成 —— 本测试读的就是 Task 14 改的那两个常量)

  • [ ] Step 4: 编译全量确认控制器无语法问题

    cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn -q test-compile
    
  • [ ] Step 5: 确认工作区状态(不提交)

    git status --short
    

Task 18: 前端管理页 + 菜单 SQL

Files:

  • Create: frontend/src/api/module_payment/leshouying.ts
  • Create: frontend/src/views/module_payment/leshouying/order/index.vue
  • Create: frontend/src/views/module_payment/leshouying/refund/index.vue
  • Create: frontend/src/views/module_payment/leshouying/merchant/index.vue
  • Create: java/src/main/resources/db/lsy-menu.sql(不执行)

Interfaces:

  • Consumes: Task 17 的管理端端点
  • Produces: 三个页面 + 菜单数据

  • [ ] Step 1: 写 API 封装

frontend/src/api/module_payment/leshouying.ts:

import request from '@/utils/request'

// ==================== 订单 ====================
export function getLsyOrderPage(params: {
  pageNo: number
  pageSize: number
  status?: string
  payScene?: string
  keyword?: string
}) {
  return request({ url: '/payment/leshouying/order/page', method: 'get', params })
}

export function getLsyOrderDetail(outTradeNo: string) {
  return request({ url: `/payment/leshouying/order/${outTradeNo}`, method: 'get' })
}

export function getLsyNotifyLogs(outTradeNo: string) {
  return request({
    url: `/payment/leshouying/order/${outTradeNo}/notify-logs`,
    method: 'get'
  })
}

// ==================== 退款 ====================
export function getLsyRefundPage(params: {
  pageNo: number
  pageSize: number
  status?: string
  keyword?: string
}) {
  return request({ url: '/payment/leshouying/refund/page', method: 'get', params })
}

export function getLsyRefundDetail(outRefundNo: string) {
  return request({ url: `/payment/leshouying/refund/${outRefundNo}`, method: 'get' })
}

// ==================== 商户映射 ====================
export function getLsyMerchantPage(params: {
  pageNo: number
  pageSize: number
  keyword?: string
}) {
  return request({ url: '/payment/leshouying/merchant/page', method: 'get', params })
}

export function saveLsyMerchant(data: Record<string, unknown>) {
  return request({ url: '/payment/leshouying/merchant', method: 'post', data })
}

export function deleteLsyMerchant(id: number) {
  return request({ url: `/payment/leshouying/merchant/${id}`, method: 'delete' })
}
  • Step 2: 写订单页

frontend/src/views/module_payment/leshouying/order/index.vue —— 照 views/module_payment/batch/index.vue 的既有列表页写法(har/ 尚在 stash 中,工作区里不存在,不要去找):app-container + el-card shadow="never" + 顶部 el-form :inline="true" 查询条 + el-table + el-pagination。

<template>
  <div class="app-container">
    <el-card shadow="never">
      <el-form :inline="true" :model="query">
        <el-form-item label="订单号">
          <el-input v-model="query.keyword" placeholder="下游/上游订单号" clearable
                    style="width: 240px" @keyup.enter="handleSearch" />
        </el-form-item>
        <el-form-item label="场景">
          <el-select v-model="query.payScene" placeholder="全部" clearable style="width: 140px">
            <el-option label="被扫" value="BARCODE" />
            <el-option label="主扫" value="QRCODE" />
            <el-option label="H5" value="H5" />
          </el-select>
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="query.status" placeholder="全部" clearable style="width: 160px">
            <el-option v-for="s in STATUS_OPTIONS" :key="s.value"
                       :label="s.label" :value="s.value" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card shadow="never" style="margin-top: 12px">
      <el-table v-loading="loading" :data="list" border stripe>
        <el-table-column prop="outTradeNo" label="下游订单号" min-width="200" show-overflow-tooltip />
        <el-table-column label="场景" width="90">
          <template #default="{ row }">{{ sceneLabel(row.payScene) }}</template>
        </el-table-column>
        <el-table-column label="金额(元)" width="110" align="right">
          <template #default="{ row }">{{ fen2yuan(row.totalAmount) }}</template>
        </el-table-column>
        <el-table-column label="已退(元)" width="110" align="right">
          <template #default="{ row }">{{ fen2yuan(row.refundedAmount) }}</template>
        </el-table-column>
        <el-table-column label="状态" width="130">
          <template #default="{ row }">
            <el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="tradeNo" label="乐收赢订单号" min-width="200" show-overflow-tooltip />
        <el-table-column label="回调" width="100">
          <template #default="{ row }">
            <el-tag v-if="row.notifyStatus === 'SUCCESS'" type="success" size="small">已送达</el-tag>
            <el-tag v-else-if="row.notifyStatus === 'FAIL'" type="danger" size="small">
              失败({{ row.notifyCount }})
            </el-tag>
            <el-tag v-else type="info" size="small">待推送</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdTime" label="创建时间" width="180" />
        <el-table-column label="操作" width="100" fixed="right">
          <template #default="{ row }">
            <el-button link type="primary" @click="openDetail(row)">详情</el-button>
          </template>
        </el-table-column>
      </el-table>

      <el-pagination
        v-model:current-page="query.pageNo"
        v-model:page-size="query.pageSize"
        :total="total"
        :page-sizes="[10, 20, 50, 100]"
        layout="total, sizes, prev, pager, next, jumper"
        style="margin-top: 12px; justify-content: flex-end"
        @change="loadData"
      />
    </el-card>

    <el-drawer v-model="detailVisible" title="订单详情" size="720px">
      <el-descriptions v-if="detail" :column="1" border>
        <el-descriptions-item label="下游订单号">{{ detail.outTradeNo }}</el-descriptions-item>
        <el-descriptions-item label="乐收赢商户号">{{ detail.mchNo }}</el-descriptions-item>
        <el-descriptions-item label="支付流水号">{{ detail.payTraceNo }}</el-descriptions-item>
        <el-descriptions-item label="乐收赢订单号">{{ detail.tradeNo || '-' }}</el-descriptions-item>
        <el-descriptions-item label="渠道订单号">{{ detail.tdTradeNo || '-' }}</el-descriptions-item>
        <el-descriptions-item label="金额">
          {{ fen2yuan(detail.totalAmount) }} 元 / 实收 {{ fen2yuan(detail.receiptAmount) }} 元
        </el-descriptions-item>
        <el-descriptions-item label="上游结果">
          {{ detail.upstreamResultCode }} {{ detail.upstreamResultMsg }}
        </el-descriptions-item>
        <el-descriptions-item v-if="detail.codeUrl" label="支付链接">
          <el-link :href="detail.codeUrl" target="_blank" type="primary">{{ detail.codeUrl }}</el-link>
        </el-descriptions-item>
        <el-descriptions-item label="备注">{{ detail.description || '-' }}</el-descriptions-item>
      </el-descriptions>

      <h4 style="margin-top: 20px">回调轨迹</h4>
      <el-table :data="notifyLogs" border size="small">
        <el-table-column prop="createdTime" label="时间" width="180" />
        <el-table-column prop="tradeType" label="类型" width="70">
          <template #default="{ row }">{{ tradeTypeLabel(row.tradeType) }}</template>
        </el-table-column>
        <el-table-column prop="verifyStatus" label="验签" width="80">
          <template #default="{ row }">
            <el-tag :type="row.verifyStatus === 'SUCCESS' ? 'success' : 'danger'" size="small">
              {{ row.verifyStatus }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="processStatus" label="处理" width="130" />
        <el-table-column label="原文" min-width="200">
          <template #default="{ row }">
            <el-popover placement="left" :width="600" trigger="click">
              <template #reference><el-button link type="primary">查看</el-button></template>
              <pre style="max-height: 400px; overflow: auto">{{ row.rawBody }}</pre>
            </el-popover>
          </template>
        </el-table-column>
      </el-table>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import {
  getLsyOrderPage, getLsyOrderDetail, getLsyNotifyLogs
} from '@/api/module_payment/leshouying'

const STATUS_OPTIONS = [
  { label: '已建单', value: 'CREATED' },
  { label: '支付中', value: 'PAYING' },
  { label: '支付成功', value: 'SUCCESS' },
  { label: '支付失败', value: 'FAIL' },
  { label: '已撤销', value: 'CANCELED' },
  { label: '已关单', value: 'CLOSED' },
  { label: '已过期', value: 'EXPIRED' },
  { label: '部分退款', value: 'PARTIAL_REFUNDED' },
  { label: '已全额退款', value: 'REFUNDED' }
]

const SCENE_LABEL: Record<string, string> = {
  BARCODE: '被扫', QRCODE: '主扫', H5: 'H5'
}

const query = reactive({
  pageNo: 1, pageSize: 20, status: '', payScene: '', keyword: ''
})
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const detailVisible = ref(false)
const detail = ref<any>(null)
const notifyLogs = ref<any[]>([])

function sceneLabel(v: string) { return SCENE_LABEL[v] || v }
function fen2yuan(fen?: number | null) {
  return fen == null ? '-' : (fen / 100).toFixed(2)
}
function statusLabel(v: string) {
  return STATUS_OPTIONS.find(s => s.value === v)?.label || v
}
function statusTagType(v: string) {
  if (v === 'SUCCESS') return 'success'
  if (v === 'FAIL') return 'danger'
  if (v === 'PAYING' || v === 'CREATED') return 'warning'
  if (v === 'REFUNDED' || v === 'PARTIAL_REFUNDED') return 'info'
  return 'info'
}
function tradeTypeLabel(v: string) {
  return ({ '0': '退款', '1': '交易', '2': '云订单关单' } as Record<string, string>)[v] || v || '-'
}

async function loadData() {
  loading.value = true
  try {
    const res: any = await getLsyOrderPage(query)
    // res 是 AxiosResponse(request.ts 的响应拦截器 return response 不拆包)
    // → res.data = {code,msg,data}  → res.data.data = PageResult
    // PageResult 的分页字段名是 list(不是 records/items)
    list.value = res.data.data?.list || []
    total.value = res.data.data?.total || 0
  } finally {
    loading.value = false
  }
}

function handleSearch() { query.pageNo = 1; loadData() }
function handleReset() {
  query.keyword = ''; query.status = ''; query.payScene = ''; query.pageNo = 1; loadData()
}

async function openDetail(row: any) {
  const res: any = await getLsyOrderDetail(row.outTradeNo)
  // ★ 两层 data:`request.ts` 的响应拦截器 `return response`,**不做解包**,
  //   所以 res.data 是整个 `{code,msg,data}` 信封,业务数据在 res.data.data。
  //   写成 res.data 会把信封塞给 el-descriptions,字段全部渲染成空 —— 页面不报错,
  //   只是"什么都没显示",是那种查半天查不出来的假成功。
  //   (本仓库既有写法即两层:全库 `.data.data` 出现 264 处。)
  detail.value = res.data.data
  const logs: any = await getLsyNotifyLogs(row.outTradeNo)
  notifyLogs.value = logs.data.data || []
  detailVisible.value = true
}

onMounted(loadData)
</script>
  • Step 3: 写退款页

frontend/src/views/module_payment/leshouying/refund/index.vue —— 结构与订单页同构,字段换成退款单:

<template>
  <div class="app-container">
    <el-card shadow="never">
      <el-form :inline="true" :model="query">
        <el-form-item label="退款单号">
          <el-input v-model="query.keyword" placeholder="下游/上游退款单号" clearable
                    style="width: 240px" @keyup.enter="handleSearch" />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="query.status" placeholder="全部" clearable style="width: 140px">
            <el-option label="已建单" value="CREATED" />
            <el-option label="退款中" value="REFUNDING" />
            <el-option label="退款成功" value="SUCCESS" />
            <el-option label="退款失败" value="FAIL" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card shadow="never" style="margin-top: 12px">
      <el-table v-loading="loading" :data="list" border stripe>
        <el-table-column prop="outRefundNo" label="下游退款单号" min-width="200" show-overflow-tooltip />
        <el-table-column prop="outTradeNo" label="原订单号" min-width="200" show-overflow-tooltip />
        <el-table-column label="退款金额(元)" width="130" align="right">
          <template #default="{ row }">{{ fen2yuan(row.refundFee) }}</template>
        </el-table-column>
        <el-table-column label="实退(元)" width="110" align="right">
          <template #default="{ row }">{{ fen2yuan(row.refundSettle) }}</template>
        </el-table-column>
        <el-table-column label="状态" width="110">
          <template #default="{ row }">
            <el-tag :type="row.status === 'SUCCESS' ? 'success'
                         : row.status === 'FAIL' ? 'danger' : 'warning'">
              {{ statusLabel(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="refundNo" label="乐收赢退款单号" min-width="200" show-overflow-tooltip />
        <el-table-column prop="createdTime" label="创建时间" width="180" />
      </el-table>

      <el-pagination
        v-model:current-page="query.pageNo"
        v-model:page-size="query.pageSize"
        :total="total"
        :page-sizes="[10, 20, 50, 100]"
        layout="total, sizes, prev, pager, next, jumper"
        style="margin-top: 12px; justify-content: flex-end"
        @change="loadData"
      />
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { getLsyRefundPage } from '@/api/module_payment/leshouying'

const STATUS_LABEL: Record<string, string> = {
  CREATED: '已建单', REFUNDING: '退款中', SUCCESS: '退款成功', FAIL: '退款失败'
}

const query = reactive({ pageNo: 1, pageSize: 20, status: '', keyword: '' })
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)

function fen2yuan(fen?: number | null) {
  return fen == null ? '-' : (fen / 100).toFixed(2)
}
function statusLabel(v: string) { return STATUS_LABEL[v] || v }

async function loadData() {
  loading.value = true
  try {
    const res: any = await getLsyRefundPage(query)
    list.value = res.data.data?.list || []
    total.value = res.data.data?.total || 0
  } finally {
    loading.value = false
  }
}

function handleSearch() { query.pageNo = 1; loadData() }
function handleReset() { query.keyword = ''; query.status = ''; query.pageNo = 1; loadData() }

onMounted(loadData)
</script>
  • Step 4: 写商户映射页

frontend/src/views/module_payment/leshouying/merchant/index.vue:

<template>
  <div class="app-container">
    <el-alert type="info" :closable="false" style="margin-bottom: 12px"
      title="A 阶段手工维护:企业的乐收赢商户号。不配置则无法为该企业下单。进件功能上线后将自动写入。" />

    <el-card shadow="never">
      <el-form :inline="true" :model="query">
        <el-form-item label="关键字">
          <el-input v-model="query.keyword" placeholder="商户号 / 商户名 / 企业ID" clearable
                    style="width: 260px" @keyup.enter="handleSearch" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleSearch">查询</el-button>
          <el-button type="success" @click="openForm(null)">新增</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card shadow="never" style="margin-top: 12px">
      <el-table v-loading="loading" :data="list" border stripe>
        <el-table-column prop="enterpriseId" label="企业 ID" min-width="180" show-overflow-tooltip />
        <el-table-column prop="mchNo" label="乐收赢商户号" min-width="160" />
        <el-table-column prop="shopNo" label="门店号" min-width="140" />
        <el-table-column prop="merchantName" label="商户名称" min-width="180" />
        <el-table-column prop="reviewStatus" label="审核状态" width="120" />
        <el-table-column label="状态" width="100">
          <template #default="{ row }">
            <el-tag :type="row.status === 'ENABLED' ? 'success' : 'info'">
              {{ row.status === 'ENABLED' ? '启用' : '停用' }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="createdTime" label="创建时间" width="180" />
        <el-table-column label="操作" width="140" fixed="right">
          <template #default="{ row }">
            <el-button link type="primary" @click="openForm(row)">编辑</el-button>
            <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
          </template>
        </el-table-column>
      </el-table>

      <el-pagination
        v-model:current-page="query.pageNo"
        v-model:page-size="query.pageSize"
        :total="total"
        :page-sizes="[10, 20, 50]"
        layout="total, sizes, prev, pager, next, jumper"
        style="margin-top: 12px; justify-content: flex-end"
        @change="loadData"
      />
    </el-card>

    <el-dialog v-model="formVisible" :title="form.id ? '编辑商户映射' : '新增商户映射'" width="560px">
      <el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
        <el-form-item label="企业 ID" prop="enterpriseId">
          <el-input v-model="form.enterpriseId" placeholder="我方 pay_enterprise.enterprise_id" />
        </el-form-item>
        <el-form-item label="乐收赢商户号" prop="mchNo">
          <el-input v-model="form.mchNo" placeholder="如 Y52211090004" />
        </el-form-item>
        <el-form-item label="门店号">
          <el-input v-model="form.shopNo" placeholder="可空" />
        </el-form-item>
        <el-form-item label="商户名称">
          <el-input v-model="form.merchantName" />
        </el-form-item>
        <el-form-item label="备注">
          <el-input v-model="form.description" type="textarea" :rows="2" />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="formVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getLsyMerchantPage, saveLsyMerchant, deleteLsyMerchant } from '@/api/module_payment/leshouying'

const query = reactive({ pageNo: 1, pageSize: 20, keyword: '' })
const list = ref<any[]>([])
const total = ref(0)
const loading = ref(false)
const formVisible = ref(false)
const formRef = ref()
const form = reactive<any>({
  id: null, enterpriseId: '', mchNo: '', shopNo: '', merchantName: '', description: ''
})

const rules = {
  enterpriseId: [{ required: true, message: '企业 ID 必填', trigger: 'blur' }],
  mchNo: [{ required: true, message: '乐收赢商户号必填', trigger: 'blur' }]
}

async function loadData() {
  loading.value = true
  try {
    const res: any = await getLsyMerchantPage(query)
    list.value = res.data.data?.list || []
    total.value = res.data.data?.total || 0
  } finally {
    loading.value = false
  }
}

function handleSearch() { query.pageNo = 1; loadData() }

function openForm(row: any) {
  Object.assign(form, row || {
    id: null, enterpriseId: '', mchNo: '', shopNo: '', merchantName: '', description: ''
  })
  formVisible.value = true
}

async function handleSubmit() {
  await formRef.value?.validate()
  await saveLsyMerchant(form)
  ElMessage.success('保存成功')
  formVisible.value = false
  loadData()
}

async function handleDelete(row: any) {
  await ElMessageBox.confirm(
    `确认删除商户映射「${row.mchNo}」?删除后该企业将无法下单。`, '提示', { type: 'warning' })
  await deleteLsyMerchant(row.id)
  ElMessage.success('已删除')
  loadData()
}

onMounted(loadData)
</script>
  • Step 5: 写菜单 SQL

java/src/main/resources/db/lsy-menu.sql(不执行):

-- 乐收赢支付通道 —— 菜单与权限
-- 【注意】本脚本未经执行。执行前请确认菜单 ID 段未被占用。
-- 参照 db/batch-menu.sql 的写法。

-- 一级目录:乐收赢
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
                      is_cache, menu_type, visible, status, perms, icon, create_by,
                      create_time, remark)
VALUES (2200, '乐收赢支付', 0, 80, 'leshouying', NULL, 1, 0, 'M', '0', '0', '', 'Money', 'admin',
        now(), '乐收赢支付通道');

-- 订单管理
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
                      is_cache, menu_type, visible, status, perms, icon, create_by,
                      create_time, remark)
VALUES (2201, '支付订单', 2200, 1, 'order', 'module_payment/leshouying/order/index', 1, 0,
        'C', '0', '0', 'payment:leshouying:order:list', 'List', 'admin', now(), '');

-- 退款管理
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
                      is_cache, menu_type, visible, status, perms, icon, create_by,
                      create_time, remark)
VALUES (2202, '退款记录', 2200, 2, 'refund', 'module_payment/leshouying/refund/index', 1, 0,
        'C', '0', '0', 'payment:leshouying:refund:list', 'RefreshLeft', 'admin', now(), '');

-- 商户映射
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, is_frame,
                      is_cache, menu_type, visible, status, perms, icon, create_by,
                      create_time, remark)
VALUES (2203, '商户映射', 2200, 3, 'merchant', 'module_payment/leshouying/merchant/index', 1, 0,
        'C', '0', '0', 'payment:leshouying:merchant:list', 'Shop', 'admin', now(), '');
  • [ ] Step 6: 前端类型检查与构建

    cd frontend && npx vue-tsc --noEmit --skipLibCheck && npx vite build
    

Expected: 无类型错误,构建成功。

  • [ ] Step 7: 确认工作区状态(不提交)

    git status --short
    

完成标准

  • cd java && /c/Users/1/tools/apache-maven-3.9.16/bin/mvn test 全绿 (★ 不许加 -q:实测 -q 会让 stdout 全空,连 Tests run: 都看不到 —— 与 Global Constraints 一致。 本行 2026-09-21 由 lead 修正,原来写的是 mvn -q test,是本计划内部的一处自相矛盾。)
  • cd frontend && npx vue-tsc --noEmit --skipLibCheck 无错误
  • git log --oneline -1 仍停在 dbe364c
  • git status --short 显示所有新增文件为 ??,无 M 之外的非预期改动
  • SQL 文件存在但未对任何数据库执行
  • grep -rn "!isPaySuccess" java/src 结果为空。未知码不得被判成失败 —— 上游某端点若回通用成功码 "SUCCESS",三个谓词全 false,!isPaySuccess 会把一笔已收到钱的支付判成失败落库。 正确形态是三谓词分派 + 未知码走「记日志 + 留在非终态 + 待人工核对」(Task 6/7/8/9/12 已如此实现)。
  • grep -rn "isPayFail" java/src/main/java --include=*.java 结果非空。 这是弱检查,只证明「显式失败判据真的有人用」;一个都没有,说明失败判定又回到了 裸比较或 !isPaySuccess。把它留在这里是因为它机械、且不会因注释而误报 —— 见下条。
  • ⚠️ 不要试图用「grep 字面量」来兜住未知码这件事,我试过,那条路是错的。 曾写下这条:grep -rlnE '"(PAY_SUCCESS|PAY_SUCEESS|PAY_FAIL|...)"' java/src 命中文件只允许是 parser 与 LsyNotifyService 两处。实测它是误报源:本计划的行文风格是在注释里引用被禁止的写法 (Task 12 的注释原文就写着 "PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL", LsyOrderService 的注释写着「如 "SUCCESS" 而非 "PAY_SUCCESS"」), 仅计划正文里带引号的码字面量就有 79 行。于是这条 grep 会把每一个「解释过这件事的文件」都判成违规, 而真正的违规(x ? A : B)它照样看不见。 误报的检查比没有检查更坏:它会被忽略,而被忽略的检查会制造「已经有人守着了」的假信心 —— 我上一条就正是这么把自己骗过去的。所以这条已删除,只保留「靠测试守」这一条路。
  • ★ 未知码这件事,唯一的执行点是测试,不是 grep。 "PAY_SUCCESS".equals(rc) ? "SUCCESS" : "FAIL" 既没有 !isPaySuccess, 也没有裸字面量落在「不该有的文件」里 —— 上面第一条 grep 放行, 「字面量按文件白名单」那条(已删除)也放行。但它是真钱缺陷(见 Task 12 的两段 ★ 注释)。 能挡住它的是可执行的测试: LsyNotifyServiceTest 必须包含 payNotify_unknownResultCode_doesNotWriteTerminalStatus 与 refundNotify_unknownResultCode_doesNotWriteTerminalFail 两条,且必须真的跑到(mvn test 全绿)。 这两条喂的都是合法签名的 result_code: "SUCCESS",断言「不写终态、不通知下游」—— 谁把三分支改回二元,它们立刻变红。 确认命令:grep -rn "unknownResultCode" java/src --include=*.java 恰好命中这两个测试方法。 同族的另外三条:LsyCancelServiceTest.barcode_unknownResultCode_refusesToCancel、 barcode_payInProcess_refusesToCancel、barcode_docSpellingPaySuceess_isNotAcceptedAsSuccess —— 它们守的是不可逆动作(撤销/关单)在「不知道能不能做」时朝不执行的方向失败。 教训:机械检查只能覆盖它能表达的形状。写不出来的形状要靠可执行的测试,不能靠「我读过代码了」; 而一条会误报的检查比没有检查更坏 —— 它会被忽略,被忽略的检查制造假信心。
  • grep -rnE "getRawJson|getRawReturnMsg|getRawReturnCode" java/src --include=*.java 只命中 LeshouyingApiException 自身与其测试。三个 Raw getter 是逐字上游原文, 进了日志就等于把未脱敏报文写进日志文件;命名是唯一的护栏,所以要用这条 grep 兜住。
  • 没有任何测试把 getRaw* 的结果直接喂给日志调用(同上,命名挡不住 log.info("{}", ex.getRawJson()))。

交付后仍需联调确认(spec §14)

这 7 项不影响实现,但会影响线上正确性,需与乐收赢确认:

  1. H5 订单如何关闭(当前本地置 EXPIRED,上游链接仍有效)
  2. 响应报文签名拼装规则(当前不做响应验签)
  3. h5pay 的轮询规则(当前靠回调 + 5 分钟兜底)
  4. v3/qrclose 的返回语义措辞与关单场景不符
  5. 回调报文是否回显 auth_code。 注意:这不改变任何代码 —— Task 12 已按「不假设上游不回显」 实现(脱敏放在写入口,不放在「上游应该不会」的假设上)。问它是为了知道这道防线实际有没有被用到: 若上游确实回显,脱敏就是每天都在生效的必需控制;若不回显,它是针对将来的防御。两种结论都不导致改代码。
  6. ★ 是否存在 /v3/* 端点返回通用成功码 result_code: "SUCCESS"(而非 "PAY_SUCCESS")。 这条阻塞 Task 5/6 的状态机,且无法从仓库内验证。见下节。 顺带会解答一个次生问题:退款接口(Task 10)若回未知码,退款会停在 REFUNDING 不再前进 —— 不是「误报失败」那么严重,但会积压。知道取值域才能判断这是否真会发生。 注意 Task 10 的在途互斥恰好依赖它不会永久停在 REFUNDING:真停住了,这张单就再也退不了款, 得人工改状态。所以这条不只是"积压",对退款侧是阻塞性的。
  7. 同步成功的退款要不要通知下游。 现状是不通知 —— 只有回调路径会 callbackService.notifyOrder, 而 createRefund 同步成功时是直接返回给调用方(管理端界面能看到),不触发下游 webhook。 如果租户的系统靠 webhook 感知退款,这条就得补;如果退款只走管理端查询,现状即可。 属于产品语义问题,不是缺陷 —— 所以没动代码,列在这里等答案。

★ 本计划未覆盖的既有风险(与上面 7 条不同:这些不需要问乐收赢,需要独立排期)

  1. ★ 共享 RestTemplate 没有任何超时。 来源见 Task 4 的 Minor #3(逐任务评审查出,我复核确认)。

    • 事实:WebMvcConfig.java:24-27 定义的 RestTemplate 是无参 new RestTemplate(), 计划全文中没有任何一处设置 connectTimeout / readTimeout;Task 4 的 LeshouyingClient 直接注入这个共享 bean(注入第二个 bean 会 NoUniqueBeanDefinitionException)。
    • 后果:上游 TCP 连上但不回包时,请求线程无限阻塞(默认 JDK HttpURLConnection 读超时为 0 = 永不超时)。Task 16 的调度器线程同样会被卡住 —— 那不是「这一笔慢」, 是对账/兜底轮询整体停摆。
    • 为什么不在本计划里改:Task 14 改的三个共享文件是 TenantApiKeyAuthFilter.java:92-96 / SecurityConfig.java:38-70 / TenantInnerInterceptor.java:53-76, 不含 WebMvcConfig。而这个 bean 同时被既有的 OpenapiService 使用 —— 给它加超时会一并改变既有下游回调路径的行为,属于本计划范围之外, 该独立成一笔带自己审查的变更,不能夹在乐收赢通道里顺手做。
    • 改法:WebMvcConfig 里换成显式工厂,例如

      SimpleClientHttpRequestFactory f = new SimpleClientHttpRequestFactory();
      f.setConnectTimeout(5_000);   // 5s
      f.setReadTimeout(30_000);     // 30s —— 乐收赢下单是同步调用,不宜过长
      return new RestTemplate(f);
      

    或 ClientHttpRequestFactorySettings 的 builder 形式。超时值要和乐收赢确认 (付款码支付是同步扣款,超时给太短会在用户已付款时误报失败)。

    • 不改的代价:一次上游网络抖动即可拖垮线程池。这条不该带着上线。

另需人工验证(单测覆盖不到,属实现正确性而非上游问题)

启动 Spring 上下文,打开 MyBatis SQL 日志,然后:

  1. 跨租户隔离 —— 用租户 A 的 API Key 调 /payment/openapi/leshouying/order/query, 确认打印出的 SQL 的 WHERE 里确实有 tenant_id = ?。 说明(2026-09-21 lead 更正):pay_lsy_* 在 CONDITIONAL_TABLES 中,但 ApiKey 请求是有认证上下文的 (该 filter 放入带租户的 LoginUser),拦截器会追加 tenant_id;要人验是因为 Mockito 单测里拦截器不跑。 同样确认 /order/close、/refund、/refund/query、三个 */create 的幂等查找。
  2. 回调入口无鉴权 —— 不带任何鉴权头 curl 一次 /payment/leshouying/notify/pay, 必须返回 {"return_code":"FAIL","return_msg":"验签失败"},且不产生任何业务状态变更。
  3. 条件更新落库 —— 同一订单重复投递一次支付成功回调,确认第二次不再触发下游回调 (即 pay_lsy_order 无二次 UPDATE,pay_lsy_notify_log 有两条记录)。
  4. 付款码没有落盘(两处都要看) —— 跑一笔真实的被扫支付,然后:
    • SELECT raw_body FROM pay_lsy_notify_log ORDER BY id DESC LIMIT 1; —— 报文结构、 return_code、trade_no 应完好,不含 18 位付款码(应为 ***);
    • 翻 logs/payment-platform-error.log,确认解析失败路径下也不含完整付款码。 单测只覆盖了脱敏器本身;「真实报文长什么样」只有联调能证明。 同时确认:sign 字段仍在 raw_body 里 —— 它是审计与排障的依据,不该被值层遮掉(sign 是 32 位十六进制, 含字母,连续数字段远短于 12 位,故不受影响;若发现被遮,说明值层阈值或报文形态与预期不符,需要复核)。
  5. 部分唯一索引真的建上了,且并发双发确实被挡住 —— 防重复退款的三道锁里,只有索引是结构性的, 而它依赖 PostgreSQL 的部分索引能力(CREATE UNIQUE INDEX ... WHERE,MySQL 没有对等物), 脚本必须真的跑到目标库上才算数(V1.8 + lsy-menu.sql 目前按约定未执行,所以这条现在只能人工验)。 验证两步:
    • \d pay_lsy_refund 应看到 uk_lsy_refund_one_pending 且带 WHERE status IN (...);
    • 手工对同一 order_id 连插两行 status='REFUNDING',第二行必须报唯一约束冲突。 若插得进去,说明索引没生效(写法或库版本问题),此时并发双发就没有任何防线 —— Task 10 的 应用层校验是读-判-写,挡不住并发。这条不通过就不能上线退款功能。

★ 阻塞项:result_code 取值域(进 Task 5/6 之前必须有答案)

Task 2 的 LeshouyingResponseParser 提供三个互斥谓词:isPaySuccess / isPayInProcess / isPayFail。 但存在一个三者皆为 false 的输入:{"return_code":"SUCCESS","result_code":"SUCCESS"}。

  • 若 /v3/* 的成功响应只会回 PAY_SUCCESS,则这不是问题(未知码自然落到人工核对分支)。
  • 若某个 /v3/* 端点确实回通用成功码 "SUCCESS",那么一笔已经收到钱的支付会落进「未知码」分支 —— 比误判为失败好,但仍然是每笔都要人工核对。

审查者已确认无法从仓库内判定,需要上游文档(doc.leshouying.cn 的 /v3/* 响应码表)。 拿到答复前:

  • 不得把这三个谓词当作穷尽;
  • 调用方(Task 6/7/9)不得写 !isPaySuccess(...) 来判失败 —— 那会把「成功」判成「失败」落库;
  • 未知码必须走「记日志 + 待人工核对」,不得静默归类。
  • 回调路径(Task 12)走同一规则的另一种写法:它不经 parser,直接比较 result_code, 因此 LsyNotifyService 的两处比较都必须是三分支,并由 payNotify_unknownResultCode_* / refundNotify_unknownResultCode_* 两条测试钉住。 这两条不是锦上添花 —— 它们是这条规则在回调路径上的唯一执行点(grep 看不见 x ? A : B)。