2026-09-22-refund-preinsert-created.md 31 KB

退款预插入 CREATED 实现计划(台账 §2.1 的主症)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 或 superpowers:executing-plans 逐任务执行。步骤用 - [ ] 复选框跟踪。

Goal: 把退款单的本地落库从「上游调用之后」提前到「上游调用之前」(status='CREATED'),堵住「上游超时 → 租户重试 → 两次真实出金」这条不依赖竞态的路径。

Architecture: LsyRefundService.createRefund 现在先 client.post 再 insert,中间没有任何事务(刻意如此,理由在类 javadoc :32-36)。改成:调上游之前先插一行 CREATED;收到响应后(说明上游已受理)补写上游来源字段并把状态推进到 REFUNDING;其余分支逻辑一字不动。状态推进仍然只走 applyNotifyResult 这一个写入口,因此顺带要修它的一处:它无条件写 finish_time,而非终态流转不该有完成时间。

Tech Stack: Java 21 / Spring Boot / MyBatis-Plus / JUnit 5 + Mockito

Spec: docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §2.1(「✅ 裁定(2026-09-22)」与「⏳ 实施进度」两节是本次的约束来源)

Global Constraints

  • 「验签当然不能跳过」——本计划不碰任何签名逻辑;不得新增开关/后门/绕过路径。
  • 「所有文案不要暴露乐收赢」——新增日志文案只说「退款」「上游」,不得出现上游公司名、leshouying、lsy 等字样;outRefundNo / refundTraceNo 这类我们自己的值可以打。
  • 不得猜测:台账 §2.1 的「⚠ 本条的『不得猜测』结论仍然有效」——回调定位键的重新选择不在本计划范围内(那是 R41,挂起中)。
  • 本计划只做 §2.1 的预插入那一半。回调那一半(换定位键)明确不做。
  • SQL 文件由我写、由用户执行;本计划不需要任何 DDL 变更(CREATED 已在 uk_lsy_refund_one_pending 的谓词里,见 V1.8:234)。
  • 构建命令:C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test —— mvnw 是坏的;不得加 -q 或 -proc:none(会静默毁掉验证);每轮跑之前先删 java/target/surefire-reports/(跨运行累积,全目录求和会读出假的测试数)。

本次改动引入的新缺口(必须如实写进台账,不得私自「解决」)

上游调用抛异常时,预插入的那行会留在 CREATED。后果两条:

  1. 该订单在该行被收敛前发不起退款(在途闸门 :108 + uk_lsy_refund_one_pending 都认它);
  2. 收敛通道是 LsyReconcileScheduler.reconcilePendingRefunds(扫 LsyRefundStatus.PENDING,CREATED ∈ 它,扫得到),但那条腿没有上界、会被老行饿死——这是台账已记的老缺口,不是本次新增的。

方向是安全的(宁可拒付,不重复出金),但必须能被人看见:Task 2 的 catch 分支为此打了 log.error 并带上 outRefundNo / refundTraceNo。


File Structure

文件 职责 本次动作
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java 退款发起 + 退款行唯一状态写入口 改 createRefund(预插入、异常日志、补字段、推进 REFUNDING);改 applyNotifyResult(finish_time 仅终态);新增私有 persistUpstreamIdentity
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java 上者的单测 新增 5 条;改 3 条既有用例的断言

Task 1: applyNotifyResult 的 finish_time 只在终态流转时写

为什么单独成一个任务: Task 2 会让 applyNotifyResult 第一次执行非终态流转(CREATED → REFUNDING)。旧实现无条件 patch.setFinishTime(OffsetDateTime.now())(:411),而那会让一笔还在途的退款带上「完成时间」——V1.8:161 这一列的注释就是「完成时间(终态)」,事后按 finish_time IS NULL 捞未完结退款会漏掉它。这是一处本次改动会新引入的数据质量缺陷,先修掉再动 Task 2。

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java:411
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java(新增两条,紧挨既有的 applyNotifyResult_* 三条)

