# 乐收赢支付通道 —— 待确认清单(联调 / 上线前必读) > 本文件是 [`2026-09-21-leshouying-payment-channel-design.md`](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().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`,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` 的版本: > > ```sql > 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) - [x] Task 17(管理端控制器)的授权链核对结果 —— **关闭**:见 §八 R-7 - [x] 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` 与下游通知会少一个字段。 **结论**:**不改状态机、不发明兜底值**。没有任何依据表明上游会不回该字段 (缺文档实证也缺联调实证),按铁律不发明。**但把静默改成可见**:在 `payPatch`(唯一的 「上游字段 → 我们字段」映射点)里,当上游回了成功码而 `trade_no` 为空时补一条 `log.error`。 理由与本模块反复强调的一致:不记日志的后果是「运营一个字节都看不到」。 **触发升级条件**:联调或生产只要出现一笔 SUCCESS 且 `trade_no` 为空,即升级为缺陷并去问上游。 ⚠ **本条此前连登记都没有** —— 它只存在于我的记忆里,台账里 grep 不到。这本身是一处欠账, 本裁定首先把它补进台账。 ### 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 掉容器** —— 那就把它要防的东西防掉了。 ### 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` 是运行期噪声,**不入库**。