Ver Fonte

wip(ac15): 3d 前置件 —— Baseline 覆盖 total_recommended_amount + signed 种子支持 recommend_userid

两件都是 W4 的必要前提,但**都还不构成 W4**(无 case、无 Op 落地、无 dispatch)。
按新纪律先落一个能编译、52 条零回归的 wip,别把半成品攒在树上。

## 一、`Baseline` 加 `GlobalRecommended` / `CampusRecommended`(三态)

`*int64` 而不是 `int64`:`nil` = **不动这一列**,非 nil = 显式设值(**含 0**)。
用 `int64` 会让 `0` 同时表示"不动"和"设成 0" —— §8「NULL ≠ 0」的老坑换个字段重演。
实现上单独一条 UPDATE:并进上面那句就必然要传值,"不动"没法用 SQL 参数表达。

## 二、`ActivityItem.RecommendUserID` + 两侧 `signed` 种子写 `recommend_userid`

`use_activity_recommend` 的游标从 **signed 行**读 `recommend_userid` / `activity_money`,
`IF(recommendUserid<=0||activityMoney<=0) THEN LEAVE` ⇒ 不种这一列,游标第一轮就 LEAVE,
三条 W4 会全部退化成"两侧零写入"的**空过**(而且照样全绿)。
target 侧 `recommend_user_id` 是 `varchar(26) NULL`,0 必须写 **NULL**(`sqlNullUID`),
写 `'0'`/空串会让身份归一断言报假差异。

## 三、`OpUseActivityRecommend` 只加了枚举与 String(),**没有** dispatch / CALL / compare

故意留空:这一支跨三域两用户,比对机器要新写,先确认前提再动。

## 报数
52 / 52 PASS / 0 FAIL | SQL 2970 次 | 未 push
Q há 8 horas atrás
pai
commit
344909ed7c

+ 14 - 6
internal/academic/dualrun/stage2/c1_activity.go

@@ -43,6 +43,13 @@ type ActivityItem struct {
 	//
 	// SC-2 要验 `type∈{2,3} 且 response_userid>0 → LEAVE`,这条守卫**只能**靠这个值触发。
 	ResponseUserID int64
+	// RecommendUserID >0 时给该报名行写 `recommend_userid`(老带新的推荐人)。
+	//
+	// 为什么只有 W4 需要它:`use_activity_recommend` 的游标从 **signed 行**读
+	// `recommend_userid` / `activity_money`(不是从活动读),并且
+	// `IF(recommendUserid<=0||activityMoney<=0) THEN LEAVE` —— 不种这一列,游标第一轮就 LEAVE,
+	// 三条 W4 case 会全部退化成"两侧零写入"的**空过**(而且照样全绿)。
+	RecommendUserID int64
 }
 
 // ActivitySeed 是"这个 case 要建活动种子"的声明。
@@ -336,9 +343,9 @@ VALUES (?, ?, ?, ?, ?, 1, ?, NOW())`,
 		// `activity_money` 与 `response_userid` 是活动链要验的两列:
 		// 前者验"`signed_add` 把活动金额抄进来",后者是"已审核不可取消"那条守卫的唯一触发器。
 		if err := tx.Exec(`
-INSERT INTO t_courses_activity_signed (activityid, userid, status, activity_money, activity_money_used, response_userid, create_time)
-VALUES (?, ?, ?, ?, ?, ?, NOW())`,
-			it.ID, uid, it.SignedStatus, yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), it.ResponseUserID).Error; err != nil {
+INSERT INTO t_courses_activity_signed (activityid, userid, status, activity_money, activity_money_used, response_userid, recommend_userid, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, NOW())`,
+			it.ID, uid, it.SignedStatus, yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), it.ResponseUserID, it.RecommendUserID).Error; err != nil {
 			return fmt.Errorf("种子 legacy 报名 %d 失败:%w", it.ID, err)
 		}
 	}
@@ -419,12 +426,13 @@ VALUES (?, ?, ?, ?, ?, 1, ?, NOW(3), NOW(3))`,
 		// 0 必须写成 **NULL**(不是 '0',也不是空串)—— 迁移口径里 0 与 NULL 同义,
 		// 写成空串会让 `assertIdentityZeroNull` 报出一条假差异。
 		respUID := sqlNullUID(it.ResponseUserID)
+		recUID := sqlNullUID(it.RecommendUserID)
 		if err := tx.Exec(`
 INSERT INTO t_courses_activity_signed
-  (activity_id, user_id, institution_id, campus_id, status, activity_money, activity_money_used, response_user_id, create_time)
-VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(3))`,
+  (activity_id, user_id, institution_id, campus_id, status, activity_money, activity_money_used, response_user_id, recommend_user_id, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(3))`,
 			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus,
-			yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), respUID).Error; err != nil {
+			yuan(it.MoneyCents), yuan(it.ActivityMoneyUsedCents), respUID, recUID).Error; err != nil {
 			return fmt.Errorf("种子 target 报名 %d 失败:%w", it.ID, err)
 		}
 	}

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