Interfaces:

  • Consumes: LsyRefundStatus.PENDING(enums/LsyRefundStatus.java:36,Set<String> = {CREATED, REFUNDING})——已存在,不改。
  • Produces: applyNotifyResult(LsyRefundEntity, String resultCode, String resultMsg, String newStatus) 签名不变、返回值语义不变;行为差异仅一处:newStatus 属于 PENDING 时不再写 finish_time。

  • [ ] Step 1: 写失败测试(两条:一条钉住新行为,一条是它的阳性对照)

在 LsyRefundServiceTest 已有的 applyNotifyResult_transitionApplied_returnsTrue 之后插入:

    @Test
    void applyNotifyResult_非终态流转_不写finishTime() {
        // finish_time 的列注释是「完成时间(终态)」(V1.8:161)。
        // §2.1 预插入之后本方法多了一个非终态流转(CREATED → REFUNDING),
        // 无条件写会让**还在途**的退款带上完成时间 —— 事后按 finish_time IS NULL
        // 捞未完结退款就会漏掉它。MP 的 NOT_NULL 策略下「patch 里为 null」= 该列不进 SET。
        LsyRefundEntity r = new LsyRefundEntity();
        r.setId(9L);
        r.setStatus("CREATED");
        when(refundMapper.update(any(), any())).thenReturn(1);

        assertTrue(service.applyNotifyResult(r, "PAY_IN_PROCESS", "处理中", "REFUNDING"));

        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper).update(captor.capture(), any());
        assertNull(captor.getValue().getFinishTime(),
                "非终态流转不得写 finish_time(该列语义是「完成时间(终态)」)");
    }

    @Test
    void applyNotifyResult_终态流转_仍然写finishTime() {
        // ★ 阳性对照:证明上面那条不是因为「finish_time 被整个删掉了」才绿的。
        //   两向都要有读数,否则「不写」与「根本不会写」在结果上无法区分。
        LsyRefundEntity r = new LsyRefundEntity();
        r.setId(9L);
        r.setStatus("REFUNDING");
        when(refundMapper.update(any(), any())).thenReturn(1);

        assertTrue(service.applyNotifyResult(r, "PAY_SUCCESS", "成功", "SUCCESS"));

        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper).update(captor.capture(), any());
        assertNotNull(captor.getValue().getFinishTime(),
                "终态必须写 finish_time —— 否则这条列永远不会被写");
    }
  • Step 2: 跑测试确认第一条红、第二条绿

Run: C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test "-Dtest=LsyRefundServiceTest#applyNotifyResult_非终态流转_不写finishTime+applyNotifyResult_终态流转_仍然写finishTime" Expected: applyNotifyResult_非终态流转_不写finishTime FAIL(expected: <null> but was: <2026-09-22T...>);applyNotifyResult_终态流转_仍然写finishTime PASS(旧实现本来就写)。 ⚠ 若第二条也红,说明测试写错了,不是实现的问题——先查测试。

  • Step 3: 改实现

把 :411 那行:

        patch.setFinishTime(OffsetDateTime.now());

换成:

        // ★ finish_time 只在**终态**流转时写。列注释是「完成时间(终态)」(V1.8:161)。
        //   本方法自 2026-09-22 起会执行非终态流转(createRefund 的 CREATED → REFUNDING,
        //   见台账 §2.1):照旧无条件写,会让一笔**还在途**的退款带上完成时间,
        //   事后按 `finish_time IS NULL` 捞未完结退款就会漏掉它。
        //   判据用 NOT PENDING(而非枚举终态白名单)是为了不新增第二份状态分类副本 ——
        //   `LsyRefundStatus.PENDING` 已经是「非终态」的唯一定义,改它一处即可,
        //   不必再维护一个 TERMINAL 集合跟着漂移。
        //   实际取值只有 SUCCESS / FAIL(三个调用点:同步成功、回调、对账兜底),两条都有测试钉住。
        if (!LsyRefundStatus.PENDING.contains(newStatus)) {
            patch.setFinishTime(OffsetDateTime.now());
        }
  • Step 4: 跑测试确认两条都绿

