Ver código fonte

wip(ac15): 3a 接线 —— 7 个 Op + legacy CALL 7 支 + 修订单号归一(仍 35 条,无新增 case)

一、7 个 Op 与 legacy 侧 CALL(照 dump 参数列表,不照调用方)
  signed_add(5, 有结果集) / signed_cancel(2, 有结果集)
  use_activity_recharge(5) / refund_activity_recharge(3, **无 userid**)
  user_discount_use(5) / user_discount_refund(3, **无 userid**) / user_discount_refund_user_activity(4)
  Case 新增 StuCourseID / SignStatus / RecommendUserID。

⚠ returnsResultSet 的判据是"正文末尾有没有 SELECT",不是"名字像不像查询":
  use_activity_recharge / refund_activity_recharge 都读 cursor 但没有 SELECT 输出,
  走 Rows() 会拿到空结果集、Success 恒 false ⇒ 会让 4 条 case 假红。

二、修一个 commit 1 的真实错误(订单号归一)
commit 1 我把 target 侧订单号归一到 `courses_student_no` —— 错了。
目标表的红线注释写得很清楚:`courses_student_no`「写入:**迁移搬入**|红线:回填完成后停用,
**禁止新写入**」;新写入路径(UseVoucher / RefundBy*)落的是 `courses_student_id`(bigint)。
所以"直接比 courses_student_no"在**迁移数据**上对,在**本批新写入的数据**上恒为 NULL。

改成:target `courses_student_id` → LEFT JOIN `t_courses_student` → `biz_no`(旧 UUID,
与 legacy 的 `courses_studentid` 同一个值);兜底 `courses_student_no`(迁移数据),
再兜底 `#<numeric>`(既不迁移也没 JOIN 上时至少要能看出是哪条,而不是静默成空串)。

三、两个影响 15 条构成的设计更正(下一 commit 生效,先记在这里)
  · SA-2 / SC-2 不是"求一致"的 case,是 **intentional-strict**:
    legacy 的守卫只 `SELECT success`(=0)就 LEAVE、**不抛错**;target 会返回业务错误码。
    所以它们要按 R19 那条路走:断言"legacy 静默 + target 显式拒绝 + 两侧都零写入",
    而不是断言"两侧 tuple 数都等于某个值"。
  · UR-1 用 RunTimes=2 同时钉"累加对"与"不幂等",但 legacy 侧第二次的 cursor 看到的是
    扣完后的 `activity_money_used` ⇒ 期望值必须逐次模拟,不能乘 2。

验收:35 条 35 PASS / 0 FAIL / Skipped 69 / 业务 181 | 基建 64 / 未分类 0(全部与改前一致)。
接线是**惰性**的:没有任何 case 走这 7 个 Op,所以旧判据一条没动。
Q 2 horas atrás
pai
commit
598827bef5

+ 23 - 5
internal/academic/dualrun/stage2/activity_snapshot.go