@@ -113,6 +113,15 @@ const (
 	// 只改 `t_courses_student` 的 `net_receipts` / `remainder_receivable` 两列。
 	// 所以比对必须走 `arrearsCompare`,通用比对的 Δbalance 对它结构性不适用。
 	OpCounteractArrears
+	// OpUseActivityRecommend 老带新结算(SP18,4 参):**唯一跨三域、两用户**的一支。
+	//
+	//	资金:被推荐人 + 推荐人 各 `balance +m` / `total_recommended_amount +m`(双表)+ 各 1 条 type=107
+	//	活动:`used_log` 一条(`activity_money_used = −m`)、`signed.status=2` 且 `activity_money_used=m`
+	//	课酬:**两次**嵌调 `counteract_arrears_course`(被推荐人一次、推荐人一次)
+	//
+	// 所以它不能走通用比对(那是"单用户 5 个增量"),也不能走活动域比对(那不管资金)。
+	// 走 `recommendCompare`,三块都要断言。
+	OpUseActivityRecommend
 )
 
 // String 返回可读名字(报告用)。
@@ -152,6 +161,8 @@ func (o Op) String() string {
 		return "sp_user_expense_cancel_recharge_activity"
 	case OpCounteractArrears:
 		return "sp_courses_student_counteract_arrears_course"
+	case OpUseActivityRecommend:
+		return "sp_courses_activity_use_activity_recommend"
 	default:
 		return "sp_user_expense_school_expenditure"
 	}
@@ -343,6 +354,23 @@ type Baseline struct {
 	CampusSpend       int64
 	CampusIncome      int64
 	CampusExpenditure int64
+
+	// GlobalRecommended / CampusRecommended 是 `total_recommended_amount` 的基线,**三态**:
+	//
+	//	nil    = **不动这一列**(保留库里原值)
+	//	非 nil = 显式设成该值(**含 0**)
+	//
+	// 为什么必须是 `*int64` 而不是 `int64`:这一列**两张表都没有 DEFAULT**
+	//(`t_user_expense` / `t_user_expense_school` 实测,见 §17.2 的更正),所以
+	// "库里是 NULL" 与 "库里是 0" 是两件不同的事,而 `0` 又必须能表达成"设成 0"。
+	// 用 `int64` 的话,`0` 会同时表示"不动"和"设成 0" —— 那就是 §8「NULL ≠ 0」
+	// 那个老坑换了个字段重演一次。
+	//
+	// W4(老带新)是唯一写这一列的一支:基线不覆盖它,就等于"基线只覆盖了一半的列",
+	// 而 `NULL + m` 仍是 NULL ⇒ case 会测成"没记推荐金额",那是 W4-B 要证明的事,
+	// 不该让 W4-A 承担(否则 W4-A 变成第二条 W4-B,"两个用户各一份"反而没人钉)。
+	GlobalRecommended *int64
+	CampusRecommended *int64
 }
 
 // AccountNulls 记录 Account 每一列是否为 SQL NULL。

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

@@ -304,6 +304,23 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 		return fmt.Errorf("legacy 设置校区基线影响 %d 行(期望 1):userid=%d inst=%d campus=%d 的校区账户不存在,基线没设上",
 			res.RowsAffected, c.LegacyUserID, c.InstitutionID, c.CampusID)
 	}
+	// `total_recommended_amount` 是**三态**:nil = 不动,非 nil = 显式设为该值(含 0)。
+	// 单独一条 UPDATE(而不是并进上面那句)是因为"不动"这个语义没法用 SQL 参数表达:
+	// 并进去就必然要传一个值,而 `0` 会同时表示"不动"和"设成 0"(§8 的老坑)。
+	if b.GlobalRecommended != nil {
+		if err := tx.Exec(`UPDATE t_user_expense SET total_recommended_amount=? WHERE userid=?`,
+			yuan(*b.GlobalRecommended), c.LegacyUserID).Error; err != nil {
+			return fmt.Errorf("legacy 设置全局 total_recommended_amount 基线失败:%w", err)
+		}
+	}
+	if b.CampusRecommended != nil {
+		if err := tx.Exec(`
+UPDATE t_user_expense_school SET total_recommended_amount=?
+ WHERE userid=? AND schoolid=? AND campusid=?`,
+			yuan(*b.CampusRecommended), c.LegacyUserID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("legacy 设置校区 total_recommended_amount 基线失败:%w", err)
+		}
+	}
 	return nil
 }
 

+ 15 - 0
internal/academic/dualrun/stage2/target.go

@@ -486,6 +486,21 @@ UPDATE t_user_expense_school SET balance=?, spend=?, total_income=?, total_expen
 		return fmt.Errorf("target 设置校区基线影响 %d 行(期望 1):%s inst=%d campus=%d 的校区账户不存在,基线没设上",
 			res.RowsAffected, canonicalUID, c.InstitutionID, c.CampusID)
 	}
+	// `total_recommended_amount` 三态,理由同 legacy 侧(nil = 不动 / 非 nil = 显式设值,含 0)。
+	if b.GlobalRecommended != nil {
+		if err := tx.Exec(`UPDATE t_user_expense SET total_recommended_amount=? WHERE user_id=?`,
+			yuan(*b.GlobalRecommended), canonicalUID).Error; err != nil {
+			return fmt.Errorf("target 设置全局 total_recommended_amount 基线失败:%w", err)
+		}
+	}
+	if b.CampusRecommended != nil {
+		if err := tx.Exec(`
+UPDATE t_user_expense_school SET total_recommended_amount=?
+ WHERE user_id=? AND institution_id=? AND campus_id=?`,
+			yuan(*b.CampusRecommended), canonicalUID, c.InstitutionID, c.CampusID).Error; err != nil {
+			return fmt.Errorf("target 设置校区 total_recommended_amount 基线失败:%w", err)
+		}
+	}
 	return nil
 }