Run: 同 Step 2 的命令 Expected: 2/2 PASS

  • Step 5: 跑全类回归(确认没打坏既有的三条 applyNotifyResult_*)

Run: C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test "-Dtest=LsyRefundServiceTest" Expected: 全绿,无 FAIL

  • [ ] Step 6: 提交

    git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java
    git commit -m "fix(退款): finish_time 只在终态流转时写 —— 预插入 CREATED 会引入非终态流转,无条件写会污染这列"
    

Task 2: 预插入 CREATED(§2.1 的主症)

Files:

  • Modify: java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java:158-196
  • Test: java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

Interfaces:

  • Consumes: Task 1 改过的 applyNotifyResult(非终态流转不写 finish_time);LsyRefundStatus.CREATED(枚举常量,.name() == "CREATED");LeshouyingApiException(client/LeshouyingApiException.java:37,三参构造器 (returnCode, returnMsg, rawJson),extends RuntimeException)。
  • Produces:

    • createRefund(LsyOrderEntity, String outRefundNo, long refundFee) 签名与返回值不变;返回的实体在成功路径上 getStatus() 仍是 SUCCESS/REFUNDING(外部可观测行为不变)。
    • 新私有方法 private void persistUpstreamIdentity(LsyRefundEntity entity):把 refundNo / qdRefundNo / refundSettle 三个字段按 id 补写到已存在的行上;不碰 status。
  • [ ] Step 1: 写四条失败测试

(a)预插入发生在上游调用之前,且那一刻状态是 CREATED。 替换既有的 refund_insertsAsRefundingFirst_neverDirectlyTerminal(:166)整条为:

    @Test
    void refund_预插入CREATED_且早于上游调用() {
        // ★ 用 InOrder 钉住**顺序**,不是只钉住「发生过 insert」——
        //   本次改动的全部意义就在这个顺序上(insert 早于 post)。
        // ★ insert 那一刻断言状态,不能用 ArgumentCaptor:
        //   captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态推到 REFUNDING/SUCCESS,
        //   于是 captor.getValue().getStatus() 在返回之后求值永远得到终态 —— 那条断言恒假,
        //   与实现有没有先插 CREATED 无关。(同 2026-09-21 那条的教训。)
        // ★ 必须仍是**非终态**:直接插 SUCCESS 的行不在 uk_lsy_refund_one_pending 的谓词里,
        //   并发第二笔退款撞不上约束 —— 互斥窗口必须真的存在。
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        doAnswer(inv -> {
            LsyRefundEntity e = inv.getArgument(0);
            assertEquals("CREATED", e.getStatus(),
                    "INSERT 那一刻必须是 CREATED(「上游可能还没收到」),不能是终态");
            e.setId(77L);           // 模拟 MyBatis-Plus 回填雪花 id,后续两次 update 都按它定位
            return 1;
        }).when(refundMapper).insert(any());
        when(refundMapper.update(any(), any())).thenReturn(1);
        when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);

        service.createRefund(order(), "RF001", 100L);

        org.mockito.InOrder io = inOrder(refundMapper, client);
        io.verify(refundMapper).insert(any());
        io.verify(client).post(eq("/v3/refundorder"), anyMap());
    }

(b)上游抛异常 ⇒ 本地已留 CREATED 行,异常继续外抛。

    @Test
    void refund_上游调用抛异常_本地已留CREATED行且异常继续外抛() {
        // ★ §2.1 的失效路径第一步:上游超时,旧实现一行都没写,而上游**可能已经受理**。
        //   本用例钉住「行已经落库」,那是后面那条重试用例能挡住的前提。
        doAnswer(inv -> {
            ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
            return 1;
        }).when(refundMapper).insert(any());
        when(client.post(eq("/v3/refundorder"), anyMap()))
                .thenThrow(new LeshouyingApiException("NETWORK_ERROR", "乐收赢接口调用失败: read timeout", null));

        assertThrows(LeshouyingApiException.class,
                () -> service.createRefund(order(), "RF001", 100L));

        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper).insert(captor.capture());
        assertEquals("CREATED", captor.getValue().getStatus(),
                "上游可能已受理 ⇒ 行必须先落库;落终态是错的(我们并不知道结果)");
        // 没收到响应 ⇒ 不该有任何推进:CREATED 表达的就是「不知道上游收到没有」。
        verify(refundMapper, never()).update(any(), any());
        // 订单侧一个字都不许动 —— 钱有没有退出去我们还不知道。
        verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
    }

