Browse Source

docs(ac15): 新增 stage2/BACKLOG.md(B1 绝对期望值缺失 / B2 归一架空结论强度 / B3 证据强度未分级)

Q 4 giờ trước cách đây
mục cha
commit
e5c23522ec
1 tập tin đã thay đổi với 283 bổ sung và 0 xóa
  1. 283 0
      internal/academic/dualrun/stage2/BACKLOG.md

+ 283 - 0
internal/academic/dualrun/stage2/BACKLOG.md

@@ -0,0 +1,283 @@
+# 双跑待办(BACKLOG)
+
+> 这里记的是**已知缺口**,不是"以后有空再说"的愿望清单。
+> 每一条都写清三件事:**现在为什么可以接受** / **什么时候它就不再可接受** / **到那时怎么做**。
+>
+> 单独开文件而不写进 `README.md` 的原因:`README.md` 是批次纪律的**定稿**,
+> 而这个是**未定稿的缺口台账**,会随批次推进增删。两者混在一起,
+> 下次有人改 README 时会把待办当成结论读走。
+>
+> 首次记录:批 3a(`DU-1` / `DU-2` 赠送额度使用)独立验收时提出,HEAD `c5e3f5b`。
+
+---
+
+## B1 · 绝对期望值缺失(镜像错误抓不到)
+
+### 现象
+
+额度行的核心断言(`activityCompare()` 里的「额度行 `discount_amount_used` 两侧一致」)
+**只判两侧相等,没有绝对期望值**:
+
+```
+Pass: lok == tok && (!lok || ld.DiscountAmountUsed == td.DiscountAmountUsed) && ...
+```
+
+配合 `diffDiscountLogMultiset` 对额度流水的多重集比对,能抓到的与抓不到的是:
+
+| 错法 | 抓得到吗 | 靠什么抓 |
+|---|---|---|
+| 两侧都没有累加(no-op) | ✅ | `_log` 非空自检(`lCnt > 0 && tCnt > 0`) |
+| target log 符号写反(+30 vs −30) | ✅ | 多重集差集(legacy 是真 SP,是 oracle) |
+| **两侧用同一种错法写成同一个错值** | ❌ | **没有任何机制** —— 这就叫镜像错误 |
+
+### 现在为什么可以接受
+
+因为 `legacy` 侧跑的是**真的 SP**(`legacy_oracle` 克隆里的原定义),
+它就是 oracle。"两侧犯同一个错"在物理上不可能发生 —— 除非**这个 SP 本身就是错的**。
+
+### 什么时候必须做 —— 不是"有空时",是"碰那 3 支时必须"
+
+**旧系统 0 调用点的那 3 支**:
+
+- `sp_courses_activity_user_discount_use`
+- `sp_courses_activity_user_discount_refund`
+- `sp_courses_activity_user_discount_refund_user_activity`
+
+这三支在旧仓**全仓 0 个调用点** ⇒ 它们**从没在生产上被跑过** ⇒
+"两侧相等"只能证明「**target 复刻了这个 SP**」,**不能证明「这个 SP 本身是对的」**。
+证据强度差一整档,而报告上的 `PASS` 长一个样。
+
+> 反直觉但必须记住:**有真实调用历史的支,镜像错误风险为零;
+> 没有调用历史的支,风险最高 —— 而它们恰恰最容易拿到"全绿"就签字。**
+
+### 怎么做
+
+给 `Case` 加绝对期望字段,例如:
+
+```go
+// ExpectDiscountUsedCents 非 nil 时,额外断言两侧 discount_amount_used **都等于**该值。
+// 这是"绝对值断言",与"两侧相等"是两道独立的闸:
+//   两侧相等 + 双方都错成同一个值 → 本条红;只判两侧相等时 → 绿。
+ExpectDiscountUsedCents *int64
+```
+
+要点:
+
+- 用**指针**而不是 `int64`:`0` 是合法期望值("扣成 0"),不能和"没声明"混在一起
+- 断言要**分别对两侧**打(per-side),不要把两侧揉成一个 `==`
+- 报告里必须把期望值和两侧实际值一起打出来,否则看不出这条在验什么
+- 先只给那 3 支挂,别全量铺开 —— 其余支由真 SP 当 oracle 已经够了
+
+### 判据
+
+一条 case 的证据强度,取决于它**排在哪种支上**:
+
+```
+有生产调用历史  →  PASS = 「target 与已验证的实现等价」   ← 可直接用
+0 调用点        →  PASS = 「target 复刻了一个未验证的 SP」 ← 必须再加绝对值断言
+```
+
+---
+
+## B2 · 归一兜底掩盖真实形态(`#<numeric>` 兜底)
+
+### 现象
+
+订单号这一列两侧形态本来就不同(见 README §19「同一列上的两套 id 空间」):
+
+- `legacy._discount_log.courses_studentid` → 旧 UUID 串
+- `target._discount_log.courses_student_id` → bigint;`courses_student_no` → 旧单号(迁移期保留)
+
+快照的归一链(`readActivitySnapshotTarget()` ⑤):
+
+```sql
+IF(l.courses_student_id IS NULL,
+   IFNULL(l.courses_student_no, ''),
+   IFNULL(s.biz_no, CONCAT('#', l.courses_student_id)))   -- ← 最后这层是兜底
+  LEFT JOIN t_courses_student s ON s.id = l.courses_student_id
+```
+
+`orderToken()` 认**三种**形态并统一归成 `<order>`:
+
+1. `orderNo == c.StuCourseID`(legacy 的旧 UUID)
+2. `orderNo == fmt.Sprint(c.StuCourseNumericID)`(target 的裸数字)
+3. `orderNo == "#" + fmt.Sprint(c.StuCourseNumericID)`(**JOIN 不到 `biz_no` 时的兜底**)
+
+问题在第 3 条:**只要 target 写了数字 id,哪怕 `t_courses_student` 里根本没有这一行、
+`biz_no` 完全没落到 log 上,两侧也会都变成 `<order>` 然后绿。**
+
+`DU-1` / `DU-2` 就是这种情况:不种 `t_courses_student` ⇒ 走 `#7` 兜底 ⇒
+绿是绿的,但**它没有证明 target 落了旧单号**。
+
+### 现在为什么可以接受
+
+`DU-1` / `DU-2` 的目的是验**额度累加与代金券分支**(`discount_amount_used` 与 `_log` 符号),
+不是验订单号落库。订单号在这两条里只是"让游标能定位到同一行"的载体。
+
+### 什么时候必须做
+
+**任何要验「订单号真落对了」的场景。** 只要一条 case 的结论会被读成
+"订单号这块没问题",它就不许走兜底分支。
+
+### 怎么做
+
+**不需要改代码 —— 需要种 `t_courses_student`。** 种了之后 `s.biz_no` 能 JOIN 上,
+快照走真正的 `JOIN t_courses_student.biz_no` 分支,`#<numeric>` 兜底不会被触发。
+
+预期闭合点:`DR-1` / `DR-2`(`sp_courses_activity_user_discount_refund`)
+用的是真实行 `f2166ec3c06204401c19e6a9647b5ead`。
+
+⚠ **要求**:DR 系的 case `Note` 里必须写明
+「**本条走真 `JOIN biz_no` 分支,覆盖 DU-1/DU-2 没覆盖的部分**」,
+否则下一个人无法从报告上区分"这条绿是 JOIN 出来的"还是"兜底兜出来的"。
+
+### 判据(照这个读报告,不会读错)
+
+> **看这条 case 的绿是来自 `biz_no` JOIN,还是来自 `#<id>` 兜底。**
+> 来自兜底 ⇒ 它**不能为"订单号正确"作证**,只能为"两侧指向同一逻辑订单"作证。
+
+排查方法:把 case 的 `StuCourseNumericID` 换成一个**肯定不存在**的 id。
+若仍然绿 ⇒ 这条走的是兜底分支,其结论强度要降一档。
+
+### 附:这条依赖的一个环境前提
+
+DU 系不种 `t_courses_student`,因此整条链依赖
+「target 允许把不存在的 id 写进 `_log.courses_student_id`」。实测:
+
+```
+yuansiwei_2026.t_courses_activity_user_discount_log 外键数 = 0
+courses_student_id = bigint NULL
+```
+
+**现在成立。但哪天有人给那张表加 FK,DU-1/DU-2 会在「种子/写入阶段」红,
+不是「比对阶段」红** —— 那时的红会被误读成"种子写错了",而不是"约束变了"。
+真要加 FK 时,先回来读这一节。
+
+---
+
+## B3 · 证据强度未分级
+
+### 现象
+
+一批 case 里同时混着「**有生产调用历史**」和「**0 调用点**」的支。
+它们都能跑成 `PASS`,而报告上的 `PASS` **长一个样** —— 于是
+
+> 「9 支全绿」
+
+这句话会被读者理解为「9 支的结论强度相同」。实际上它们**天然分三档**,
+把 B/C 档的绿和 A 档的绿放进同一张总表,等于把「没验证过生产构型」
+读成「生产上验证过了」。
+
+**这是 B1 的报告层版本**:
+
+- B1 说的是**断言机制**不够(缺绝对期望值 ⇒ 抓不到镜像错误)
+- B3 说的是**报告措辞**不够(不分档 ⇒ 读者无从判断这条绿值多少)
+
+两者都要治。只修 B1 不修 B3,报告仍然会把三档混成一句"全绿"。
+
+### 现在为什么可以接受
+
+每支的调用点数**已经实测过、并且记在方案里**(见下方附录)。
+也就是说"分档所需的事实"是齐的,只是**还没被强制要求写进报告**。
+
+### 什么时候必须做 —— 门槛是「任何对外报告之前」
+
+不是"有空的时候"。只要这份结果会离开本批次(周报、评审、验收单、
+给业务方的说明),就必须先分档标注。内部口头同步可以不分档,
+但**口头同步的内容一旦被转述出去,档位就丢了** —— 所以实际上线也很低。
+
+### 怎么做
+
+按**外部调用点数**分档,逐支标注,不许汇总成一句:
+
+| 档 | 外部调用点 | 一致性证据能当什么用 |
+|---|---|---|
+| **A** | **≥ 1** | 可当**业务正确性证据**:旧系统这条路径有真实生产流量,`target == legacy` 等价于"没改坏线上行为" |
+| **B** | **= 0** | 只能当**复刻证据**:只证明"照 dump 正文实现对了",**不证明 SP 本身对** ⇒ 必须另找业务侧确认语义 |
+| **C** | **= 0 且 `CALL` 被注释** | **死代码**,不进分母;跑它只证明"照一段从未执行的代码实现对了" |
+
+配套两条硬要求:
+
+1. **报告里逐支标注档位**,禁止"9 支全绿"这种汇总句
+2. **分档依据要可复现**:调用点数怎么数的要写出来(见附录的测量法),
+   否则下一个批次换了人数一遍会得出不同档位
+
+### 判据(一句话)
+
+> **验证能证明「我们的实现与旧系统一致」,不能证明「旧系统是对的」。**
+> 两者只在「旧系统这条路径有真实生产流量」时才重合。
+>
+> 而「有没有生产流量」这件事,**只能靠数调用点**,不能靠感觉。
+
+### 附录:9 支活动 SP 的实测调用点数
+
+测量方法(PHP 侧,去掉方法定义行):
+
+```
+grep '<method>(' <php repo> | grep -v 'function <method>'
+```
+
+| 档 | SP | 外部调用点 | 备注 |
+|---|---|---:|---|
+| A | `sp_courses_activity_signed_add` | 4 | — |
+| A | `sp_courses_activity_signed_cancel` | 4 | — |
+| A | `sp_courses_activity_use_activity_recharge` | 3 | — |
+| A | `sp_courses_activity_refund_activity_recharge` | 4 | — |
+| A | `sp_courses_activity_use_activity_recommend` | 1 | **9 支里唯一写资金表的**(双表双列 + 107 流水 ×2 + 嵌套核销),见 README §17.2 |
+| B | `sp_courses_activity_user_discount_use` | **0** | `DU-1` / `DU-2` 在这档 |
+| B | `sp_courses_activity_user_discount_refund` | **0** | `DR-*` 在这档 |
+| B | `sp_courses_activity_user_discount_refund_user_activity` | **0** | — |
+| C | `sp_courses_activity_user_discount_usable` | **0** | `CALL` 整行被注释(`CoursesActivityUserDiscountHelper.php:94`),生产实际跑的是**上一行的 PHP 内联 SQL** ⇒ SP 从未被执行过,见 README §17.3 |
+
+⇒ **分布:5 A / 3 B / 1 C。**
+
+⚠ 由此得到一个反直觉的结论,值得单独记住:
+
+> **`DU-1` / `DU-2` 的 `PASS` 属于 B 档,是复刻证据,不是业务证据。**
+> 它们是本批次里"最容易被误读成业务已验证"的两条 ——
+> 因为它们的数字看起来最干净(`0 → 30.00`、`0 → 100.00`、log 符号正确)。
+
+⚠ 同时注意 `user_discount_usable`(C 档)**不应进 `DefaultCases`** ——
+它是死代码,进分母会让"分母变小"看起来像是漏了 case,实际是它本来就不该被数。
+
+---
+
+## 方法总结(B1 / B2 / B3 共通,也是本文件最该被记住的一句)
+
+> **兜底与归一的作用是「让比对能进行」,不是「证明两件事相同」。**
+>
+> 凡是走了兜底分支的比对,它的结论强度都要**降一档**:
+> 从「两侧一致」降为「**两侧指向同一逻辑对象**」。
+>
+> 同理,凡是 oracle 自身未经验证的比对,结论强度也要**降一档**:
+> 从「与已验证实现等价」降为「**复刻了一个未验证的实现**」。
+>
+> 同理,凡是**混档汇报**的结果,结论强度也要**按最低那一档读**:
+> 「9 支全绿」实际强度 = 其中 3 支 B 档 + 1 支 C 档的强度,不是 9 支 A 档的强度。
+>
+> 这三次降档都不是缺陷,是**口径**。缺陷是**降了档却没在报告上写出来** ——
+> 那样后面的人会把 `PASS` 当成比它实际更强的结论去用。
+
+### 降档一览(三条各自的触发点与降法)
+
+| 条目 | 触发条件 | 结论从 | 降为 |
+|---|---|---|---|
+| B1 | 断言只判两侧相等 | 两侧一致 | 「两侧相等」——镜像错误不在覆盖内 |
+| B2 | 比对走了兜底分支(`#<numeric>`) | 两侧一致 | 两侧指向**同一逻辑对象** |
+| B3 | 该支外部调用点 = 0 | 与已验证实现等价 | **复刻**了一个未验证的实现 |
+
+三条可以**叠加**,叠加时按最弱那档读。`DU-1` / `DU-2` 是 B2 + B3 同时命中:
+
+```
+DU-2 的 PASS 实际含义
+  = 「target 复刻了 legacy 的 type=3 分支」(B3 降档,0 调用点)
+  ∩ 「两侧的那个订单号指向同一逻辑对象,但没证明旧单号落库」(B2 降档)
+  ⇒ 能证明:代金券按面额扣这个分支被正确复刻
+  ⇒ 不能证明:① 这个分支的业务语义是对的  ② 订单号在 target 真的落了旧单号
+```
+
+配套的两条操作习惯:
+
+- 看到绿先问「**这条绿是哪条分支产生的**」:兜底分支?恒真断言?`0 vs 0`?
+- 再问「**这条绿值多少档**」:数这支的调用点;`= 0` 就把结论里的"验证"改成"复刻"
+- 只有能回答"是主判据、走的是主路径、非空、且档位已标注"的绿,才可以往交付结论里写