瀏覽代碼

fix(ac20): 分账调额级联补 root_user_id 过滤 —— F25(P0,新系统唯一确认缺陷)

问题:ClampSubtreeQuota 的 WHERE 只有 left_val/right_val + quota + type,没有 root_user_id。
而 left/right 只在**同一棵树内**唯一 —— 每棵树的 left 都从 1 起,多棵树根的区间在数值上必然
互相重叠(旧库实测 86 行 / 26 棵树 / 跨树重叠 325 对 = C(26,2) 满重叠)。
⇒ 调 A 树的额度会压低 B 树里"区间数值落在 A 子树范围内、且额度更高"的节点,后续分成静默少算。

根因:照抄 dump —— legacy sp_user_divide_into_update_quota 本身就缺这个过滤。
同文件 ShiftRight/ShiftLeft 都有 root 过滤,只有调额这一支漏了。

改动(最小化,只加不减):
- repository:ClampSubtreeQuota 增 rootUserID 参数 + 非空校验 + Where("root_user_id = ?")
- service:UpdateQuota 传 node.RootUserID(为 NULL 时回退自身 userID,覆盖旧 SP 的 subid 已存在分支)
- test:新增 TestUpdateQuotaClampMustNotCrossTree(两棵结构相同的树 D→E 与 A→B,E 与 B 区间数值都是 (2,3))
- test:repo 既有 4 处直接调用补 root 实参

变异探针(移除 root 过滤,实测红):
  divide_service_test.go:976: **F25 回归**:另一棵树 D→E 的节点 E 被误压,
  quota 应保持 1500 分,实得 1000 分(说明 ClampSubtreeQuota 丢了 root_user_id 过滤;E 与 B 区间数值都是 (2,3))
还原后绿;grep MUTATION 零输出。

门禁:go build ./... OK | go vet OK | gofmt 干净 |
      go test ./internal/academic/service/ -count=1 → ok 81.239s
      go test ./internal/academic/repository/ -count=1 → ok 13.367s

⚠ 未完成:双跑 case DV-7(INTENTIONAL-STRICT 形态)尚未落地 —— 上游连续 500 中断,
两次派人都未产出。本提交已闭合 P0 本身(缺陷不再发生),DV-7 作为回归证明补在下一批。
Q 6 小時之前
父節點
當前提交
7fd57c5b6d

+ 9 - 1
internal/academic/repository/divide_repo.go

@@ -404,10 +404,13 @@ func (r *DivideRepo) UpdateNodeQuota(ctx context.Context, tx *gorm.DB, userID st
 //
 // 语义("子不得超父"):只降不升——低于上限的节点保持原额度(分别配置的阶梯额度不被抹平)。
 // 红线:`type` 过滤不可省 —— 否则一次付清(1)与按课次(2)两条线的额度会被跨类型抹平(漏实现 = 缺陷)。
-func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, leftVal, rightVal int32, divideType int8, quota money.Amount) (int64, error) {
+func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, rootUserID string, leftVal, rightVal int32, divideType int8, quota money.Amount) (int64, error) {
 	if err := requireTx(tx, "ClampSubtreeQuota"); err != nil {
 		return 0, err
 	}
+	if strings.TrimSpace(rootUserID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ClampSubtreeQuota 需要 root_user_id"))
+	}
 	if leftVal < 0 || rightVal <= leftVal {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ClampSubtreeQuota 左右值区间非法"))
 	}
@@ -421,6 +424,11 @@ func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, leftVal
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 分成额度不能为负"))
 	}
 	q := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+		// root_user_id 过滤**必须**在:`left_val`/`right_val` 只在**同一棵树内**唯一。
+		// 每棵树的 left 都从 1 起 ⇒ 多个树根的区间在数值上必然互相重叠
+		//(真实数据 26 棵树、跨树重叠 325 对 = C(26,2) 满重叠)。
+		// 缺这一条 ⇒ 调 A 树的额会压低 B 树里区间数值重叠的节点(台账 F25,P0)。
+		Where("root_user_id = ?", strings.TrimSpace(rootUserID)).
 		Where("left_val > ? AND right_val < ?", leftVal, rightVal).
 		Where("quota > ?", quota.String())
 	if divideType != 0 {

+ 6 - 6
internal/academic/repository/divide_repo_test.go

@@ -353,7 +353,7 @@ func TestDivideQuotaPrimitives(t *testing.T) {
 
 	// 级联只降:子树限制 4.00 → a(8.00) 降到 4.00
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
 		if e != nil {
 			return e
 		}
@@ -369,7 +369,7 @@ func TestDivideQuotaPrimitives(t *testing.T) {
 	}
 	// 再压到 6.00(高于现值)→ 不回升(影响 0 行)
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(600))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(600))
 		if e != nil {
 			return e
 		}