(c)★ 目的本身:超时后拿同一个 out_refund_no 重试,必须被在途闸门挡住、且第二次上游调用根本没发生。

    @Test
    void refund_上游超时后重试_被在途闸门挡住不再出金() {
        // ★★ 这条是 §2.1 预插入**存在的理由**。
        //   旧实现的失效链(不依赖任何竞态):post 抛异常 ⇒ 本地零行 ⇒
        //   重试时在途校验读空表 ⇒ 放行;refund_trace_no 每次重新 fastSimpleUUID()、
        //   请求体里也不含 out_refund_no ⇒ 上游收到的是一笔**全新**退款,
        //   它连去重的键都没有 ⇒ 两次真实出金。
        //   预插入之后,第一次失败留下的 CREATED 行让第二步撞闸门。
        LsyRefundEntity stuck = new LsyRefundEntity();
        stuck.setId(77L);
        stuck.setStatus("CREATED");     // 第一次 post 抛异常留下的那一行
        when(refundMapper.selectList(any())).thenReturn(List.of(stuck));

        IllegalStateException ex = assertThrows(IllegalStateException.class,
                () -> service.createRefund(order(), "RF001", 100L));
        assertTrue(ex.getMessage().contains("退款正在处理中"), "实得: " + ex.getMessage());
        // ★ 非空转的关键:第二笔必须**根本没发出去**。
        //   只断言抛异常的话,一个「抛完异常仍然发请求」的实现也会绿。
        verify(client, never()).post(anyString(), anyMap());
    }

(d)上游回传的三个字段必须补写到预插入的那一行上。

    @Test
    void refund_上游响应里的字段_补写到预插入的那一行() {
        // refund_no 是**对外叫「退款单号」的那个值**(LsyQueryDetailAssembler 的白名单
        // detail.refund_no,`LsyQueryDetailAssembler:23`),而它是一个租户可见字段。
        // 预插入时它还不可知,只在响应这一刻可得 —— 不在这里落库就永久丢失,
        // 因为退款回调报文**不带**这些字段(台账 R41 的实测结论)。
        when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
        doAnswer(inv -> {
            ((LsyRefundEntity) inv.getArgument(0)).setId(77L);
            return 1;
        }).when(refundMapper).insert(any());
        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, times(2)).update(captor.capture(), any());
        // 不按下标取(那会把用例绑死在实现的调用顺序上),按内容找。
        assertTrue(captor.getAllValues().stream().anyMatch(x -> "R001".equals(x.getRefundNo())),
                "上游回传的 refund_no 必须落库 —— 它是对外 detail.refund_no 的唯一来源");
        assertTrue(captor.getAllValues().stream().anyMatch(x -> Long.valueOf(100L).equals(x.getRefundSettle())),
                "refund_settle 同样只在响应这一刻可得");
        // 状态推进也必须真的发生(CREATED → REFUNDING)。
        assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
                "收到响应 = 上游已受理 ⇒ 必须离开 CREATED");
    }
  • Step 2: 跑这四条,确认全红

Run: C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test "-Dtest=LsyRefundServiceTest" Expected: 四条新用例全部 FAIL((a) expected: <CREATED> but was: <REFUNDING>;(b)(c) 无行可查/无异常;(d) Wanted 2 times but was 1)。 ⚠ 先把每条的红读出来再动手——若某条意外是绿的,说明该条没在测它声称的东西,先修测试。

  • Step 3: 改 createRefund

