Просмотр исходного кода

docs(ac15): 记录只读结果集双跑(RS-1~RS-3)的判据、实测覆盖与 12 项未覆盖

README §22:为什么另起一套机器(只读 ⇒ Δ 恒 0 ⇒ 假绿)、四条判据
(列覆盖 / 侧向覆盖 / 三态 / 第三腿 / 精度口径)、三条 case 的认领-豁免-断言数
与「有牙的证据」(10 条骨架单测 + 一次 1 分钱变异实测转红 3 项)。
未覆盖一节逐条写明:死参数 / 早退分支 / A21 扇出 / id=1 差异 / 三个回退档 /
FLOAT 精度 / teacher_exp 列 decimal(6,2) vs model 声明 (6,3) 的实测漂移。
Q 5 часов назад
Родитель
Сommit
9364a0df33
1 измененных файлов с 80 добавлено и 0 удалено
  1. 80 0
      internal/academic/dualrun/stage2/README.md

+ 80 - 0
internal/academic/dualrun/stage2/README.md

@@ -1599,3 +1599,83 @@ target  MIN(start_time) 折平 ⇒ 60 + 60 = 120.00,两行都核完,不报
 >
 > ⇒ **凡候选集由 repo 层过滤提供的 SP,双跑都缺一层"候选集非空/钱真的动了"的正向断言。**
 > 这是结构性缺口,不是 RA 两支的局部问题。
