2026-09-21-leshouying-payment-channel-open-questions.md 55 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 ★★ 退款失败的同步/异步语义不对称 —— 唯一一条可能重复出金(承重前提已解除,行为未改)

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

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

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

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

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

  • 同步失败: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 真的答得出来(未知码情形对账腿同样不落状态);
  • ⇒ 这是一次可做但未做的行为变更,需要单独裁定,本增量只解除它的前提。
  • 仍未做:handleRefundNotify 的 TRADE_REFUND_FAIL 依旧写 FAIL 释闸。

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。


四、数据可见性与排查提示(不是 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(管理端控制器)的授权链核对结果
  • Task 18(前端 + 菜单 SQL)的 permission 字符串逐字对照