p 的拼装(:142-156)不动。从 :158 起按下面替换到 :196:

        // ★★ 预插入必须在 `client.post` **之前**(台账 §2.1)。
        //   为什么:下一个语句一旦抛异常(超时 / 连接重置),方法就此终止 ——
        //   旧实现一行都不写,而**上游可能已经受理了这笔退款**。
        //   后果不是"少记一笔":租户拿同一个 out_refund_no 重试时,
        //   在途闸门读的是本地行(一行都没有)⇒ 放行;refund_trace_no 每次重新生成,
        //   请求体里也不含 out_refund_no ⇒ 上游收到一笔**全新**退款,它连去重的键都没有
        //   ⇒ 两次真实出金。这一行提前钉住之后,重试会撞上面 :108 的在途校验
        //   (CREATED ∈ LsyRefundStatus.PENDING,也 ∈ uk_lsy_refund_one_pending 的谓词)。
        //
        //   ★ 为什么是 CREATED 而不是 REFUNDING:这两个状态在这里**语义不同** ——
        //     CREATED = 「我们记下了,但**还不知道上游有没有收到**」;
        //     REFUNDING = 「上游确定收到了,等结果」。这个区别对来对账的人是有用的:
        //     前者该拿 refund_trace_no 去问"你到底见过这笔没有",后者该问"它结果怎样"。
        //     在此之前 CREATED 在退款侧**零写入点**(全模块唯一写 CREATED 的是
        //     LeshouyingOpenController 写**订单**那张表),而索引谓词早已包含它(V1.8:234)。
        //   ★ 用 .name() 而不是字面量 "CREATED":这个值一旦拼错,行就落在
        //     uk_lsy_refund_one_pending 的谓词之外,互斥窗口**静默**消失。
        //
        //   ⚠ 代价(如实记,未修):下一句抛异常时这一行会**留在 CREATED**,
        //     于是这张单在该行被收敛前发不起退款。方向是安全的(宁可拒付、不重复出金),
        //     收敛通道是 LsyReconcileScheduler.reconcilePendingRefunds(CREATED ∈ PENDING,
        //     扫得到),但那条腿**没有上界、会被老行饿死** —— 台账已记的老缺口。
        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.setNotifyStatus("PENDING");
        entity.setNotifyCount(0);
        // 上游来源的 refund_no / qd_refund_no / refund_settle 此刻**还不知道**,收到响应后补
        entity.setStatus(LsyRefundStatus.CREATED.name());
        refundMapper.insert(entity);

        Map<String, Object> resp;
        try {
            resp = client.post("/v3/refundorder", p);
        } catch (RuntimeException e) {
            // ★ 必须 log.error:这一行**已经落库了**,而它在外面看不见 ——
            //   调用方只看到接口失败,运营看到的只是"这单暂时退不了款"。
            //   不打出 outRefundNo / refundTraceNo,事后没人能把这两件事连起来。
            //   重新抛出:调用方可见行为与本改动之前完全一致(失败还是失败)。
            //   两个实参都不套 LsyLogSanitizer:都是我们自己的值
            //   (outRefundNo 由入口 @Pattern 保证,refundTraceNo 是本方法上面的 fastSimpleUUID);
            //   err 用 getMessage() 而不是整栈 —— 它的 sanitize 在 LeshouyingApiException 里做过。
            log.error("退款上游调用失败, 本地已留 CREATED 行(该单在该行收敛前无法再次退款), "
                            + "需人工/对账核对上游是否已受理: outRefundNo={}, refundTraceNo={}, err={}",
                    outRefundNo, refundTraceNo, e.getMessage());
            throw e;
        }

        // 收到响应 = 上游已受理 ⇒ 补上游来源的字段 + 推进 CREATED → REFUNDING。
        //   两个动作分开做,因为**退款行的状态只有一个写入口**(applyNotifyResult):
        //   refund_no / qd_refund_no / refund_settle 与状态无关,不能塞进那个 patch。
        //   ★ 必须在这一刻落库:它们只出现在这里 —— 退款回调报文不带这三个字段(台账 R41),
        //   丢在这里就再也补不回来。其中 refund_no 是对外那个「退款单号」。
        entity.setRefundNo(str(resp.get("refund_no")));
        entity.setQdRefundNo(str(resp.get("qd_refund_no")));
        entity.setRefundSettle(parseLong(resp.get("refund_settle")));
        persistUpstreamIdentity(entity);

        // 无条件推进(不管 result_code 是成功/失败/在途):上游**回过话**就说明它收到了请求,
        // 此后本行不再表达"上游可能压根没收到"。PAY_FAIL / 未知码留在 REFUNDING
        // 是下面两个分支各自的既定语义(R11 / R21),它们在本句之后一个字都不改。
        // CREATED ∈ PAY_WRITE_FROM,所以这一步能命中;返回 false 只可能是本地写库失败,
        // applyNotifyResult 自己会 log.info,且后续的终态流转(同样从 CREATED/REFUNDING 出发)
        // 仍然能成功 —— 故这里不重复告警。
        applyNotifyResult(entity, str(resp.get("result_code")),
                str(resp.get("return_msg")), LsyRefundStatus.REFUNDING.name());