+
+## §22 只读结果集双跑(RS-1 ~ RS-3)
+
+### 为什么另起一套机器
+
+写路径那 55 条比的是**写入终态**(before/after 快照 + Δ 断言)。有三支 SP **只读、不写库**:
+
+- `sp_user_expense_get_bill`(端点 17 学生账单,一行 9 列)
+- `sp_user_expense_school_get_refundable_amount`(可退金额,一行一列)
+- `sp_course_payment_detail_get_teacher_detail`(端点 59 试算的底层,整行 17 列)
+
+对它们,Δ 恒为 0 ⇒ 塞进通用比对只会得到「双方都没动 ⇒ 双方一致」的**假绿**。
+所以判据换成:**同一份数据 + 同一组入参 → 两侧各拿结果集 → 逐列比**。
+
+入口独立(`go run ./cmd/dualrun resultsets`),**不进 `DefaultCases`**:两条链不同构,不共用一个分母。
+
+### 机器的四条判据(`resultset_compare.go`)
+
+1. **列覆盖**:`dump 结果集列数 == 认领列 + 显式豁免列`,且每一列**恰好**一条声明。
+   无人认领的列 = 永远不被比对 —— 与「判定块整体不执行」同族,所以它是基建断言、必须 FAIL 而不是跳过。
+2. **侧向覆盖**:target 返回的每个字段也必须被认领或被显式列为「target 独有」,否则同样是盲区。
+3. **三态**:`NULL ≠ 0.00`(`SUM` 无行是 NULL;折成 0 会把「没算过」伪装成「算出来是 0」)。
+4. **第三腿**:每列除了「两侧相等」,还要与**由种子声明算出的期望**比 ——
+   只有两侧相等时,两侧同时错会得到一条完全对称的绿。
+5. **精度口径**:金额按 2 位归一;`PrecisionCheck` 额外断言 dump 侧第 3 位小数为 0
+   (3→2 的「无损」从假设变事实)。**但每支 SP 的精度形态不同,不能一概而论**:
+   bill 是 `DECIMAL(10,3)`(3→2 收敛)、refundable 是 `FLOAT`(4 字节单精度)、
+   teacher_detail 是 `float(10,1)`(1→2 **扩位**)——后两者各写一条自己的断言说明。
+
+### 三条 case 的实测覆盖
+
+| case | dump 列 | 认领 | 豁免 | 业务断言 | 基建 |
+| --- | --- | --- | --- | --- | --- |
+| RS-1 可退金额 | 1 | 1 | 0 | 3 | 4 |
+| RS-2 账单 | 9 | 9 | 0 | 24 | 4 |
+| RS-3 课酬明细 | 17 | 6 | 11 | 17 | 4 |
+
+- RS-1 用六张工单各钉 SP 的一个 `WHERE` 条件(审批 / 类型 / 校区 / 赠送已用尽 / 一单两行扇出),
+  期望 `min(800,1000) − 115 = 685.00` 由声明算出。
+- RS-2 的 9 列来自**三个不同集合**(`Gift` / 期初结余 / 期末结余),
+  所以 `currentUsedGiveCost = Gift + 期初 − 期末` 不是恒等式能凑出来的;
+  窗口后一条流水钉「期末不含右端」;账单恒等式两侧各自成立。
+  `BillSummary` 另 3 个字段(`FlowExclusiveEnd` / `FlowInclusiveEnd` / `CampusIDs`)**不属于 9 列**,
+  显式列为 target 独有并对声明断言。
+- RS-3 是**部分映射**:target 端点 59 只回 9 个派生字段,dump 回整行。
+  11 个豁免列逐条带理由,分三类:命中判据(折成 `ruleTier` 下发)/ 身份归属(int vs ULID)/
+  展示性文本。target 独有 3 个字段(`schemeCampusId` / `schemeScope` / `ruleTier`)单独对声明断言。
+
+### 有牙的证据(不是"这次数据没踩到")
+
+- 骨架单测 10 条把每条判据**故意弄红一次**(无人认领列 / 声明错列名 / 豁免无理由 /
+  target 多字段 / NULL 当 0 / 0 行 / 第 3 位非零)。
+- 集成侧做过一次变异:把 target 的可退金额减 1 分,实测**转红 3 项**(列差异 + 列断言 + 期望腿),
+  还原后复跑 PASS。
+
+### 未覆盖 / 无法比对(按 case 列,不许写"无")
+
+- **RS-1**:三个早退分支(校区 ≤0 / 全局 ≤0 / 截断后 ≤0)与「截断真正生效」(campus > global)
+  未覆盖 —— 本种子 `campus=800 < global=1000`。要覆盖需再加 3~4 条 case。
+- **RS-1**:`refundableAmount` 是 `FLOAT`(4 字节)。本种子的值都精确可表示,
+  **对真实数据不成立**(float32 在 0.01 级不可精确表示)。需要产品确认「可退金额是否允许容差」,
+  这不是 target 单侧能修的。
+- **RS-2**:`inCampusids` 是**死参数**(dump 全程未引用)。流水表无 campus 列,
+  所以「不过滤」这件事**无法用结果集证伪** —— 只能按契约声明它不过滤。
+- **RS-2**:`FlowInclusiveEnd` 与 `FlowExclusiveEnd` 在本种子下相等(没有 `t = to` 的流水),
+  「含右端 vs 不含右端」的差别**没有被区分**。要区分需再种一条 `create_time = end` 的流水。
+- **RS-2**:§3.8-A21 的扇出缺陷(同一活动多条 signed 会让 `used` 额重复计入)未覆盖 ——
+  种子每个活动只有 1 条 signed。Go **刻意不复刻** A21,所以补这条会得到**预期红**,
+  应单独做成「登记差异」case,而不是塞进本 case 求绿。
+- **RS-2**:`type=108` 双来源「只归因不裁剪」未覆盖。
+- **RS-3**:`coursePaymentDetailid>1` 的**有意差异**(dump 是「≥2 才短路」⇒ id=1 的规则永不生效;
+  Go 是 `>0` ⇒ 会生效)未覆盖 —— 本 case 规则 id=990002。要验必须专门造 id=1。
+- **RS-3**:持证档③、年级/科目通配档②、机构级方案回退(`campus_id=0`)、
+  分类不存在 ⇒ 空结果集 ⇒ 3112,**均未覆盖**(本 case 只钉个人精确档①)。
+- **RS-3**:`t_course_payment_detail.teacher_exp_min/max` 在 target **实际 DDL 是 `decimal(6,2)`**,
+  而 `model.CoursePaymentDetail` 的 tag 与 service 注释都写 `decimal(6,3)`
+  (注释理由是「收敛到 2 位会让 0.999 与 1.000 并列命中」)。这是**实测漂移,未修**,
+  需软件侧确认 —— 它直接影响 `BETWEEN` 的命中集合。
+- **三支共同**:只覆盖「1 个结果集 / 1 行」形态;多结果集、多行未覆盖
+  (这三支 SP 结构上只返回 1 行,所以这是结构性上限,不是遗漏)。