Selaa lähdekoodia

fix(academic): 仓储层按 dump 对齐(V-02 位移 root 限定 / V-04 活动维度 / V-05 取消归零 / A7 / A11 / A6)

全部依据 dump 原文,逐项标行号。P0 的 A1(老带新判据)、D1(BFS 起点)已在 `9d8de5a` 闭环,本提交为剩余仓储层项。

## V-02 · 左右值位移缺 root 限定(dump `763707-763708`)
`divide_repo.go:shift` 增 `rootUserID` 入参 → `WHERE root_user_id = ? AND <col> >= ?`;`ShiftRight/ShiftLeft` 透传。
**漏 root 限定的后果**:多棵树共用一张表、区间可能重叠 → 同输入产出不同 left/right,双跑快照 diff 必然不一致。
`rootUserID` 为空 → ErrBadParam(必填,防"忘了传"退化回旧行为)。
- 新增 `TestDivideShiftRootIsolation`:两棵树区间完全重叠(1..6 / 1..6),位移 T1 后 **T2 一字不动**(漏 root 限定必红)。

## V-04 / A8 · 赠送池缺活动维度(dump `763216`)
`activity_discount_repo.go:ListUsable(ctx, userID, activityID int64, order)`:`activityID>0` 加 `activity_id = ?`,
`0` = 不限(仅汇总场景);`ORDER BY id ASC` 保留并注释"dump 无显式序,InnoDB 主键序等价"。
- 新增 `TestListUsableActivityIsolation`:A 活动扣减不影响 B 活动的池(漏活动维度必红)。

## V-05 / A4 · 取消报名三列归零(dump `763028`)
`activity_repo.go` 新增 `CancelSigned(ctx, tx, userID, activityID)`:**一条 UPDATE** 同置
`status=0, activity_money='0.00', activity_money_used='0.00'`(保留作用域过滤);`UpdateSignedStatus` 保留给审核推进。
- 新增 `TestCancelSignedZeroesMoney`:三列归零 + affected=1 + 跨机构 0 行 + 参数防御。

## A7 · SumUsable 改等值口径(dump `763182-763183`)
`campus_id = ?` **等值**(去掉 `IN (?,0)` 超集);`institutionID<=0` **不加任何机构/校区过滤**(平台视角);
越权断言按裁定移除(防线移到 Handler 角色白名单)。
- `TestSumUsableThreeParamScoping` 期望重推:`(1,2)=10.00`(不再含机构级池 15)、`(1,0)=125.00`、
  `(0,0)=175.00`(平台)、`(0,2)=60.00`、`(2,2)=50.00`。

## A11 · 退回对象改为流水口径(dump `763057`)
- 新增 `ListDiscountLogsByStudentCourse(coursesStudentID)`:`discount_amount_used < 0`、`ORDER BY id DESC`、
  非 nil 空切片;**不接受方向参数**(从根上消除 ORDER BY 注入面)。
  流水表**自身无作用域列** → 作用域经 `JOIN pool` 取(`pool.institution_id` + `campus_id IN (?,0)`),
  语义与活动域一致(首次实现误用 Scoped 直查流水表 → `no such column: institution_id`,已修正)。
- **删除** `activity_repo.go:ListSignedByStudentCourse`(`signed←pool←log` 三表 join,**dump 无任何对应 SP**)。
- 原 `TestListSignedByStudentCourseCursor` **改为** `TestListDiscountLogsByStudentCourseCursor`(不删除):
  同样造数,改测新原语——负值才入选、固定 id DESC、正值/他报课记录被排除、无匹配返非 nil 空切片。

## A6 · 级联加 type 过滤(dump `763872-763873`,仓储侧)
`ClampSubtreeQuota(ctx, tx, leftVal, rightVal, divideType int8, quota)`:`divideType>0` 加 `type = ?`;
`0` = 不筛(admin 全量场景);非法类型 → ErrBadParam。
- 新增 `TestClampSubtreeQuotaTypeFilter`:父 type=1 降到 4 → 只压同类型子 A,**B(type=2) 保持 8**;
  再以 `divideType=0` 全量压 2 行。

## D11 · 改绑重置 create_time(仓储侧,dump 改绑分支 `create_time=now()`)
`divide_repo.go:UpdateLogOrderNo` 的 `Updates` 增加 `create_time = CURRENT_TIMESTAMP`。

## 其他
- `UpdateNodeParent` 已无业务调用方(D4 删掉改挂分支)→ 注释标记「不属于 dump 流程,仅数据修复用」。

`go build ./... && go vet ./... && go test ./... -short` 全绿(仓储 70 用例)。
Q 19 tuntia sitten
vanhempi
sitoutus
2a8fc3249f

+ 86 - 22
internal/academic/repository/activity_discount_repo.go

@@ -76,11 +76,14 @@ func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) {
 	return r.DB.WithContext(ctx), nil
 }
 
-// ListUsable 列出用户可用赠送金额池(按游标方向取用/退回顺序)。
+// ListUsable 列出用户可用赠送金额池(**活动维度**,dump `yuansiwei_finance.sql:763216`)。
 //
 // 入参:
 //   - ctx 请求上下文;
 //   - userID 学生(canonical_user_id ULID;空 → ErrBadParam);
+//   - activityID 活动 ID(**>0 时只取该活动的赠送池**,对齐 dump `WHERE userid=? AND activityid=?`;
+//     **0 = 不限活动**,仅"汇总所有活动可用额"的场景使用——扣减路径必须传具体 activityID,
+//     否则会把 A 活动的消费扣到 B 活动的池上);
 //   - order 游标方向(**顺序不可改**,源自 dump):
 //     OrderAsc  = user_discount_use 使用顺序(先入先出);
 //     OrderDesc = user_discount_refund 退回顺序(后入先退)。
