Pārlūkot izejas kodu

wip(ac15): 3a 种子可配 —— ActivitySeed.Items / MoneyCents / ResponseUserID(仍 35 条)

为 W1 做的种子扩展,C1/C2-10 行为一字不改:
· ActivityItem.MoneyCents:`t_courses_activity.money` 之前恒种 0.00,而 W1 要验
  "signed_add 把活动金额抄进 signed.activity_money" —— 种 0 的话"没抄"与"抄了 0"相同,那条断言就是空过。
· ActivityItem.ResponseUserID:SC-2 的守卫 `type∈{2,3} 且 response_userid>0 → LEAVE`
  **只能**靠这个值触发。
· ActivitySeed.Items:非 nil 时整体替换 C1 标准矩阵,ID 由 base+i 分配(case 不许写死 id,
  否则自增水位被永久推高且两侧指向不同行)。
· target 侧 identity 列写 NULL 而不是 0/空串(迁移口径 0 与 NULL 同义),
  否则 assertIdentityZeroNull 会报假差异。
· RunOne 改用 buildPlan,并给活动链 6 个 Op 自动补 ActivityID(id 只有运行期才知道)。

验收:35 条 35 PASS / 0 FAIL / Skipped 69 / 业务 181 | 基建 64 / 未分类 0(全部不变)。
Q 2 stundas atpakaļ
vecāks
revīzija
96ac4c6790

+ 56 - 7
internal/academic/dualrun/stage2/c1_activity.go

@@ -31,6 +31,16 @@ type ActivityItem struct {
 	InstitutionID int64 // 旧 schoolid
 	CampusID      int64 // 0 = 全机构池(所有自营校区)
 	SignedStatus  int32 // 该用户在本次活动上的报名状态(只对"本 case 用户"有意义)
+	// MoneyCents 是活动金额(`t_courses_activity.money`,元→分)。
+	//
+	// 之前所有活动都种 0.00(C1/C2-10 不关心它),但活动链的 W1 要验
+	// "`signed_add` 把活动金额抄进 `signed.activity_money`" —— 种 0 的话
+	// "没抄"和"抄了 0"长得一样,那条断言就是空过。
+	MoneyCents int64
+	// ResponseUserID >0 时给该报名行写 `response_userid`("已审核")。
+	//
+	// SC-2 要验 `type∈{2,3} 且 response_userid>0 → LEAVE`,这条守卫**只能**靠这个值触发。
+	ResponseUserID int64
 }
 
 // ActivitySeed 是"这个 case 要建活动种子"的声明。
@@ -48,6 +58,12 @@ type ActivitySeed struct {
 	// legacy 的 `t_courses_student.id` 就是旧 UUID,target 的 `id` 是 bigint 自增、
 	// 真正对应旧 UUID 的是 `biz_no`。用 biz_no 做锚点,两侧才指向同一行。
 	//
+	// Items 非 nil 时**整体替换** `C1ActivityPlan` 的默认矩阵(case 自定义构型)。
+	//
+	// `ID` 留 0 即可:由 `buildPlan` 按 `base+i` 分配。**case 里不许写死 id** ——
+	// 写死会把自增水位永久推高,而且两侧会指向不同的活动行。
+	Items []ActivityItem
+
 	// 只有 `use_activity_recommend`(W4)需要它:它嵌调两次
 	// `sp_courses_student_counteract_arrears_course`,写的是 `t_courses_student` 的
 	// `net_receipts` / `remainder_receivable`(第三个域)。
@@ -92,6 +108,23 @@ func C1ActivityPlan(base int64, otherUsers map[int64]int32) *ActivityPlan {
 	}
 }
 
