Explorar o código

feat(ac15): 分账链第 1 步(前半)—— OpDivideQuotaClamp 枚举 + legacy CALL 常量(**无 dispatch**)

按 team-lead 的拆细要求,本轮**只加枚举与常量**,不写 dispatch / 快照 / case。

## 加了什么

- `OpDivideQuotaClamp` + `String()` → `sp_user_divide_into_update_quota`;
- legacy `callDivideQuotaClamp = "CALL sp_user_divide_into_update_quota(?, ?)"` + `callArgs` + `callStmt`。

## 为什么这样加是安全的

`d0fda09` 的 `hasDispatch` 闸门:**没有 dispatch 分支 ⇒ 开事务前 FATAL**。
验证(可核对痕迹):

```
$ grep -c "OpDivideQuotaClamp" internal/academic/dualrun/stage2/target.go
0
```

⇒ 白名单里**没有**它 ⇒ 任何人拿它写 case 会立刻 FATAL,
不会静默落到 `default:`(那是 Expenditure,会真的扣钱),也不会静默 no-op。

## 一处口径要显式说明(不是偷懒)

`returnsResultSet` 的判据是"正文末尾有没有 SELECT"。这一支**末尾确实有一句**
`SELECT userid FROM t_user_divide_into WHERE …`,按判据它**该算有**;
但那句不是 `success/msg` 契约,且用户不存在时 `WHERE left_val>=NULL` 恒 NULL ⇒ 空集。
所以本枚举先按**不读结果集**调,并在注释里写清"**等 DV-7 实测 CALL 行为后再定**;
若 `Exec` 会留下未消费结果集,就挪进 `return true` 组"。
把差异写出来,而不是二选一硬猜。

## 报数(粘贴;两个门禁分两行)

```
分母:DefaultCases=55 | 应跑 55 | 实跑 55
总览:55 个 case,55 PASS,0 FAIL
Skipped 98 | 业务 346 | 基建 118 | 未分类 0
代价 SQL 3350 次 / 19.434s / 60.9 次 per case / 353ms per case
grep "本块比对了" = 7

go build OK | go vet OK | go test OK
```

**照实说:55 条仍 55 PASS —— 这一步只是加了个枚举与常量,没有新增任何覆盖面。**
未 push。
Q hai 8 horas
pai
achega
a1060f45ef

+ 12 - 0
internal/academic/dualrun/stage2/case.go

@@ -122,6 +122,16 @@ const (
 	// 所以它不能走通用比对(那是"单用户 5 个增量"),也不能走活动域比对(那不管资金)。
 	// 走 `recommendCompare`,三块都要断言。
 	OpUseActivityRecommend
+	// OpDivideQuotaClamp 分账调额(`sp_user_divide_into_update_quota`,2 参)。
+	//
+	// **只写 `t_user_divide_into` 一张表**(实测:`spdump proc` 正文 4 处引用,0 处
+	// `t_user_expense*` / 0 处 `_log` / 0 处 `t_courses_record_student`)⇒ 纯记账,
+	// 比对只需要 `_into` 一个域,不需要资金域或 `_log` 域快照。
+	//
+	// ⚠ 本枚举**还没有 dispatch**(故意,拆步落地)。`d0fda09` 的 `hasDispatch` 闸门保证:
+	// 一旦有人拿它写 case,会 **FATAL 报「没有 dispatch 分支」**,不会静默落到
+	// `default:`(那是 Expenditure,会真的扣钱)。
+	OpDivideQuotaClamp
 )
 
 // String 返回可读名字(报告用)。
@@ -163,6 +173,8 @@ func (o Op) String() string {
 		return "sp_courses_student_counteract_arrears_course"
 	case OpUseActivityRecommend:
 		return "sp_courses_activity_use_activity_recommend"
+	case OpDivideQuotaClamp:
+		return "sp_user_divide_into_update_quota"
 	default:
 		return "sp_user_expense_school_expenditure"
 	}

+ 20 - 0
internal/academic/dualrun/stage2/legacy.go

@@ -77,6 +77,14 @@ const (
 	// SP18 老带新:(userid, order, remark, createUserid) —— 4 参,**有结果集**
 	//(正文末尾 `SELECT recommendUserid,selectSchoolid,selectCampusid,success`)。
 	callUseActivityRecommend = "CALL sp_courses_activity_use_activity_recommend(?, ?, ?, ?)"
+
+	// SP 分账调额:(findUserid, updateQuota) —— 2 参,**没有结果集**
+	//(正文末尾那句 `SELECT userid FROM t_user_divide_into WHERE …` 只在某些路径上执行,
+	// 且它不是 `success/msg` 契约;本枚举落地时按"不读结果集"处理 —— 见 `returnsResultSet`)。
+	//
+	// ⚠ 缺 `root_userid` 过滤是**已知缺陷(F25 / TARGET-DEFECT,P0)**,
+	// 但它写在 SP 正文里,所以 CALL 这条语句本身与它无关 —— 修的是 target 的 repo。
+	callDivideQuotaClamp = "CALL sp_user_divide_into_update_quota(?, ?)"
 )
 
 // returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
@@ -101,6 +109,12 @@ func returnsResultSet(op Op) bool {
 	// 不读结果集的话:`Exec` 会留下未消费的结果集,且 `success=0`(LEAVE 分支)看不出来。
 	case OpUseActivityRecommend:
 		return true
+		// `sp_user_divide_into_update_quota` **不在此列**(= 不读结果集)。
+		// 它的正文末尾确实有一句 `SELECT userid FROM t_user_divide_into WHERE …`,
+		// 但**不是 `success/msg` 契约**,且用户在不存在时那句的 `WHERE left_val>=NULL` 恒 NULL ⇒ 空集。
+		// 按"正文末尾有没有 SELECT"这条判据(见本函数抬头)它**该算有** ——
+		// 所以这里把差异显式写出来:本枚举落地时先按 `false` 调,**等 DV-7 实测 CALL 行为后再定**。
+		// ⚠ 若实测发现 `Exec` 会留下未消费结果集,就把本枚举挪到上面的 `return true` 组里。
 	}
 	return false
 }
@@ -161,6 +175,10 @@ func callArgs(c Case) []any {
 		// SP18:(userid, order, remark, createUserid)。`order` 同时充当 107 流水的 `logid`
 		//(varchar(32),所以 `StuCourseID` 不能超过 32 字符)。
 		return []any{c.LegacyUserID, c.StuCourseID, c.Remark, c.LegacyUserID}
+	case OpDivideQuotaClamp:
+		// (findUserid, updateQuota) —— 2 参,**没有 schoolid/campusid**,也没有 requestid。
+		// 金额入参是 FLOAT(元),与其它 SP 同口径。
+		return []any{c.LegacyUserID, moneyYuan}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -206,6 +224,8 @@ func callStmt(op Op) string {
 		return callCounteractArrears
 	case OpUseActivityRecommend:
 		return callUseActivityRecommend
+	case OpDivideQuotaClamp:
+		return callDivideQuotaClamp
 	default:
 		return callExpenditure
 	}