@@ -92,10 +95,13 @@ func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) {
 //
 // 可用口径(dump user_discount_usable 一致):仅累加 discount_amount > discount_amount_used 的行
 // (负赠送/已用尽的行不参与),故本方法只返回正可用行。
-func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, order string) ([]model.CoursesActivityUserDiscount, error) {
+func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, activityID int64, order string) ([]model.CoursesActivityUserDiscount, error) {
 	if strings.TrimSpace(userID) == "" {
 		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable 需要 user_id"))
 	}
+	if activityID < 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable activity_id 不得为负"))
+	}
 	dir, err := normalizeOrder(order)
 	if err != nil {
 		return nil, err
@@ -106,8 +112,12 @@ func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, or
 	}
 	q := scopedCampusRows(db.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
 	q = q.Where("user_id = ?", userID).
-		Where("discount_amount > discount_amount_used").
-		Order("id " + dir)
+		Where("discount_amount > discount_amount_used")
+	if activityID > 0 {
+		q = q.Where("activity_id = ?", activityID)
+	}
+	// dump 的 usable 无显式 ORDER BY;InnoDB 等值/主键扫描等价于主键升序,故 asc 口径与 dump 一致
+	q = q.Order("id " + dir)
 	var rows []model.CoursesActivityUserDiscount
 	if err := q.Find(&rows).Error; err != nil {
 		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
@@ -120,33 +130,34 @@ func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, or
 // 入参:
 //   - ctx 请求上下文;
 //   - userID 学生(ULID;空 → ErrBadParam);
-//   - institutionID 机构 ID(原 schoolid;<=0 → ErrBadParam);
-//   - campusID 校区 ID(原 campusid;0 = 机构级口径,即不限定校区)。
+//   - institutionID 机构 ID(原 schoolid;**<=0 = 平台视角:不加任何机构过滤**);
+//   - campusID 校区 ID(原 campusid;**>0 = 该校区等值**;0 = 不限定校区)。
 //
 // 出参:
 //   - money.Amount:可用额度合计(单位:分;无可用行为 0);
-//   - ErrBadParam / ErrUnscoped(1004,"报表参数越权")/ ErrDBError。
-//
-// 作用域(两条例外之外的**显式参数口径**,安全红线):
+//   - ErrBadParam / ErrDBError。
 //
-//	dump 的 usable(schoolid, campusid, userid) 由调用方传参(报表/退费需要"指定机构校区"视角),
-//	与请求作用域可能不同;此处**不叠加**请求作用域过滤,但做一次防越权断言:
-//	非超管且 institutionID != Sc.InstitutionID → ErrUnscoped(禁止借参数跨机构读他人赠送金额)。
+// 作用域(audit A7,dump `yuansiwei_finance.sql:763182-763183`):
 //
-// 校区过滤:campusID > 0 → `campus_id IN (campusID, 0)`(0=机构所有自营校区,与 dump 一致);
-// campusID = 0 → 只按机构汇总(机构级报表视角)。
+//	dump 的 usable(schoolid, campusid, userid) 三个条件都是**等值**:`schoolid=? AND campusid=? AND userid=?`,
+//	且 schoolid/campusid 传 0 时不参与过滤(平台/机构级汇总视角)。
+//	- `institutionID > 0` → `institution_id = ?`(等值,**不再用 `IN (?,0)` 超集**——
+//	  超集会把"机构级池(campus=0)"混进"指定校区可用额",与 dump 差一个池);
+//	- `campusID > 0`     → `campus_id = ?`(等值);
+//	- `institutionID<=0` → **不加**机构/校区过滤(平台视角,字段语义见下);
+//	越权防线**不在本方法**:按裁定移到 Handler 角色白名单(平台视角只对 super_admin/academic 开放)。
 //
 // 实现说明(金额精度):**不用 SQL SUM()**。sqlite 把 decimal 存 NUMERIC 时 SUM 返回 REAL,
 // 会把分位带进浮点;MySQL 返回 DECIMAL(字符串) 虽精确,但两库行为不一致。
 // 因此这里只让 DB 做过滤,**逐行在 Go 侧用 money.Amount(int64 分)累加**(用户池行数极小,
 // 性能可忽略),换取"零浮点"的确定性。
 func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, institutionID, campusID int64) (money.Amount, error) {
-	if strings.TrimSpace(userID) == "" || institutionID <= 0 {
-		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 需要 user_id/institution_id"))
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: SumUsable 需要 user_id"))
 	}
-	if r != nil && r.Sc != nil && !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
-		return 0, infraerrors.WithCause(infraerrors.ErrUnscoped,
-			errString("repository: SumUsable 机构参数越权(作用域机构与入参不一致)"))
+	if institutionID < 0 || campusID < 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: SumUsable 机构/校区参数不得为负(0=不筛)"))
 	}
 	db, err := r.db(ctx)
 	if err != nil {
@@ -154,11 +165,13 @@ func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, ins
 	}
 	q := db.Model(&model.CoursesActivityUserDiscount{}).
 		Where("user_id = ?", userID).
-		Where("institution_id = ?", institutionID).
 		Where("discount_amount > discount_amount_used")
+	// dump 763182-763183:机构与校区都是**等值**条件;传 0 视为不筛(平台/机构级汇总视角)
+	if institutionID > 0 {
+		q = q.Where("institution_id = ?", institutionID)
+	}
 	if campusID > 0 {
-		// campus_id=0 = 机构所有自营校区:与指定校区同属可见范围(dump IN (x,0))
-		q = q.Where("campus_id IN ?", []int64{campusID, 0})
+		q = q.Where("campus_id = ?", campusID)
 	}
 	var rows []model.CoursesActivityUserDiscount
 	if err := q.Find(&rows).Error; err != nil {
@@ -179,6 +192,57 @@ func (r *ActivityDiscountRepo) SumUsable(ctx context.Context, userID string, ins
 	return total, nil
 }
 
+// ListDiscountLogsByStudentCourse 按报课记录取**赠送金额退回对象**(audit A11,dump `:763057`)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - coursesStudentID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam)。
+//
+// 出参:
+//   - []model.CoursesActivityUserDiscountLog:该报课记录产生的**负值流水**(消费行,`discount_amount_used < 0`),
+//     按 id **降序**(最近使用优先,dump 退回路径固定顺序);空结果返回非 nil 切片;
+//   - ErrBadParam / ErrDBError。
+//
+// SQL(dump 763057 原文口径 + 多租户作用域 JOIN):
+//
+//	SELECT dlog.* FROM t_courses_activity_user_discount_log dlog
+//	  JOIN t_courses_activity_user_discount pool ON pool.id = dlog.activity_user_discount_id
+//	 WHERE dlog.courses_student_id = ? AND dlog.discount_amount_used < 0
+//	   AND pool.institution_id = ? AND pool.campus_id IN (?, 0)   -- 作用域(普通角色)
+//	 ORDER BY dlog.id DESC
+//
+// 为什么只取负值行:退回时要按"当初扣了多少"逐笔等额回冲,正值行是退回动作本身(不能拿来回冲);
+// 排序固定 DESC(dump 无参数化方向)——**本方法不接受方向参数**,从根上消除 ORDER BY 注入面。
+//
+// 与旧 `ListSignedByStudentCourse` 的区别(audit A11):那条路径(signed ← pool ← log 三表 join)
+// **在 dump 中没有任何对应 SP 依据**,已删除;退回对象一律以本方法为准(流水是唯一事实来源)。
+func (r *ActivityDiscountRepo) ListDiscountLogsByStudentCourse(ctx context.Context, coursesStudentID int64) ([]model.CoursesActivityUserDiscountLog, error) {
+	if coursesStudentID <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: ListDiscountLogsByStudentCourse 需要 courses_student_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	rows := make([]model.CoursesActivityUserDiscountLog, 0, 8)
+	// 流水表**自身无作用域列**(DDL:id/discount/pool_id/courses_student_id/…,无 institution_id),
+	// 故作用域从"流水 → 池"JOIN 取得(池有 institution_id + campus_id),语义与活动域一致(campus 超集)。
+	q := db.Table("t_courses_activity_user_discount_log AS dlog").
+		Select("dlog.*").
+		Joins("JOIN t_courses_activity_user_discount AS pool ON pool.id = dlog.activity_user_discount_id").
+		Where("dlog.courses_student_id = ?", coursesStudentID).
+		Where("dlog.discount_amount_used < 0")
+	q = scopedCampusRows(q, r.Sc, "pool")
+	if err := q.Order("dlog.id DESC").Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	if rows == nil {
+		rows = make([]model.CoursesActivityUserDiscountLog, 0)
+	}
+	return rows, nil
+}
+
 // IncUsed 累加优惠账户的「已使用赠送金额」(单账户原子增减)。
 //
 // 入参:

+ 98 - 25
internal/academic/repository/activity_discount_repo_test.go

@@ -49,7 +49,7 @@ func amountOf(t *testing.T, db *gorm.DB, id int64) money.Amount {
 	return amt
 }
 
-// TestListUsableFilterAndOrder 可用口径(discount > used)与游标方向。
+// TestListUsableFilterAndOrder 可用口径(discount > used)+ 活动维度 + 游标方向。
 func TestListUsableFilterAndOrder(t *testing.T) {
 	db := newActivityRepoDB(t)
 	user := "01HUSER000000000000000011"
@@ -61,60 +61,133 @@ func TestListUsableFilterAndOrder(t *testing.T) {
 	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
 	ctx := context.Background()
 
-	asc, err := repo.ListUsable(ctx, user, OrderAsc)
+	asc, err := repo.ListUsable(ctx, user, 0, OrderAsc)
 	if err != nil {
 		t.Fatalf("ListUsable(asc) 失败: %v", err)
 	}
 	if len(asc) != 2 || asc[0].ID != 1 || asc[1].ID != 4 {
 		t.Fatalf("可用行应为 {1,4}(升序),实得 %+v", asc)
 	}
-	desc, err := repo.ListUsable(ctx, user, OrderDesc)
+	desc, err := repo.ListUsable(ctx, user, 0, OrderDesc)
 	if err != nil {
 		t.Fatalf("ListUsable(desc) 失败: %v", err)
 	}
 	if len(desc) != 2 || desc[0].ID != 4 || desc[1].ID != 1 {
 		t.Fatalf("可用行应为 {4,1}(降序),实得 %+v", desc)
 	}
-	if _, err := repo.ListUsable(ctx, "", OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ListUsable(ctx, "", 0, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
 	}
-	if _, err := repo.ListUsable(ctx, user, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ListUsable(ctx, user, 0, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
 	}
+	if _, err := repo.ListUsable(ctx, user, -1, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("activity_id<0 应 ErrBadParam,实得 %v", err)
+	}
+	// activityID>0 只取该活动(seedPool 里全是活动 1)
+	onlyOne, err := repo.ListUsable(ctx, user, 1, OrderAsc)
+	if err != nil {
+		t.Fatalf("ListUsable(activity=1) 失败: %v", err)
+	}
+	if len(onlyOne) != 2 {
+		t.Fatalf("活动 1 可用行应为 2,实得 %d", len(onlyOne))
+	}
+	none, err := repo.ListUsable(ctx, user, 2, OrderAsc)
+	if err != nil {
+		t.Fatalf("ListUsable(activity=2) 失败: %v", err)
+	}
+	if len(none) != 0 {
+		t.Fatalf("活动 2 应无可用行(活动维度隔离),实得 %d", len(none))
+	}
+}
+
+// TestListUsableActivityIsolation A 活动的扣减不影响 B 活动的池(audit A8,dump 763216)。
+//
+// 若 ListUsable 漏了 `activity_id = ?`(或不传 activityID),扣 A 活动时会按 id 升序先吃光 B 活动的池
+// → 用户"用 A 的赠送额度却扣了 B 的池",且退费时退回对象错位。本用例是该缺陷的回归门禁。
+func TestListUsableActivityIsolation(t *testing.T) {
+	db := newActivityRepoDB(t)
+	user := "01HUSER000000000000000021"
+	// 活动 1 的池 id=1(后建),活动 2 的池 id=2(id 更大)——故意让 id 序与活动序相反
+	if err := db.Create(&model.CoursesActivityUserDiscount{
+		ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user,
+		Income: "0.00", DiscountAmount: "10.00", DiscountAmountUsed: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 池 1 失败: %v", err)
+	}
+	if err := db.Create(&model.CoursesActivityUserDiscount{
+		ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user,
+		Income: "0.00", DiscountAmount: "20.00", DiscountAmountUsed: "0.00",
+	}).Error; err != nil {
+		t.Fatalf("seed 池 2 失败: %v", err)
+	}
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	rows, err := repo.ListUsable(ctx, user, 2, OrderAsc)
+	if err != nil {
+		t.Fatalf("ListUsable(activity=2) 失败: %v", err)
+	}
+	if len(rows) != 1 || rows[0].ID != 2 {
+		t.Fatalf("活动 2 只应命中池 2,实得 %+v", rows)
+	}
+	if _, err := repo.IncUsed(ctx, db, 2, money.FromCents(2000)); err != nil {
+		t.Fatalf("扣减活动 2 的池失败: %v", err)
+	}
+	if got := amountOf(t, db, 1); got != 0 {
+		t.Fatalf("活动 1 的池不得被活动 2 的扣减影响,实得 %s", got.String())
+	}
 }
 
-// TestSumUsableThreeParamScoping dump usable 3 参口径:机构 + 校区(campus=0 含全机构池)。
+// TestSumUsableThreeParamScoping dump usable 3 参**等值**口径(audit A7,dump 763182-763183)。
+//
+// dump:`WHERE schoolid=? AND campusid=? AND userid=?`,两个作用域参数传 0 视为**不筛**(平台/机构级汇总)。
+// 关键差异(相对旧实现的 `IN (campus,0)` 超集):机构级池(campus=0)**不并入**指定校区可用额。
+//
+// 池清单(用户 user,可用 = discount - used):
+//
+//	pool1 inst1 campus2  可用 10    pool2 inst1 campus0  可用 15(机构级池)
+//	pool3 inst1 campus3  可用 100   pool4 inst2 campus2  可用 50
+//	pool5 inst1 campus2  可用 0(用尽)  pool6 他人 —— 用户过滤永远排除
 func TestSumUsableThreeParamScoping(t *testing.T) {
 	db := newActivityRepoDB(t)
 	user := "01HUSER000000000000000012"
-	seedPool(t, db, 1, 1, 2, user, "10.00", "0.00")                      // 本校区:可用 10
-	seedPool(t, db, 2, 1, 0, user, "20.00", "5.00")                      // 机构所有自营校区:可用 15
-	seedPool(t, db, 3, 1, 3, user, "100.00", "0.00")                     // 他校区:本校区视角不计
-	seedPool(t, db, 4, 2, 2, user, "50.00", "0.00")                      // 他机构:一律不计
-	seedPool(t, db, 5, 1, 2, user, "5.00", "5.00")                       // 用尽:不计
-	seedPool(t, db, 6, 1, 2, "01HOTHER00000000000001", "999.00", "0.00") // 他人:不计
+	seedPool(t, db, 1, 1, 2, user, "10.00", "0.00")
+	seedPool(t, db, 2, 1, 0, user, "20.00", "5.00")
+	seedPool(t, db, 3, 1, 3, user, "100.00", "0.00")
+	seedPool(t, db, 4, 2, 2, user, "50.00", "0.00")
+	seedPool(t, db, 5, 1, 2, user, "5.00", "5.00")
+	seedPool(t, db, 6, 1, 2, "01HOTHER00000000000001", "999.00", "0.00")
 
 	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
 	ctx := context.Background()
 
-	if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 2500 {
-		t.Fatalf("SumUsable(1,2) 应为 25.00(10+15),实得 %s err=%v", got.String(), err)
+	// ① 本校区分池:inst=1 AND campus=2 → pool1(10) + pool5(0) = 10.00(**不含**机构级池 15)
+	if got, err := repo.SumUsable(ctx, user, 1, 2); err != nil || got.Cents() != 1000 {
+		t.Fatalf("SumUsable(1,2) 应为 10.00(等值口径,不含 campus=0 池),实得 %s err=%v", got.String(), err)
 	}
+	// ② 不限定校区:inst=1 → 10 + 15 + 100 = 125.00
 	if got, err := repo.SumUsable(ctx, user, 1, 0); err != nil || got.Cents() != 12500 {
-		t.Fatalf("SumUsable(1,0) 应为 125.00(机构级口径含他校区 100),实得 %s err=%v", got.String(), err)
+		t.Fatalf("SumUsable(1,0) 应为 125.00,实得 %s err=%v", got.String(), err)
+	}
+	// ③ 平台视角:inst=0 且 campus=0 → 不过滤机构/校区 → 10+15+100+50 = 175.00
+	if got, err := repo.SumUsable(ctx, user, 0, 0); err != nil || got.Cents() != 17500 {
+		t.Fatalf("SumUsable(0,0) 平台视角应为 175.00,实得 %s err=%v", got.String(), err)
 	}
-	// 越权:非超管传入其它机构 → 1004
-	if _, err := repo.SumUsable(ctx, user, 2, 2); !errors.Is(err, infraerrors.ErrUnscoped) {
-		t.Fatalf("跨机构参数应 ErrUnscoped,实得 %v", err)
+	// ④ 只按校区分池(跨机构同名校区):campus=2 → pool1(10) + pool4(50) = 60.00
+	if got, err := repo.SumUsable(ctx, user, 0, 2); err != nil || got.Cents() != 6000 {
+		t.Fatalf("SumUsable(0,2) 应为 60.00,实得 %s err=%v", got.String(), err)
 	}
-	// 超管可跨机构(报表视角)
-	admin := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2, SuperAdmin: true})
-	if got, err := admin.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 {
-		t.Fatalf("超管 SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err)
+	// ⑤ 他机构等值:inst=2 → 50.00(越权防线已按裁定移到 Handler 角色白名单,仓储不再拦)
+	if got, err := repo.SumUsable(ctx, user, 2, 2); err != nil || got.Cents() != 5000 {
+		t.Fatalf("SumUsable(2,2) 应为 50.00,实得 %s err=%v", got.String(), err)
 	}
-	// 参数防御
-	if _, err := repo.SumUsable(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) {
-		t.Fatalf("institutionID<=0 应 ErrBadParam,实得 %v", err)
+	// 参数防御:负数非法(0 才是"不筛")
+	if _, err := repo.SumUsable(ctx, user, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("institutionID<0 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.SumUsable(ctx, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
 	}
 	// 无可用行 → 0(不是错误)
 	if got, err := repo.SumUsable(ctx, "01HNOBODY00000000000001", 1, 2); err != nil || !got.IsZero() {

+ 31 - 33
internal/academic/repository/activity_repo.go

@@ -386,49 +386,47 @@ func (r *ActivityRepo) IncreaseNumberSign(ctx context.Context, tx *gorm.DB, acti
 	return res.RowsAffected, nil
 }
 
-// ListSignedByStudentCourse 按报课记录回溯报名行(退费/撤销时定位需要回滚的 signed)。
-//
-// 发现路径(dump 的退费回滚链):报课记录 id → 赠送金额使用流水 log.courses_student_id
-// → 赠送金额池 pool.id → (pool.activity_id, pool.user_id) → signed(同活动同用户)。
+// CancelSigned 取消报名并**归零赠送/活动金额**(dump `yuansiwei_finance.sql:763028`)。
 //
 // 入参:
 //   - ctx 请求上下文;
-//   - stuCourseID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam);
-//   - order 游标方向(OrderAsc/OrderDesc,**顺序写死不可改**):
-//     OrderAsc = 按使用先后(先用的活动先处理,dump 消费路径);
-//     OrderDesc = 按最近使用优先(dump 退回路径)。
+//   - tx 事务句柄(**必传**:与报名人数增减、赠送池回滚同事务);
+//   - userID 报名用户(ULID;空 → ErrBadParam);activityID 活动 ID(<=0 → ErrBadParam)。
 //
 // 出参:
-//   - []model.CoursesActivitySigned:去重后的报名行(同一活动多笔流水只返回一行);
+//   - int64:受影响行数(0 = 该 (user, activity) 不存在或不在作用域内 → Service 返回 3001);
 //   - ErrBadParam / ErrDBError。
 //
-// 事务:只读(ctx 内注入 tx 时自动复用)。
-func (r *ActivityRepo) ListSignedByStudentCourse(ctx context.Context, stuCourseID int64, order string) ([]model.CoursesActivitySigned, error) {
-	if stuCourseID <= 0 {
-		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListSignedByStudentCourse 需要 courses_student_id"))
-	}
-	dir, err := normalizeOrder(order)
-	if err != nil {
-		return nil, err
+// SQL(dump 原文:一条 UPDATE 同置三列,**不可拆成多次更新**):
+//
+//	UPDATE t_courses_activity_signed
+//	   SET status = 0, activity_money = 0, activity_money_used = 0
+//	 WHERE user_id = ? AND activity_id = ? [AND institution_id = ? AND campus_id IN (?,0)]
+//
+// 红线:
+//   - 三列**必须同一条语句归零**——分步更新会在中间态出现"已取消但金额仍在",被对账/双跑读到;
+//   - 审核推进(status 单向推进、不动金额)仍走 `UpdateSignedStatus`,两者用途不可混;
+//   - 归零是"取消"语义的一部分(dump 明确 SET 0),金额不会回补到别处,故调用方必须已做业务校验
+//     (如老带新已发奖励 → 3108 不可撤销,判定在 Service)。
+func (r *ActivityRepo) CancelSigned(ctx context.Context, tx *gorm.DB, userID string, activityID int64) (int64, error) {
+	if err := requireTx(tx, "CancelSigned"); err != nil {
+		return 0, err
 	}
-	db, err := r.db(ctx)
-	if err != nil {
-		return nil, err
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: CancelSigned 需要 user_id/activity_id"))
 	}
-	q := db.Table("t_courses_activity_signed AS signed").
-		Distinct("signed.*").
-		Joins("JOIN t_courses_activity_user_discount AS pool ON pool.activity_id = signed.activity_id AND pool.user_id = signed.user_id").
-		Joins("JOIN t_courses_activity_user_discount_log AS dlog ON dlog.activity_user_discount_id = pool.id").
-		Where("dlog.courses_student_id = ?", stuCourseID).
-		Where("signed.status <> ?", model.ActivitySignedCancelled)
-	q = scopedCampusRows(q, r.Sc, "signed")
-	// 游标方向来自白名单常量(dump 固定顺序),禁止调用方拼 SQL
-	q = q.Order("signed.id " + dir)
-	var rows []model.CoursesActivitySigned
-	if err := q.Find(&rows).Error; err != nil {
-		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	q := scopedCampusRows(tx.WithContext(ctxOrBackground(ctx)).Model(&model.CoursesActivitySigned{}), r.Sc, "")
+	res := q.Where("user_id = ? AND activity_id = ?", userID, activityID).
+		UpdateColumns(map[string]any{
+			"status":              model.ActivitySignedCancelled,
+			"activity_money":      "0.00",
+			"activity_money_used": "0.00",
+		})
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
 	}
-	return rows, nil
+	return res.RowsAffected, nil
 }
 
 // wrapQueryErr 把查询错误归一为 academic 错误契约(不存在 → ErrNotFound,其余 → ErrDBError)。

+ 113 - 17
internal/academic/repository/activity_repo_test.go

@@ -487,8 +487,13 @@ func TestUpdateSignedStatusRows(t *testing.T) {
 	}
 }
 
-// TestListSignedByStudentCourseCursor 游标方向(ASC/DESC,dump 顺序)与白名单校验。
-func TestListSignedByStudentCourseCursor(t *testing.T) {
+// TestListDiscountLogsByStudentCourseCursor 赠送退回对象(audit A11,dump 763057):
+// 只取负值流水、固定 id DESC、不接收方向参数(从根上消除 ORDER BY 注入面)。
+//
+// 替换说明:原 `TestListSignedByStudentCourseCursor` 测的 `signed←pool←log` 三表 join 路径
+// **在 dump 中没有任何对应 SP 依据**(audit A11 已删除该方法)。为避免"测错了 → 完全没测",
+// 本用例保留同样的造数,改为测新原语:退回对象一律以 `discount_log` 负值流水为准。
+func TestListDiscountLogsByStudentCourseCursor(t *testing.T) {
 	db := newActivityRepoDB(t)
 	ctx := context.Background()
 	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
@@ -497,7 +502,6 @@ func TestListSignedByStudentCourseCursor(t *testing.T) {
 	seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
 	seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedDeal)
 
-	// 两个池 + 两条流水(同一报课记录 900)
 	pools := []model.CoursesActivityUserDiscount{
 		{ID: 1, ActivityID: 1, InstitutionID: 1, CampusID: 2, UserID: user, Income: "100.00", DiscountAmount: "10.00", CreateTime: time.Now()},
 		{ID: 2, ActivityID: 2, InstitutionID: 1, CampusID: 2, UserID: user, Income: "200.00", DiscountAmount: "20.00", CreateTime: time.Now()},
@@ -508,9 +512,12 @@ func TestListSignedByStudentCourseCursor(t *testing.T) {
 		}
 	}
 	stuCourse := int64(900)
+	otherCourse := int64(901)
 	logs := []model.CoursesActivityUserDiscountLog{
 		{ID: 1, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-10.00", CreateTime: time.Now()},
 		{ID: 2, ActivityUserDiscountID: 2, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-20.00", CreateTime: time.Now()},
+		{ID: 3, ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "5.00", CreateTime: time.Now()},    // 正值=退回动作,不得作为退回对象
+		{ID: 4, ActivityUserDiscountID: 1, CoursesStudentID: &otherCourse, DiscountAmountUsed: "-7.00", CreateTime: time.Now()}, // 别的报课记录
 	}
 	for i := range logs {
 		if err := db.Create(&logs[i]).Error; err != nil {
@@ -518,31 +525,120 @@ func TestListSignedByStudentCourseCursor(t *testing.T) {
 		}
 	}
 
-	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
 
-	asc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderAsc)
+	rows, err := repo.ListDiscountLogsByStudentCourse(ctx, stuCourse)
 	if err != nil {
-		t.Fatalf("ListSignedByStudentCourse(asc) 失败: %v", err)
+		t.Fatalf("ListDiscountLogsByStudentCourse 失败: %v", err)
 	}
-	if len(asc) != 2 || asc[0].ID >= asc[1].ID {
-		t.Fatalf("ASC 应返回按 id 升序的 2 行,实得 %v", asc)
+	// 只含该报课记录的**负值**行,且固定 id DESC(dump 退回顺序:最近使用优先)
+	if len(rows) != 2 {
+		t.Fatalf("应只返回 2 条负值流水,实得 %d:%+v", len(rows), rows)
 	}
-	desc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderDesc)
-	if err != nil {
-		t.Fatalf("ListSignedByStudentCourse(desc) 失败: %v", err)
+	if rows[0].ID != 2 || rows[1].ID != 1 {
+		t.Fatalf("必须按 id DESC 固定排序,实得 %d,%d", rows[0].ID, rows[1].ID)
 	}
-	if len(desc) != 2 || desc[0].ID <= desc[1].ID {
-		t.Fatalf("DESC 应返回按 id 降序的 2 行,实得 %v", desc)
+	for _, r := range rows {
+		if amountOfLog(t, r.DiscountAmountUsed) >= 0 {
+			t.Fatalf("退回对象必须来自负值流水,实得 %s", r.DiscountAmountUsed)
+		}
+	}
+
+	// 无匹配 → 非 nil 空切片
+	empty, err := repo.ListDiscountLogsByStudentCourse(ctx, 999)
+	if err != nil {
+		t.Fatalf("无匹配查询失败: %v", err)
 	}
-	// 非法方向必须拒绝(防 ORDER BY 注入)
-	if _, err := repo.ListSignedByStudentCourse(ctx, stuCourse, "asc; DROP TABLE x"); !errors.Is(err, infraerrors.ErrBadParam) {
-		t.Fatalf("非法排序方向应 ErrBadParam,实得 %v", err)
+	if empty == nil || len(empty) != 0 {
+		t.Fatalf("无匹配应返回非 nil 空切片,实得 %v", empty)
 	}
-	if _, err := repo.ListSignedByStudentCourse(ctx, 0, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
+	// 参数防御
+	if _, err := repo.ListDiscountLogsByStudentCourse(ctx, 0); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("stuCourseID<=0 应 ErrBadParam,实得 %v", err)
 	}
 }
 
+// amountOfLog 把流水金额字符串转分(测试断言用;解析失败判红)。
+func amountOfLog(t *testing.T, raw string) money.Amount {
+	t.Helper()
+	amount, err := money.Parse(raw)
+	if err != nil {
+		t.Fatalf("解析流水金额 %q 失败: %v", raw, err)
+	}
+	return amount
+}
+
+// TestCancelSignedZeroesMoney 取消报名三列同置 0(audit V-05 / dump 763028)。
+//
+// dump:`SET status=0, activity_money=0, activity_money_used=0`(一条 UPDATE)。
+// 红线断言:**金额必须归零**(旧实现只改 status,残留金额会被对账/双跑当成"已使用的赠送额")。
+func TestCancelSignedZeroesMoney(t *testing.T) {
+	db := newActivityRepoDB(t)
+	ctx := context.Background()
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
+	user := "01HUSER000000000000000022"
+	row := &model.CoursesActivitySigned{
+		ID: 1, ActivityID: 1, UserID: user, InstitutionID: 1, CampusID: 2,
+		Status: model.ActivitySignedSigned, ActivityMoney: "10.00", ActivityMoneyUsed: "3.00",
+		CreateTime: time.Now(),
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed signed 失败: %v", err)
+	}
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.CancelSigned(ctx, tx, user, 1)
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("应影响 1 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("CancelSigned 失败: %v", err)
+	}
+
+	var got model.CoursesActivitySigned
+	if err := db.Where("id = ?", 1).First(&got).Error; err != nil {
+		t.Fatalf("回读报名行失败: %v", err)
+	}
+	if got.Status != model.ActivitySignedCancelled {
+		t.Fatalf("status 应归 0,实得 %d", got.Status)
+	}
+	if amountOfLog(t, got.ActivityMoney) != 0 || amountOfLog(t, got.ActivityMoneyUsed) != 0 {
+		t.Fatalf("activity_money/activity_money_used 必须归零,实得 %s/%s",
+			got.ActivityMoney, got.ActivityMoneyUsed)
+	}
+
+	// 越权/不存在:跨机构行数 0(不报错,由 Service 判 3001)
+	other := NewActivityRepo(db, &appscope.Scope{InstitutionID: 2, CampusID: 5})
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := other.CancelSigned(ctx, tx, user, 1)
+		if e != nil {
+			return e
+		}
+		if n != 0 {
+			t.Fatalf("跨机构取消应 0 行(作用域拦截),实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("跨机构用例失败: %v", err)
+	}
+	// 参数防御
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.CancelSigned(ctx, tx, "", 1)
+		if !errors.Is(e, infraerrors.ErrBadParam) {
+			t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", e)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("参数防御用例失败: %v", err)
+	}
+}
+
 // TestNormalizeOrder 方向归一(大小写/空白容忍,非法拒绝)。
 func TestNormalizeOrder(t *testing.T) {
 	for _, in := range []string{"", "asc", "ASC", " Asc "} {

+ 51 - 19
internal/academic/repository/divide_repo.go

@@ -112,41 +112,55 @@ func (r *DivideRepo) GetSubtree(ctx context.Context, leftVal, rightVal int32, di
 	return rows, nil
 }
 
-// ShiftRight 右值扩张:`right_val = right_val + delta WHERE right_val >= threshold`。
+// ShiftRight 右值扩张:`right_val = right_val + delta WHERE root_user_id = ? AND right_val >= threshold`。
 //
 // 入参:
 //   - ctx 请求上下文;
 //   - tx 事务句柄(**必传**);
-//   - threshold int32:扩张阈值(新节点插入点 = 父节点右值;**父与全部祖先**的 right 均 >= 它,一次覆盖);
+//   - rootUserID **所属树根**(ULID;**必填**,见下方红线);
+//   - threshold int32:扩张阈值(新节点插入点 = 父节点右值;父与全部祖先的 right 均 >= 它,一次覆盖);
 //   - delta int32:增量(插入子节点固定 +2;撤销固定 -2)。
 //
 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
 //
-// 红线:与 ShiftLeft **必须同一事务、同一 threshold**(两步各自只动一列,合成标准嵌套集插入算法;
-// 拆事务会让树在中间态不可用)。
-func (r *DivideRepo) ShiftRight(ctx context.Context, tx *gorm.DB, threshold int32, delta int32) (int64, error) {
-	return r.shift(ctx, tx, "right_val", threshold, delta)
+// 红线(audit V-02,dump `yuansiwei_finance.sql:763707-763708`):
+//
+//	dump 原文的位移语句带 `WHERE root_userid = rootUserid AND left_val >= parentRight`。
+//	**漏掉 root 限定的后果**:多棵树共用一张表(各自 root 的左右值区间可能重叠/相邻),
+//	同输入会产出不同的 left/right → 双跑快照 diff 必然不一致,且会污染别的树的区间。
+//	(同一 dump 的另一处 left 位移同理,行号 763708。)
+//
+// 与 ShiftLeft **必须同一事务、同一 rootUserID 与 threshold**(两步各自只动一列,
+// 合成标准嵌套集插入算法;拆事务会让树在中间态不可用)。
+func (r *DivideRepo) ShiftRight(ctx context.Context, tx *gorm.DB, rootUserID string, threshold int32, delta int32) (int64, error) {
+	return r.shift(ctx, tx, "right_val", rootUserID, threshold, delta)
 }
 
-// ShiftLeft 左值扩张:`left_val = left_val + delta WHERE left_val >= threshold`。
+// ShiftLeft 左值扩张:`left_val = left_val + delta WHERE root_user_id = ? AND left_val >= threshold`。
 //
 // 入参:语义同 ShiftRight(只作用于 left_val 列——即"位于插入点右侧的兄弟及其后代")。
 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
-func (r *DivideRepo) ShiftLeft(ctx context.Context, tx *gorm.DB, threshold int32, delta int32) (int64, error) {
-	return r.shift(ctx, tx, "left_val", threshold, delta)
+func (r *DivideRepo) ShiftLeft(ctx context.Context, tx *gorm.DB, rootUserID string, threshold int32, delta int32) (int64, error) {
+	return r.shift(ctx, tx, "left_val", rootUserID, threshold, delta)
 }
 
 // shift 左右值扩张的公共实现(列名由内部常量传入,**不接受外部字符串**,防注入)。
 //
-// 入参:col 列名("left_val"/"right_val");threshold/delta 语义见 ShiftRight。
+// 入参:col 列名("left_val"/"right_val");rootUserID 树根(必填);
+// threshold/delta 语义见 ShiftRight。
 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
-func (r *DivideRepo) shift(ctx context.Context, tx *gorm.DB, col string, threshold int32, delta int32) (int64, error) {
+func (r *DivideRepo) shift(ctx context.Context, tx *gorm.DB, col, rootUserID string, threshold int32, delta int32) (int64, error) {
 	if err := requireTx(tx, "shift("+col+")"); err != nil {
 		return 0, err
 	}
 	if col != "left_val" && col != "right_val" {
 		return 0, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: shift 列名非法"))
 	}
+	if strings.TrimSpace(rootUserID) == "" {
+		// root 限定不可省:漏了会串改其它树的区间(audit V-02)
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: shift 需要 root_user_id(多树隔离,dump 763707)"))
+	}
 	if threshold < 0 {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: shift 阈值不能为负"))
 	}
@@ -154,6 +168,7 @@ func (r *DivideRepo) shift(ctx context.Context, tx *gorm.DB, col string, thresho
 		return 0, nil
 	}
 	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+		Where("root_user_id = ?", strings.TrimSpace(rootUserID)).
 		Where(col+" >= ?", threshold).
 		UpdateColumn(col, gorm.Expr(col+" + ?", delta))
 	if res.Error != nil {
@@ -304,7 +319,11 @@ func (r *DivideRepo) ListLogsByStudentOrder(ctx context.Context, studentID, orde
 	return rows, nil
 }
 
-// UpdateNodeParent 改挂节点父链(`subid 已存在`分支:只改父/根/层级,**不重排左右值**)。
+// UpdateNodeParent 改挂节点父链(只改父/根/层级,**不重排左右值**)。
+//
+// ⚠ **当前无业务调用方**(audit D4:dump `:763695` 的 "subid 已存在" 分支是**完全 no-op**,
+// 分支体内没有任何写动作)。本方法因此**不属于 dump 流程**,仅保留给后台数据修复;
+// 若将来要用于业务链路,必须先评审——它不重排左右值,改挂自带子树的节点会产生区间交叠。
 //
 // 入参:
 //   - ctx 请求上下文;tx 事务句柄(**必传**);
@@ -367,34 +386,46 @@ func (r *DivideRepo) UpdateNodeQuota(ctx context.Context, tx *gorm.DB, userID st
 	return res.RowsAffected, nil
 }
 
-// ClampSubtreeQuota 额度级联下降:把子树内**超过**上限的节点统一压到上限。
+// ClampSubtreeQuota 额度级联下降:把子树内**同分成类型**且**超过**上限的节点统一压到上限。
 //
 // 入参:
 //   - ctx;tx(**必传**);
 //   - leftVal/rightVal int32 父节点区间(rightVal <= leftVal → ErrBadParam);
+//   - divideType 分成类型过滤(model.DivideTypeOneOff/PerLesson;**0 = 不筛**,仅 admin 全量场景);
 //   - quota 上限(单位:分;负 → ErrBadParam)。
 //
 // 出参:int64 受影响行数;ErrBadParam / ErrDBError。
 //
-// SQL:`UPDATE ... SET quota = ? WHERE left_val > ? AND right_val < ? AND quota > ?`
+// SQL(audit A6 / dump `yuansiwei_finance.sql:763872-763873` 原文条件,**缺一不可**):
+//
+//	UPDATE t_user_divide_into SET quota=updateQuota
+//	 WHERE left_val>uLeft AND right_val<uRight AND quota>updateQuota AND type=uType;
+//
 // 语义("子不得超父"):只降不升——低于上限的节点保持原额度(分别配置的阶梯额度不被抹平)。
-func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, leftVal, rightVal int32, quota money.Amount) (int64, error) {
+// 红线:`type` 过滤不可省 —— 否则一次付清(1)与按课次(2)两条线的额度会被跨类型抹平(漏实现 = 缺陷)。
+func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, leftVal, rightVal int32, divideType int8, quota money.Amount) (int64, error) {
 	if err := requireTx(tx, "ClampSubtreeQuota"); err != nil {
 		return 0, err
 	}
 	if leftVal < 0 || rightVal <= leftVal {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ClampSubtreeQuota 左右值区间非法"))
 	}
+	if divideType != 0 && divideType != model.DivideTypeOneOff && divideType != model.DivideTypePerLesson {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ClampSubtreeQuota 分成类型非法"))
+	}
 	if err := quota.Validate(); err != nil {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
 	}
 	if quota.IsNegative() {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 分成额度不能为负"))
 	}
-	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+	q := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
 		Where("left_val > ? AND right_val < ?", leftVal, rightVal).
-		Where("quota > ?", quota.String()).
-		UpdateColumn("quota", quota.String())
+		Where("quota > ?", quota.String())
+	if divideType != 0 {
+		q = q.Where("type = ?", divideType)
+	}
+	res := q.UpdateColumn("quota", quota.String())
 	if res.Error != nil {
 		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
 	}
@@ -443,7 +474,8 @@ func (r *DivideRepo) UpdateLogOrderNo(ctx context.Context, tx *gorm.DB, id int64
 	if id <= 0 || strings.TrimSpace(orderNo) == "" {
 		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateLogOrderNo 需要 id/order_no"))
 	}
-	fields := map[string]any{"order_no": orderNo}
+	// create_time 重置为 DB 当前时间(dump 改绑分支:`create_time=now()`,让改绑后的明细排到最新)
+	fields := map[string]any{"order_no": orderNo, "create_time": gorm.Expr("CURRENT_TIMESTAMP")}
 	if strings.TrimSpace(remark) != "" {
 		fields["remark"] = remark
 	}

+ 112 - 12
internal/academic/repository/divide_repo_test.go

@@ -120,14 +120,14 @@ func TestDivideShiftLeftRight(t *testing.T) {
 
 	// 在 root 右值前插入:先 right>=6 +2,再 left>=6 +2(无节点满足)
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		n, e := repo.ShiftRight(ctx, tx, 6, 2)
+		n, e := repo.ShiftRight(ctx, tx, root, 6, 2)
 		if e != nil {
 			return e
 		}
 		if n != 1 {
 			t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n)
 		}
-		_, e = repo.ShiftLeft(ctx, tx, 6, 2)
+		_, e = repo.ShiftLeft(ctx, tx, root, 6, 2)
 		return e
 	}); err != nil {
 		t.Fatalf("位移失败: %v", err)
@@ -137,10 +137,10 @@ func TestDivideShiftLeftRight(t *testing.T) {
 	}
 	// 在 a 右值前插入:right>=3 全体 +2(root、a、b),left>=3 全体 +2(b)
 	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
-		if _, e := repo.ShiftRight(ctx, tx, 3, 2); e != nil {
+		if _, e := repo.ShiftRight(ctx, tx, root, 3, 2); e != nil {
 			return e
 		}
-		_, e := repo.ShiftLeft(ctx, tx, 3, 2)
+		_, e := repo.ShiftLeft(ctx, tx, root, 3, 2)
 		return e
 	}); err != nil {
 		t.Fatalf("位移失败: %v", err)
@@ -155,18 +155,62 @@ func TestDivideShiftLeftRight(t *testing.T) {
 		t.Fatalf("b 应为 (6,7),实得 (%d,%d)", got.LeftVal, got.RightVal)
 	}
 
-	// 防御:tx nil / 阈值非法 / delta=0
-	if _, err := repo.ShiftRight(ctx, nil, 1, 2); !errors.Is(err, infraerrors.ErrInternal) {
+	// 防御:tx nil / root 缺失 / 阈值非法 / delta=0
+	if _, err := repo.ShiftRight(ctx, nil, root, 1, 2); !errors.Is(err, infraerrors.ErrInternal) {
 		t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
 	}
-	if _, err := repo.ShiftLeft(ctx, repo.DB, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ShiftRight(ctx, repo.DB, "", 1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("root 缺失应 ErrBadParam(多树隔离必填),实得 %v", err)
+	}
+	if _, err := repo.ShiftLeft(ctx, repo.DB, root, -1, 2); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("阈值 <0 应 ErrBadParam,实得 %v", err)
 	}
-	if n, err := repo.ShiftLeft(ctx, repo.DB, 1, 0); err != nil || n != 0 {
+	if n, err := repo.ShiftLeft(ctx, repo.DB, root, 1, 0); err != nil || n != 0 {
 		t.Fatalf("delta=0 应空操作,实得 n=%d err=%v", n, err)
 	}
 }
 
+// TestDivideShiftRootIsolation 两棵树互不影响(audit V-02,dump 763707-763708 的 root 限定)。
+//
+// 造两棵树的 left/right **区间完全重叠**(T1: 1..6,T2: 1..6)。T2 一字不动是本用例的核心断言 ——
+// 若 shift 漏了 `root_user_id = ?`,T2 的节点会被同一次位移连带改写(双跑 diff 必红)。
+func TestDivideShiftRootIsolation(t *testing.T) {
+	db := newDivideRepoDB(t)
+	t1Root, t2Root := "01HROOT000000000000000101", "01HROOT000000000000000102"
+	t1a, t2a := "01HNODEA00000000000000101", "01HNODEA00000000000000102"
+	seedDivideNode(t, db, t1Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t1Root)
+	seedDivideNode(t, db, t1a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &t1Root, &t1Root)
+	// T2 用同一套左右值(区间重叠,最能暴露漏 root 限定的问题)
+	seedDivideNode(t, db, t2Root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &t2Root)
+	seedDivideNode(t, db, t2a, model.DivideTypeOneOff, 4, 5, 2, "5.00", &t2Root, &t2Root)
+
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if _, e := repo.ShiftRight(ctx, tx, t1Root, 3, 2); e != nil {
+			return e
+		}
+		_, e := repo.ShiftLeft(ctx, tx, t1Root, 3, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("T1 位移失败: %v", err)
+	}
+
+	// T1 被正确改写
+	if got := loadNode(t, db, t1Root); got.LeftVal != 1 || got.RightVal != 8 {
+		t.Fatalf("T1 root 应为 (1,8),实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+	// T2 **一字不动**(漏 root 限定时这里会红)
+	if got := loadNode(t, db, t2Root); got.LeftVal != 1 || got.RightVal != 6 {
+		t.Fatalf("T2 root 不应被波及,应为 (1,6),实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+	if got := loadNode(t, db, t2a); got.LeftVal != 4 || got.RightVal != 5 {
+		t.Fatalf("T2 子节点不应被波及,应为 (4,5),实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+}
+
 // TestDivideInsertNodeGuards 嵌套集不变式(left<right)与事务必传。
 func TestDivideInsertNodeGuards(t *testing.T) {
 	db := newDivideRepoDB(t)
@@ -309,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, money.FromCents(400))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(400))
 		if e != nil {
 			return e
 		}
@@ -325,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, money.FromCents(600))
+		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, model.DivideTypeOneOff, money.FromCents(600))
 		if e != nil {
 			return e
 		}
@@ -364,9 +408,65 @@ 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, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
+	if _, err := repo.ClampSubtreeQuota(ctx, db, 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) {
+		t.Fatalf("非法分成类型应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestClampSubtreeQuotaTypeFilter 混合类型:级联只压**同一分成类型**的子节点(audit A6,dump 763873)。
+//
+// 父(type=1, quota 10) ├─ A(type=1, 8) └─ B(type=2, 8)
+// 父降到 4 → 只有 A 被压到 4,**B 保持 8**(按课次线的额度不受一次付清线影响)。
+// 若漏了 `type = ?` 过滤,B 会被一起压扁 → 本用例红。
+func TestClampSubtreeQuotaTypeFilter(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000201"
+	a := "01HNODEA00000000000000201"
+	b := "01HNODEB00000000000000201"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
+	seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "8.00", &root, &root)
+	seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "8.00", &root, &root)
+
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	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))
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("只应压制同类型 1 个节点(A),实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("ClampSubtreeQuota 失败: %v", err)
+	}
+	if got := centsOf(t, loadNode(t, db, a).Quota); got != 400 {
+		t.Fatalf("A(type=1) 应压到 4.00,实得 %d 分", got)
+	}
+	if got := centsOf(t, loadNode(t, db, b).Quota); got != 800 {
+		t.Fatalf("B(type=2) 不得被跨类型压制,应保持 8.00,实得 %d 分", got)
+	}
+
+	// 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))
+		if e != nil {
+			return e
+		}
+		if n != 2 {
+			t.Fatalf("divideType=0 应压制 2 个节点,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("ClampSubtreeQuota(0) 失败: %v", err)
+	}
 }
 
 // TestDivideLogPrimitives InsertLog 类型校验 / UpdateLogOrderNo 改绑 / DeleteLog 删行。
@@ -440,7 +540,7 @@ func TestDivideLogPrimitives(t *testing.T) {
 	}
 }
 
-// TestDivideUpdateNodeParentFillsRoot 改挂分支显式写 root(修正 dump 的 NULL 缺陷)。
+// TestDivideUpdateNodeParentFillsRoot 改挂原语显式写 root(**仅数据修复用**,不在 dump 流程内)。
 func TestDivideUpdateNodeParentFillsRoot(t *testing.T) {
 	db := newDivideRepoDB(t)
 	root := "01HROOT000000000000000005"