然后在 applyNotifyResult 附近新增私有方法:

    /**
     * 把**上游来源**的三个字段补写到预插入的那一行上。
     *
     * <p>【为什么单独一次 UPDATE、不并进 {@link #applyNotifyResult}】那三个列与状态无关,
     * 而本模块的硬约束是「退款行的状态只有一个写入口」。把身份字段塞进状态补丁会把两件事
     * 耦合成一件,将来任一侧变动的理由都不再单独成立。
     *
     * <p>【条件只看 id,不看 status】响应到达与对账腿收敛理论上可以交错:若此刻本行已被
     * 对账推到终态,仍要写这三个字段 —— 它们只是上游标识,写上去是**增加信息**,
     * 不会覆盖任何结论。加 status 条件反而会把这个信息丢掉。
     *
     * <p>【为什么必须此刻落库】它们只出现在创建退款的那次响应里;退款回调报文不带
     * 这三个字段(台账 R41 实测)。其中 {@code refund_no} 是对外叫「退款单号」的那个值
     * ({@code LsyQueryDetailAssembler} 的白名单 {@code detail.refund_no})。
     */
    private void persistUpstreamIdentity(LsyRefundEntity entity) {
        LsyRefundEntity patch = new LsyRefundEntity();
        patch.setRefundNo(entity.getRefundNo());
        patch.setQdRefundNo(entity.getQdRefundNo());
        patch.setRefundSettle(entity.getRefundSettle());
        patch.setUpdatedTime(OffsetDateTime.now());

        UpdateWrapper<LsyRefundEntity> w = new UpdateWrapper<>();
        w.eq("id", entity.getId());
        refundMapper.update(patch, w);
    }

同时删除 :191-195 那段「一律先落非终态」的注释块与 entity.setStatus("REFUNDING") —— 它的第 1 条理由(索引谓词)已由上面的 CREATED 承接,第 2 条理由(状态单一写入口)现在由 Step 3 里的 applyNotifyResult 承接。不要留着:那段注释会把 CREATED 读成「没有先落非终态」。

  • Step 4: 跑全类,修既有用例

Run: C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test "-Dtest=LsyRefundServiceTest" Expected: 除下面两条外全绿。

refund_payFail_leavesStatusRefundingAndNeverSettlesOrder(:246)与 refund_unknownResultCode_leavesStatusRefundingAndNeverSettlesOrder(:269)会红,因为两条都断言 verify(refundMapper, never()).update(any(), any()),而预插入之后收到响应必然产生两次写(补身份字段 + 推进 REFUNDING)。把这两条里的那一行 verify(refundMapper, never()).update(any(), any()); 换成:

        // 断言"没有落终态",而不是"没有 update":§2.1 预插入之后,收到响应必然产生
        // 两次写 —— 补上游身份字段 + 推进 CREATED → REFUNDING,两次都不是终态。
        // 旧的 verify(never()).update() 会被这两次误伤(它当初表达的就是"一个状态都不许写",
        // 而现在的正确含义是"不许写**终态**")。
        var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
        verify(refundMapper, times(2)).update(captor.capture(), any());
        assertTrue(captor.getAllValues().stream().noneMatch(
                        x -> Set.of("SUCCESS", "FAIL").contains(x.getStatus())),
                "PAY_FAIL/未知码不得被落成终态 —— 落 FAIL 会释放 uk_lsy_refund_one_pending 的互斥窗口");
        assertTrue(captor.getAllValues().stream().anyMatch(x -> "REFUNDING".equals(x.getStatus())),
                "必须推进到 REFUNDING:上游回过话就说明它收到了这笔请求");

