소스 검색

docs(台账): 新增 §八 裁定汇总 - 一次判完 9 条开放项

用户「都裁定一下」。每条写清结论 + 源码位置级依据 + 「如果错了代价是什么」。

R-1 ★★ §2.2 的 TRADE_REFUND_FAIL 判定为**改成守闸**(未实施)。
  同一上游语义两条路判断相反:同步 PAY_FAIL(createRefund:289-314)只记日志不写状态;
  回调 TRADE_REFUND_FAIL(LsyNotifyService:255-256)落 FAIL 释闸。
  决定性依据(此前未记录):释闸后**两条补救通道都是死的** —— 对账腿只扫
  LsyRefundStatus.PENDING(FAIL 不在其中,永不被问第二次),而唯一的探测器
  LATE_REFUND_AFTER_TERMINAL(:299-302)在 :233 早退之后,R41 使 refundNo 恒 null
  ⇒ 每次回调都在 :233 返回 ⇒ 该分支真实流量下不可达。
  ⇒ 现状 = 判错就永久错 + 可重复出金。
  ⚠ 如实标注我没有的那一半依据:**没有实证表明上游会误报**。裁定不建立在该假设上,
  而建立在上面的确定事实 + 代价不对称。
  落地需要新审计词(候选 HELD_FOR_RECONCILE);「不新造上游词汇」只约束上游字段值。

R-2 SUCCESS 而 trade_no 为空:payPatch:457 无条件写 + MP 的 NOT_NULL 策略 ⇒ 静默丢字段。
  退款不受影响(createRefund:154-158 有回退 pay_trace_no 的分支)。裁定只加 log.error
  把静默改成可见 + 补登记 —— ⚠ 此前台账里根本没有这条,只在我的记忆里。

R-3 §2.5 崩溃窗口:核清了悬案。push 自己吞异常返回 false(LsyCallbackService:227-231)
  ⇒ push 不抛;notifyOrder 能抛的只有 resolveUrl(:86) 与 recordOrderNotifyResult(:104),
  都在落 FAIL 之前 ⇒ 那行不被 retryFailed 捞到。**担忧成立但不修** ——
  触发条件是 DB 写失败而补偿也是写库 ⇒ 没有可用的补偿动作,是「修不了」而非「没修」。

R-4 §2.7 永久性 4xx 也重试 8 次:不修(对齐 OpenapiService 需要一张「哪些 4xx 可恢复」
  的表,没有依据;当前行为安全、窗口有界)。

R-5 §3.3 无上下文加载测试:做,但**第一步是实测可行性**(本仓 411 个测试全是 Mockito、
  不碰容器,加 @SpringBootTest 会真装配 DS/Redis/MyBatis)。跑不通就降级为切片或
  记录「需外部依赖、不可行」并关闭。不得为让它绿而 mock 掉容器。

R-6 §一 待问上游清单:第 3 问问法已作废(R41 证明问题对象搞错),收敛为 4 问
  (1、2、4、5),且第 2 问与 R41 是同一个问题应合并。不阻塞开发。

R-7 台账「七、待补」4 个 checkbox 全部关闭。⚠ Task 18 只核到「库里 component_path
  → 仓库 .vue」这条静态链(三文件实测 10582/4653/9613 B,旧 leshouying/ 目录已删),
  没起 dev server 用浏览器走过,permission 与路由守卫的运行时一致性仍未实测。

R-8 R41 维持挂起不重开;但记下 R-1 落地会让 R41 优先级上升。

R-9 7 个提交未 push(对外动作,未自动执行);stash 0626d3e0 不丢;application.yml
  的 redis.host 继续有意不提交;logs/ 不入库。
alphaH 3 주 전
부모
커밋
dbbae2631a
1개의 변경된 파일과 156개의 추가작업 그리고 2개의 파일을 삭제
  1. 156 2
      docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md

+ 156 - 2
docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md

@@ -653,5 +653,159 @@ component / is_frame / is_cache / menu_type / visible / perms / ...`),**本
        两者混成一句会读成「每 30 秒查每一单」——那是条假全称。
        **将来若有人把 `pay_lsy_order` 移出该集合,或给 `@Scheduled` 配上
        `SecurityContext` 传播,这些扫描会立刻退化为单租户、且没有任何测试会报警**(R44)
-- [ ] Task 17(管理端控制器)的授权链核对结果
-- [ ] Task 18(前端 + 菜单 SQL)的 `permission` 字符串逐字对照
+- [x] Task 17(管理端控制器)的授权链核对结果 —— **关闭**:见 §八 R-7
+- [x] Task 18(前端 + 菜单 SQL)的 `permission` 字符串逐字对照 —— **关闭**:见 §八 R-7
+
+---
+
+## 八、裁定汇总(2026-09-22,用户「都裁定一下」)
+
+本节把此前**未裁定**或**只在记忆里、台账里没有**的开放项一次判完。
+每条写清:结论、依据(源码位置,不是印象)、以及**如果错了代价是什么**。
+
+### R-1 ★★ §2.2 的 `TRADE_REFUND_FAIL` → **改成守闸**
+
+**结论:改。** 同一个上游语义(「这笔退款没成功」)经两条路到达,现在判断相反:
+
+| 路径 | 位置 | 现在怎么做 |
+|---|---|---|
+| 同步应答 `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`。**
+本条不建立在「上游会误报」这个未经证实的假设上,而建立在**依据二**(确定的事实)与
+**依据一**(代价不对称)上。
+
+**落地要点**(未实施):
+- `TRADE_REFUND_FAIL` 分支不再落 FAIL,改为 `log.error`(让人立刻看得见)+ 审计行。
+- 需要**一个新的审计状态词**:`UNKNOWN_RESULT_CODE` 是「不认识这个码」,语义不同,
+  不能复用;`APPLIED` 更不能(本次没有回写)。候选 `HELD_FOR_RECONCILE`
+  (=「扣住,留待对账腿拿权威答案」)。
+  ⚠ 「不新造上游词汇」那条约束针对的是**上游字段值**(如 `result_code` 的字面量,
+  必须文档实证);审计状态词本来就是**我们自己的**词表,新增是允许的。
+- 副作用(是好的):退款行的 `FAIL` 从此**只由一条路径写** —— 对账腿问上游拿到的
+  权威答案(`settleFromQuery:437-439`)。规则收敛成「终态只由权威答案写」。
+- 有测试要改:现有一条钉住 `TRADE_REFUND_FAIL → FAIL` 的用例会红,**那正是预期的红**。
+
+### 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` 是运行期噪声,**不入库**。