2026-09-21-leshouying-payment-channel-open-questions.md 78 KB

乐收赢支付通道 —— 待确认清单(联调 / 上线前必读)

本文件是 2026-09-21-leshouying-payment-channel-design.md 的伴随文档。代码里凡写「见待确认清单」处,指的就是本文件。

它记录三类东西:必须问上游/产品的、已知缺口(尤其涉及钱的)、部署前必须手工执行的。

状态:Task 1–13 已交付,Task 14–18 进行中。标「待补」的小节尚未覆盖。 每条都注明依据;未验证的标「未验证」—— 请勿把未验证项当结论使用。

关于来源标记:文中 R13 / M6 / R41 这类编号、Task N 评审 §x、台账 P-T12-N 都是开发过程的内部记录引用(评审报告、计划、开发台账),供追溯用, 在本仓库里解析不到。每条结论的正文都是自足的 —— 不需要去查那些编号。


一、必须问上游(乐收赢)的问题

按优先级排列,第 1、3、4 问会影响代码结构,越早问越好。

# 问题 不问的后果
1 退款回调的 notify_url 走哪个字段?(R13) 退款回调地址可能根本没配上
2 定位键该用 trade_no 还是 pay_trace_no?(M6) 回调可能定位不到订单
3 /v3/refundorder 的同步响应在非终态时是否回传 refund_no?(R41) 若否:该行 refund_no 落 NULL,而回调只按 refund_no 定位 ⇒ 这笔退款永远收敛不了。修法必须改为按 out_refund_no 定位(我们自己生成、NOT NULL、有全局唯一索引),但这会连带改动退款结果码分支与其测试
4 退款完成是否需要单独推下游? 目前没有这条通路(见 §2.4)。若要,需要新增一条腿
5 /v3/refundorder 同步响应会不会给裸 SUCCESS(不带 refund_no)? 若会:退款结果码分支及其测试必须一起改(R21)

★★ 2026-09-21 更正(上表第 3 问那一行的「修法」句含假前提,照着修会修错),三点:

  1. 「out_refund_no 我们自己生成」 —— 假,且在本任务之前就是假的: /refund 入口传的是 dto.getOutRefundNo()(LeshouyingOpenController), 即租户提供的值;入口 @Pattern(LsyBizNoPattern)只管字符集与长度, 既不生成它,也不保证它跨租户唯一。
  2. 「有全局唯一索引」 —— 本次收窄之后为假:uk_lsy_refund_out_refund_no 已改成 (tenant_id, out_refund_no)(论证见 V1.8 中该索引上方新增的注释)⇒ 它不再是 未认证路径可用的定位键(两个不同租户可以合法持有同一个 out_refund_no)。
  3. ⇒ 本次收窄并没有堵死第 3 问的修法,只是把定位键指向了另一列:真正满足 「我们自己生成 + NOT NULL + 全局唯一」的是 refund_trace_no —— LsyRefundService 里由 IdUtil.fastSimpleUUID() 生成(构造上全局唯一), 列定义是 NOT NULL,索引 uk_lsy_refund_trace_no 是全局唯一且本次未改动。 修法若要走未认证定位,应当指向它,而不是 out_refund_no。 ⚠ 未定项(不得当结论用):这条路成立的前提是上游退款回调报文里回传了 refund_trace_no —— 那是我们收的报文,是否含该字段必须联调确认。 本文件不预设它成立;若上游两个键都不回传,则第 3 问的修法仍然不成立, 需另找定位键 —— 别把本节读成「改用 refund_trace_no 就解决了」。 (现状:实现按 refund_no 定位 —— 上游给的、可空、全局唯一; 下面「未认证查询的铁律」一节今天仍然成立。)

★★ 2026-09-22 裁定:上面的「未定项」已落地为否定,且比预想更彻底

实测(Playwright 直读上游 opendocs 全文,非二手转述;两页都做了整页刷新, 因为只改 hash 的 goto 不会让这个 SPA 重渲染 —— 第一次读到的仍是上一页, 靠 document.title 变成「交易异步回调」才确认刷新成功):

  • 退款异步回调 #/611206641/196236531 的完整字段表(24 行)与请求示例里 没有 refund_no、没有 out_refund_no、没有 refund_trace_no、没有 qd_refund_no;
  • 交易异步回调 #/611206641/196236530 同样没有(两页对照,排除「退款页只是没写全」);
  • 退款回调唯一新增的定位类字段是 o_trade_no(表注逐字:「交易原订单,退款回调时返回 2024-12-24 新增」);
  • refund_no / out_refund_no 在那两页上一次都没出现。

⇒ 第 3 问那行给的两种指法(out_refund_no / refund_trace_no)全部不可实现 —— 回调报文里根本没有任何退款标识。上一段结尾那句 「若上游两个键都不回传,则第 3 问的修法仍然不成立」已触发。 第 3 问的问法本身要改:不该再问「同步响应非终态时是否回传 refund_no」 (那只影响对账腿的查询键,它另有 refund_trace_no 回落), 该问的是「退款回调报文里哪个字段能唯一对应到一笔退款」。

