# `internal/academic/dualrun` —— AC-15 双跑 harness(阶段 1:课酬链) 本包只做**一条链路**的双跑比对: | 侧 | 实现 | 怎么跑 | |---|---|---| | **oracle** | legacy PHP(`CoursesRecordHelper::settlementExpenses`) | **真跑**:起一个 PHP 进程,事务内结算、`finally` 回滚,读**异常前已写入**的库值 | | **target** | Go 纯函数(`service.computeRecordReward` + `distributeAmount`) | 只算不写:输入取自 oracle 侧同一份观测,**不连 `yuansiwei_2026`** | 阶段 2~4 的写 SP、活动链、分账链**不在本包范围**。 --- ## 1. 两侧不对称(读报告前必看) 这不是"两边各跑一次入库": - **oracle 侧会写库**(写在事务里,结束必定回滚) - **target 侧只调纯函数**,一个字节都不写 理由是课酬属于"由输入算出一个金额"的逻辑,纯函数足以判定;target 库也没有对应历史数据, 真跑只会引入无关变量。**报告里必须原样保留这段说明**,否则读的人会以为两侧都真跑了。 ## 2. 数据源守卫(`guard.go`) 两道条件,缺一不可: 1. **host 白名单**:只允许 `127.0.0.1` / `localhost` / `::1` 2. **库白名单**:`SELECT DATABASE()` 必须 ∈ {`finance_bk`, `legacy_finance`, `legacy_oracle`, `yuansiwei_2026`} 违反时 **panic**(不返回 error)——这类错误一旦被忽略,会以"连到错误实例后的正常结果"形式污染整轮结论。 写库另有更严的允许表(阶段 1 只允许 `legacy_oracle` / `legacy_finance`)。 allowed-list 而非 deny-list:代码里**不出现任何具体远端地址**,日志里的 host 一律脱敏(`127.0.***.**`)。 ## 3. 启动静态断言 `AssertNoSysdate`:目标库所有 routine 定义里 `sysdate(` 出现数必须为 **0**,否则拒绝运行。 依据:`SET SESSION timestamp` 冻结 `NOW()` 但**不冻结 `SYSDATE()`**(P0 §6 组 1)。 一旦有 SP 用 `SYSDATE()`,冻结时钟会静默失效而结果看起来正常。 ## 4. 每个 case 新连接 oracle 侧**每个 case 起一个新 PHP 进程**(= 新连接)。依据(P0 §8.1 Q5): `MEMORY` 引擎临时表的行**不参与 ROLLBACK**,只靠回滚清不掉;`sp_user_expense_get_bill` 正是 12 张 MEMORY 临时表。 进程隔离同时解决三件事:临时表残留、表结构残留、会话变量(`timestamp` / `time_zone`)污染。 ## 5. A/B 重置接口 | 类 | 判据 | 重置方式 | |---|---|---| | **A** | 只有 DML,或只用 `TEMPORARY` 表 | `START TRANSACTION` + `ROLLBACK` | | **B** | 含 `TRUNCATE` / 持久表 DDL / `LOCK TABLES` | 快照恢复 | 阶段 1 的两个入口(`sp_course_payment_detail_get_teacher_detail`、`settlementExpenses`)都是 **A 类**, 当前由 PHP 探针内的事务 + `finally` 回滚实现;B 类接口位置见 `Case.Mutations` 的扩展点。 ## 6. 三步分层归因(`attribution.go`,顺序不可换) | 步 | 比什么 | 不等时 | |---|---|---| | 1 | `income`:legacy `round(Σcost,3)` vs 迁移后 `Σ ROUND(cost,2)` | 精度收敛,记 Δincome,**继续第 2 步** | | 2 | 规则:oracle 实返 `detail_id` vs runner 复刻的兜底判定 | **真缺陷**(规则选错 / 层级错) | | 3 | `teacher_reward` | `Δreward ≈ Δincome × pct/100` ⇒ 精度收敛;**不成比例 ⇒ 真缺陷** | 第 3 步的**成比例判据**是关键:没有它,所有带 3 位小数的记录都会红,然后有人会去改一个本来正确的算法。 ## 7. 路径证据(约束 17) 每条 case 记录:`detail_id` 是否命中、命中层级(`teacher-classify` / `teacher-grade-subject` / `cert-tier` / `generic-tier`)、是否退化、`isSupplement` 取值、`rewardType`。 **数值相等但路径不同 ⇒ `PATH-DIVERGENCE`**,交人工判。R4 就是"数值相等掩盖路径不同"的实例。 ## 8. 冻结时钟 - 两侧同源、**`Truncate(time.Second)`**;`SET SESSION timestamp=<整秒>` + `time_zone='+08:00'` - 教龄仍取 **PHP 侧 `time()`** 的结果 —— A22:PHP 时钟不受 `SET timestamp` 影响,不假装能冻 ## 9. 归因取值(只有六个) `MATCH` / `PRECISION-CONVERGENCE` / `DEFECT` / `ORACLE-DEGRADED` / `PATH-DIVERGENCE` / `ENV-FAILURE` 汇总必须给出六类计数,不能只给"通过率"。 ## 10. CLI ```bash dualrun stage1 --case-set real100 dualrun stage1 --case-set synthetic dualrun stage1 --all --out report.json --md report.md dualrun stage1 --dry-run dualrun stage1 --all --idempotency ``` 环境变量(口令只经环境变量,不落盘):`AC15_DB_HOST` / `AC15_DB_PORT` / `AC15_DB_NAME` / `AC15_DB_USER` / `AC15_DB_PASS` / `AC15_LEGACY_REPO` / `AC15_PHP_BIN`。 退出码:`0` 跑完(无论结论);`2` 参数错误;`3` 环境层面失败(守卫 / 断言 / 连接)。 ## 11. 用例集 | 类别 | 数量 | 说明 | |---|---|---| | `real100` | 100 | `status=2` + `income>0` + 有规则命中;按 `percentage` 区间 × 人数区间**分层抽样**(不是取前 100 条) | | `synthetic` | 6 类 × 5 | 四级兜底(classify / cert 必须造数)、income 三位小数、income=0 夹下限、`isSupplement` 两值、人数不整除、`rewardType=2` 查表 | **不做**:`getTeacherPaymentDetail` 无命中的记录(占 6.25%)—— 只标 `ORACLE-DEGRADED`,不判定。 ## 12. 与生产代码的唯一接触点 `internal/academic/service/dualrun_shim.go` —— 两个**只做转发、不含逻辑**的导出函数 (`ComputeRecordRewardForDualRun` / `DistributeRecordRewardForDualRun`)。 为什么需要它:`computeRecordReward` / `distributeAmount` 是包内私有,harness 在另一个包。 为什么不能让 harness 自己再写一份公式:那会变成"用第二份实现校验第一份", 一旦两份都错、错法一致,双跑会给出全绿结论 —— 这正是要避免的自证。