(Set 已在测试文件 import;两条用例的其余断言 —— assertEquals("REFUNDING", r.getStatus()) 与 verify(orderService, never()).applyRefundResult(...) —— 保持不变,它们仍然正确。)

  • Step 5: 跑全类,确认全绿

Run: 同 Step 4 Expected: Tests run: N, Failures: 0, Errors: 0

  • Step 6: 跑整个模块 + 全仓测试,确认没有别处被打坏

先删报告目录:rm -rf D:\project2\payment-platform\java\target\surefire-reports Run: C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f D:\project2\payment-platform\java\pom.xml test Expected: 基线是本轮之前的 403 绿 / 0 失败;本次净增 6 条(Task 1 两条 + Task 2 四条),故应约 409 绿 / 0 失败。实际数字按 target/surefire-reports/ 里 Tests run: 逐文件求和报出——不要用目录里残留的旧 xml。

  • Step 7: 更新台账(§2.1 的「⏳ 实施进度」)

在 docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §2.1 的「⏳ 实施进度」里把「未做(本条的主症)」那条移到「已做」,并原文记入上面「本次改动引入的新缺口」两条(遗留 CREATED 行阻塞该单 + 收敛腿无上界)。R41 那条保持「未做」不动。

  • [ ] Step 8: 提交

    git add java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md
    git commit -m "fix(退款): 上游调用前预插入 CREATED 行 - 堵住超时重试导致的重复出金(台账 §2.1)"
    

Self-Review

Spec coverage(对台账 §2.1 裁定逐条核)

裁定原文要求 落在哪
「:149 上游调用之前预插入 CREATED」 Task 2 Step 3(refundMapper.insert(entity) 移到 client.post 之前)
「CREATED 在退款侧零写入点…索引谓词早已包含它」 Task 2 Step 3 注释 + Step 1(a) 断言 insert 时即 CREATED
「⚠ 预插入只堵得住超时那一半;回调那一半要换定位键 = R41」 不做,Global Constraints 已列明;Task 2 Step 7 要求 R41 保持「未做」
「⚠ 本条的『不得猜测』结论仍然有效」 Global Constraints 已列明:本计划不碰任何定位键
「不加事务…记录消失比记录不准危险得多」 未引入任何 @Transactional(预插入与 post 仍不在同一事务里——这正是它能挡住重试的原因)

Placeholder scan: 无 TBD / TODO;所有代码步骤都给了可粘贴的完整代码;没有「类似 Task N」的引用。

Type consistency: persistUpstreamIdentity(LsyRefundEntity) 在 Task 2 的 Interfaces 里声明,在 Step 3 里定义、并在同一段里被调用;applyNotifyResult 的四个入参类型未变;LsyRefundStatus.CREATED.name() / .REFUNDING.name() 返回 String,与 setStatus(String) 及 applyNotifyResult(..., String newStatus) 匹配;测试里 Set.of(...) / ArgumentCaptor / times(2) 的用法与文件既有风格一致。

已知的、刻意留下的不完美(不得当成遗漏): refund_payFail_* 与 refund_unknownResultCode_* 两条既有用例断言的是「没有落终态」而非「什么都没写」——这在语义上比旧断言弱一档,但方向仍然安全(终态才是释放互斥窗口的那个动作),且新断言额外钉住了「必须推进到 REFUNDING」。