@@ -43,7 +43,8 @@ import (
 //	自增 id(两侧起点不同)           → 排除
 //	create_time / response_time       → 排除(NOW() 逐条不同)
 //	`activity_user_discount_id`(父行自增 id) → 换成 (activity_id, user_id)
-//	`courses_student_id`(bigint)    → 换成 `courses_student_no`(旧 UUID)
+//	`courses_student_id`(bigint)    → **JOIN `t_courses_student` 换成 `biz_no`(旧 UUID)**,
+//	                                   `courses_student_no` 只作兜底(见下)
 //	`user_id`(ULID)                 → legacy 侧经 CanonicalUIDFor 换成 ULID,两侧同键
 
 // tupleSep / nullMark 是"把一行拼成一个可比较的字符串"用的分隔符。
@@ -411,8 +412,9 @@ SELECT id, `+readRound2("net_receipts")+`, `+readRound2("remainder_receivable")+
 //
 // ⚠ 三处必须记住的映射:
 //   - `t_courses_activity.schoolid/campusid` → `institution_id/campus_id`
-//   - `t_courses_activity_user_discount_log.courses_studentid` → **`courses_student_no`**
-//     (不是 `courses_student_id`;后者是 bigint 自增,两侧必然不同)
+//   - `t_courses_activity_user_discount_log.courses_studentid`(旧库里的 UUID 串)
+//     → target 侧**先看 `courses_student_id` 反查 `t_courses_student.biz_no`**,
+//     `courses_student_no` 只作兜底。理由见下面 ⑤ 的注释(这一条我改过一次)。
 //   - `t_courses_student.id`(bigint 自增)→ 定位用 **`biz_no`**(旧 UUID)
 func readActivitySnapshotTarget(ctx context.Context, tx *gorm.DB, c Case, uid string) (*ActivitySnapshot, error) {
 	p := c.ActivityPlan
@@ -562,13 +564,29 @@ SELECT activity_id, user_id,
 		_ = rows.Close()
 	}
 
-	// ⑤ 赠送额度流水:父行 id 归一成 (activity_id, user_id);订单号用 `courses_student_no`
+	// ⑤ 赠送额度流水:父行 id 归一成 (activity_id, user_id);订单号归一成**旧 UUID**
+	//
+	// ⚠ 这里我改过一次,原因是读了目标表的红线注释:
+	//   `courses_student_no` 的说明是「报课记录旧单号(迁移期保留旧 UUID)|写入:**迁移搬入**|
+	//   红线:回填完成后停用,**禁止新写入**」
+	// 而 `courses_student_id` 才是新写入路径(`UseVoucher` / `RefundBy*`)真正落的值。
+	//
+	// 所以"直接比 `courses_student_no`"在**迁移数据**上是对的,在**本批新写入的数据**上恒为 NULL ——
+	// 而本批恰好全是新写入。正确归一是:
+	//   legacy  `courses_studentid`(UUID 串,= 旧 `t_courses_student.id`)
+	//   target  `courses_student_id` → JOIN `t_courses_student` → `biz_no`(同一个 UUID 串)
+	// 两边都归一成 biz_no,才指向同一行。兜底用 `courses_student_no`(迁移数据),
+	// 再兜底用 `#<numeric>`(既不迁移也没 JOIN 上时,至少要能看出是哪一条,而不是静默成空串)。
 	{
 		rows, err := tx.Raw(`
-SELECT d.activity_id, d.user_id, IFNULL(l.courses_student_no,''),
+SELECT d.activity_id, d.user_id,
+       IF(l.courses_student_id IS NULL,
+          IFNULL(l.courses_student_no, ''),
+          IFNULL(s.biz_no, CONCAT('#', l.courses_student_id))) AS order_ref,
        `+readRound3("l.discount_amount_used")+`, IFNULL(l.memo,'')
   FROM t_courses_activity_user_discount_log l
   JOIN t_courses_activity_user_discount d ON d.id = l.activity_user_discount_id
+  LEFT JOIN t_courses_student s ON s.id = l.courses_student_id
  WHERE d.activity_id IN (?)`, ids).Rows()
 		if err != nil {
 			return nil, fmt.Errorf("读 target 赠送流水失败:%w", err)

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

@@ -89,6 +89,21 @@ const (
 	// 但 `_balance`/`_money`/`_badDate` 都是 `DECLARE ... DECIMAL DEFAULT 0`(= DECIMAL(10,0)),
 	// 于是 100.70 → 101、50.9 → 51。target 不复刻这套整数化(见 c2_refund.go 抬头)。
 	OpCustomRefund
+	// OpSignedAdd 报名(`sp_courses_activity_signed_add`,5 参,自带结果集 `SELECT success`)。
+	// 写 `t_courses_activity_signed`(**DELETE+INSERT**)与 `t_courses_activity.number_sign +1`。
+	OpSignedAdd
+	// OpSignedCancel 取消报名(2 参,自带结果集):status/两个金额归 0,`number_sign −1`。
+	OpSignedCancel
+	// OpUseActivityRecharge 消耗充值赠送额度(5 参):`activity_money_used +m` + `used_log(−m)`。
+	OpUseActivityRecharge
+	// OpRefundActivityRecharge 冲回充值赠送额度(3 参):`activity_money_used −m` + `used_log(+m)`。
+	OpRefundActivityRecharge
+	// OpUserDiscountUse 使用赠送额度(5 参):`discount_amount_used +m` + `_log(−m)`。
+	OpUserDiscountUse
+	// OpUserDiscountRefund 退回赠送额度(3 参,**只按 courses_studentid 定位、不带 userid**)。
+	OpUserDiscountRefund
+	// OpUserDiscountRefundByActivity 按活动退回(4 参,cursor `LIMIT 0,1`)。
+	OpUserDiscountRefundByActivity
 	// OpCancelRechargeActivity 取消充值活动(SP8):balance−、total_income−(双表),
 	// 报名状态置 **0**(不是 4),1 条流水 type=209;机构 id 由**校区**反查(SP 无 schoolid 入参)。
 	OpCancelRechargeActivity
@@ -113,6 +128,20 @@ func (o Op) String() string {
 		return "sp_user_expense_school_cleared_balance"
 	case OpCustomRefund:
 		return "sp_user_expense_custom_refund"
+	case OpSignedAdd:
+		return "sp_courses_activity_signed_add"
+	case OpSignedCancel:
+		return "sp_courses_activity_signed_cancel"
+	case OpUseActivityRecharge:
+		return "sp_courses_activity_use_activity_recharge"
+	case OpRefundActivityRecharge:
+		return "sp_courses_activity_refund_activity_recharge"
+	case OpUserDiscountUse:
+		return "sp_courses_activity_user_discount_use"
+	case OpUserDiscountRefund:
+		return "sp_courses_activity_user_discount_refund"
+	case OpUserDiscountRefundByActivity:
+		return "sp_courses_activity_user_discount_refund_user_activity"
 	case OpCancelRechargeActivity:
 		return "sp_user_expense_cancel_recharge_activity"
 	default:
@@ -141,8 +170,21 @@ type Case struct {
 	ToCampusID      int64
 
 	// ActivityID 仅 OpCancelRechargeActivity 使用:被取消的活动 id(SP8 的 inputActivityid)。
+	// 活动链的 7 支也用它:报名 / 消耗 / 退回针对的那个活动。
 	ActivityID int64
 
+	// StuCourseID 是报课订单号(旧库 `t_courses_student.id`,即 `biz_no`)。
+	//
+	// ⚠ 两侧传**同一个字符串**:legacy 的 SP 直接把它写进 `used_log.orderid` /
+	// `_discount_log.courses_studentid`;target 的 service 收的是 int64,落地写
+	// `courses_student_id`,快照再 JOIN `t_courses_student` 换回 `biz_no`(见 activity_snapshot.go ⑤)。
+	StuCourseID string
+
+	// SignStatus 是 `signed_add` 的 inputStatus(<1 会被 SP 抬成 1,所以用 0 才测得到那条默认)。
+	SignStatus int32
+	// RecommendUserID 是 `signed_add` 的推荐人(0 = 无;老带新活动才有意义)。
+	RecommendUserID int64
+
 	// SelfSupport 仅 OpClearBalance 使用:校区是否自营。
 	// 它是 SP 里 `IF(campusSelfSupport)` 的唯一判据,决定活动失效范围:
 	//   self_support=1(自营)→ `campus_id IN (campusID, 0)`(含"全机构池"活动)

+ 57 - 1
internal/academic/dualrun/stage2/legacy.go

@@ -51,6 +51,22 @@ const (
 	callCancelRecharge = "CALL sp_user_expense_cancel_recharge_activity(?, ?, ?, ?, ?, ?, ?)"
 	// SP9 自定义退费:(userid, schoolid, campusid, money, requestid, remark, createUserid)
 	callCustomRefund = "CALL sp_user_expense_custom_refund(?, ?, ?, ?, ?, ?, ?)"
+
+	// ---- 活动链 7 支(批 3a)。参数顺序一律照 dump 的参数列表,不照调用方 ---- //
+	// 报名:(userid, activityid, status, recommendUserid, createUserid) —— 5 参,SELECT success
+	callSignedAdd = "CALL sp_courses_activity_signed_add(?, ?, ?, ?, ?)"
+	// 取消报名:(userid, activityid) —— 2 参,SELECT success
+	callSignedCancel = "CALL sp_courses_activity_signed_cancel(?, ?)"
+	// 消耗充值赠送额度:(userid, activityid, stuCourseId, amount, memo) —— **没有结果集**
+	callUseActivityRecharge = "CALL sp_courses_activity_use_activity_recharge(?, ?, ?, ?, ?)"
+	// 冲回充值赠送额度:(stuCourseId, amount, memo) —— **没有 userid**,没有结果集
+	callRefundActivityRecharge = "CALL sp_courses_activity_refund_activity_recharge(?, ?, ?)"
+	// 使用赠送额度:(userid, activityid, stuCourseId, amount, memo) —— 没有结果集
+	callUserDiscountUse = "CALL sp_courses_activity_user_discount_use(?, ?, ?, ?, ?)"
+	// 退回赠送额度:(stuCourseId, amount, memo) —— **没有 userid**,3 参
+	callUserDiscountRefund = "CALL sp_courses_activity_user_discount_refund(?, ?, ?)"
+	// 按活动退回:(userid, activityid, amount, memo) —— 4 参
+	callUserDiscountRefundByActivity = "CALL sp_courses_activity_user_discount_refund_user_activity(?, ?, ?, ?)"
 )
 
 // returnsResultSet 标记"该 SP 会 SELECT 出结果集"。
@@ -60,8 +76,19 @@ const (
 // 这类 SP 不能用 `Exec` 调 —— 必须把结果集读干净,否则连接上会留下未消费的结果集。
 // 而且**结果集本身是契约**:`success=0` 是"成功返回但业务失败",只看 error 会漏掉。
 func returnsResultSet(op Op) bool {
+	switch op {
 	// SP8 与 SP9 的每条路径都以 `SELECT ...` 结尾(成功 `success,msg`;失败也 `success,msg`)。
-	return op == OpCancelRechargeActivity || op == OpCustomRefund
+	case OpCancelRechargeActivity, OpCustomRefund:
+		return true
+	// 活动链只有这两支有结果集(正文末尾 `SELECT success;`)。其余 5 支是纯写。
+	//
+	// ⚠ 判据是**正文末尾有没有 SELECT**,不是"名字像不像查询":
+	// `use_activity_recharge` / `refund_activity_recharge` 都读 cursor,但**没有** SELECT 输出;
+	// 把它们的调用走 `Rows()` 会拿到空结果集,`Success` 永远是 false —— 那会让 4 条 case 假红。
+	case OpSignedAdd, OpSignedCancel:
+		return true
+	}
+	return false
 }
 
 // callArgs 按 Op 组出 CALL 的实参(顺序与 dump 一致)。
@@ -98,6 +125,21 @@ func callArgs(c Case) []any {
 		// SP9:(userid, schoolid, campusid, money, requestid, remark, createUserid) —— **有 schoolid**
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.RequestID, c.Remark, c.LegacyUserID}
+	case OpSignedAdd:
+		// SP9a:(userid, activityid, status, recommendUserid, createUserid)
+		return []any{c.LegacyUserID, c.ActivityID, c.SignStatus, c.RecommendUserID, c.LegacyUserID}
+	case OpSignedCancel:
+		return []any{c.LegacyUserID, c.ActivityID}
+	case OpUseActivityRecharge:
+		return []any{c.LegacyUserID, c.ActivityID, c.StuCourseID, moneyYuan, c.Remark}
+	case OpRefundActivityRecharge:
+		return []any{c.StuCourseID, moneyYuan, c.Remark}
+	case OpUserDiscountUse:
+		return []any{c.LegacyUserID, c.ActivityID, c.StuCourseID, moneyYuan, c.Remark}
+	case OpUserDiscountRefund:
+		return []any{c.StuCourseID, moneyYuan, c.Remark}
+	case OpUserDiscountRefundByActivity:
+		return []any{c.LegacyUserID, c.ActivityID, moneyYuan, c.Remark}
 	default:
 		return []any{c.LegacyUserID, c.InstitutionID, c.CampusID, moneyYuan,
 			c.Type, c.RequestID, c.Remark, c.LegacyUserID}
@@ -125,6 +167,20 @@ func callStmt(op Op) string {
 		return callCancelRecharge
 	case OpCustomRefund:
 		return callCustomRefund
+	case OpSignedAdd:
+		return callSignedAdd
+	case OpSignedCancel:
+		return callSignedCancel
+	case OpUseActivityRecharge:
+		return callUseActivityRecharge
+	case OpRefundActivityRecharge:
+		return callRefundActivityRecharge
+	case OpUserDiscountUse:
+		return callUserDiscountUse
+	case OpUserDiscountRefund:
+		return callUserDiscountRefund
+	case OpUserDiscountRefundByActivity:
+		return callUserDiscountRefundByActivity
 	default:
 		return callExpenditure
 	}