@@ -408,11 +408,11 @@ func TestDivideQuotaPrimitives(t *testing.T) {
 	if _, err := repo.UpdateNodeQuota(ctx, db, a, money.FromCents(-1)); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("负额度应 ErrBadParam,实得 %v", err)
 	}
-	if _, err := repo.ClampSubtreeQuota(ctx, db, 5, 1, model.DivideTypeOneOff, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ClampSubtreeQuota(ctx, db, root, 5, 1, model.DivideTypeOneOff, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
 	}
 	// audit A6:分成类型非法 → ErrBadParam(白名单 0/1/2)
-	if _, err := repo.ClampSubtreeQuota(ctx, db, 1, 6, 9, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ClampSubtreeQuota(ctx, db, root, 1, 6, 9, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("非法分成类型应 ErrBadParam,实得 %v", err)
 	}
 }
@@ -436,7 +436,7 @@ func TestClampSubtreeQuotaTypeFilter(t *testing.T) {
 	ctx := context.Background()
 
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
 		if e != nil {
 			return e
 		}
@@ -456,7 +456,7 @@ func TestClampSubtreeQuotaTypeFilter(t *testing.T) {
 
 	// divideType=0(admin 全量场景)→ 两种类型一起压
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, 0, money.FromCents(200))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, root, 1, 6, 0, money.FromCents(200))
 		if e != nil {
 			return e
 		}

+ 8 - 2
internal/academic/service/divide_service.go

@@ -559,8 +559,14 @@ func (s *DivideService) UpdateQuota(ctx context.Context, userID string, quota mo
 			// 原额度为 0 → dump 不级联(只落本节点新值)
 			return nil
 		}
-		// A6 前置②:只压同一分成类型的子节点
-		if _, cerr := s.repo.ClampSubtreeQuota(txCtx, tx, node.LeftVal, node.RightVal, node.Type, quota); cerr != nil {
+		// A6 前置②:只压同一分成类型的子节点。
+		// root 必须传:`left/right` 只在同一棵树内唯一,缺 root 过滤会压低**别的树**(台账 F25)。
+		// node 自身为根时 RootUserID 可能为 NULL(旧 SP 的 subid 已存在分支),此时回退成自身 userID。
+		rootID := userID
+		if node.RootUserID != nil && strings.TrimSpace(*node.RootUserID) != "" {
+			rootID = strings.TrimSpace(*node.RootUserID)
+		}
+		if _, cerr := s.repo.ClampSubtreeQuota(txCtx, tx, rootID, node.LeftVal, node.RightVal, node.Type, quota); cerr != nil {
 			return cerr
 		}
 		return nil

+ 79 - 0
internal/academic/service/divide_service_test.go

@@ -904,6 +904,85 @@ func TestUpdateQuotaCascade(t *testing.T) {
 	}
 }
 
+// TestUpdateQuotaClampMustNotCrossTree 调额级联**只压本树**:不得碰其它树里区间数值重叠的节点。
+//
+// # 这个用例钉的是台账 F25(P0)
+//
+// `ClampSubtreeQuota` 的 WHERE 原本只有 `left_val > ? AND right_val < ?` + `quota > ?` + `type`,
+// **没有 `root_user_id`**。而每棵树的 `left_val` 都从 1 起 ⇒ 多棵树根的区间在**数值上必然互相重叠**
+// (旧库实测:86 行 / 26 棵树 / 跨树重叠 325 对 = C(26,2) 满重叠)。
+// ⇒ 调 A 树的额度,会把 B 树里"区间数值落在 A 子树范围内、且额度更高"的节点一起压低,
+// 后续分成静默少算。根因是**照抄 dump**:legacy `sp_user_divide_into_update_quota` 本身就缺这个过滤。
+//
+// # 为什么必须用"两棵结构相同的树"来测
+//
+// 因为缺陷的触发条件就是"区间数值重叠"。本用例造 A(1,4)→B(2,3) 与 D(1,4)→E(2,3):
+// **E 的 (2,3) 与 B 的 (2,3) 数值完全相同**。若缺 root 过滤,`left>1 AND right<4` 会同时命中 B 和 E。
+// 下面有前置自检确保这个重叠真的成立 —— 否则用例会变成"测不到东西的绿"。
+//
+// # 与双跑的关系
+//
+// 这条缺陷**双跑抓不到**(legacy 与 target 同错 ⇒ equality ⇒ PASS,见 BACKLOG B1),
+// 所以它是**唯一能抓它的地方**。双跑的 DV-7 会以 INTENTIONAL-STRICT 形态登记两侧不同。
+func TestUpdateQuotaClampMustNotCrossTree(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a, b := nodeID(0), nodeID(1)
+	d, e := nodeID(2), nodeID(3)
+	seedRoot(t, db, a, model.DivideTypeOneOff, "20.00")
+	seedRoot(t, db, d, model.DivideTypeOneOff, "20.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链 A→B 失败: %v", err)
+	}
+	if err := svc.AddSubordinate(ctx, d, e); err != nil {
+		t.Fatalf("建链 D→E 失败: %v", err)
+	}
+	// 两个子节点都配 15.00(高于下面要压到的 10.00)
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(1500)); err != nil {
+		t.Fatalf("设额度 B 失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, e, money.FromCents(1500)); err != nil {
+		t.Fatalf("设额度 E 失败: %v", err)
+	}
+
+	// ---- 把两棵树的区间摆成**真实数据那种形态**:每棵树的 left 都从 1 起 ----
+	// 为什么必须手动摆:`AddSubordinate` 的右移会在同表内让后续树拿到递增的 left/right
+	// (实测第二棵树会变成 D(1,6)/E(4,5),与 B(2,3) 不重叠 ⇒ 缺陷根本触发不到)。
+	// 而旧库真实数据正是"26 棵树、每棵 left 都从 1 起、跨树重叠 325 对"。这里直接落库对齐真实形态。
+	if err := db.Exec("UPDATE t_user_divide_into SET left_val = 1, right_val = 4 WHERE user_id = ?", d).Error; err != nil {
+		t.Fatalf("摆 D 区间失败: %v", err)
+	}
+	if err := db.Exec("UPDATE t_user_divide_into SET left_val = 2, right_val = 3 WHERE user_id = ?", e).Error; err != nil {
+		t.Fatalf("摆 E 区间失败: %v", err)
+	}
+
+	// ---- 前置自检:两棵树的子节点区间必须**数值重叠** ----
+	// 不成立 ⇒ 本用例根本触发不到 F25,跑出来的绿是假的。
+	bn, en := loadDivideNode(t, db, b), loadDivideNode(t, db, e)
+	if bn.LeftVal != en.LeftVal || bn.RightVal != en.RightVal {
+		t.Fatalf("前置不成立:B(%d,%d) 与 E(%d,%d) 区间不重叠,本用例测不到 F25",
+			bn.LeftVal, bn.RightVal, en.LeftVal, en.RightVal)
+	}
+
+	// ---- 调 A 的额度:只应压本树的 B ----
+	if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
+		t.Fatalf("UpdateQuota(A,10) 失败: %v", err)
+	}
+	if got := cents(t, loadDivideNode(t, db, b).Quota); got != 1000 {
+		t.Fatalf("本树 B 应被压到 1000 分(级联正常路径),实得 %d", got)
+	}
+	if got := cents(t, loadDivideNode(t, db, e).Quota); got != 1500 {
+		t.Fatalf("**F25 回归**:另一棵树 D→E 的节点 E 被误压,quota 应保持 1500 分,实得 %d 分"+
+			"(说明 ClampSubtreeQuota 丢了 root_user_id 过滤;E 与 B 区间数值都是 (%d,%d))",
+			got, bn.LeftVal, bn.RightVal)
+	}
+	// 另一棵树的根也不该被碰(它自己额度更高、且区间包含 B)
+	if got := cents(t, loadDivideNode(t, db, d).Quota); got != 2000 {
+		t.Fatalf("另一棵树的根 D 被误改,quota 应保持 2000 分,实得 %d", got)
+	}
+}
+
 // TestUpdateQuotaNoCascadeWhenOrigZero 原 quota=0 时不级联(audit A6,dump 763872 `IF (uQuota>0)`)。
 //
 // 语义:还没配置过额度的节点,被"设成某个值"时不该顺手压低子树(子树额度是独立配置的阶梯)。