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)」与「⏳ 实施进度」两节是本次的约束来源)
leshouying、lsy 等字样;outRefundNo / refundTraceNo 这类我们自己的值可以打。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。后果两条:
:108 + uk_lsy_refund_one_pending 都认它);LsyReconcileScheduler.reconcilePendingRefunds(扫 LsyRefundStatus.PENDING,CREATED ∈ 它,扫得到),但那条腿没有上界、会被老行饿死——这是台账已记的老缺口,不是本次新增的。方向是安全的(宁可拒付,不重复出金),但必须能被人看见:Task 2 的 catch 分支为此打了 log.error 并带上 outRefundNo / refundTraceNo。
| 文件 | 职责 | 本次动作 |
|---|---|---|
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 条既有用例的断言 |
applyNotifyResult 的 finish_time 只在终态流转时写为什么单独成一个任务: Task 2 会让 applyNotifyResult 第一次执行非终态流转(CREATED → REFUNDING)。旧实现无条件 patch.setFinishTime(OffsetDateTime.now())(:411),而那会让一笔还在途的退款带上「完成时间」——V1.8:161 这一列的注释就是「完成时间(终态)」,事后按 finish_time IS NULL 捞未完结退款会漏掉它。这是一处本次改动会新引入的数据质量缺陷,先修掉再动 Task 2。
Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java:411java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java(新增两条,紧挨既有的 applyNotifyResult_* 三条)Interfaces:
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 —— 否则这条列永远不会被写");
}
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(旧实现本来就写)。
⚠ 若第二条也红,说明测试写错了,不是实现的问题——先查测试。
把 :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());
}
Run: 同 Step 2 的命令 Expected: 2/2 PASS
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 会引入非终态流转,无条件写会污染这列"
CREATED(§2.1 的主症)Files:
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java:158-196java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.javaInterfaces:
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");
}
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)。
⚠ 先把每条的红读出来再动手——若某条意外是绿的,说明该条没在测它声称的东西,先修测试。
createRefundp 的拼装(: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 读成「没有先落非终态」。
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(...) —— 保持不变,它们仍然正确。)
Run: 同 Step 4
Expected: Tests run: N, Failures: 0, Errors: 0
先删报告目录: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。
在 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)"
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」。