本文件是
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 问那一行的「修法」句含假前提,照着修会修错),三点:
「—— 假,且在本任务之前就是假的:out_refund_no我们自己生成」/refund入口传的是dto.getOutRefundNo()(LeshouyingOpenController), 即租户提供的值;入口@Pattern(LsyBizNoPattern)只管字符集与长度, 既不生成它,也不保证它跨租户唯一。「有全局唯一索引」—— 本次收窄之后为假:uk_lsy_refund_out_refund_no已改成(tenant_id, out_refund_no)(论证见V1.8中该索引上方新增的注释)⇒ 它不再是 未认证路径可用的定位键(两个不同租户可以合法持有同一个out_refund_no)。- ⇒ 本次收窄并没有堵死第 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(requireByMchNojavadoc)✅ 删「退款回调按 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)—— 重新定位后若仍走未认证路径,同等约束依然需要;且它们是已执行库的既成结构。⚠ 两处自我订正(本清单原文写错):
- 原文称 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(纯元数据、幂等、不碰数据与索引), 把那两句重发一次。待用户执行,执行前文件与库在这两句文案上分叉。- §3.1 称「
V1.8与lsy-menu.sql从未执行过」。错,见 §3.1 的订正。
支付侧将有两条兜底腿(reconcilePendingOrders 问上游 + expireStaleOrders 过期收敛
—— 均为 Task 16 的产物,当前都不存在);退款侧连计划内的都没有。
失效场景:上游的退款回调早于我们 createRefund 落库到达 ⇒ 代码落一条审计行
REFUND_NOT_FOUND 就结束了。上游钱已退、我们账上没有这笔退款,事后无任何自动通道能补上。
依据:Task 12 评审 §5-6。交终审单独判(台账 P-T12-8)。
成立,且原文把可达路径写窄了。 原文只写了「回调早于落库到达」这条竞态;实际有一条不依赖竞态的兄弟路径,概率高得多。
事实:
LsyRefundService:149 client.post("/v3/refundorder", p) —— 上游退款在此发生,不可撤销。:181-182 才 setStatus("REFUNDING") + refundMapper.insert(entity) —— 本地行在此才出现。@Transactional(grep -c '@Transactional' 返回 1,但那 1 处是类 javadoc :32 里的字样,不是注解)⇒ 没有回滚可依赖。:32-36:「若包在事务里…结果就是『上游钱已退、本地无记录』—— 记录消失比记录不准危险得多」。★ 关键:不加事务保证的只是「插入成功后不再被回滚」,它没有关上窗口本身。 插入这一步自己失败、或根本走不到时,结果仍是「上游钱已退、本地无记录」。
兄弟路径(不依赖竞态):
out_refund_no=X 发起退款;:149 的上游调用抛异常(超时 / 连接重置)⇒ 方法在此终止,一行都没写;X 重试 ⇒ 在途前置校验(:99-104)读的是本地行,一行都没有 ⇒ 放行;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("") → "",则第二笔这样的退款插入时会撞该唯一索引 ⇒ 又回到「上游已受理、本地插入异常」这个窗口。能否发生取决于上游,未证。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。
LsyReconcileScheduler.reconcilePendingRefunds(CREATED ∈ PENDING,扫得到),
但那条腿没有上界、会被老行饿死——这是本台账已记的老缺口,不是本次新增的。
方向是安全的(宁可拒付,不重复出金)⇒ 这是用可用性换资金安全的有意取舍。
为让它能被人看见,catch 分支打了 log.error 并带上 outRefundNo / refundTraceNo
(该行在接口返回里看不见,不记就没人能把「租户说退款失败」与「某行卡在 CREATED」连起来)。CREATED 行 refund_no 仍为 null
(补齐要等同步响应,而回调可能早于它到达)⇒ 回调照样落 REFUND_NOT_FOUND。createRefund 里插入了约 60 行注释与代码,本台账上文引用的 :149 / :181-182 等行号已整体下移;
按内容定位(client.post("/v3/refundorder") / refundMapper.insert),不要按行号。| 路径 | 写什么状态 | 在途闸门 |
|---|---|---|
| 同步失败 | 留在 REFUNDING |
持着(uk_lsy_refund_one_pending 挡住第二笔) |
| 异步失败 | 写 FAIL 终态 |
释放(闸门开了,第二笔退款可以插进来) |
⇒ 若上游误报失败、之后又实际退成功,信了异步那一侧就会真的再退一次。
依据:Task 12 评审 §5-7。交终审单独判,标为高优先(台账 P-T12-9)。
成立。表里两行都复核无误:
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 裁定),届时守闸才变安全。
退款对账腿已落地(§2.3 实施记录)⇒ 上面那条「写 FAIL 是唯一出口」不再成立: 真失败的退款现在有第二条出口(对账腿拿上游权威答案后落 FAIL)。
⚠ 但本条的代码行为一个字都没改 —— 这是刻意的,不是漏做:
reconcileDelaySeconds 之后才有出口,
且出口依赖上游 /v3/queryrefund 真的答得出来(未知码情形对账腿同样不落状态);handleRefundNotify 的 TRADE_REFUND_FAIL 依旧写 FAIL 释闸。REFUNDING(已修,2026-09-22)一笔回调丢失的退款会永久停在 REFUNDING(LsyRefundStatus.PENDING 含它)。
源头与 §1 第 3 问同源。没有自动收敛通道。
依据:LsyRefundStatus.java 的 PENDING javadoc 自陈 + R41。
成立。且修复所需的原语已经在仓库里,只差接线。
reconcile() 四腿全是订单)。queryRefund(LsyRefundEntity) 已实现:LsyRefundService:282 → client.post("/v3/queryrefund", p)(:291)。LeshouyingOpenController:236 refundQuery → :240。没有任何调度器扫它。⇒ 补一条 reconcilePendingRefunds() 进 LsyReconcileScheduler.reconcile() 即可,形状与既有的 reconcilePendingOrders() 完全同构(都是「非终态 + 超过阈值 → 查上游 → 收敛」)。这条腿落地后:
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),而那条审计行本该用来报警「上游说退了、我们没这笔」—— 系统性误报会把它淹掉。
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 的「本增量引入的新缺口」。
结论:不造终态、也不取「时间上界」;给退款行加一个会前进的进度游标** next_query_time,
把扫描从「单调谓词 + 任意 100 条」换成「非单调谓词 + 按游标升序的 100 条」。**
根因(结构性的,不是 LIMIT 太小、也不是「少了个 ORDER BY」)
created_time <= now - 60s 是单调的:一旦为真永远为真 ⇒ 候选集只进不出。LsyRefundService.settleFromQuery 的 PAY_IN_PROCESS 分支与未知码分支
都直接 return null,一次 mapper 调用都没有(applyPayResult 那条路只在真流转时才写,
命中 0 行的条件更新同样不改元组)。⇒ 这些行的 created_time 不动、堆里的物理位置也不动。ORDER BY,返回的也是同一批。
最可能的计划是走 uk_lsy_refund_one_pending(它的谓词 status IN ('CREATED','REFUNDING')
与本查询谓词完全重合,部分索引直接可用),按 order_id 升序扫 ≈ 雪花号升序 ≈ 最老的先返回——
恰好是最坏的那种「稳定」。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:新机制下它不再是饿死的成因。query_count 才第一次有了读者。代价与落地约束
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 为空(逐字节相同)才跑的全量 |
⇒ 断言双向都实测了:该红的红、该绿的绿;红的那两条读数说明这些用例真的在测这个机制, 而不是碰巧通过。
⚠ 未完成的一步(阻塞发版):db/lsy-refund-reconcile-cursor.sql 尚未执行。
我没有执行 DDL 的权限(本项目规矩:SQL 我写、由你跑)。该文件含加列 + 加索引 + 回填,
必须在部署新代码之前跑完——新代码的扫描条件含 next_query_time,列不存在会抛 PostgreSQL 42703。
回填取 created_time(即立刻到期),方向安全;回填不带 status 条件,避免在 SQL 里抄第三份
IN ('CREATED','REFUNDING')。文件末尾有三句核对 SQL(列类型 / NULL 计数期望 0 / 索引定义)。
⚠ 本节原文写于 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 有真实调用链了,
这个问题的答案从「影响一个死通道」变成「影响一条活通道」。
notifyOrder 抛异常时,该次通知没有补偿路径(重试依赖「FAIL 已落库 + 调度器捞取」,
而异常路径是否一定落 FAIL 未逐一验证 —— 台账 P-T12-7,终审判)。
上限 downstreamNotifyMaxRetries = 8(LeshouyingProperties.java:59)。
商户在窗口内补配回调地址即可恢复,超窗口即永久放弃。
按 5 分钟一轮算,窗口约 40 分钟(轮次间隔已确认:LsyReconcileScheduler.reconcile()
的 @Scheduled(fixedDelay = 5 * 60 * 1000),与 downstreamNotifyMaxRetries = 8 相乘)。
★ 不要把「可恢复」理解成无限期。
本模块的回调只按 is2xxSuccessful() 分类失败,没有「4xx 终态、只重试 5xx」的概念。
既有的 OpenapiService.sendCallback:415-421 是有这个概念的。
属设计取舍,但要知情:商户 URL 配错时,其日志会连续吃 8 次请求。
V1.8 与 lsy-menu.sql 从未执行过⚠ 本节原标题与正文均为误断,已作废。 实测(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;
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。
本项目没有任何上下文加载测试。实测(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=UNKNOWN2 退款-订单状态不允许 HTTP 500 HTTP 200 + code 400 + msg=订单当前状态不可退款: PAYING3 关单(前置) — HTTP 200 + status=CLOSED4 重复关已 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。
| # | 事实 | 排查时的意思 |
|---|---|---|
| 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 更正(上一条注记的「安全」结论对本列用不上,且漏了相反后果),两点:
- 上一条注记回答的问题是「未认证路径上的查询,只允许用有全局唯一约束的列做定位键」, 而
out_trade_no根本不是未认证路径的定位键(那两条未认证定位走pay_trace_no/refund_no) ⇒ 那句「这条是安全的」用不到本列身上:它只是一条与本列无关的旁证, 既没证明本列应当全局唯一,也不构成本列不能收窄的理由。- 上一条注记漏掉了全局唯一在本列上的相反后果:本列是租户自己提供的单号, 跨租户撞号是正常的;而它的全部查询都带租户条件 ⇒ 租户 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)不受影响。
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 更正(本小节原文的机理写错了,实测推翻),两处:
「只因为—— 假。 已认证(pay_lsy_merchant在CONDITIONAL_TABLES里」LoginUser且tenantId != null)时,该表在不在CONDITIONAL_TABLES里,ignoreTable都返回false(不在任何集合里则走:173 return false)。 ⇒ 集合成员身份对这条已认证路径没有任何影响。「移出该集合 ⇒ 会静默变成可跨租户删除」—— 假(同上)。 只有把它放进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 的人查阅。
application.yml 里的 leshouying.key 是上游文档给的「测试机构」密钥。
线上必须换成进件后的正式密钥。SecurityConfig.java 里有一条已证伪的注释(「filter 认证失败时无 request attribute,
Controller 会抛 401 JSON 响应」)—— 实测:认证失败时 filter 自己写错误响应后就 return,
请求根本到不了 Controller。它描述的是既有的 /payment/openapi/account/** 那条,
与本次新增的乐收赢通道无关,故有意未改(改了会把 diff 扩到任务外)。以下小节等 Task 14–18 交付后由终审补全:
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)permission 字符串逐字对照