+// buildPlan 按 case 的声明构造实际 plan(id 由 base 决定)。
+//
+// 两条路径:
+//   - `Items == nil`  → 用 C1 的标准矩阵(C1/C2-10 的行为**一字不改**)
+//   - `Items != nil`  → 用 case 给的项,`ID` 按 `base+i` 覆盖
+func (s *ActivitySeed) buildPlan(base int64) *ActivityPlan {
+	if len(s.Items) == 0 {
+		return C1ActivityPlan(base, s.OtherUsers)
+	}
+	items := make([]ActivityItem, len(s.Items))
+	for i, it := range s.Items {
+		it.ID = base + int64(i)
+		items[i] = it
+	}
+	return &ActivityPlan{BaseID: base, Items: items, OtherUsers: s.OtherUsers}
+}
+
 // ActivityIDs 返回本 plan 全部活动 id(升序)。
 func (p *ActivityPlan) ActivityIDs() []int64 {
 	if p == nil {
@@ -238,15 +271,18 @@ func seedLegacyActivities(ctx context.Context, tx *gorm.DB, c Case, uid int64) e
 		if err := tx.Exec(`
 INSERT INTO t_courses_activity (id, type, schoolid, campusid, money, status, create_time)
 VALUES (?, ?, ?, ?, ?, 1, NOW())`,
-			it.ID, it.Type, it.InstitutionID, it.CampusID, "0.00").Error; err != nil {
+			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents)).Error; err != nil {
 			return fmt.Errorf("种子 legacy 活动 %d 失败:%w", it.ID, err)
 		}
 		if it.SignedStatus < 0 {
 			continue
 		}
+		// `activity_money` 与 `response_userid` 是活动链要验的两列:
+		// 前者验"`signed_add` 把活动金额抄进来",后者是"已审核不可取消"那条守卫的唯一触发器。
 		if err := tx.Exec(`
-INSERT INTO t_courses_activity_signed (activityid, userid, status, create_time)
-VALUES (?, ?, ?, NOW())`, it.ID, uid, it.SignedStatus).Error; err != nil {
+INSERT INTO t_courses_activity_signed (activityid, userid, status, activity_money, response_userid, create_time)
+VALUES (?, ?, ?, ?, ?, NOW())`,
+			it.ID, uid, it.SignedStatus, yuan(it.MoneyCents), it.ResponseUserID).Error; err != nil {
 			return fmt.Errorf("种子 legacy 报名 %d 失败:%w", it.ID, err)
 		}
 	}
@@ -278,17 +314,21 @@ func seedTargetActivities(ctx context.Context, tx *gorm.DB, c Case, uid string)
 		if err := tx.Exec(`
 INSERT INTO t_courses_activity (id, type, institution_id, campus_id, money, status, create_time, updated_at)
 VALUES (?, ?, ?, ?, ?, 1, NOW(3), NOW(3))`,
-			it.ID, it.Type, it.InstitutionID, it.CampusID, "0.00").Error; err != nil {
+			it.ID, it.Type, it.InstitutionID, it.CampusID, yuan(it.MoneyCents)).Error; err != nil {
 			return fmt.Errorf("种子 target 活动 %d 失败:%w", it.ID, err)
 		}
 		if it.SignedStatus < 0 {
 			continue
 		}
+		// `recommend_user_id` / `response_user_id` 在 target 是 `varchar(26) NULL`:
+		// 0 必须写成 **NULL**(不是 '0',也不是空串)—— 迁移口径里 0 与 NULL 同义,
+		// 写成空串会让 `assertIdentityZeroNull` 报出一条假差异。
+		respUID := sqlNullUID(it.ResponseUserID)
 		if err := tx.Exec(`
 INSERT INTO t_courses_activity_signed
-  (activity_id, user_id, institution_id, campus_id, status, create_time)
-VALUES (?, ?, ?, ?, ?, NOW(3))`,
-			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus).Error; err != nil {
+  (activity_id, user_id, institution_id, campus_id, status, activity_money, response_user_id, create_time)
+VALUES (?, ?, ?, ?, ?, ?, ?, NOW(3))`,
+			it.ID, uid, it.InstitutionID, it.CampusID, it.SignedStatus, yuan(it.MoneyCents), respUID).Error; err != nil {
 			return fmt.Errorf("种子 target 报名 %d 失败:%w", it.ID, err)
 		}
 	}
@@ -447,3 +487,12 @@ SELECT COUNT(*) FROM t_courses_activity_signed s
 	}
 	return n, nil
 }
+
+// sqlNullUID 把 legacy int identity 转成 target 的 `varchar(26) NULL`:
+// `0` ⇒ NULL(迁移口径:0 与 NULL 同义),>0 ⇒ ULID。
+func sqlNullUID(legacyID int64) any {
+	if legacyID <= 0 {
+		return nil
+	}
+	return CanonicalUIDFor(legacyID)
+}

+ 10 - 3
internal/academic/dualrun/stage2/run.go

@@ -524,11 +524,18 @@ func RunOne(ctx context.Context, cfg Config, c Case) (*CaseResult, error) {
 		if err != nil {
 			return nil, err
 		}
-		c.ActivityPlan = C1ActivityPlan(base, c.Activity.OtherUsers)
+		c.ActivityPlan = c.Activity.buildPlan(base)
 		// 取消充值的对象是矩阵里的**第一条**(本级校区、type=2、status=2)——
 		// 用 plan 里的 id 而不是写死,避免"活动 id 换了但 case 没跟上"。
-		if c.Op == OpCancelRechargeActivity {
-			c.ActivityID = c.ActivityPlan.Items[0].ID
+		// 活动链的 7 支要指定"对哪个活动操作":id 只有运行期才知道,所以在这里补上。
+		// 已有值(如 cancel 的 ActivityID)不覆盖。
+		if c.Op == OpCancelRechargeActivity ||
+			c.Op == OpSignedAdd || c.Op == OpSignedCancel ||
+			c.Op == OpUseActivityRecharge || c.Op == OpUserDiscountUse ||
+			c.Op == OpUserDiscountRefundByActivity {
+			if len(c.ActivityPlan.Items) > 0 {
+				c.ActivityID = c.ActivityPlan.Items[0].ID
+			}
 		}
 		res.Case = c // 报告里也要能看到实际用的活动 id
 	}