# 退款预插入 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` = `{CREATED, REFUNDING}`)——已存在,不改。 - Produces: `applyNotifyResult(LsyRefundEntity, String resultCode, String resultMsg, String newStatus)` 签名不变、返回值语义不变;**行为差异仅一处**:`newStatus` 属于 `PENDING` 时不再写 `finish_time`。 - [ ] **Step 1: 写失败测试(两条:一条钉住新行为,一条是它的阳性对照)** 在 `LsyRefundServiceTest` 已有的 `applyNotifyResult_transitionApplied_returnsTrue` 之后插入: ```java @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: but was: <2026-09-22T...>`);`applyNotifyResult_终态流转_仍然写finishTime` **PASS**(旧实现本来就写)。 ⚠ 若第二条也红,说明测试写错了,不是实现的问题——先查测试。 - [ ] **Step 3: 改实现** 把 `:411` 那行: ```java patch.setFinishTime(OffsetDateTime.now()); ``` 换成: ```java // ★ 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: 提交** ```bash 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`)整条为: ```java @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` 行,异常继续外抛。** ```java @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` 重试,必须被在途闸门挡住、且第二次上游调用根本没发生。** ```java @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)上游回传的三个字段必须补写到预插入的那一行上。** ```java @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: but was: `;(b)(c) 无行可查/无异常;(d) `Wanted 2 times but was 1`)。 ⚠ **先把每条的红**读出来**再动手**——若某条意外是绿的,说明该条没在测它声称的东西,先修测试。 - [ ] **Step 3: 改 `createRefund`** `p` 的拼装(`:142-156`)**不动**。从 `:158` 起按下面替换到 `:196`: ```java // ★★ 预插入必须在 `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 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` 附近新增私有方法: ```java /** * 把**上游来源**的三个字段补写到预插入的那一行上。 * *

【为什么单独一次 UPDATE、不并进 {@link #applyNotifyResult}】那三个列与状态无关, * 而本模块的硬约束是「退款行的状态只有一个写入口」。把身份字段塞进状态补丁会把两件事 * 耦合成一件,将来任一侧变动的理由都不再单独成立。 * *

【条件只看 id,不看 status】响应到达与对账腿收敛理论上可以交错:若此刻本行已被 * 对账推到终态,仍要写这三个字段 —— 它们只是上游标识,写上去是**增加信息**, * 不会覆盖任何结论。加 status 条件反而会把这个信息丢掉。 * *

【为什么必须此刻落库】它们只出现在创建退款的那次响应里;退款回调报文不带 * 这三个字段(台账 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 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());` 换成: ```java // 断言"没有落终态",而不是"没有 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: 提交** ```bash 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」。