连带实测(MyBatis-Plus 行为,一次性探针,读数后已删): new LambdaQueryWrapper<LsyRefundEntity>().eq(LsyRefundEntity::getRefundNo, null) 生成 (refund_no = #{ew.paramNameValuePairs.MPGENVAL1}),绑定值 MPGENVAL1=null —— null 条件不被省略(不是退化成无 WHERE 的全表 LIMIT 1,那会更糟:会随机命中一行), 而 PG 里 refund_no = NULL 恒为 UNKNOWN ⇒ 0 行。 ⇒ handleRefundNotify 的定位每一次都落空,走 REFUND_NOT_FOUND 分支后返回 SUCCESS。 该分支之后的所有逻辑(三分支词表、幂等闸门、LATE_REFUND_AFTER_TERMINAL)在生产上是死代码。

为什么测试全绿(两个因素叠加,缺一不可): ① LsyNotifyServiceTest 用 when(refundMapper.selectOne(any())) 打桩,any() 把 wrapper 整个吃掉 ⇒ 定位键在测试里根本不可观测; ② 夹具 refundBody() 又凭空塞了 refund_no(该字段出自 Task 12 brief 的测试样例, 不是上游文档),使报文看上去是对的。 ⇒ 测试对这条缺陷完全失明;这也是「绿色测试不等于被覆盖」的又一例。

连带事实(本裁定顺带测出):pay_lsy_notify_log.refund_no 这一列在真实上游报文下恒为 null —— 它的唯一写入点是 logNotify 读 params.get("refund_no"),而两个回调页都没有该字段。 只有伪造报文(入口免登录)能填它。故 LsyNotifyLogEntity:32 的 「退款回调的定位键」与 V1.8 里「未认证定位走 refund_no」那几句现在都是假的。

影响面(为什么不必恐慌,也不能不修):异步退款的收敛已由 2026-09-22 新增的 退款对账腿覆盖(它按 status+created_time 扫描,不依赖任何上游字段), 所以「钱动了我们不知道」这条不成立。回调从「唯一收敛通道」彻底降级为 一条从未通过电的、只会写误导性审计行与 log.error 的死腿。

未决(本裁定不碰,按既定额度挂起):改用哪个键重建定位。 候选是 o_trade_no(→ 订单 → 该单唯一的非终态退款行,唯一性由我们自己的 uk_lsy_refund_one_pending 保证)或 pay_trace_no。 但文档没有定义这两个字段在退款回调里指的是「原支付的」还是「本笔退款的」 —— 两种读法都自洽、结论相反,选任一都是猜测。 另有一个已实测否定的旁证:退款回调示例里 pay_trace_no 是 32 位 hex UUID, 而我们的 payTraceNo(LeshouyingOpenController:359)与 refundTraceNo 都是 IdUtil.fastSimpleUUID() ⇒ 同一格式,无法据此区分;交易回调示例里那个值是 17 位数字, 但那份示例来自上游自己的测试商户,格式差异不构成证据。故不据此推断。 待联调抓到真实报文(raw_body 已逐条落库)或上游答复后再定。

✅ 订正记录(2026-09-22 实施:只改注释与夹具,不动定位逻辑)

裁定为「挂起,先不动」⇒ 不做任何猜测性重定位;只把「按 refund_no 定位」这句假话 在各处改成实测事实。生产代码逻辑一行未改(handleRefundNotify 定位段原样保留)。

位置 处置
service/LsyNotifyService(handleRefundNotify 定位段) ✅ 加实测说明块:24 行字段表无退款标识 / .eq(col,null) 照发谓词绑 null / PG 恒 0 行 / 后续分支为死区 / 影响范围 / 定夺依据;并注明 REFUND_NOT_FOUND 出现不是异常信号
service/LsyNotifyService(logNotify 的 setRefundNo 注释) ✅ 本清单原先漏掉的第 7 处(只列了 handleRefundNotify;logNotify 里那句「退款回调的定位键」同样是假话)
entity/LsyNotifyLogEntity(refundNo 字段注释) ✅ 改为「期望定位键」;并写明该列可被未认证的伪造报文写入(验签失败分支同样写审计行、字段值直接取自请求体)⇒ 非 null 时先怀疑伪造
service/LsyMerchantService(requireByMchNo javadoc) ✅ 删「退款回调按 refund_no 定位」,补「退款回调目前定位不了任何东西」
service/LsyQueryDetailAssembler(白名单依据列表) ✅ 留引文、标注括注为假;白名单结论不变(它仍是我们要对外叫「退款单号」的值)。2026-09-22 补:该处末尾「台账 §一已把它列进📌 待订正清单(库里的 COMMENT ON 与建表脚本一并待改)」是第 9 处——它引用的假括注早改了,但这句交叉引用还停在旧状态(那份清单已换成「✅ 订正记录」,且两处都已改完)。这处与上面 8 处不同类:前 8 处是「说假话」,它是「引用一句假话并声称它待改」。台账原先那份计数只统计前者,故漏了它
test/.../LsyNotifyServiceTest ✅ 夹具摘掉凭空字段;selectOne(any()) 改为捕获 wrapper 断言绑定值;新增钉住现状的表征测试 + 正对照
db/migration/V1.8(6 处,全部改动后行号) ✅ :98、:179 删掉「/ refund_no」半句;:231 上方整段论据重写(结论保留、理由换掉);:254、:349 两条 COMMENT ON 去掉假括注;:340 上方注明该索引目前无查询会用到但保留;pay_lsy_notify_log.refund_no 列旁注明两边都落 null ⇒ 本列现状空转
core/tenant/TenantInnerInterceptor(未认证查询通则的「✓ 全局唯一」清单) ✅ 第 8 处,原清单也没有(靠「定位键」这个词表全仓 grep 才捞出来)。它把 refund_no 列为可用的未认证定位键、并在「只能改用 pay_trace_no / refund_no 这类上游给的键」处再次点名。唯一性成立(索引在库),但读者会据此重新推出「退款回调按 refund_no 查」这个已被否定的结论。已就地加 ⚠ 并点明「退款侧目前没有可用的上游键」

索引本体全部保留(uk_lsy_refund_refund_no、idx_lsy_notify_refund_no)—— 重新定位后若仍走未认证路径,同等约束依然需要;且它们是已执行库的既成结构。

⚠ 两处自我订正(本清单原文写错):

  1. 原文称 V1.8 那三处「均为 -- 注释(非 COMMENT ON)⇒ 改动不与已执行库分叉」。错: 其中 :254(pay_lsy_refund.refund_no)与 :349(pay_lsy_notify_log.refund_no) 就是 COMMENT ON。本裁定正文里那句 ★「若是后者,改文件会让它与已执行的库分叉」 才是对的 —— 台账自相矛盾,以 ★ 为准。 ⇒ 已另写 db/lsy-refund-no-comment-fix.sql(纯元数据、幂等、不碰数据与索引), 把那两句重发一次。待用户执行,执行前文件与库在这两句文案上分叉。
  2. §3.1 称「V1.8 与 lsy-menu.sql 从未执行过」。错,见 §3.1 的订正。

二、已知缺口(涉及钱的排前面)

2.1 ★★ 退款侧预插入缺失(对账那一半已于 2026-09-22 实施)—— 唯一一条「钱动了我们不知道」

支付侧将有两条兜底腿(reconcilePendingOrders 问上游 + expireStaleOrders 过期收敛 —— 均为 Task 16 的产物,当前都不存在);退款侧连计划内的都没有。

失效场景:上游的退款回调早于我们 createRefund 落库到达 ⇒ 代码落一条审计行 REFUND_NOT_FOUND 就结束了。上游钱已退、我们账上没有这笔退款,事后无任何自动通道能补上。

依据:Task 12 评审 §5-6。交终审单独判(台账 P-T12-8)。

✅ 裁定(2026-09-22,逐条重读源码,行号已核对)

成立,且原文把可达路径写窄了。 原文只写了「回调早于落库到达」这条竞态;实际有一条不依赖竞态的兄弟路径,概率高得多。

事实:

  • LsyRefundService:149 client.post("/v3/refundorder", p) —— 上游退款在此发生,不可撤销。
  • :181-182 才 setStatus("REFUNDING") + refundMapper.insert(entity) —— 本地行在此才出现。
  • 类与方法上零个 @Transactional(grep -c '@Transactional' 返回 1,但那 1 处是类 javadoc :32 里的字样,不是注解)⇒ 没有回滚可依赖。
  • 作者有意识地不加事务,理由写在 :32-36:「若包在事务里…结果就是『上游钱已退、本地无记录』—— 记录消失比记录不准危险得多」。

★ 关键:不加事务保证的只是「插入成功后不再被回滚」,它没有关上窗口本身。 插入这一步自己失败、或根本走不到时,结果仍是「上游钱已退、本地无记录」。

兄弟路径(不依赖竞态):

  1. 租户以 out_refund_no=X 发起退款;
  2. :149 的上游调用抛异常(超时 / 连接重置)⇒ 方法在此终止,一行都没写;
  3. 上游可能已受理这笔退款 —— 我们不知道;
  4. 租户用同一个 X 重试 ⇒ 在途前置校验(:99-104)读的是本地行,一行都没有 ⇒ 放行;
  5. refund_trace_no 在 :130 每次重新 IdUtil.fastSimpleUUID(),而请求体 p(:133-147 逐字段核过)不含 out_refund_no ⇒ 上游收到的是一笔全新的退款请求,它连去重的键都没有。

⇒ 两次真实出金,本地只在第二次留下一条行。

uk_lsy_refund_out_refund_no (tenant_id, out_refund_no)(V1.8:177)看起来正是为这一步准备的,但它只在「第一次落了行」时才生效 —— 而「第一次没落行」正是超时的定义。

★ 修法结论:预插入 CREATED 是必要的一半,不足以修完。

「先插 CREATED、再调上游」正是枚举与索引谓词已经预留的位置 —— LsyRefundStatus.PENDING = {CREATED, REFUNDING}(LsyRefundStatus:31)、uk_lsy_refund_one_pending ... WHERE status IN ('CREATED','REFUNDING')(V1.8:234),而 CREATED 在退款侧零写入点(全模块 grep:唯一写 CREATED 的 LeshouyingOpenController:361 写的是订单)。这条堵得住上面那条超时重试路径。

但堵不住回调那条:LsyNotifyService:203-205 用 eq(getRefundNo, refundNo) 定位,而 refund_no 是上游给的、我们只在响应里才学到(LsyRefundService:160)。预插入的 CREATED 行 refund_no 仍是 null ⇒ 回调照样落到 REFUND_NOT_FOUND(:206-214)。

⇒ 回调那条要通,必须把定位键换成我们事先就知道的字段(out_refund_no 或 refund_trace_no)—— 这正是 §3 的 R41 已写下的修法。R41 不是一条独立的开放问题,它是 §2.1 的另一半。

本地无法裁定的一点(不猜):退款回调报文里是否有 out_refund_no / refund_trace_no。

  • 夹具 LsyNotifyServiceTest.refundBody(:125-129)只有 return_code / result_code / refund_no / trade_no / mch_no 五个字段;
  • 但夹具是我们自己的建模、不是上游报文实证 —— 我们只按 refund_no 查,多给的字段也看不见;
  • ⇒ 选哪个定位键,只能由上游文档裁定。

顺带证清的两点(都查过,不要重复怀疑):

  • str()(:382-384)对 null 返回 null,所以多行 refund_no = NULL 不会撞 uk_lsy_refund_refund_no(V1.8:222,全表唯一)—— PG 唯一索引允许多个 NULL。
  • 条件性风险:若上游在非终态响应里回传的是空串而非缺字段,str("") → "",则第二笔这样的退款插入时会撞该唯一索引 ⇒ 又回到「上游已受理、本地插入异常」这个窗口。能否发生取决于上游,未证。

⏳ 实施进度(2026-09-22)

  • 已做 ①:退款对账腿(见 §2.3 的实施记录)。它让第二条路径(第 4 步重试后仍有一行落库的情形)有了收敛通道。
  • 已做 ②(本条的主症):上游调用之前预插入 CREATED。 createRefund 现在先 refundMapper.insert(entity)(status = LsyRefundStatus.CREATED.name()), 之后才 client.post("/v3/refundorder", p);收到响应(= 上游已受理)再补上游身份字段并推进 CREATED → REFUNDING。行不存在 ⇒ 在途闸门与 uk_lsy_refund_one_pending 都没有可拦的东西—— 这一点已经消除。
    • 预插入用 CREATED 而非 REFUNDING 的理由:两者语义不同,CREATED = 「记下了,但还不知道上游有没有收到」, 对账时该拿 refund_trace_no 去问;REFUNDING = 「上游确定收到了、等结果」,该问结果。
    • 补上游字段(refund_no / qd_refund_no / refund_settle)走新增的私有方法 persistUpstreamIdentity, 不并进 applyNotifyResult——那三个列与状态无关,而退款行的状态只有一个写入口。 这三个字段只在响应这一刻可得(回调报文不带,见 R41),其中 refund_no 是对外 detail.refund_no 的唯一来源。
    • 顺带修的一处:applyNotifyResult 原先无条件写 finish_time。本次改动让它第一次执行非终态流转, 而该列注释是「完成时间(终态)」⇒ 改成 !PENDING.contains(newStatus) 才写。 否则一笔还在途的退款会带上完成时间,事后按 finish_time IS NULL 捞未完结退款会漏掉它。
    • 测试:LsyRefundServiceTest 34 绿;全仓 408 绿 / 0 失败(改前 403)。 两向变异实测:①插入写成 REFUNDING ⇒ 恰好 2 条红(断言插入时状态的两条); ②把 insert 挪回 post 之后 ⇒ 恰好 2 条红(含钉顺序的 InOrder 那条); ③无害改动(重排三个身份字段赋值)⇒ 34 条仍全绿。 ⚠ refund_上游超时后重试_被在途闸门挡住不再出金 这条改前就是绿的(闸门逻辑本就认 CREATED)—— 它是表征测试,钉的是「闸门不许被收窄成只认 REFUNDING」这个反方向,不是红转绿测试。
  • ⚠ 本次改动引入的新缺口(未修,如实记):client.post 抛异常时,预插入的那行会留在 CREATED。
    1. 该订单在该行被收敛前发不起退款(在途闸门 + 部分唯一索引都认它);
    2. 收敛通道是 LsyReconcileScheduler.reconcilePendingRefunds(CREATED ∈ PENDING,扫得到), 但那条腿没有上界、会被老行饿死——这是本台账已记的老缺口,不是本次新增的。 方向是安全的(宁可拒付,不重复出金)⇒ 这是用可用性换资金安全的有意取舍。 为让它能被人看见,catch 分支打了 log.error 并带上 outRefundNo / refundTraceNo (该行在接口返回里看不见,不记就没人能把「租户说退款失败」与「某行卡在 CREATED」连起来)。
  • 未做:R41(需上游文档裁定回调报文的字段)。 ⚠ 预插入只堵得住超时那一半;回调那一半要换定位键 = R41(见上面 §2.1 正文与 §3 的 R41)。 本次改动没有让回调那条路径变好或变坏:预插入的 CREATED 行 refund_no 仍为 null (补齐要等同步响应,而回调可能早于它到达)⇒ 回调照样落 REFUND_NOT_FOUND。
  • ⚠ 本条的「不得猜测」结论仍然有效:选哪个定位键只能由上游文档裁定,不许从我们自己的测试夹具体反推。
  • 📌 行号提示:本次改动在 createRefund 里插入了约 60 行注释与代码,本台账上文引用的 :149 / :181-182 等行号已整体下移; 按内容定位(client.post("/v3/refundorder") / refundMapper.insert),不要按行号。

2.2 ★★ 退款失败的同步/异步语义不对称 —— 唯一一条可能重复出金(已修,2026-09-22)

路径 写什么状态 在途闸门
同步失败 留在 REFUNDING 持着(uk_lsy_refund_one_pending 挡住第二笔)
异步失败 写 FAIL 终态 释放(闸门开了,第二笔退款可以插进来)

⇒ 若上游误报失败、之后又实际退成功,信了异步那一侧就会真的再退一次。

依据:Task 12 评审 §5-7。交终审单独判,标为高优先(台账 P-T12-9)。

✅ 裁定(2026-09-22,逐条重读源码,行号已核对)

⚠ 本节行号是裁定时刻的快照,别当现状用。 本条的落地(下面「✅ 已实施」) 在 LsyNotifyService.handleRefundNotify 里加长了约 35 行 ⇒ 该文件本节之后的行号 已整体下移;LsyRefundService 同理(在其第 508 行附近加了注释)。 与 V1.8 里的同款告诫一样:按内容定位,别按行号。

成立。表里两行都复核无误:

  • 同步失败:LsyRefundService:205-261 两个失败分支(PAY_FAIL :205-230、未知码 :231-261)都只 log.error、不写任何状态 ⇒ 行留在 REFUNDING ⇒ 在途闸门持着。代码自己写明理由(:216-218):「落 FAIL 会释放互斥窗口:若上游这个 PAY_FAIL 是偶发误报(钱其实出去了),第二笔退款就真的放行了,方向正好是资金最坏的那一侧。」
  • 异步失败:LsyNotifyService:230-236 TRADE_REFUND_FAIL → outcome = "FAIL" → applyNotifyResult(:252)写 FAIL。FAIL 不在 LsyRefundStatus.PENDING 里 ⇒ 在途闸门释放。

⇒ 同一条上游语义("退款失败"),同步路径判它「不可信、不许放闸」,异步路径判它「可信、放闸」。两份判断直接冲突,且冲突方向正是代码自己点名的「资金最坏的那一侧」。

★ 但这不是疏漏,是一个没人写下来的权衡 —— 而它当前是承重的:

reconcile()(LsyReconcileScheduler:123-145)四条腿全是订单(reconcilePendingOrders / closeExpiredQrOrders / expireStaleOrders / retryFailed),退款侧零对账 —— reconcilePendingOrders 的查询对象是 LsyOrderEntity(:148-156)。

⇒ 若异步失败也不放闸,一笔真失败的退款会永久停在 REFUNDING(= §2.3),该订单再也退不了款(在途校验 LsyRefundService:99-104 + 部分唯一索引 V1.8:234 双双拦住)。 ⇒ 在没有退款对账的前提下,写 FAIL 是唯一能让失败退款离开在途状态的通道。 拿掉它 = 把一个罕见的重复出金风险,换成一个必然发生的永久卡单。

作者知道这条路会产生账实不符,并建了探测器:LsyNotifyService:273-285 —— 若本行已是终态(FAIL)而上游这次说退款成功,记 ★ 账实不符…钱可能已退给用户,需人工核对! + 审计值 LATE_REFUND_AFTER_TERMINAL。 ★ 但这只是事后探测:探测器响的时候,第二笔退款可能已经发出去了。

⇒ 裁定:§2.2 的正确修法不是「把异步也改成守闸」(那会引爆 §2.3 的永久卡单),而是先补上退款对账(见 §2.3 裁定),届时守闸才变安全。

✅ 承重前提已解除(2026-09-22)

退款对账腿已落地(§2.3 实施记录)⇒ 上面那条「写 FAIL 是唯一出口」不再成立: 真失败的退款现在有第二条出口(对账腿拿上游权威答案后落 FAIL)。

⚠ 但本条的代码行为一个字都没改 —— 这是刻意的,不是漏做:

  • 改成守闸会让「上游误报 TRADE_REFUND_FAIL」这个场景不再释放互斥窗口,方向上更安全;
  • 但它同时把「回调丢了」的失败退款推迟到一个 reconcileDelaySeconds 之后才有出口, 且出口依赖上游 /v3/queryrefund 真的答得出来(未知码情形对账腿同样不落状态);
  • ⇒ 这是一次可做但未做的行为变更,需要单独裁定,本增量只解除它的前提。

✅ 已实施(2026-09-22,裁定 R-1 落地)

LsyNotifyService.handleRefundNotify 的 TRADE_REFUND_FAIL 分支不再 outcome = "FAIL", 改为 log.error + 审计行(新词 HELD_FOR_RECONCILE)+ 直接返回,不调 applyNotifyResult ⇒ 退款行留在 REFUNDING,两把锁继续生效。

读数 结果
全量测试 412 绿 / 0 失败(改前 411,差 = 新增 1 条)
变异 M-A(把守闸精确变回释闸:只改「写状态」这一件事,日志保留) 恰好 1 条红 —— 正是新用例
变异 M-B(无害:换日志措辞 + 交换 log 与审计行的先后 + 审计词走局部常量) 22 全绿(刻意钝感)
改前 RED applyNotifyResult(..., TRADE_REFUND_FAIL, null, FAIL) —— 红在正确的位置

★ 一处订正:推翻我此前写下的「有测试要改」。 §八 R-1 的「落地要点」当时写着 「现有一条钉住 TRADE_REFUND_FAIL → FAIL 的用例会红,那正是预期的红」—— 这句是假的。实测:TRADE_REFUND_FAIL 这个字面量在全测试树里只出现在两处注释中 (LsyNotifyServiceTest:164、LsyRefundServiceTest:562),没有任何一条用例把 TRADE_REFUND_FAIL 喂进 handleRefundNotify;调用点全是 refundBody("TRADE_REFUND")。 ⇒ 该分支改前是零覆盖,本次新写的用例是它的第一条覆盖,不存在「要改的旧用例」。 (教训与 §约束文档自己会失真同类:我对自己刚写完的台账做计数断言,没有实测。)

连带改动(都是为了让文档不再失真,不是顺手重构):

  • LsyRefundService.applyNotifyResult 的「三个调用点」注释补一句:FAIL 现在只剩 settleFromQuery 一条路能传。判据是 5 个调用点逐个量过(非终态 1、SUCCESS 3、FAIL 1)。
  • V1.8 两处「超过 16 字符的恰好三条/去重后共 9 个」→ 四条/10 个 (HELD_FOR_RECONCILE = 18,是全部 10 个里第三长)。两处都改了 —— 该注释自己要求「两处必须列同一组取值」。
  • V1.8 的 COMMENT ON COLUMN ... process_status 词表补上第 10 个词。 实测库里 process_status 已是 VARCHAR(32)(就地加宽那次确实生效了), 10 个词逐一对过列宽,超宽 0 个。但库里那句注释还是旧的 9 词版 ⇒ 已交付 db/lsy-notify-process-status-comment.sql(纯元数据、幂等,待用户执行)。 ⚠ 「库里实际写过哪些 process_status 取值」这一读数无意义:pay_lsy_notify_log 0 行 (空洞为真),别把它当成「这 10 个词都用过」。

2.3 ★ 退款单可能永久卡在 REFUNDING(已修,2026-09-22)

一笔回调丢失的退款会永久停在 REFUNDING(LsyRefundStatus.PENDING 含它)。 源头与 §1 第 3 问同源。没有自动收敛通道。

依据:LsyRefundStatus.java 的 PENDING javadoc 自陈 + R41。

✅ 裁定(2026-09-22)

成立。且修复所需的原语已经在仓库里,只差接线。

  • 「退款侧零对账」的反例证据见 §2.2 裁定(reconcile() 四腿全是订单)。
  • queryRefund(LsyRefundEntity) 已实现:LsyRefundService:282 → client.post("/v3/queryrefund", p)(:291)。
  • 但它的唯一调用方是下游面向租户的 OpenAPI 端点:LeshouyingOpenController:236 refundQuery → :240。没有任何调度器扫它。

⇒ 补一条 reconcilePendingRefunds() 进 LsyReconcileScheduler.reconcile() 即可,形状与既有的 reconcilePendingOrders() 完全同构(都是「非终态 + 超过阈值 → 查上游 → 收敛」)。这条腿落地后:

  1. §2.3 直接消除(卡住的行会被自动收敛);
  2. §2.2 的守闸才变安全(歧义结果可以停住等对账,不再需要靠写 FAIL 来解锁);
  3. §2.1 的超时重试那一半也更稳(预插入的 CREATED 行有了收敛通道)。

★ 顺序上值得注意:加了退款对账腿之后,R41 的严重性下降,但不会消失。 对账腿的扫描条件是 status + created_time(不依赖任何上游字段),问上游时用 refund_no,没有 refund_no 时回落到 refund_trace_no + refund_time(LsyRefundService.queryRefund)—— 这两个都是我们事先就知道的值。所以若 R41 的答案是「非终态响应不回传 refund_no」,行仍能被对账捞回来 —— 回调从「唯一收敛通道」降级为「快通道」。 但它仍应修:若定位键系统性不对,每一次退款回调都会稳定落到 REFUND_NOT_FOUND(LsyNotifyService:206-214),而那条审计行本该用来报警「上游说退了、我们没这笔」—— 系统性误报会把它淹掉。

✅ 已实施(2026-09-22)

LsyReconcileScheduler.reconcilePendingRefunds()(第 ⑤ 条腿)+ LsyRefundService.settleFromQuery()。 扫描 status IN LsyRefundStatus.PENDING 且 created_time <= now - reconcileDelaySeconds(默认 60s), LIMIT 100,逐条 try/catch 隔离。解释点按本类纪律放在 Service,调度器不解析报文。

上游依据:退款查询 result_code ∈ {PAY_SUCCESS,PAY_IN_PROCESS,PAY_FAIL}(上游文档 §15), 且文档明说「PAY_IN_PROCESS 需调用退款查询接口确认退款结果」⇒ 查询是被指定的权威确认通道, 所以这条腿敢在 PAY_FAIL 上落 FAIL(createRefund 的同步分支仍守闸 —— 它手上没有权威答案)。

⚠ 本腿未覆盖的(新记录 → 2026-09-22 已裁定,见下):它只扫非终态,所以「已落终态的行收到迟到成功」 (LATE_REFUND_AFTER_TERMINAL)仍然只有事后探测。另外它没有上界 —— 退款行不存在安全的 本地终态(写 FAIL 会释放两道防重复退款的锁),所以做不出订单侧 absoluteExpireHours 那样的 强制收敛;一个永远拿不到权威答案的行会被无限期每轮问一次,并占着 LIMIT 100 的配额, 老行堆积后会静默饿死新行。上界该取多少我没有上游依据,故不发明 —— 见增量计划 .claude/plan/2026-09-22-refund-reconciliation.md 的「本增量引入的新缺口」。

✅ 裁定:饿死缺口(2026-09-22)

结论:不造终态、也不取「时间上界」;给退款行加一个会前进的进度游标** next_query_time, 把扫描从「单调谓词 + 任意 100 条」换成「非单调谓词 + 按游标升序的 100 条」。**

根因(结构性的,不是 LIMIT 太小、也不是「少了个 ORDER BY」)

  1. 谓词 created_time <= now - 60s 是单调的:一旦为真永远为真 ⇒ 候选集只进不出。
  2. 卡住的行根本不写库:LsyRefundService.settleFromQuery 的 PAY_IN_PROCESS 分支与未知码分支 都直接 return null,一次 mapper 调用都没有(applyPayResult 那条路只在真流转时才写, 命中 0 行的条件更新同样不改元组)。⇒ 这些行的 created_time 不动、堆里的物理位置也不动。
  3. 1 + 2 合起来 ⇒ 同一条 SQL、同一个执行计划、同一批元组 ⇒ 即使没有 ORDER BY,返回的也是同一批。 最可能的计划是走 uk_lsy_refund_one_pending(它的谓词 status IN ('CREATED','REFUNDING') 与本查询谓词完全重合,部分索引直接可用),按 order_id 升序扫 ≈ 雪花号升序 ≈ 最老的先返回—— 恰好是最坏的那种「稳定」。
  4. ⇒ 饿死不是概率事件,是稳态。LIMIT 100 之外的行永远不会被问到。

触发门槛比直觉低:「卡住」不需要上游出错。PAY_IN_PROCESS 是上游文档认可的合法长态 (银行卡退款按天计),所以积压=在途退款量本身。100 行 ÷ 3 天 ≈ 34 笔/天就够顶满配额。 这不是病态场景,是正常放量就会踩到的。

为什么必须修(伤害面):§2.1 刚堵出来的那个缺口 —— post 超时后留在 CREATED 的行 —— 没有回调会来(上游可能压根没收到那笔请求),对账腿是它唯一的出口。 把对账腿饿死 = 让那个缺口重新变成无界。

为什么不能照订单侧取一个时间上界:absoluteExpireHours 安全,是因为「把未支付的订单收敛为 EXPIRED」是安全方向;退款行没有对应物 —— 写 FAIL 会同时释放 uk_lsy_refund_one_pending 与在途闸门 ⇒ 重复出金。⇒ 「上界该取多少」这个问法本身就问错了:这里缺的不是一个数字。

机制(仓内先例,不是新发明):pay_lsy_order 已有同名列 —— next_query_time TIMESTAMP WITH TIME ZONE,列注释原文「下次查询时间(退避调度)」, 配套索引 idx_lsy_order_poll (status, pay_scene, next_query_time),用法见 LsyOrderPollScheduler.pollBarcodeOrders:.le(nextQueryTime, now).orderByAsc(nextQueryTime).last("LIMIT 100")。 退款侧照抄形状:insert 时 next_query_time = now + reconcileDelaySeconds; 扫描 status IN LsyRefundStatus.PENDING AND next_query_time <= now ORDER BY next_query_time ASC LIMIT 100; 每轮处理完(无论收没收敛)都把它推到 now + reconcileDelaySeconds。

无饿死的证明:被本轮处理过的行,其新值 now + delay 严格大于 now ≥ 所有「已到期且本轮未处理」 的行的值 ⇒ 下一轮选中的 100 行与本轮选中的(仍留在 PENDING 的)不相交 ⇒ 每一行都在 ⌈N/100⌉ 轮内必被问到(N=当轮到期行数)。等待时间与积压量成正比,而不是无穷。

不做的事(含理由)

  • 不加 query_count。不是「用不上」,是仓内已有前车之鉴:pay_transfer.next_retry_at 就是一列 「只写不读」的重试列,被记为本项目既有缺陷。固定间隔的轮转里它没有任何读者 ⇒ 加了就是重犯。
  • 不改订单侧那两条腿(reconcilePendingOrders / closeExpiredQrOrders)。它们的谓词同样单调 (code_url_expire_time <= now 也一样),但候选集有排水阀:expireStaleOrders 会在 h5ExpireMinutes / absoluteExpireHours 之后把它们移出非终态 ⇒ 集合有界 ⇒ 不存在永久饿死。 这个不对称正是「只有退款腿需要游标」的原因,不是遗漏。
  • 不动 LIMIT 100:新机制下它不再是饿死的成因。
  • 不做指数退避:那需要发明一个上限数字(正是没有依据的东西),而固定间隔的轮转已经消除了无界 失败模式。留一个将来可判的触发条件:若 N 长期 ≫ 100(例如持续 >1000),再考虑退避—— 届时 query_count 才第一次有了读者。

代价与落地约束

  • 一行 DDL(新增列 + 索引)。必须先执行 SQL 再发版:代码会查一个不存在的列。
  • 存量非终态行必须回填 next_query_time。不回填的后果是静默的:next_query_time <= now 对 NULL 求值为 NULL(不是 true)⇒ 那些行永远不进对账集。
  • 列类型必须 TIMESTAMP WITH TIME ZONE、实体字段用 OffsetDateTime(与 pay_lsy_order.next_query_time 一致)—— 本仓有过「全库时间列漂移」的实测缺陷,此处再开一个 timestamp without time zone 就是重开那个口子。
  • 插入口只有一处(createRefund 的预插入),且已有测试捕获该实体 ⇒ 补一条 「插入时 nextQueryTime 非空」的断言即可钉住 NULL 这条路。不在查询谓词里加 OR next_query_time IS NULL 兜底:那会把 NULL 行重新变成「永远到期」,是同一个缺口的复辟。

实施结果(2026-09-22,已入库未提交)

落了 5 个文件:LsyRefundEntity(加 nextQueryTime)、LsyRefundService(预插入时写初值 now + reconcileDelaySeconds,多一个只为游标初值而依赖的 LeshouyingProperties)、 LsyReconcileScheduler(换谓词 + ORDER BY next_query_time ASC + 每轮末 pushNextReconcileTime, 守卫写在 WHERE 里)、V1.8 建表文件(三处,供全新安装与 LsyEntityMappingTest 双向校验)、 新建 db/lsy-refund-reconcile-cursor.sql(见下)。测试 +3 条。

读数(每次跑前删 target/surefire-reports/):

项 读数
全量 411 绿 / 0 失败,BUILD SUCCESS(改前 408,差值 = 新增 3 条,自洽)
LsySchedulerTest 21 绿;LsyRefundServiceTest 34 绿
变异 M1(orderByAsc→Desc,必须红) 恰好 1 条红,正是那条排序断言。失败消息把真实 SQL 段打了出来:(status IN (…) AND next_query_time <= …) ORDER BY next_query_time DESC LIMIT 100 —— 顺带实证了 getSqlSegment() 包含 ORDER BY(此前只是推测)
变异 M2(删掉 pushNextReconcileTime(refund);,必须红) 恰好 3 条红,正是断言「推游标」的那三条(未收敛 / 抛异常 / 非终态守卫)
变异 M3(把 patch.setNextQueryTime 与 setUpdatedTime 两行对调,无害,必须仍绿) 21 绿——测试没有对无语义的语句顺序过敏
还原核对 变异后文件与变异前备份 diff 为空(逐字节相同)才跑的全量

⇒ 断言双向都实测了:该红的红、该绿的绿;红的那两条读数说明这些用例真的在测这个机制, 而不是碰巧通过。

✅ 已执行并核对(2026-09-22,用户跑完 SQL 后我直连库只读取数)。

该文件含加列 + 加索引 + 回填,必须在部署新代码之前跑完——新代码的扫描条件含 next_query_time, 列不存在会抛 PostgreSQL 42703(顺序现已满足)。回填取 created_time(即立刻到期),方向安全; 回填不带 status 条件,避免在 SQL 里抄第三份 IN ('CREATED','REFUNDING')。

核对读数(脚本 D:\lsy-regress\cursorcheck.py 与 filevsdb.py,只读事务,凭据只打印长度):

项 读数
列 next_query_time,timestamp with time zone,is_nullable=YES,default=None
列注释 库里 '下次对账时间(退避调度)',与 V1.8 的 COMMENT ON 逐字相同
索引 idx_lsy_refund_reconcile ON (status, next_query_time),形状对
文件 ↔ 活库 4 张表列名集合逐表一致(pay_lsy_refund 23/23);85 条列注释库/文件逐字相同,差 0
NULL 计数 0 —— ⚠ 但 pay_lsy_refund 是空表(total_rows=0),这条是空洞为真

⚠ 回填只被证明「跑通了」,没被证明「逻辑对」:表为空 ⇒ UPDATE … WHERE next_query_time IS NULL 作用 0 行,「NULL = 0」不含任何信息。所以「不留 NULL」这条不变量现在全靠 Java 侧撑着, 而列在库里 default=None——任何不走 setNextQueryTime 的插入都会产生一个静默游标为 NULL 的行。 当场复核该洞是闭的:全仓 refundMapper.insert 只有一处(LsyRefundService:210), 紧邻其上的 :208 就是 setNextQueryTime(...);模块内无 XML mapper、无 @Insert; LsyRefundServiceTest 对捕获到的实体断言 getNextQueryTime() 非空。

一处无害但需记下的分叉:库里该列在 attnum=23(ALTER 追加到末尾),而 V1.8 文件里它排在第 14 位 (upstream_result_msg 之后)。全新安装与既有库的列序不同。已核实无消费者依赖列序: LsyEntityMappingTest 比的是名字 Set<String>,MyBatis-Plus 按实体生成显式列清单, 且模块内零 SELECT *。故不改。

顺带核对(同一批只读查询):lsy-menu-rename.sql 也已执行——sys_menu 4 行里三个页面 component_path 都是 module_payment/collection/{order,refund,merchant}/index,旧 leshouying 路径 0 行; sys_role_menus 授权 8 行 = 管理员角色(id=1) 与租户角色(id=2) 各 4 条。 ⇒ 前端三页静默落 404.vue 的风险解除。

2.4 下游回调重试通道当前完全不通 —— 已不成立(2026-09-22 复核实测)

⚠ 本节原文写于 LsyReconcileScheduler 类文件还不存在时,两条腿现在都通。 保留原文是为了 留下「当时确实不通」这个事实,但不要再引用它的结论。

腿 原文断言 现状(2026-09-22 逐个符号复核)
调用者 retryFailed() 零调用者 ✅ 有调用者:LsyReconcileScheduler.reconcile() 第 ④ 条腿 try { callbackService.retryFailed(); }
订单腿 不生效 ✅ 通:retryFailed() 捞 notify_status='FAIL' 的订单 → notifyOrder → recordOrderNotifyResult 写 SUCCESS/FAIL
退款腿自锁 闭环、一行都捞不到 ✅ 自锁已解除:notifyRefund 现在有第二个调用者 —— LsyRefundService.pushRefundCallbackIfTerminal(挂在 applyNotifyResult 这个唯一状态写入口上)⇒ 退款行到终态时必被推一次,notify_status 必被写成 SUCCESS/FAIL ⇒ FAIL 行真的能被 retryFailed() 捞到

⇒ 结论(订正):下游回调重试两条腿都通。「退款推送失败会被重试」这句话现在成立。 ⚠ 与之相关的旧注释已同步订正:LsyReconcileScheduler 类 javadoc 里那段「不得写成『退款推送失败会被重试』」 以及 LsyRefundStatus / 若干服务注释里「退款侧没有兜底对账」的说法,全仓 12 行已清零。

⇒ 联调必问第 4 问仍然有效:退款完成是否要单独推下游?现在 notifyRefund 有真实调用链了, 这个问题的答案从「影响一个死通道」变成「影响一条活通道」。

2.5 ★ 下游通知的崩溃窗口无补偿

notifyOrder 抛异常时,该次通知没有补偿路径(重试依赖「FAIL 已落库 + 调度器捞取」, 而异常路径是否一定落 FAIL 未逐一验证 —— 台账 P-T12-7,终审判)。

2.6 ★ 下游回调的重试是「有界可恢复」,不是「永久可恢复」

上限 downstreamNotifyMaxRetries = 8(LeshouyingProperties.java:59)。 商户在窗口内补配回调地址即可恢复,超窗口即永久放弃。 按 5 分钟一轮算,窗口约 40 分钟(轮次间隔已确认:LsyReconcileScheduler.reconcile() 的 @Scheduled(fixedDelay = 5 * 60 * 1000),与 downstreamNotifyMaxRetries = 8 相乘)。 ★ 不要把「可恢复」理解成无限期。

2.7 商户端点返回永久性 4xx(401/400)也会被重试 8 次

本模块的回调只按 is2xxSuccessful() 分类失败,没有「4xx 终态、只重试 5xx」的概念。 既有的 OpenapiService.sendCallback:415-421 是有这个概念的。 属设计取舍,但要知情:商户 URL 配错时,其日志会连续吃 8 次请求。


三、部署前必须手工执行

3.1 V1.8 与 lsy-menu.sql 从未执行过 —— 已执行(2026-09-22 实测订正)

⚠ 本节原标题与正文均为误断,已作废。 实测(JDBC 直连库目录读 pg_class / pg_attribute / pg_indexes,探针脚本用完即删):

读数 结果
to_regclass('public.pay_lsy_refund') / ('public.pay_lsy_notify_log') 两表均存在
索引 uk_lsy_refund_refund_no、uk_lsy_refund_out_refund_no、uk_lsy_refund_trace_no、uk_lsy_refund_one_pending(含部分索引的 WHERE 子句,逐字相符)、idx_lsy_notify_refund_no 等全部存在
列注释 pay_lsy_refund.refund_no 与 pay_lsy_notify_log.refund_no 的 col_description 正是 V1.8 里 COMMENT ON 的原文(逐字相符)
lsy-menu.sql 该文件自己的文件头已写着「本脚本已于 2026-09-22 执行」「再跑本脚本不会更新已有行」
行数 pay_lsy_order=4、pay_lsy_refund=0、pay_lsy_notify_log=0

⇒ 不要按本节原文去「首次执行」这两个脚本。 V1.8 通篇 CREATE TABLE IF NOT EXISTS 与 CREATE UNIQUE INDEX IF NOT EXISTS,重跑时表与索引会静默跳过(文件自己 :2xx 那段就警告过「按内容找、别按行号」);但 COMMENT ON 不是 IF NOT EXISTS 保护的, 重跑会覆盖注释 —— 这正好是 db/lsy-refund-no-comment-fix.sql 不需要重跑整个 V1.8 的原因。

本项目仍然没有 Flyway / Liquibase,V1.8 也确实被多次编辑(R26/R27/R28/R29/R33) ——「改文件不会影响已建好的库」这个陷阱依然成立且更危险(因为库已经建好了)。

重复值预检仍可跑,但原版 SQL 有假阳性:GROUP BY 把 NULL 当作彼此相等 (与 = 不同),所以库里只要有 ≥2 行 refund_no IS NULL,原版就会报出 NULL | N —— 而 PG 的唯一索引允许多个 NULL,那不是问题。 (当前 0 行,跑起来是空集;本列在真实流量下恒为 null,将来必然踩到这个假阳性。) ⇒ 改用带 WHERE refund_no IS NOT NULL 的版本:

SELECT refund_no, count(*) FROM pay_lsy_refund
 WHERE refund_no IS NOT NULL GROUP BY 1 HAVING count(*) > 1;

3.2 ★ lsy-menu.sql 的原计划版本一条都跑不通(已重写)

原版用的是 RuoYi(若依)的 sys_menu 列名(menu_id / menu_name / order_num / path / component / is_frame / is_cache / menu_type / visible / perms / ...),本工程一个都没有; 且漏了 NOT NULL 的 uuid、写死了 menu_id 会与序列撞 PK。 已按真实 DDL 重写(模板 java/sql/005_invitation_menu.sql)。见 R45a。

3.3 ★ 首次运行前没有任何东西检查过「应用能不能启动」

本项目没有任何上下文加载测试。实测(2026-09-21):src/test 下 26 个测试文件, @SpringBootTest / @WebMvcTest / @DataJpaTest / @MybatisPlusTest / @ContextConfiguration / @ExtendWith(SpringExtension.class) —— 六种容器入口全部零命中;全部单测走 Mockito,不碰 Spring 容器。 ⇒ 新增的每个 @Service / @RestController / @Mapper,其 bean 装配只在真正启动那一刻 第一次被检查。缺 bean、bean 二义、新 mapper 不在 @MapperScan 范围内, 任何一条都只在启动时炸,而测试套件保持全绿。

✅ 2026-09-22 实机复核:已启动,且错误出口整族实测通过(8/8)

用户重启后端后(:8001 监听中,PID 41000),用租户 key 打实机(D:\lsy-regress\live_recheck.py, 凭据由脚本自己从 sys_tenant_api_key 取、只打印长度):

# 场景 原状 实测
1 未支付单查单 HTTP 500 HTTP 200 + data.local_status=PAYING + data.detail.pay_result=UNKNOWN
2 退款-订单状态不允许 HTTP 500 HTTP 200 + code 400 + msg=订单当前状态不可退款: PAYING
3 关单(前置) — HTTP 200 + status=CLOSED
4 重复关已 CLOSED 的单 HTTP 500 HTTP 200 + code 500 + msg=撤销/关单未成功,请稍后重试
5 多传未知字段 HTTP 500 HTTP 400 + msg=未知字段 "typo_field_xyz",报文里的字段名一律用下划线式,请核对
6 路径不存在 HTTP 500 HTTP 404 + msg=接口不存在
7 查不存在的单 — HTTP 200 + code 404 + msg=订单不存在(业务错误走 200,非 HTTP 404 —— 见下)

⇒ 上面「错误出口整族失守」那族在真实环境确实修好了,不再是「测试绿但没上过真机」。 顺带关闭本节的大部分担忧:应用能启动,且 LeshouyingOpenController 的 7 条 /payment/openapi/pay/* 路由真的可服务(不是只在单测里装配得上)。 但「任何一条装配缺陷都只在启动时炸」这个结构性风险本身仍在(仍然没有上下文加载测试)。

⚠ 两条 FAIL 是我自己的断言写错,不是缺陷(记下来是因为它正是本项目反复踩的坑): 第 1 条我读的是顶层 detail,实际在 data.detail;第 7 条我把它当 HTTP 404, 而业务错误的约定就是 HTTP 200 + code(文档 §3.6 那句「别信 HTTP 状态码」)。 ⇒ 断言必须照规格写,不能照印象写(先打印报文再断言)。修正后 8/8。

3.4 db/lsy-notify-process-status-comment.sql(非阻塞,纯注释同步)

裁定 R-1 新增了第 10 个审计词 HELD_FOR_RECONCILE(2026-09-22), pay_lsy_notify_log.process_status 的列注释词表要跟着补一个。 实测:库里该列已是 VARCHAR(32)(10 个词逐一对过列宽,超宽 0 个)⇒ 宽度不用改; 要改的只有那句注释本身。纯元数据、幂等、不碰数据也不碰索引 ⇒ 不阻塞任何事 —— 不发也不影响功能,只是查库的人少看到一个词。同款先例:db/lsy-refund-no-comment-fix.sql。


四、数据可见性与排查提示(不是 bug,但排查时会踩)

# 事实 排查时的意思
1 RECEIVED 审计行按设计不可见:它写在查到订单归属之前,tenant_id 落 NULL;而管理端 /{outTradeNo}/notify-logs 按 tenant_id 过滤 ⇒ 只看得见 APPLIED/REJECTED 查「回调到底有没有到」时,看不到受理那一行。归属未知时确实无法归属,所以不是 bug(Task 12 评审 §5-3)
2 菜单权限字符串必须逐字相同:PermissionCheck 是 authorities::contains 全等匹配 新页面若对普通用户 403,按序查:(a) sys_menu.permission 与 @PreAuthorize 是否逐字相同;(b) sys_role_menus 有没有授权行。超管账号测不出这个问题(ROLE_SUPERUSER 直接放行)
3 主扫 / H5 下单返回 PAYING 而不是 SUCCESS 与「下单成功」的直觉不符,是设计如此
4 上游返回的字符串只作文本/二维码内容渲染,不许拼进 HTML 已在实现里约束(无 v-html)

五、未认证查询的铁律(跨子项目义务)

未认证路径上的查询,只允许用「有全局唯一约束的列」做定位键。

理由:pay_lsy_* 四表在 TenantInnerInterceptor.CONDITIONAL_TABLES 里, 无认证时不追加 tenant_id 条件 ⇒ 此时没有任何租户隔离, 用一个非唯一的列 selectOne 会命中任意租户的行。

已登记的欠账(子项目 B / 后续开发开工前先补):

表 列 现状 补什么
pay_lsy_merchant mch_no 无唯一索引 将来若出现按 mch_no 的未认证查询,必须先补 uk_lsy_merchant_mch_no。当前无此查询,故是惰性的
pay_lsy_order trade_no 仅普通索引(idx_lsy_order_trade_no) 将来若出现按 trade_no 的未认证定位,必须先补唯一索引 —— 否则 applyPayResult 按 id 回写会把钱记到别人单上

注:uk_lsy_order_out_trade_no 是全局唯一(V1.8 收窄前只有 (out_trade_no),不含 tenant_id) —— 这条是安全的。

★★ 2026-09-21 更正(上一条注记的「安全」结论对本列用不上,且漏了相反后果),两点:

  1. 上一条注记回答的问题是「未认证路径上的查询,只允许用有全局唯一约束的列做定位键」, 而 out_trade_no 根本不是未认证路径的定位键(那两条未认证定位走 pay_trace_no / refund_no) ⇒ 那句「这条是安全的」用不到本列身上:它只是一条与本列无关的旁证, 既没证明本列应当全局唯一,也不构成本列不能收窄的理由。
  2. 上一条注记漏掉了全局唯一在本列上的相反后果:本列是租户自己提供的单号, 跨租户撞号是正常的;而它的全部查询都带租户条件 ⇒ 租户 B 撞号时应用层查不到那一行 ⇒ 一路走到 insert ⇒ 撞全局唯一索引 ⇒ DuplicateKeyException ⇒ 500,且永久如此。 索引已按 (tenant_id, out_trade_no) 收窄(论证见 V1.8 中 uk_lsy_order_out_trade_no 上方新增的注释); pay_lsy_refund.out_refund_no 同形同改(V1.8 中 uk_lsy_refund_out_refund_no 上方新增的注释同理)。 本次收窄不触及上面 §5 的铁律:铁律约束的是未认证定位键,而这两列从来不是 —— §5 表里登记的两条欠账(mch_no、trade_no)不受影响。

5.1 同形欠账:已认证路径上也有一条「安全来自别处」

LsyMerchantService.delete(Long id)(实测 :105-110)用 selectById(id) + deleteById(id) 删映射, 自己没有任何租户条件。它今天安全的唯一前提是:管理端是已认证路径, TenantApiKeyAuthFilter 放进 SecurityContext 的 LoginUser 带 tenantId ⇒ TenantInnerInterceptor.ignoreTable("pay_lsy_merchant") 返回 false ⇒ 正常追加 tenant_id ⇒ 删别人的行时 selectById 返回 null ⇒ 抛 404。

★★ 2026-09-21 更正(本小节原文的机理写错了,实测推翻),两处:

  1. 「只因为 pay_lsy_merchant 在 CONDITIONAL_TABLES 里」 —— 假。 已认证(LoginUser 且 tenantId != null)时,该表在不在 CONDITIONAL_TABLES 里, ignoreTable 都返回 false(不在任何集合里则走 :173 return false)。 ⇒ 集合成员身份对这条已认证路径没有任何影响。
  2. 「移出该集合 ⇒ 会静默变成可跨租户删除」 —— 假(同上)。 只有把它放进 IGNORE_TABLES(:161,无条件返回 true)才会。

正确的判断规则:CONDITIONAL_TABLES 的成员身份只在无认证路径上起作用 (无认证时不在集合里 ⇒ ignoreTable 也是 false ⇒ 被追加 tenant_id = 0 ⇒ 查不到行 ⇒ fail-closed)。 所以本文件 §5(无认证路径)与 §6.5 的 R44(调度器)结论仍然成立, 只有本小节 §5.1 原先把这条规则误用到了已认证路径上。

将来若把 pay_lsy_merchant 放进 IGNORE_TABLES,delete 会静默变成可跨租户删除, 而没有任何测试会报警 —— 这条依然是真的,只是触发条件收窄为「放进 IGNORE_TABLES」。 (Task 17 会建一个 LeshouyingAdminWiringTest,但它只断言集合成员,不断言这个后果)。

处置:有意未改 —— 加显式条件会掩盖「这条路径必须有认证」这个真正的前提, 且属冗余防御。登记在此,供将来改动 IGNORE_TABLES 的人查阅。


六、应当知情的实现取舍(不是缺陷)

  1. 本计划不产出面向下游用户的 API / 接入文档。
  2. application.yml 里的 leshouying.key 是上游文档给的「测试机构」密钥。 线上必须换成进件后的正式密钥。
  3. 开发测试阶段按文档给的测试信息走通支付/收款生命周期;线上版本必须先进件。
  4. Task 10 有若干有意偏离计划之处(见该任务报告)。
  5. SecurityConfig.java 里有一条已证伪的注释(「filter 认证失败时无 request attribute, Controller 会抛 401 JSON 响应」)—— 实测:认证失败时 filter 自己写错误响应后就 return, 请求根本到不了 Controller。它描述的是既有的 /payment/openapi/account/** 那条, 与本次新增的乐收赢通道无关,故有意未改(改了会把 diff 扩到任务外)。

七、待补

以下小节等 Task 14–18 交付后由终审补全:

  • Task 14(共享文件必改点 + 白名单)的交付事实
  • Task 16 落地后的实际轮次间隔、以及两个调度器里那些扫描的租户语义依赖 —— ★ 该依赖已登记:下面这些扫描依赖 CONDITIONAL_TABLES 的无认证分支才有全租户语义。 按类名 + 方法名锚定,不写条数 —— 数是会漂的,类名不会: · LsyReconcileScheduler.reconcile(@Scheduled(fixedDelay = 5 * 60 * 1000),即每 5 分钟): 子任务 reconcilePendingOrders / closeExpiredQrOrders / expireStaleOrders 都在 orderMapper 上扫 pay_lsy_order(最后一个方法里含 H5 过期与绝对过期两条阈值扫描); · LsyOrderPollScheduler.pollBarcodeOrders(@Scheduled(fixedDelay = 30 * 1000),即每 30 秒 —— 这就是原句「四条扫描」漏掉的那条,它比上面那条频繁 10 倍): pollBarcodeOrders 的范围扫描,以及 pollOne 撤销前的 selectById 复查(主键查,同表)。 ★ 这两个 5 分钟 / 30 秒是任务本身的间隔,与每行的 next_query_time 退避不是一回事: 后者由 LeshouyingProperties.barcodePollIntervalMs 驱动、写在订单行上,各单各自推进。 两者混成一句会读成「每 30 秒查每一单」——那是条假全称。 将来若有人把 pay_lsy_order 移出该集合,或给 @Scheduled 配上 SecurityContext 传播,这些扫描会立刻退化为单租户、且没有任何测试会报警(R44)
  • Task 17(管理端控制器)的授权链核对结果 —— 关闭:见 §八 R-7
  • Task 18(前端 + 菜单 SQL)的 permission 字符串逐字对照 —— 关闭:见 §八 R-7

八、裁定汇总(2026-09-22,用户「都裁定一下」)

本节把此前未裁定或只在记忆里、台账里没有的开放项一次判完。 每条写清:结论、依据(源码位置,不是印象)、以及如果错了代价是什么。

R-1 ★★ §2.2 的 TRADE_REFUND_FAIL → 改成守闸

结论:改。 同一个上游语义(「这笔退款没成功」)经两条路到达,裁定当时判断相反:

⚠ 下表第三列是裁定时刻(2026-09-22)的现状快照,不是现状 —— 本条已落地,异步那一格现在也是守闸(实施记录见 §2.2 的「✅ 已实施」)。 保留原表是因为它记录的正是「当时错在哪」,改掉就看不出这条裁定在修什么了。

路径 位置 裁定当时怎么做
同步应答 PAY_FAIL LsyRefundService.createRefund:289-314 守闸:只 log.error,不写任何状态,留在 REFUNDING
异步回调 TRADE_REFUND_FAIL LsyNotifyService.handleRefundNotify:255-256 释闸:outcome = "FAIL" → applyNotifyResult(..., "FAIL")

依据一(方向不对称且不可逆)。 守闸的代价是可用性:该单暂时发不起退款,有界等待 (对账腿下一轮就问,饿死缺口已于本次修掉 ⇒ 每行 ⌈N/100⌉ 轮内必被问到),且运营看得见。 释闸的代价是资金安全:FAIL 一落,uk_lsy_refund_one_pending 与在途闸门同时释放 ⇒ 这张单可以立刻再退一笔 ⇒ 若前一笔其实成功了,就是重复出金,不可逆。

依据二(决定性,此前未记录):释闸之后两条补救通道都是死的。

  • 对账腿 reconcilePendingRefunds 只扫 LsyRefundStatus.PENDING(LsyRefundStatus:36, = {CREATED, REFUNDING})⇒ FAIL 行永远不被问第二次;
  • 唯一的「事后探测器」LATE_REFUND_AFTER_TERMINAL(LsyNotifyService:299-302)在 handleRefundNotify 的 :233 早退之后,而 R41 使 refundNo 在真实报文下恒为 null ⇒ 每次回调都在 :233 返回 ⇒ 该分支在真实流量下不可达。

⇒ 现状 = 落 FAIL + 无对账覆盖 + 无回调补救 = 一旦判错就永久错,且可以重复出金。

依据三(仓内先例方向一致)。 LsyNotifyService:250-251 自己写着 「『卡住等上游确认』远好于『静默判失败后放行重退』」—— 那是未知码分支的理由。 TRADE_REFUND_FAIL 是已知码,但已知只说明「我们知道上游说了什么」, 不说明「上游说的对」;它落的 FAIL 与未知码担心的那个 FAIL 是同一个状态、同两把锁。

依据四(本模块吃过多次的形态)。 同一语义两份副本判断相反,正是本模块反复被咬的 「两份副本各自漂移」。

⚠ 反方论点(成立,但被权衡掉):上游在回调里说失败是最终结果,比同步即时应答 更权威,守闸显得过度保守。已计入并接受:边际成本是一轮延迟 + 一次额外查询, 边际收益是消除一条不可逆的资金风险。

⚠ 我没有的那一半依据:没有实证表明上游会误报 TRADE_REFUND_FAIL。 本条不建立在「上游会误报」这个未经证实的假设上,而建立在依据二(确定的事实)与 依据一(代价不对称)上。

落地要点 → ✅ 已实施(2026-09-22)。实施读数与连带改动见 §2.2 的「✅ 已实施」。

  • TRADE_REFUND_FAIL 分支不再落 FAIL,改为 log.error + 审计行 + 直接返回。
  • 审计词取 HELD_FOR_RECONCILE(18 字符,process_status 是 VARCHAR(32),放得下)。 不复用 UNKNOWN_RESULT_CODE:那是「不认识这个码」,语义不同(这里恰恰是认识)。 ⚠ 「不新造上游词汇」那条约束针对的是上游字段值(如 result_code 的字面量, 必须文档实证);审计状态词本来就是我们自己的词表,新增是允许的。
  • 副作用(是好的,已落地):退款行的 FAIL 从此只由一条路径写 —— 对账腿问上游拿到的 权威答案(settleFromQuery)。规则收敛成「终态只由权威答案写」。
  • ⚠ 本节原先写的「有测试要改:现有一条钉住 TRADE_REFUND_FAIL → FAIL 的用例会红」 是假的 —— 该分支改前零覆盖(字面量只出现在两处注释里)。详见 §2.2 的订正。

R-2 status='SUCCESS' 而 trade_no 为空 → 登记 + 最小可见化

事实(本次核过):LsyOrderService.payPatch:457 无条件 patch.setTradeNo(str(resp.get("trade_no"))); MyBatis-Plus 默认 NOT_NULL 策略会把 null 字段从 UPDATE 列清单里剔掉 ⇒ 上游若没回 trade_no,已支付订单会以 trade_no = NULL 落库,全程静默。

消费点(逐个核过):createRefund:154-158 有 null 分支(回退 pay_trace_no) ⇒ 退款不受影响;requireOrder 按 trade_no 查单/关单会查不到(租户仍可用 out_trade_no); LsyQueryDetailAssembler 与下游通知会少一个字段。

结论:不改状态机、不发明兜底值。没有任何依据表明上游会不回该字段 (缺文档实证也缺联调实证),按铁律不发明。但把静默改成可见:当上游回了成功码而 trade_no 为空时补一条 log.error。 理由与本模块反复强调的一致:不记日志的后果是「运营一个字节都看不到」。 触发升级条件:联调或生产只要出现一笔 SUCCESS 且 trade_no 为空,即升级为缺陷并去问上游。

⚠ 本条此前连登记都没有 —— 它只存在于我的记忆里,台账里 grep 不到。这本身是一处欠账, 本裁定首先把它补进台账。

✅ 已实施(2026-09-22)—— 但落点与下面的字面不同

⚠ 订正:本节原先写的落点是 payPatch,照字面做会误报。 /v3/qrpay 与 /v3/h5pay 的 PAY_SUCCESS 描述的是「下单请求被受理」 (见 LsyOrderService.createQrOrder:222-235 的长注释),订单刻意落 PAYING、 收款尚未发生,那个响应本来就没有 trade_no。判据挂在 payPatch 上 ⇒ 每一笔主扫下单都响一次 = 本模块明确在防的告警疲劳(真告警会被淹掉, 这项可见化等于白做)。

  • 按调用点逐个量过(不是推断):全 main 里 isPaySuccess 为真的调用点恰好 4 个 —— createQrOrder:220、createH5Order:317 落 PAYING(都不该响); settleFromQuery:414、回调 LsyNotifyService:144 落 SUCCESS(都该响)。
  • 实测佐证:变异 M-A(把裁定字面原样加进 payPatch)⇒ qr_paySuccessWithoutTradeNo_doesNotAlarm 红,捕获到的正是变异自己打的那行。
  • ⇒ 真阳性一个不少,假阳性归零。

实际落点:LsyOrderService.applyPayResult 的 rows == 1 块内, 判据 "SUCCESS".equals(newStatus) && StrUtil.isBlank(patch.getTradeNo())。

决定 为什么
挂「真的要写 SUCCESS」这一刻,不在「上游回了成功码」那一刻 异常的定义是我们记了一笔收款成功却没有上游交易号;只回了个成功码但订单落 PAYING 不是异常
放 applyPayResult 而非 payPatch 上一条的结果;且这里拿得到 order(payPatch 拿不到)⇒ 日志能带 outTradeNo,运营才定位得到
放 rows == 1 里面 同一单的回调会被上游重推,放外面 = 每次重推响一次。里面 = 每单至多一次
isBlank 而非 != null 上游回 "trade_no": "" 时落的是非 null 空串,同样是「没给」;而空串连运营的捞法(trade_no IS NULL)都捞不到

两向变异读数(每条都实测):

变异 改了什么 预期 实测
M-A 把裁定字面原样加进 payPatch 1 红 1 红(qr_paySuccessWithoutTradeNo_doesNotAlarm)
M-B 判据整体挪到条件更新之前 1 红 1 红(applyPayResult_duplicateNotification_doesNotAlarmAgain)
M-C1 把日志措辞整句换掉(无害) 全绿 全绿 —— 守卫不咬措辞
M-C2 isBlank → isEmpty 全绿 全绿 ⇒ 一处未钉住的区分

M-C2 是已知缺口,如实记下:isBlank 与 isEmpty 只在「纯空白 trade_no」上分叉, 无测试覆盖。不补测试 —— 上游回 " " 不现实,为它补断言就是对着不可能输入写测试。 选 isBlank 是取更保守的一侧,不是因为有实证。

新增的 4 条测试里,有 2 条是「钉子」不是「驱动」: applyPayResult_duplicateNotification_doesNotAlarmAgain 与 qr_paySuccessWithoutTradeNo_doesNotAlarm 是负向断言,改代码之前就是绿的 (负向断言本就如此)—— 所以它们必须靠变异证明可红,上面 M-A / M-B 正是干这个的。 applyPayResult_alarmsOnlyForTheOrderMissingTradeNo 把正负对照放在同一次日志捕获里 (两条断言共用一个 appender):捕获若失效得到 0 条 ⇒ 第一条断言红, 「缺 trade_no 才响」这个负读数因此不是空洞通过。

测试数:412 → 416(+4)。全量 416 绿 / 0 失败。

R-3 §2.5 下游通知的崩溃窗口 → 担忧成立,但不修(修法在触发条件下不可用)

本次核清了「异常路径是否一定落 FAIL」这个悬了很久的问题: push 自己吞掉所有异常并返回 false(LsyCallbackService:227-231)⇒ push 不抛; notifyOrder:85-105 里能抛的只有两处,且都在落 FAIL 之前:

  • resolveUrl(:86)—— 查 apiKeyMapper / confMapper,DB 故障时抛;
  • recordOrderNotifyResult(:104)—— updateById,DB 故障时抛。

⇒ 这两处抛出时,该行既没推成功、也没落 FAIL,于是 retryFailed()(只捞 notify_status='FAIL')永远捞不到它。台账 §2.5 的担忧成立。

结论:登记为已知边界,不修。 理由不是「不重要」,而是没有可用的补偿动作: 触发条件是数据库写失败,而补偿手段(写库落 FAIL)在同一条件下同样失败。 真正的兜底在运维层(DB 可用性),不在应用层。这是一个「修不了」的项,不是「没修」的项。

R-4 §2.7 商户端点永久性 4xx 也重试 8 次 → 不修

结论:保持现状。 既有 OpenapiService.sendCallback:390+ 有 4xx/5xx 的概念, 一致性上支持对齐;但对齐需要一张「哪些 4xx 可恢复」的表,而这张表没有依据 (商户侧实现各不相同,401 可能是「密钥还没配好、配好就通」)。当前行为是安全的 (多推几次不会造成资金问题,只是商户日志噪音),窗口本身有界(§2.6:8 次 ≈ 40 分钟)。 按「宁可不做也不错做」保持。属于知情项,不是欠账。

R-5 §3.3 无上下文加载测试 → 做,但先实测可行性

结论:做。 这是本节唯一一个既能做、又不需要任何上游依据的项: 它不发明数字、不猜语义,纯粹是「让装配缺陷在 CI 里炸,而不是在启动那一刻才炸」。 实机复核(§3.3 的 ✅ 小节)证明了这一次能启动,但没消除结构性风险。

⚠ 已知的真实约束:本仓 411 个测试全部是 Mockito 单测、不碰 Spring 容器, 加 @SpringBootTest 会真的去装配数据源 / Redis / MyBatis。 所以第一步不是写测试,是实测:先写一个最小的 @SpringBootTest 跑一遍 —— 跑得通就留;跑不通(要真连 DB / Redis)就降到只装配 leshouying 相关 bean 的切片 (@ContextConfiguration(classes = …)),或明确记录「因需要外部依赖而不可行」并关闭本条。 不得为了让它绿而给测试加 mock 掉容器 —— 那就把它要防的东西防掉了。

✅ 已实施(2026-09-22)—— 判据自相矛盾,按意图落地;两处偏离见下

交付物:src/test/java/com/payment/platform/PaymentApplicationContextTest.java,2 个用例。 全量回归 418 绿 / 0 失败(改前 416)。

★ 裁定的判据本身自相矛盾(同 §八 R-2 那种「两半各自都对、合起来是错的」): 上面写的是「跑得通就留;跑不通(要真连 DB / Redis)就降级或关闭」,把 「跑得通」与「要真连 DB/Redis」写成了互斥的两支。实测两支同时成立 —— 它跑得通(BUILD SUCCESS),也确实要真连 DB。所以按字面无法判定,只能实测后按意图落点。

实测读数(三个探针,各跑一次真容器;探针文件测完即删)

探针 结果 根因 / 说明
库指向死端口(spring.datasource.druid.url → 127.0.0.1:59999) 红 dataSource bean 自己建不起来:org.postgresql…PSQLException: Connection refused。本仓是 Druid 不是 Hikari,starter 在建 bean 时就去取连接(不是 Hikari 那种懒到首次使用的语义)⇒ 全文测试必须挂着库隧道
Redis 指向死端口(spring.data.redis.port → 6399) 绿 Redis 不是装配的必要条件
不掐调度(原样 @SpringBootTest) 绿,但有对外副作用 @EnableScheduling 在 PaymentApplication 上;全仓 7 个类、7 个 @Scheduled 方法,0 个设 initialDelay ⇒ 默认 0 ⇒ 上下文一 refresh 就立刻跑一轮。实测运行期间在 scheduling-1 线程上真打上游支付网关:/v3/queryorder、/v3/qrclose 各一次(200),并真写 Redis

两处按意图落地的偏离(都不能照字面做;按 constraint-docs-drift-anchor-by-name 第 6 条, 意图无歧义 ⇒ 落地 + 登记 + 报告,不构成决策点)

  1. 保留全文 @SpringBootTest,不降级为切片。 裁定给的另一条出路是 @ContextConfiguration(classes = …) 切片,但它要求手写一份 bean 清单 —— 而清单的遗漏正是 R-5 要抓的那一类缺陷(缺 bean / mapper 不在扫描范围)。 让守卫依赖一份会过期的清单,等于把它要防的东西请回来。
  2. 在测试上下文里换掉 TaskScheduler(NeverRunsSchedules:只登记不执行)。 这一条不属于裁定禁止的「mock 掉容器」:容器照常 refresh, 每一个业务 bean(含两个调度类)照常创建、照常注入、照常参与装配检查, 被替掉的只是基础设施里的调度器。理由:测试套件不该在每次 mvn test 时朝支付网关发请求 —— 而「需要真连库」那一半代价,裁定自己在括号里已经预料并接受了。

三条正对照(全是正向断言是有意的:「没发生」是自证的,只有把「该在的还在」钉住才不是空话)

断言 挡的空转方式
容器在用的 TaskScheduler 就是替换后的那个类 bean 没被采纳、真调度器照跑(那时本用例会向支付网关发请求却仍然绿)
@Scheduled 仍被发现(登记数 > 0) 调度整个失效(@EnableScheduling 被摘)—— 那时「不发外部请求」是因为压根没调度,绿灯成空话
调度类 LsyReconcileScheduler 仍照常装配 为了不发请求干脆把调度 bean 摘出容器,R-5 的装配网会漏掉它们

两向变异读数

变异 预期 实测
M-1 只摘 PaymentApplication 的 @EnableScheduling 应红 绿 —— 变异体不成立:全仓有两个 @EnableScheduling(PaymentApplication + core/scheduler/ScheduledTasks),摘一个调度还活着,② 绿得正确
M-1b 两个 @EnableScheduling 都摘 应红 1 红,正是「@Scheduled 仍被发现」那条
M-3 摘掉 LsyOrderService 的 @Service(制造装配缺陷) 应红 2 errors,UnsatisfiedDependencyException(leshouyingNotifyController)⇒ R-5 的网确实能抓装配缺陷,不再等到启动那一刻
无害方向 应仍绿 三个被变异文件按 md5 还原到变异前字节;最终全量 418 绿

★ M-1 那次「绿」差点被读成「正对照是摆设」 —— 实际上是变异体没奏效。 「变异后仍绿」有两种含义(断言空洞 / 变异无效),必须查清是哪一种再下结论。

已知代价(登记,不修):从今以后 mvn test 需要库隧道在;隧道断则整轮红, 且红在「上下文加载」而不是某个用例上 —— 判读时看异常类型 (UnsatisfiedDependencyException / NoSuchBeanDefinitionException = 装配缺陷;连接被拒 = 环境)。

R-6 §一「必须问上游的 5 个问题」 → 整理为对外清单;其中一问的问法已作废

结论:

  • 第 3 问已作废(问法本身错):R41 的实测结论是「退款回调报文里没有任何退款标识」, 它把问题的对象搞错了 —— 真正要问的是「退款回调报文里哪个字段能唯一对应一笔退款」。 台账 §一 的 ★★ 更正段已订正这一点,此处确认它从待问清单里移除。
  • 第 5 问仍有效(/v3/refundorder 同步响应会不会给裸 SUCCESS)。
  • 第 1、2、4 问仍有效。
  • ⇒ 待问清单收敛为 4 问(1、2、4、5),且第 2 问与 R41 是同一个问题,应合并。
  • 不阻塞开发:按用户既有裁定(开发测试阶段先用文档给的测试信息把生命周期走通), 继续推进,不等待答复。

R-7 台账「七、待补」的 4 个 checkbox → 关闭

  • Task 14 / 16:内容已在本台账内写全(16 的租户语义依赖按类名 + 方法名锚定, 刻意不写条数 —— 数是会漂的)。勾掉。
  • Task 17(管理端控制器授权链):菜单 4 行、授权 8 行(管理员角色 id=1 + 租户角色 id=2 各 4 条) 已在库中核对。关闭;若发现某个角色看不到页面再单独开。
  • Task 18(permission 字符串逐字对照):静态链已闭合——库里三个页面的 component_path 是 module_payment/collection/{order,refund,merchant}/index,对应 .vue 文件实测都在 (10582 / 4653 / 9613 B),旧 leshouying/ 目录已删。关闭。 ⚠ 这不是「实机打开过页面」:我只核到「库里的路径 → 仓库里的文件」这条静态链, 没有起前端 dev server 用浏览器走一遍。permission 字符串与路由守卫的运行时一致性 仍未实测。若将来页面打不开,先看这里。

R-8 R41 → 维持挂起,不重开

用户 2026-09-22 已裁定「挂起,先不动」,本次不重开。 但记一条影响面的变化:R-1 若落地,handleRefundNotify 的 refund == null 早退(:233)之后的部分会更彻底地成为死代码 —— 连同 lateRefundAfterTerminal 这一点残存的探测能力一起消失。 ⇒ R-1 落地会让 R41 的优先级上升:定位键一旦重选成功,恢复的不只是一个分支, 而是「终态行收到迟到成功」这条唯一的事后探测通道。解冻条件不变:真实报文或上游答复。

R-9 收尾项

  • 7 个提交未 push(28c0b65 … 2179be5)。push 是对外动作,不在本轮自动执行。
  • stash 0626d3e0 不丢。 当初留它的理由是「工作没提交」;现在工作已入库, 但丢弃它零收益、保留它零成本(它是免费的备份)。⇒ 保留。
  • application.yml 的 redis.host: localhost 是本地私有改动,继续有意不提交。
  • logs/ 与 .codegraph/daemon.pid 是运行期噪声,不入库。