Pārlūkot izejas kodu

feat(academic): AC-06 活动仓储层 + AC-09 分账服务(嵌套集/BFS/级联额度)

AC-06 活动仓储(repository,继承 BaseRepo 强制机构作用域):
- ActivityRepo:GetByID/GetByIDs(批量 IN 防 N+1)、UpsertSigned(DELETE+INSERT 幂等,
  对齐 dump signed_add)、CountSignedByUser(JOIN type=4 跨活动去重)、
  UpdateSignedStatus、IncreaseNumberSign(CASE WHEN 下限 0,跨库可移植)、
  ListSignedByStudentCourse(ASC/DESC 白名单常量,dump 游标顺序)
- ActivityDiscountRepo:ListUsable(可用=discount>used)、SumUsable(dump 3 参口径,
  Go 侧 money 累加避免 sqlite SUM 返回 REAL)、IncUsed、IncUsedBatch(单语句 CASE id)、
  InsertDiscountLog / InsertUsedLog
- 作用域例外:活动 campus_id=0 语义为"本机构所有自营校区"→ 统一
  `institution_id=? AND campus_id IN (?,0)` 超集过滤(BaseRepo.CampusScoped=false)

AC-09 分账服务(全局推广体系,按 §11.1 拍板不继承 BaseRepo):
- DivideRepo:GetByUserID/GetSubtree/ShiftRight/ShiftLeft/InsertNode/InsertLog/DeleteLog/
  FindLog(两级幂等键)/ListLogsByStudentOrder/UpdateNodeParent(补齐 root,修正 dump NULL)/
  UpdateNodeQuota/ClampSubtreeQuota/IncTotalIncome/UpdateLogOrderNo
- DivideService:AddSubordinate(服务端算左右值,右值扩张+2 覆盖父与祖先)、
  AddLog(递归改迭代 BFS,深度上限 100,成环兜底 3111)、DeleteLog(改绑/回滚两分支)、
  UpdateQuota(子不得超父 3111 + 子树级联下降只降不升)
- 全部写路径走 TxRunner.WithinTx,事务内下游经 WithTxContext 透传同一 tx

其他:
- 审计时间兜底 ensureCreateTime(实测:CreateTime 无 default tag 时 GORM 会显式写
  0001-01-01,DB 默认值兜不住)
- 金额一律 money.Amount(int64 分),零 float64
- 新增单测 57 个:SQL 形状(DryRun/回调捕获)、100 节点随机树不变式、BFS 深度边界、
  两级幂等、撤销两分支、额度级联、作用域超集、number_sign 下限、审计时间

验证:go build ./... && go vet ./... && go test ./... -short 全绿
Alex 21 stundas atpakaļ
vecāks
revīzija
d95282c78c

+ 335 - 0
internal/academic/repository/activity_discount_repo.go

@@ -0,0 +1,335 @@
+// 活动赠送金额池仓储(t_courses_activity_user_discount / _user_discount_log / _used_log)——AC-06。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.2 #14~#19(赠送金额池 4 SP)/ §1.5(金额)
+// / §1.7(scoping)。
+//
+// 覆盖 SP 映射:
+//
+//	user_discount_usable          → SumUsable(dump 3 参汇总口径:schoolid + campusid + userid)
+//	user_discount_use             → ListUsable(ASC) + IncUsed/IncUsedBatch + InsertDiscountLog(负值)
+//	user_discount_refund          → ListUsable(DESC) + IncUsed(负 delta) + InsertDiscountLog(正值)
+//	user_discount_refund_user_activity → ListUsable(DESC)(同活动池优先退回)
+//	use_activity_recommend        → IncUsed + InsertUsedLog(老带新结算写负值)
+//
+// 金额口径(红线):库里 discount_amount / discount_amount_used 是 decimal(12,2) **字符串列**,
+// Go 侧一律转 money.Amount(int64 分)再运算;SQL 侧只用字符串字面量做加减(decimal 精确),
+// **绝不**把金额转 float64(sqlite 的 SUM() 返回 REAL,会让分位失真——见 SumUsable 实现说明)。
+package repository
+
+import (
+	"context"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// UsedDelta 批量累加「已使用赠送金额」的单元素(id + 增量)。
+//
+// 字段:
+//   - DiscountID int64:优惠账户 ID(t_courses_activity_user_discount.id;<=0 的条目被跳过)。
+//   - Delta money.Amount:discount_amount_used 的增量(正=消费/占用,负=退回冲正;单位:分)。
+type UsedDelta struct {
+	// DiscountID 优惠账户 ID(DB 自增主键)。
+	DiscountID int64
+	// Delta 已使用金额增量(正=消费,负=退回;单位:分)。
+	Delta money.Amount
+}
+
+// ActivityDiscountRepo 活动赠送金额池仓储。
+//
+// 表:
+//   - t_courses_activity_user_discount(池:可用 = discount_amount − discount_amount_used);
+//   - t_courses_activity_user_discount_log(使用/退回流水,只增,负=消费/正=退回);
+//   - t_courses_activity_used_log(老带新获得/使用记录,结算写负值)。
+//
+// 作用域:三表均带 institution_id + campus_id(campus_id=0 = 机构所有自营校区)→ scopedCampusRows。
+type ActivityDiscountRepo struct {
+	*BaseRepo
+}
+
+// NewActivityDiscountRepo 构造赠送金额池仓储。
+//
+// 入参:db *gorm.DB / sc *appscope.Scope(语义同 NewActivityRepo)。
+// 出参:*ActivityDiscountRepo(CampusScoped=false,校区维度走 IN (campus,0) 超集语义)。
+func NewActivityDiscountRepo(db *gorm.DB, sc *appscope.Scope) *ActivityDiscountRepo {
+	r := &ActivityDiscountRepo{BaseRepo: NewBaseRepo(db, sc)}
+	r.CampusScoped = false
+	return r
+}
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务)。
+func (r *ActivityDiscountRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityDiscountRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// ListUsable 列出用户可用赠送金额池(按游标方向取用/退回顺序)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 学生(canonical_user_id ULID;空 → ErrBadParam);
+//   - order 游标方向(**顺序不可改**,源自 dump):
+//     OrderAsc  = user_discount_use 使用顺序(先入先出);
+//     OrderDesc = user_discount_refund 退回顺序(后入先退)。
+//
+// 出参:
+//   - []model.CoursesActivityUserDiscount:可用行(`discount_amount > discount_amount_used`),
+//     金额为字符串原文,调用方用 money.Parse 转 Amount;
+//   - ErrBadParam / ErrDBError。
+//
+// 可用口径(dump user_discount_usable 一致):仅累加 discount_amount > discount_amount_used 的行
+// (负赠送/已用尽的行不参与),故本方法只返回正可用行。
+func (r *ActivityDiscountRepo) ListUsable(ctx context.Context, userID string, order string) ([]model.CoursesActivityUserDiscount, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListUsable 需要 user_id"))
+	}
+	dir, err := normalizeOrder(order)
+	if err != nil {
+		return nil, err
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := scopedCampusRows(db.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
+	q = q.Where("user_id = ?", userID).
+		Where("discount_amount > discount_amount_used").
+		Order("id " + dir)
+	var rows []model.CoursesActivityUserDiscount
+	if err := q.Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// SumUsable 汇总用户可用赠送金额(**对齐 dump `usable` 的 3 参口径**:机构 + 校区 + 用户)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 学生(ULID;空 → ErrBadParam);
+//   - institutionID 机构 ID(原 schoolid;<=0 → ErrBadParam);
+//   - campusID 校区 ID(原 campusid;0 = 机构级口径,即不限定校区)。
+//
+// 出参:
+//   - money.Amount:可用额度合计(单位:分;无可用行为 0);
+//   - ErrBadParam / ErrUnscoped(1004,"报表参数越权")/ ErrDBError。
+//
+// 作用域(两条例外之外的**显式参数口径**,安全红线):
+//
+//	dump 的 usable(schoolid, campusid, userid) 由调用方传参(报表/退费需要"指定机构校区"视角),
+//	与请求作用域可能不同;此处**不叠加**请求作用域过滤,但做一次防越权断言:
+//	非超管且 institutionID != Sc.InstitutionID → ErrUnscoped(禁止借参数跨机构读他人赠送金额)。
+//
+// 校区过滤:campusID > 0 → `campus_id IN (campusID, 0)`(0=机构所有自营校区,与 dump 一致);
+// campusID = 0 → 只按机构汇总(机构级报表视角)。
+//
+// 实现说明(金额精度):**不用 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 r != nil && r.Sc != nil && !r.Sc.SuperAdmin && r.Sc.InstitutionID != institutionID {
+		return 0, infraerrors.WithCause(infraerrors.ErrUnscoped,
+			errString("repository: SumUsable 机构参数越权(作用域机构与入参不一致)"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	q := db.Model(&model.CoursesActivityUserDiscount{}).
+		Where("user_id = ?", userID).
+		Where("institution_id = ?", institutionID).
+		Where("discount_amount > discount_amount_used")
+	if campusID > 0 {
+		// campus_id=0 = 机构所有自营校区:与指定校区同属可见范围(dump IN (x,0))
+		q = q.Where("campus_id IN ?", []int64{campusID, 0})
+	}
+	var rows []model.CoursesActivityUserDiscount
+	if err := q.Find(&rows).Error; err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	total := money.Amount(0)
+	for i := range rows {
+		totalAmount, perr := money.Parse(rows[i].DiscountAmount)
+		if perr != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		usedAmount, perr := money.Parse(rows[i].DiscountAmountUsed)
+		if perr != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrDBError, perr)
+		}
+		total = total.Add(totalAmount.Sub(usedAmount))
+	}
+	return total, nil
+}
+
+// IncUsed 累加优惠账户的「已使用赠送金额」(单账户原子增减)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - id 优惠账户 ID(<=0 → ErrBadParam);
+//   - delta 增量(正=消费,负=退回冲正;单位:分;0 → 空操作)。
+//
+// 出参:
+//   - int64:受影响行数(0 = 账户不存在/不在作用域内);
+//   - ErrBadParam / ErrDBError。
+//
+// 红线:
+//   - `discount_amount_used <= discount_amount` 的业务约束由 **Service** 在扣减前用可用额校验
+//     (不足 → 3109),本方法**不做截断**——静默截断会破坏"池余额 = 流水累计"的对账恒等式;
+//   - 与流水写入(InsertDiscountLog)必须同事务。
+func (r *ActivityDiscountRepo) IncUsed(ctx context.Context, tx *gorm.DB, id int64, delta money.Amount) (int64, error) {
+	if err := requireTx(tx, "IncUsed"); err != nil {
+		return 0, err
+	}
+	if id <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncUsed 需要 discount id"))
+	}
+	if delta.IsZero() {
+		return 0, nil
+	}
+	if err := delta.Validate(); err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+	}
+	q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
+	res := q.Where("id = ?", id).
+		UpdateColumn("discount_amount_used", gorm.Expr("discount_amount_used + ?", delta.String()))
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// IncUsedBatch 批量累加多个优惠账户的「已使用赠送金额」(**单语句 CASE WHEN**,禁止逐行 UPDATE)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - items 增量集合(同一 id 出现多次时**后者覆盖前者**;<=0 的 id 与零增量条目被忽略)。
+//
+// 出参:
+//   - int64:受影响行数;
+//   - ErrBadParam / ErrDBError(空集合 → 0, nil,不发 SQL)。
+//
+// 为什么必须单语句(性能/正确性双约束):
+//   - 一次跨多个池扣减若逐行 UPDATE,N 个账户 = N 次往返 + N 个独立执行计划;
+//   - 单语句在事务内原子生效,减少锁持有时间(并发退费场景)。
+//
+// SQL:
+//
+//	UPDATE t_courses_activity_user_discount
+//	   SET discount_amount_used = discount_amount_used + CASE id WHEN ? THEN ? ... ELSE 0 END
+//	 WHERE id IN (...) [AND institution_id = ? AND campus_id IN (?,0)]
+//
+// 红线:金额以**字符串**字面量入场(decimal 精确),禁止 float。
+func (r *ActivityDiscountRepo) IncUsedBatch(ctx context.Context, tx *gorm.DB, items []UsedDelta) (int64, error) {
+	if err := requireTx(tx, "IncUsedBatch"); err != nil {
+		return 0, err
+	}
+	// 归一:去重(后者覆盖)+ 过滤非法 id/零增量,保持入参顺序(CASE 分支顺序可预期,便于断言)
+	order := make([]int64, 0, len(items))
+	deltaByID := make(map[int64]money.Amount, len(items))
+	for _, it := range items {
+		if it.DiscountID <= 0 || it.Delta.IsZero() {
+			continue
+		}
+		if err := it.Delta.Validate(); err != nil {
+			return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+		}
+		if _, ok := deltaByID[it.DiscountID]; !ok {
+			order = append(order, it.DiscountID)
+		}
+		deltaByID[it.DiscountID] = it.Delta
+	}
+	if len(order) == 0 {
+		return 0, nil
+	}
+
+	var sqlText strings.Builder
+	sqlText.WriteString("discount_amount_used + CASE id")
+	caseArgs := make([]any, 0, len(order)*2)
+	for _, id := range order {
+		sqlText.WriteString(" WHEN ? THEN ?")
+		caseArgs = append(caseArgs, id, deltaByID[id].String())
+	}
+	sqlText.WriteString(" ELSE 0 END")
+
+	q := scopedCampusRows(tx.Model(&model.CoursesActivityUserDiscount{}), r.Sc, "")
+	res := q.Where("id IN ?", order).UpdateColumn("discount_amount_used", gorm.Expr(sqlText.String(), caseArgs...))
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// InsertDiscountLog 写入赠送金额使用/退回流水(**只增**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - row 流水行(ActivityUserDiscountID>0;DiscountAmountUsed 负=消费/正=退回)。
+//
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 红线:流水与池余额变动(IncUsed)必须同事务;流水禁止 UPDATE/DELETE(审计底线)。
+func (r *ActivityDiscountRepo) InsertDiscountLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUserDiscountLog) error {
+	if err := requireTx(tx, "InsertDiscountLog"); err != nil {
+		return err
+	}
+	if row == nil {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 为 nil"))
+	}
+	if row.ActivityUserDiscountID <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: discount log 缺少 activity_user_discount_id"))
+	}
+	ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// InsertUsedLog 写入老带新活动金额获得/使用记录(**只增**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - row 记录行(ActivityID>0、UserID 非空;老带新结算写**负值** activity_money_used)。
+//
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 红线:符号方向固定(结算负值);撤销报名前置校验按 (activity_id, user_id) 判存在(3108),
+// 故本表写入必须与 signed 状态推进同事务。
+func (r *ActivityDiscountRepo) InsertUsedLog(ctx context.Context, tx *gorm.DB, row *model.CoursesActivityUsedLog) error {
+	if err := requireTx(tx, "InsertUsedLog"); err != nil {
+		return err
+	}
+	if row == nil {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 为 nil"))
+	}
+	if row.ActivityID <= 0 || strings.TrimSpace(row.UserID) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: used log 缺少 activity_id/user_id"))
+	}
+	ensureCreateTime(&row.CreateTime) // 审计时间兜底(见 activity_repo.go ensureCreateTime 说明)
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(row).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}

+ 296 - 0
internal/academic/repository/activity_discount_repo_test.go

@@ -0,0 +1,296 @@
+// ActivityDiscountRepo 单测(AC-06):可用额口径 / dump 3 参汇总 / 原子增减 / 批量单语句 CASE。
+package repository
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// seedPool 插入一条赠送金额池(显式 ID)。
+func seedPool(t *testing.T, db *gorm.DB, id, inst, campus int64, userID, discount, used string) {
+	t.Helper()
+	row := &model.CoursesActivityUserDiscount{
+		ID:                 id,
+		ActivityID:         1,
+		InstitutionID:      inst,
+		CampusID:           campus,
+		UserID:             userID,
+		Income:             "0.00",
+		DiscountAmount:     discount,
+		DiscountAmountUsed: used,
+		CreateTime:         time.Now(),
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed pool(%d) 失败: %v", id, err)
+	}
+}
+
+// amountOf 读取池的「已使用金额」(money 归类,规避 sqlite NUMERIC 回读差异)。
+func amountOf(t *testing.T, db *gorm.DB, id int64) money.Amount {
+	t.Helper()
+	var row model.CoursesActivityUserDiscount
+	if err := db.Where("id = ?", id).First(&row).Error; err != nil {
+		t.Fatalf("读池(%d) 失败: %v", id, err)
+	}
+	amt, err := money.Parse(row.DiscountAmountUsed)
+	if err != nil {
+		t.Fatalf("解析 used(%d) 失败: %v", id, err)
+	}
+	return amt
+}
+
+// TestListUsableFilterAndOrder 可用口径(discount > used)与游标方向。
+func TestListUsableFilterAndOrder(t *testing.T) {
+	db := newActivityRepoDB(t)
+	user := "01HUSER000000000000000011"
+	seedPool(t, db, 1, 1, 2, user, "10.00", "0.00")  // 可用
+	seedPool(t, db, 2, 1, 2, user, "20.00", "20.00") // 用尽:不可用
+	seedPool(t, db, 3, 1, 2, user, "-5.00", "0.00")  // 负赠送:不可用
+	seedPool(t, db, 4, 1, 2, user, "7.50", "2.50")   // 可用
+
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	asc, err := repo.ListUsable(ctx, user, 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)
+	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) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.ListUsable(ctx, user, "desc,1;--"); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestSumUsableThreeParamScoping dump usable 3 参口径:机构 + 校区(campus=0 含全机构池)。
+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") // 他人:不计
+
+	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)
+	}
+	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)
+	}
+	// 越权:非超管传入其它机构 → 1004
+	if _, err := repo.SumUsable(ctx, user, 2, 2); !errors.Is(err, infraerrors.ErrUnscoped) {
+		t.Fatalf("跨机构参数应 ErrUnscoped,实得 %v", 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)
+	}
+	// 参数防御
+	if _, err := repo.SumUsable(ctx, user, 0, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("institutionID<=0 应 ErrBadParam,实得 %v", err)
+	}
+	// 无可用行 → 0(不是错误)
+	if got, err := repo.SumUsable(ctx, "01HNOBODY00000000000001", 1, 2); err != nil || !got.IsZero() {
+		t.Fatalf("无可用行应返回 0,实得 %s err=%v", got.String(), err)
+	}
+}
+
+// TestIncUsedAndBatch 原子增减 + 批量单语句 CASE(正负增量都精确到分)。
+func TestIncUsedAndBatch(t *testing.T) {
+	db := newActivityRepoDB(t)
+	user := "01HUSER000000000000000013"
+	seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
+	seedPool(t, db, 2, 1, 2, user, "100.00", "0.00")
+	seedPool(t, db, 3, 1, 2, user, "100.00", "10.00")
+
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	// 单账户:消费 12.34
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.IncUsed(ctx, tx, 1, money.FromCents(1234))
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("IncUsed 应影响 1 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("IncUsed 失败: %v", err)
+	}
+	if got := amountOf(t, db, 1); got.Cents() != 1234 {
+		t.Fatalf("id=1 used 应为 12.34,实得 %s", got.String())
+	}
+
+	// 批量:id1 +1.00 / id2 +2.50 / id3 -0.01(退回冲正)
+	sqls := captureSQL(t, db)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
+			{DiscountID: 1, Delta: money.FromCents(100)},
+			{DiscountID: 2, Delta: money.FromCents(250)},
+			{DiscountID: 3, Delta: money.FromCents(-1)},
+		})
+		return e
+	}); err != nil {
+		t.Fatalf("IncUsedBatch 失败: %v", err)
+	}
+	// 立即断言(后续读库也会被回调捕获,故此处先判再读)
+	if len(*sqls) != 1 {
+		t.Fatalf("批量增减必须 1 条语句(禁止逐行 UPDATE),实得 %d 条: %v", len(*sqls), *sqls)
+	}
+	if !strings.Contains((*sqls)[0], "CASE id") {
+		t.Fatalf("批量增减应为 CASE WHEN 单语句,SQL=%q", (*sqls)[0])
+	}
+	if got := amountOf(t, db, 1); got.Cents() != 1334 {
+		t.Fatalf("id=1 used 应为 13.34,实得 %s", got.String())
+	}
+	if got := amountOf(t, db, 2); got.Cents() != 250 {
+		t.Fatalf("id=2 used 应为 2.50,实得 %s", got.String())
+	}
+	if got := amountOf(t, db, 3); got.Cents() != 999 {
+		t.Fatalf("id=3 used 应为 9.99(10.00-0.01),实得 %s", got.String())
+	}
+}
+
+// TestIncUsedBatchEdgeCases 空集合/零增量/非法 id 不发 SQL;同 id 重复出现以后者为准。
+func TestIncUsedBatchEdgeCases(t *testing.T) {
+	db := newActivityRepoDB(t)
+	user := "01HUSER000000000000000014"
+	seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	sqls := captureSQL(t, db)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.IncUsedBatch(ctx, tx, nil)
+		if e != nil {
+			return e
+		}
+		if n != 0 {
+			t.Fatalf("空集合应 0 行,实得 %d", n)
+		}
+		_, e = repo.IncUsedBatch(ctx, tx, []UsedDelta{{DiscountID: 0, Delta: money.FromCents(1)}, {DiscountID: 1, Delta: 0}})
+		return e
+	}); err != nil {
+		t.Fatalf("IncUsedBatch 边界用例失败: %v", err)
+	}
+	if len(*sqls) != 0 {
+		t.Fatalf("无有效条目不应发 SQL,实得 %v", *sqls)
+	}
+
+	// 同 id 两次:后者覆盖前者
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncUsedBatch(ctx, tx, []UsedDelta{
+			{DiscountID: 1, Delta: money.FromCents(500)},
+			{DiscountID: 1, Delta: money.FromCents(700)},
+		})
+		return e
+	}); err != nil {
+		t.Fatalf("IncUsedBatch(重复 id) 失败: %v", err)
+	}
+	if got := amountOf(t, db, 1); got.Cents() != 700 {
+		t.Fatalf("重复 id 应以后者为准(7.00),实得 %s", got.String())
+	}
+
+	// tx 缺失 → ErrInternal
+	if _, err := repo.IncUsed(nil, nil, 1, money.FromCents(1)); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
+	}
+	if _, err := repo.IncUsedBatch(ctx, nil, []UsedDelta{{DiscountID: 1, Delta: 1}}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
+	}
+	if _, err := repo.IncUsed(ctx, repo.DB, 0, money.FromCents(1)); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestInsertDiscountAndUsedLog 两条流水写入(参数校验 + 落库形态)。
+func TestInsertDiscountAndUsedLog(t *testing.T) {
+	db := newActivityRepoDB(t)
+	user := "01HUSER000000000000000015"
+	seedPool(t, db, 1, 1, 2, user, "100.00", "0.00")
+	repo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	stuCourse := int64(901)
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return repo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
+			ActivityUserDiscountID: 1,
+			CoursesStudentID:       &stuCourse,
+			DiscountAmountUsed:     "-10.00",
+			CreateTime:             time.Now(),
+		})
+	}); err != nil {
+		t.Fatalf("InsertDiscountLog 失败: %v", err)
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		return repo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
+			ActivityID:        1,
+			UserID:            user,
+			OrderNo:           ptrString("901"),
+			ActivityMoneyUsed: "-10.00",
+			CreateTime:        time.Now(),
+		})
+	}); err != nil {
+		t.Fatalf("InsertUsedLog 失败: %v", err)
+	}
+
+	var dlogCount, ulogCount int64
+	if err := db.Model(&model.CoursesActivityUserDiscountLog{}).Count(&dlogCount).Error; err != nil {
+		t.Fatalf("count discount log 失败: %v", err)
+	}
+	if err := db.Model(&model.CoursesActivityUsedLog{}).Count(&ulogCount).Error; err != nil {
+		t.Fatalf("count used log 失败: %v", err)
+	}
+	if dlogCount != 1 || ulogCount != 1 {
+		t.Fatalf("两条流水应各 1 行,实得 discount=%d used=%d", dlogCount, ulogCount)
+	}
+
+	// 参数/事务防御
+	if err := repo.InsertDiscountLog(ctx, nil, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 1}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
+	}
+	if err := repo.InsertDiscountLog(ctx, repo.DB, &model.CoursesActivityUserDiscountLog{ActivityUserDiscountID: 0}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("缺 discount id 应 ErrBadParam,实得 %v", err)
+	}
+	if err := repo.InsertUsedLog(ctx, repo.DB, &model.CoursesActivityUsedLog{ActivityID: 0, UserID: user}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("缺 activity_id 应 ErrBadParam,实得 %v", err)
+	}
+	if err := repo.InsertUsedLog(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// ptrString 字符串指针构造(测试用)。
+func ptrString(s string) *string { return &s }

+ 458 - 0
internal/academic/repository/activity_repo.go

@@ -0,0 +1,458 @@
+// 活动仓储(t_courses_activity / t_courses_activity_signed)——AC-06 活动报名主链路。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.2(活动 9 SP)/ §1.5(幂等语义演进)
+// / §1.7(scoping 与两条例外)/ §10.3(事务边界只在 Service)。
+//
+// 覆盖 SP 映射:
+//
+//	signed_add        → UpsertSigned(DELETE+INSERT 幂等,对齐 dump)
+//	signed_cancel     → UpdateSignedStatus + IncreaseNumberSign(服务层组合成状态机)
+//	sign 限一次(3106) → CountSignedByUser(type4Only=true)
+//	退费回滚赠送金额   → ListSignedByStudentCourse(按报课记录定位报名行)
+//
+// 作用域(关键设计,与 BaseRepo.Scoped 的差异必须读):
+//
+//	t_courses_activity.campus_id = 0 的语义是「本机构所有**自营**校区」(DDL §2「7.」+ 设计 §11-2),
+//	因此校园维度过滤**不能**用 `campus_id = ?`,否则全机构活动会被裁掉;
+//	本仓储统一用 scopedCampusRows:`institution_id = ? AND campus_id IN (?, 0)`(超集语义)。
+//	→ BaseRepo.CampusScoped 置 false(避免严格等值过滤),机构过滤仍由 scopedCampusRows 强制,
+//	  安全红线(跨机构越权)不因这条例外而放松。
+//
+// 事务纪律:写方法一律接收 `tx *gorm.DB`(**仓储不开事务**);读方法经 db(ctx) 取句柄,
+// ctx 内已注入事务(WithTxContext)时自动复用同一 tx,保证事务内读一致。
+package repository
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// 游标方向常量(源自 dump 的固定排序,调用方只能二选一,禁止拼接其它 SQL 片段)。
+//
+// 语义(dump 口径,**顺序不可改**):
+//   - OrderAsc :按 id 升序——"消费/使用"路径(先入先出,旧 SP 游标);
+//   - OrderDesc:按 id 降序——"退回/回滚"路径(后入先退,旧 SP 游标)。
+//
+// 安全红线:方向值来自本白名单,绝不接受调用方透传的原始字符串(防 ORDER BY 注入)。
+const (
+	// OrderAsc 升序(使用路径)。
+	OrderAsc = "asc"
+	// OrderDesc 降序(退回路径)。
+	OrderDesc = "desc"
+)
+
+// normalizeOrder 校验并归一排序方向(白名单;空串按 OrderAsc 处理)。
+//
+// 入参:order string——调用方传入的方向(大小写/空白不敏感)。
+// 出参:归一后的 "asc"/"desc";非法值 → infraerrors.ErrBadParam(2002)。
+func normalizeOrder(order string) (string, error) {
+	switch strings.ToLower(strings.TrimSpace(order)) {
+	case "", OrderAsc:
+		return OrderAsc, nil
+	case OrderDesc:
+		return OrderDesc, nil
+	default:
+		return "", infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: 非法排序方向 "+order))
+	}
+}
+
+// scopedCampusRows 追加作用域过滤(机构强制 + 校区"含全机构池"超集)。
+//
+// 入参:
+//   - db *gorm.DB:基础查询(nil → 返回 nil)。
+//   - sc *appscope.Scope:请求作用域(nil → panic fail-fast,与 BaseRepo.Scoped 同口径)。
+//   - alias string:表别名前缀(如 "signed.");JOIN 场景必须限定,否则 ambiguous column。
+//
+// 出参:*gorm.DB——追加过滤后的链式查询。
+//
+// 过滤规则:
+//  1. sc.SuperAdmin=true → 不加任何过滤(平台超管跨机构视角);
+//  2. 否则强制 `alias.institution_id = sc.InstitutionID`(安全红线,不可绕过);
+//  3. 仅当 sc.CampusID > 0 时追加 `alias.campus_id IN (sc.CampusID, 0)`——
+//     0=机构所有自营校区,属该校区可见范围(设计 §11-2)。
+func scopedCampusRows(db *gorm.DB, sc *appscope.Scope, alias string) *gorm.DB {
+	if db == nil {
+		return nil
+	}
+	if sc == nil {
+		panic(errNoScope.Error())
+	}
+	if sc.SuperAdmin {
+		return db
+	}
+	col := func(name string) string {
+		if alias == "" {
+			return name
+		}
+		return alias + "." + name
+	}
+	db = db.Where(col("institution_id")+" = ?", sc.InstitutionID)
+	if sc.CampusID > 0 {
+		// campus_id=0 = 机构所有自营校区(超集语义,不能用等值)
+		db = db.Where(col("campus_id")+" IN ?", []int64{sc.CampusID, 0})
+	}
+	return db
+}
+
+// requireTx 校验写方法必须携带事务句柄(仓储不开事务,设计 §10.3)。
+//
+// 入参:tx *gorm.DB(可 nil)。出参:nil 合法;ErrInternal 表示调用方漏传事务。
+func requireTx(tx *gorm.DB, op string) error {
+	if tx == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: "+op+" 必须传入 tx(禁止脱离事务写资金/状态)"))
+	}
+	return nil
+}
+
+// ActivityRepo 活动方案 + 报名仓储。
+//
+// 表:t_courses_activity(活动方案)/ t_courses_activity_signed(报名·UK(user_id, activity_id))。
+// 作用域:两表均带 institution_id + campus_id(冗余作用域列)→ 统一走 scopedCampusRows。
+type ActivityRepo struct {
+	*BaseRepo
+}
+
+// NewActivityRepo 构造活动仓储。
+//
+// 入参:db *gorm.DB(连接池句柄)/ sc *appscope.Scope(请求作用域;系统任务可 nil,一查询即 panic)。
+// 出参:*ActivityRepo。
+//
+// 说明:CampusScoped 置 false —— 校区维度由 scopedCampusRows 以 `IN (campus,0)` 超集语义处理
+// (见文件头「作用域」段),机构过滤不受影响。
+func NewActivityRepo(db *gorm.DB, sc *appscope.Scope) *ActivityRepo {
+	r := &ActivityRepo{BaseRepo: NewBaseRepo(db, sc)}
+	r.CampusScoped = false
+	return r
+}
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
+//
+// 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *ActivityRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: ActivityRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// GetByID 按主键取单个活动(**作用域强制**:跨机构活动一律 ErrNotFound,不泄露存在性)。
+//
+// 入参:
+//   - ctx 请求上下文(取消/超时传播);
+//   - id 活动 ID(<=0 → ErrBadParam,避免全表扫描)。
+//
+// 出参:
+//   - *model.CoursesActivity:命中行(含 money 字符串原文,金额换算由 Service 用 money.Amount);
+//   - ErrNotFound(3001):不存在或不在作用域内;
+//   - ErrDBError(5002):SQL/驱动异常(不返回 gorm 原错)。
+func (r *ActivityRepo) GetByID(ctx context.Context, id int64) (*model.CoursesActivity, error) {
+	if id <= 0 {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: activity id 必须 > 0"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.CoursesActivity
+	q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
+	if err := q.Where("id = ?", id).First(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetByID(t_courses_activity)")
+	}
+	return &out, nil
+}
+
+// GetByIDs 批量取活动(**防 N+1**:一次 IN 查询,返回 map 供调用方 O(1) 索引)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - ids 活动 ID 集合(空/全非法 → 返回空 map 且**不发 SQL**)。
+//
+// 出参:
+//   - map[int64]*model.CoursesActivity:命中行索引(**缺失的 id 不在 map 中**,调用方按需判缺失,
+//     语义上等价"不在作用域内或不存在",禁止据此区分存在性);
+//   - ErrDBError(5002)。
+//
+// 实现说明:入参先去重 + 剔除 <=0(防无意义扫描);结果不保证顺序(map 语义)。
+func (r *ActivityRepo) GetByIDs(ctx context.Context, ids []int64) (map[int64]*model.CoursesActivity, error) {
+	out := make(map[int64]*model.CoursesActivity, len(ids))
+	clean := make([]int64, 0, len(ids))
+	seen := make(map[int64]struct{}, len(ids))
+	for _, id := range ids {
+		if id <= 0 {
+			continue
+		}
+		if _, ok := seen[id]; ok {
+			continue
+		}
+		seen[id] = struct{}{}
+		clean = append(clean, id)
+	}
+	if len(clean) == 0 {
+		return out, nil
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var rows []model.CoursesActivity
+	q := scopedCampusRows(db.Model(&model.CoursesActivity{}), r.Sc, "")
+	if err := q.Where("id IN ?", clean).Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	for i := range rows {
+		out[rows[i].ID] = &rows[i]
+	}
+	return out, nil
+}
+
+// UpsertSigned 报名落库(**DELETE+INSERT 幂等**,对齐 dump `signed_add`)。
+//
+// 依据:dump 的 signed_add 先 `DELETE FROM t_courses_activity_signed WHERE userid=? AND activityid=?`
+// 再 INSERT——同一 (user, activity) 只留一条,连历史 status=0 的旧行一并清掉(设计 §1.5)。
+// 目标态 UK(user_id, activity_id) 兜底;此处保留 DELETE+INSERT 语义(而非 OnConflict 覆盖),
+// 因为状态机要求"重新报名 = 全字段重置"(activity_money/recharge_amount/审核字段清零)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**,nil → ErrInternal);
+//   - signed 报名行(ActivityID>0、UserID 非空;institution_id 为 0 时用请求作用域补齐)。
+//
+// 出参:nil 成功;ErrBadParam(参数非法)/ ErrDBError(SQL 异常,事务由调用方回滚)。
+//
+// 事务/一致性(红线):
+//   - DELETE **不叠作用域**:UK(user_id, activity_id) 全局唯一,若历史迁移行的作用域列脏,
+//     叠作用域会漏删 → INSERT 撞 UK 直接失败;activity_id 的归属已由调用方 GetByID 作用域校验;
+//   - campus_id **不由本方法补齐**:它必须是活动上的 campus_id 快照(0 有"全自营校区"语义,
+//     用请求作用域值覆盖会篡改语义)→ 由 Service 从活动行复制后传入;
+//   - 报名人数增减(IncreaseNumberSign)由 Service 在同一事务内配套调用。
+func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *model.CoursesActivitySigned) error {
+	if err := requireTx(tx, "UpsertSigned"); err != nil {
+		return err
+	}
+	if signed == nil {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 为 nil"))
+	}
+	if signed.ActivityID <= 0 || strings.TrimSpace(signed.UserID) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: signed 缺少 activity_id/user_id"))
+	}
+	if signed.InstitutionID == 0 {
+		inst, _ := r.GetScopedID()
+		signed.InstitutionID = inst
+	}
+	// ① 幂等清旧行(dump:DELETE WHERE userid AND activityid)
+	if err := tx.Where("user_id = ? AND activity_id = ?", signed.UserID, signed.ActivityID).
+		Delete(&model.CoursesActivitySigned{}).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	// ② 插入新行(状态机全字段重置;审计时间兜底,见 ensureCreateTime)
+	ensureCreateTime(&signed.CreateTime)
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(signed).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// CountSignedByUser 统计用户有效报名数(**type=4 跨活动去重判定**,3106 老带新限一次)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 报名用户(canonical_user_id ULID;空 → ErrBadParam);
+//   - type4Only true=只统计老带新(t_courses_activity.type=4)活动;false=统计全部活动类型。
+//
+// 出参:
+//   - int64:有效报名记录数(**status != 0**,已取消/过期行不计);
+//   - ErrBadParam / ErrDBError。
+//
+// SQL 口径(对齐 dump,表名/列名限定防 JOIN ambiguous):
+//
+//	SELECT count(*) FROM t_courses_activity_signed signed
+//	  JOIN t_courses_activity activity ON activity.id = signed.activity_id
+//	 WHERE signed.user_id = ? AND signed.status <> 0 AND activity.type = 4
+//	   AND signed.institution_id = ? AND signed.campus_id IN (?, 0)   -- 作用域
+//
+// 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行),故 COUNT(*) 即
+// "该用户参加过多少个 type=4 活动";>0 即可返回 3106(设计 §3.2 signed_add)。
+func (r *ActivityRepo) CountSignedByUser(ctx context.Context, userID string, type4Only bool) (int64, error) {
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: CountSignedByUser 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return 0, err
+	}
+	q := db.Table("t_courses_activity_signed AS signed").
+		Where("signed.user_id = ?", userID).
+		Where("signed.status <> ?", model.ActivitySignedCancelled)
+	if type4Only {
+		q = q.Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
+			Where("activity.type = ?", model.ActivityTypeRecommend)
+	}
+	q = scopedCampusRows(q, r.Sc, "signed")
+	var count int64
+	if err := q.Count(&count).Error; err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return count, nil
+}
+
+// UpdateSignedStatus 更新报名状态(状态机推进 / 取消报名)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - userID 报名用户(ULID);activityID 活动 ID;status 目标状态(model.ActivitySigned*)。
+//
+// 出参:
+//   - int64:受影响行数(0 = 该 (user, activity) 不存在或不在作用域内,Service 据此返回 3001);
+//   - ErrBadParam / ErrDBError。
+//
+// 说明:状态机的**合法性判定**在 Service(如 2→1 仅取消路径、已审核不可取消 3107),
+// 仓储只做「条件更新」,不解释状态流转规则(避免业务规则沉淀到数据层)。
+func (r *ActivityRepo) UpdateSignedStatus(ctx context.Context, tx *gorm.DB, userID string, activityID int64, status int32) (int64, error) {
+	if err := requireTx(tx, "UpdateSignedStatus"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" || activityID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateSignedStatus 需要 user_id/activity_id"))
+	}
+	q := scopedCampusRows(tx.Model(&model.CoursesActivitySigned{}), r.Sc, "")
+	res := q.Where("user_id = ? AND activity_id = ?", userID, activityID).
+		UpdateColumn("status", status)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// IncreaseNumberSign 报名人数原子增减(**下限 0**,防取消竞态把人数减成负数)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - activityID 活动 ID(<=0 → ErrBadParam);delta 增量(报名 +1 / 取消 -1;0 → 空操作)。
+//
+// 出参:
+//   - int64:受影响行数(0 = 活动不存在/不在作用域内);
+//   - ErrBadParam / ErrDBError。
+//
+// SQL(跨库可移植的下限保护,不用 GREATEST/MAX 以免方言差异):
+//
+//	UPDATE t_courses_activity
+//	   SET number_sign = CASE WHEN number_sign + <delta> < 0 THEN 0 ELSE number_sign + <delta> END
+//	 WHERE id = ? [AND institution_id = ? AND campus_id IN (?,0)]
+//
+// 红线:与 UpsertSigned/UpdateSignedStatus **必须同事务**(否则出现"报名行在、人数没加"的脏读)。
+func (r *ActivityRepo) IncreaseNumberSign(ctx context.Context, tx *gorm.DB, activityID int64, delta int32) (int64, error) {
+	if err := requireTx(tx, "IncreaseNumberSign"); err != nil {
+		return 0, err
+	}
+	if activityID <= 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncreaseNumberSign 需要 activity_id"))
+	}
+	if delta == 0 {
+		return 0, nil
+	}
+	expr := gorm.Expr("CASE WHEN number_sign + ? < 0 THEN 0 ELSE number_sign + ? END", delta, delta)
+	q := scopedCampusRows(tx.Model(&model.CoursesActivity{}), r.Sc, "")
+	res := q.Where("id = ?", activityID).UpdateColumn("number_sign", expr)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// ListSignedByStudentCourse 按报课记录回溯报名行(退费/撤销时定位需要回滚的 signed)。
+//
+// 发现路径(dump 的退费回滚链):报课记录 id → 赠送金额使用流水 log.courses_student_id
+// → 赠送金额池 pool.id → (pool.activity_id, pool.user_id) → signed(同活动同用户)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - stuCourseID 报课记录 ID(t_courses_student.id;<=0 → ErrBadParam);
+//   - order 游标方向(OrderAsc/OrderDesc,**顺序写死不可改**):
+//     OrderAsc = 按使用先后(先用的活动先处理,dump 消费路径);
+//     OrderDesc = 按最近使用优先(dump 退回路径)。
+//
+// 出参:
+//   - []model.CoursesActivitySigned:去重后的报名行(同一活动多笔流水只返回一行);
+//   - 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
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	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)
+	}
+	return rows, nil
+}
+
+// wrapQueryErr 把查询错误归一为 academic 错误契约(不存在 → ErrNotFound,其余 → ErrDBError)。
+//
+// 入参:err 原生错误;op 操作名(仅用于日志关联,不进入响应体)。
+// 出参:*infraerrors.AppError(ErrNotFound / ErrDBError)。
+func wrapQueryErr(err error, op string) error {
+	if err == nil {
+		return nil
+	}
+	if errors.Is(err, gorm.ErrRecordNotFound) {
+		return infraerrors.WithCause(infraerrors.ErrNotFound, err)
+	}
+	return infraerrors.WithCause(infraerrors.ErrDBError, fmt.Errorf("%s: %w", op, err))
+}
+
+// ctxOrBackground 保证传给 gorm 的 ctx 非 nil(gorm.WithContext(nil) 会 panic)。
+func ctxOrBackground(ctx context.Context) context.Context {
+	if ctx != nil {
+		return ctx
+	}
+	return context.Background()
+}
+
+// ensureCreateTime 审计时间兜底(零值 → 当前时间)。
+//
+// 为什么需要(实测缺陷):AC-01 的活动域 model 用的是 `CreateTime` 字段名(非 GORM 约定的
+// `CreatedAt`),且 tag 只有 `not null` 没有 `default:CURRENT_TIMESTAMP(3)`;
+// 实测 GORM 会把 time.Time 零值**显式**写进 INSERT(得到 0001-01-01),DB 默认值根本兜不住。
+// 审计时间是对账依据(红线),故在仓储写入前统一兜底,避免调用方漏填造成时间列被写坏。
+//
+// 入参:dst *time.Time——待写入行的 create_time 字段地址(nil 时忽略)。
+func ensureCreateTime(dst *time.Time) {
+	if dst != nil && dst.IsZero() {
+		*dst = time.Now()
+	}
+}

+ 588 - 0
internal/academic/repository/activity_repo_test.go

@@ -0,0 +1,588 @@
+// ActivityRepo 单测(AC-06):作用域超集语义 / 批量防 N+1 / DELETE+INSERT 幂等 /
+// type=4 跨活动去重 / number_sign 下限 / 游标方向白名单。
+//
+// 测试栈:glebarez/sqlite(纯 Go,CGO_ENABLED=0 可跑)——真实库用例走 t.TempDir 文件库,
+// SQL 形状用例走 DryRun 句柄(与 base_test.go 同一套 helper)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+	appscope "yuansiwei/unified-backend/internal/infra/scope"
+)
+
+// newActivityRepoDB 活动域真实库(文件库;含报名/赠送池/流水 5 表 AutoMigrate)。
+func newActivityRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_activity_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(活动文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(
+		&model.CoursesActivity{},
+		&model.CoursesActivitySigned{},
+		&model.CoursesActivityUserDiscount{},
+		&model.CoursesActivityUserDiscountLog{},
+		&model.CoursesActivityUsedLog{},
+	); err != nil {
+		t.Fatalf("AutoMigrate(活动 5 表) 失败: %v", err)
+	}
+	return db
+}
+
+// seedActivity 插入一条活动(显式 ID,便于断言)。
+func seedActivity(t *testing.T, db *gorm.DB, id, inst, campus int64, typ int8, money string) *model.CoursesActivity {
+	t.Helper()
+	row := &model.CoursesActivity{
+		ID:            id,
+		Type:          typ,
+		InstitutionID: inst,
+		CampusID:      campus,
+		Money:         money,
+		Status:        model.ActivityStatusEnabled,
+		CreateTime:    time.Now(),
+		UpdatedAt:     time.Now(),
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed activity(%d) 失败: %v", id, err)
+	}
+	return row
+}
+
+// seedSigned 插入一条报名行(显式 ID)。
+func seedSigned(t *testing.T, db *gorm.DB, id, activityID, inst, campus int64, userID string, status int32) {
+	t.Helper()
+	row := &model.CoursesActivitySigned{
+		ID:            id,
+		ActivityID:    activityID,
+		UserID:        userID,
+		InstitutionID: inst,
+		CampusID:      campus,
+		Status:        status,
+		CreateTime:    time.Now(),
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed signed(%d) 失败: %v", id, err)
+	}
+}
+
+// captureSQL 注册 Query/Update 回调,收集执行过的 SQL 语句(断言防 N+1 / 单语句 CASE)。
+func captureSQL(t *testing.T, db *gorm.DB) *[]string {
+	t.Helper()
+	out := make([]string, 0, 8)
+	collect := func(d *gorm.DB) {
+		if d.Statement != nil && d.Statement.SQL.Len() > 0 {
+			out = append(out, d.Statement.SQL.String())
+		}
+	}
+	if err := db.Callback().Query().After("gorm:query").Register("test:capture_query", collect); err != nil {
+		t.Fatalf("注册 Query 回调失败: %v", err)
+	}
+	if err := db.Callback().Update().After("gorm:update").Register("test:capture_update", collect); err != nil {
+		t.Fatalf("注册 Update 回调失败: %v", err)
+	}
+	return &out
+}
+
+// TestScopedCampusRowsSQL 校区过滤必须是 `IN (campus, 0)` 超集(不是等值),机构过滤强制注入。
+func TestScopedCampusRowsSQL(t *testing.T) {
+	db := newDryRunDB(t)
+	sc := &appscope.Scope{InstitutionID: 42, CampusID: 7}
+	q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
+	var out []model.CoursesActivity
+	if err := q.Where("id = ?", 1).Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	sql := q.Statement.SQL.String()
+	if !strings.Contains(sql, "institution_id = ?") {
+		t.Errorf("应强制注入 institution_id,SQL=%q", sql)
+	}
+	if !strings.Contains(sql, "campus_id IN") {
+		t.Errorf("校区过滤必须是 IN (campus,0) 超集(campus_id=0 语义=全自营校区),SQL=%q", sql)
+	}
+	if strings.Contains(sql, "campus_id = ?") {
+		t.Errorf("不得用等值过滤(会裁掉全机构活动),SQL=%q", sql)
+	}
+}
+
+// TestScopedCampusRowsSuperAdminAndCampusZero 超管不过滤;campus=0 时不加校区条件。
+func TestScopedCampusRowsSuperAdminAndCampusZero(t *testing.T) {
+	db := newDryRunDB(t)
+	// 超管
+	sc := &appscope.Scope{InstitutionID: 42, CampusID: 7, SuperAdmin: true}
+	q := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc, "")
+	var out []model.CoursesActivity
+	if err := q.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	if sql := q.Statement.SQL.String(); strings.Contains(sql, "institution_id") || strings.Contains(sql, "campus_id") {
+		t.Errorf("超管不应注入作用域过滤,SQL=%q", sql)
+	}
+	// 机构级视角(campus=0)
+	sc2 := &appscope.Scope{InstitutionID: 42, CampusID: 0}
+	q2 := scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), sc2, "")
+	if err := q2.Find(&out).Error; err != nil {
+		t.Fatalf("DryRun Find 失败: %v", err)
+	}
+	if sql := q2.Statement.SQL.String(); strings.Contains(sql, "campus_id") {
+		t.Errorf("CampusID=0 不应注入校区过滤,SQL=%q", sql)
+	}
+}
+
+// TestScopedCampusRowsNilScopePanics 无作用域进入数据层 → panic(fail-fast)。
+func TestScopedCampusRowsNilScopePanics(t *testing.T) {
+	db := newDryRunDB(t)
+	defer func() {
+		rec := recover()
+		if rec == nil {
+			t.Fatal("Sc 为 nil 时应 panic")
+		}
+		if !strings.Contains(rec.(string), "missing tenant scope") {
+			t.Fatalf("panic 文案不符: %v", rec)
+		}
+	}()
+	_ = scopedCampusRows(db.Session(&gorm.Session{NewDB: true}).Model(&model.CoursesActivity{}), nil, "")
+}
+
+// TestGetByIDScoping 跨机构活动 → ErrNotFound;campus_id=0 活动在本校区视角可见。
+func TestGetByIDScoping(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
+	seedActivity(t, db, 2, 1, 0, model.ActivityTypeVoucher, "50.00") // 0=机构所有自营校区
+	seedActivity(t, db, 3, 2, 2, model.ActivityTypeRecommend, "30.00")
+
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+
+	if got, err := repo.GetByID(context.Background(), 1); err != nil || got.ID != 1 {
+		t.Fatalf("GetByID(1) 应命中,got=%v err=%v", got, err)
+	}
+	if _, err := repo.GetByID(context.Background(), 2); err != nil {
+		t.Fatalf("campus_id=0 的活动应在本校区视角可见(IN (campus,0)),实得 %v", err)
+	}
+	if _, err := repo.GetByID(context.Background(), 3); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("跨机构活动必须 ErrNotFound(不泄露存在性),实得 %v", err)
+	}
+	if _, err := repo.GetByID(context.Background(), 0); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("id<=0 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestGetByIDsNoNPlusOne 批量取数:去重 + 单条 SQL(防 N+1)+ 缺失 id 不在 map 中。
+func TestGetByIDsNoNPlusOne(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "100.00")
+	seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "50.00")
+	seedActivity(t, db, 3, 9, 2, model.ActivityTypeRecommend, "30.00") // 他机构:应被裁掉
+
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	sqls := captureSQL(t, db)
+
+	got, err := repo.GetByIDs(context.Background(), []int64{1, 2, 2, 3, 0, -1})
+	if err != nil {
+		t.Fatalf("GetByIDs 失败: %v", err)
+	}
+	if len(got) != 2 || got[1] == nil || got[2] == nil {
+		t.Fatalf("应返回 {1,2} 两行(去重+作用域裁剪),实得 %v", got)
+	}
+	if len(*sqls) != 1 {
+		t.Fatalf("批量取数必须是 1 条 SQL(防 N+1),实得 %d 条: %v", len(*sqls), *sqls)
+	}
+	if !strings.Contains((*sqls)[0], "IN") {
+		t.Fatalf("应为 IN 批量查询,SQL=%q", (*sqls)[0])
+	}
+
+	// 空/全非法 → 空 map 且不发 SQL
+	*sqls = (*sqls)[:0]
+	empty, err := repo.GetByIDs(context.Background(), []int64{0, -3})
+	if err != nil || len(empty) != 0 {
+		t.Fatalf("空 ids 应返回空 map 且无错误,实得 %v err=%v", empty, err)
+	}
+	if len(*sqls) != 0 {
+		t.Fatalf("空 ids 不应发 SQL,实得 %v", *sqls)
+	}
+}
+
+// TestUpsertSignedDeleteInsertIdempotent DELETE+INSERT 幂等:同一 (user, activity) 只留一条且全字段重置。
+func TestUpsertSignedDeleteInsertIdempotent(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 10, 1, 2, model.ActivityTypeRecharge, "100.00")
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	var firstID int64
+	err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		row := &model.CoursesActivitySigned{
+			ActivityID:    10,
+			UserID:        "01HUSER000000000000000001",
+			CampusID:      2,
+			Status:        model.ActivitySignedSigned,
+			ActivityMoney: "100.00",
+			CreateTime:    time.Now(),
+		}
+		if err := repo.UpsertSigned(ctx, tx, row); err != nil {
+			return err
+		}
+		firstID = row.ID
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("首次 UpsertSigned 失败: %v", err)
+	}
+
+	// 模拟"先取消(status=0)后重新报名":旧行必须被 DELETE 掉,不能残留两条
+	err = runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		row := &model.CoursesActivitySigned{
+			ActivityID:    10,
+			UserID:        "01HUSER000000000000000001",
+			CampusID:      2,
+			Status:        model.ActivitySignedAttended,
+			ActivityMoney: "88.00",
+			CreateTime:    time.Now(),
+		}
+		return repo.UpsertSigned(ctx, tx, row)
+	})
+	if err != nil {
+		t.Fatalf("二次 UpsertSigned 失败: %v", err)
+	}
+
+	var rows []model.CoursesActivitySigned
+	if err := db.Where("user_id = ? AND activity_id = ?", "01HUSER000000000000000001", 10).Find(&rows).Error; err != nil {
+		t.Fatalf("查询报名行失败: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("DELETE+INSERT 幂等后应仅 1 行,实得 %d", len(rows))
+	}
+	if rows[0].Status != model.ActivitySignedAttended {
+		t.Fatalf("重新报名应全字段重置,实得 status=%d", rows[0].Status)
+	}
+	// 金额列在 sqlite 下按 NUMERIC 亲和回读("88.00" → 88),故用 money 归一后比较语义值
+	if amt, perr := money.Parse(rows[0].ActivityMoney); perr != nil || amt.Cents() != 8800 {
+		t.Fatalf("重新报名应重置 activity_money=88.00,实得 %q (err=%v)", rows[0].ActivityMoney, perr)
+	}
+	if rows[0].ID == firstID {
+		t.Fatalf("重新报名应插入新行(旧行被 DELETE),ID 未变 = %d", rows[0].ID)
+	}
+	if rows[0].InstitutionID != 1 {
+		t.Fatalf("institution_id 应由作用域补齐为 1,实得 %d", rows[0].InstitutionID)
+	}
+}
+
+// TestUpsertSignedGuards 事务/参数防御。
+func TestUpsertSignedGuards(t *testing.T) {
+	repo := NewActivityRepo(newActivityRepoDB(t), &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	if err := repo.UpsertSigned(ctx, nil, &model.CoursesActivitySigned{ActivityID: 1, UserID: "u"}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx 为 nil 应 ErrInternal(禁止脱离事务写),实得 %v", err)
+	}
+	if err := repo.UpsertSigned(ctx, repo.DB, &model.CoursesActivitySigned{ActivityID: 0, UserID: "u"}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("activity_id<=0 应 ErrBadParam,实得 %v", err)
+	}
+	if err := repo.UpsertSigned(ctx, repo.DB, nil); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("nil 行应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestCountSignedByUserType4Dedup type=4 跨活动去重计数;status=0 不计。
+func TestCountSignedByUserType4Dedup(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecommend, "10.00")
+	seedActivity(t, db, 2, 1, 2, model.ActivityTypeRecommend, "20.00")
+	seedActivity(t, db, 3, 1, 2, model.ActivityTypeRecommend, "30.00")
+	seedActivity(t, db, 4, 1, 2, model.ActivityTypeRecharge, "40.00")
+
+	user := "01HUSER000000000000000002"
+	seedSigned(t, db, 1, 1, 1, 2, user, model.ActivitySignedSigned)
+	seedSigned(t, db, 2, 2, 1, 2, user, model.ActivitySignedAttended)
+	seedSigned(t, db, 3, 3, 1, 2, user, model.ActivitySignedCancelled) // 已取消:不计
+	seedSigned(t, db, 4, 4, 1, 2, user, model.ActivitySignedSigned)    // type=2:type4Only 不计
+	seedSigned(t, db, 5, 1, 1, 2, "01HUSER000000000000000009", model.ActivitySignedSigned)
+
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	ctx := context.Background()
+
+	type4, err := repo.CountSignedByUser(ctx, user, true)
+	if err != nil {
+		t.Fatalf("CountSignedByUser(type4) 失败: %v", err)
+	}
+	if type4 != 2 {
+		t.Fatalf("type=4 有效报名数应为 2(取消行不计),实得 %d", type4)
+	}
+	all, err := repo.CountSignedByUser(ctx, user, false)
+	if err != nil {
+		t.Fatalf("CountSignedByUser(all) 失败: %v", err)
+	}
+	if all != 3 {
+		t.Fatalf("全部有效报名数应为 3,实得 %d", all)
+	}
+	if _, err := repo.CountSignedByUser(ctx, "  ", true); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestIncreaseNumberSignFloorZero 报名人数原子增减且下限 0。
+func TestIncreaseNumberSignFloorZero(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 7, 1, 2, model.ActivityTypeRecharge, "0.00")
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	readSign := func() int32 {
+		var row model.CoursesActivity
+		if err := db.Where("id = ?", 7).First(&row).Error; err != nil {
+			t.Fatalf("读活动失败: %v", err)
+		}
+		return row.NumberSign
+	}
+
+	// 取消到负数 → 钳制为 0
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncreaseNumberSign(ctx, tx, 7, -5)
+		return e
+	}); err != nil {
+		t.Fatalf("IncreaseNumberSign(-5) 失败: %v", err)
+	}
+	if got := readSign(); got != 0 {
+		t.Fatalf("number_sign 下限应为 0(防取消竞态),实得 %d", got)
+	}
+	// +2
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncreaseNumberSign(ctx, tx, 7, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("IncreaseNumberSign(+2) 失败: %v", err)
+	}
+	if got := readSign(); got != 2 {
+		t.Fatalf("number_sign 应为 2,实得 %d", got)
+	}
+	// -1
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncreaseNumberSign(ctx, tx, 7, -1)
+		return e
+	}); err != nil {
+		t.Fatalf("IncreaseNumberSign(-1) 失败: %v", err)
+	}
+	if got := readSign(); got != 1 {
+		t.Fatalf("number_sign 应为 1,实得 %d", got)
+	}
+	// delta=0 → 空操作
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.IncreaseNumberSign(ctx, tx, 7, 0)
+		if n != 0 {
+			t.Fatalf("delta=0 应无影响行,实得 %d", n)
+		}
+		return e
+	}); err != nil {
+		t.Fatalf("IncreaseNumberSign(0) 失败: %v", err)
+	}
+	// 不存在/越权活动 → 0 行
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.IncreaseNumberSign(ctx, tx, 999, 1)
+		if e != nil {
+			return e
+		}
+		if n != 0 {
+			t.Fatalf("活动不存在应 0 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("IncreaseNumberSign(999) 失败: %v", err)
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.IncreaseNumberSign(ctx, nil, 7, 1)
+		return e
+	}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx 为 nil 应 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestUpdateSignedStatusRows 状态更新返回影响行数(0 → Service 判 3001)。
+func TestUpdateSignedStatusRows(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 5, 1, 2, model.ActivityTypeVoucher, "0.00")
+	user := "01HUSER000000000000000003"
+	seedSigned(t, db, 1, 5, 1, 2, user, model.ActivitySignedSigned)
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.UpdateSignedStatus(ctx, tx, user, 5, model.ActivitySignedCancelled)
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("应更新 1 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("UpdateSignedStatus 失败: %v", err)
+	}
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.UpdateSignedStatus(ctx, tx, "01HNOPE000000000000000001", 5, model.ActivitySignedSigned)
+		if e != nil {
+			return e
+		}
+		if n != 0 {
+			t.Fatalf("不存在的报名行应 0 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("UpdateSignedStatus(缺失行) 失败: %v", err)
+	}
+
+	var row model.CoursesActivitySigned
+	if err := db.Where("id = ?", 1).First(&row).Error; err != nil {
+		t.Fatalf("读报名行失败: %v", err)
+	}
+	if row.Status != model.ActivitySignedCancelled {
+		t.Fatalf("状态应更新为 0,实得 %d", row.Status)
+	}
+}
+
+// TestListSignedByStudentCourseCursor 游标方向(ASC/DESC,dump 顺序)与白名单校验。
+func TestListSignedByStudentCourseCursor(t *testing.T) {
+	db := newActivityRepoDB(t)
+	ctx := context.Background()
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "10.00")
+	seedActivity(t, db, 2, 1, 2, model.ActivityTypeVoucher, "20.00")
+	user := "01HUSER000000000000000004"
+	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()},
+	}
+	for i := range pools {
+		if err := db.Create(&pools[i]).Error; err != nil {
+			t.Fatalf("seed pool 失败: %v", err)
+		}
+	}
+	stuCourse := int64(900)
+	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()},
+	}
+	for i := range logs {
+		if err := db.Create(&logs[i]).Error; err != nil {
+			t.Fatalf("seed discount log 失败: %v", err)
+		}
+	}
+
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+
+	asc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderAsc)
+	if err != nil {
+		t.Fatalf("ListSignedByStudentCourse(asc) 失败: %v", err)
+	}
+	if len(asc) != 2 || asc[0].ID >= asc[1].ID {
+		t.Fatalf("ASC 应返回按 id 升序的 2 行,实得 %v", asc)
+	}
+	desc, err := repo.ListSignedByStudentCourse(ctx, stuCourse, OrderDesc)
+	if err != nil {
+		t.Fatalf("ListSignedByStudentCourse(desc) 失败: %v", err)
+	}
+	if len(desc) != 2 || desc[0].ID <= desc[1].ID {
+		t.Fatalf("DESC 应返回按 id 降序的 2 行,实得 %v", desc)
+	}
+	// 非法方向必须拒绝(防 ORDER BY 注入)
+	if _, err := repo.ListSignedByStudentCourse(ctx, stuCourse, "asc; DROP TABLE x"); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("非法排序方向应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.ListSignedByStudentCourse(ctx, 0, OrderAsc); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("stuCourseID<=0 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestNormalizeOrder 方向归一(大小写/空白容忍,非法拒绝)。
+func TestNormalizeOrder(t *testing.T) {
+	for _, in := range []string{"", "asc", "ASC", " Asc "} {
+		if got, err := normalizeOrder(in); err != nil || got != OrderAsc {
+			t.Fatalf("normalizeOrder(%q) = (%q,%v),期望 (asc,nil)", in, got, err)
+		}
+	}
+	if got, err := normalizeOrder("DESC"); err != nil || got != OrderDesc {
+		t.Fatalf("normalizeOrder(DESC) = (%q,%v),期望 (desc,nil)", got, err)
+	}
+	if _, err := normalizeOrder("id desc, (select 1)"); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("非法方向应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestWriteAuditTimeFallback 零值 create_time 必须被仓储兜底。
+//
+// 背景(实测确认):活动域 model 的审计列名为 `CreateTime`(非 GORM 约定的 `CreatedAt`),
+// 且无 `default:CURRENT_TIMESTAMP(3)` tag → GORM 会把 0001-01-01 显式写库,DB 默认值兜不住。
+// 审计时间是对账依据,故三条写路径(signed / discount_log / used_log)都必须兜底。
+func TestWriteAuditTimeFallback(t *testing.T) {
+	db := newActivityRepoDB(t)
+	seedActivity(t, db, 1, 1, 2, model.ActivityTypeRecharge, "0.00")
+	seedPool(t, db, 1, 1, 2, "01HUSER000000000000000099", "10.00", "0.00")
+	repo := NewActivityRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	drepo := NewActivityDiscountRepo(db, &appscope.Scope{InstitutionID: 1, CampusID: 2})
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	user := "01HUSER000000000000000099"
+	stuCourse := int64(902)
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if e := repo.UpsertSigned(ctx, tx, &model.CoursesActivitySigned{
+			ActivityID: 1, UserID: user, CampusID: 2, Status: model.ActivitySignedSigned,
+		}); e != nil {
+			return e
+		}
+		if e := drepo.InsertDiscountLog(ctx, tx, &model.CoursesActivityUserDiscountLog{
+			ActivityUserDiscountID: 1, CoursesStudentID: &stuCourse, DiscountAmountUsed: "-1.00",
+		}); e != nil {
+			return e
+		}
+		return drepo.InsertUsedLog(ctx, tx, &model.CoursesActivityUsedLog{
+			ActivityID: 1, UserID: user, OrderNo: ptrString("902"), ActivityMoneyUsed: "-1.00",
+		})
+	}); err != nil {
+		t.Fatalf("写入失败: %v", err)
+	}
+
+	var signed model.CoursesActivitySigned
+	if err := db.Where("user_id = ?", user).First(&signed).Error; err != nil {
+		t.Fatalf("读 signed 失败: %v", err)
+	}
+	if signed.CreateTime.IsZero() {
+		t.Fatal("signed.create_time 必须被兜底(否则审计时间被写成 0001-01-01)")
+	}
+	var dlog model.CoursesActivityUserDiscountLog
+	if err := db.Where("activity_user_discount_id = ?", 1).First(&dlog).Error; err != nil {
+		t.Fatalf("读 discount log 失败: %v", err)
+	}
+	if dlog.CreateTime.IsZero() {
+		t.Fatal("discount_log.create_time 必须被兜底")
+	}
+	var ulog model.CoursesActivityUsedLog
+	if err := db.Where("user_id = ?", user).First(&ulog).Error; err != nil {
+		t.Fatalf("读 used log 失败: %v", err)
+	}
+	if ulog.CreateTime.IsZero() {
+		t.Fatal("used_log.create_time 必须被兜底")
+	}
+}

+ 462 - 0
internal/academic/repository/divide_repo.go

@@ -0,0 +1,462 @@
+// 分账树仓储(t_user_divide_into / t_user_divide_into_log)——AC-09。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.3 #22~#25(分账 4 SP)
+// / §1.7「全局无作用域表·例外二」/ §11.1 拍板 11-12。
+//
+// **本仓储不继承 BaseRepo**(设计 §11.1 拍板:全局推广体系无 institution_id/campus_id):
+//   - 两表无租户列,`Scoped` 无从下手(叠 institution_id 会 Unknown column);
+//   - 越权防线改为 **Handler 角色白名单(super_admin/academic)+ Service 显式数据范围参数**,
+//     本文件的数据访问方法因此**不接受**作用域参数,评审时必须显式标注"平台级/全局数据";
+//   - 禁止把本仓储用于任何机构/校区维度的报表裁剪(需要裁剪时按 user_id 精确取数)。
+//
+// 表语义(Nested Set):
+//
+//	t_user_divide_into     一人一节点(PK user_id);root_user_id 标记所属树;
+//	                       left_val/right_val 为嵌套集区间(恒有 left < right);layer 根=1。
+//	t_user_divide_into_log 分成明细(只增;type=1 同 user+student / type=2 同 user+student+order 幂等)。
+//
+// 事务纪律:写方法一律接收 `tx *gorm.DB`(仓储不开事务,事务边界在 DivideService)。
+package repository
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// DivideRepo 分账树仓储(全局数据,**不继承 BaseRepo**,见文件头)。
+type DivideRepo struct {
+	// DB 数据库句柄(连接池由 infra/db 统一建立;nil 时任何查询返回 ErrInternal)。
+	DB *gorm.DB
+}
+
+// NewDivideRepo 构造分账树仓储。
+//
+// 入参:db *gorm.DB——连接池句柄(必填)。
+// 出参:*DivideRepo。
+func NewDivideRepo(db *gorm.DB) *DivideRepo { return &DivideRepo{DB: db} }
+
+// db 取查询句柄(ctx 内含 tx 时复用同一事务,保证事务内读一致)。
+//
+// 入参:ctx context.Context(可 nil)。出参:(*gorm.DB, error);未装配 DB → ErrInternal。
+func (r *DivideRepo) db(ctx context.Context) (*gorm.DB, error) {
+	if r == nil || r.DB == nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrInternal, errString("repository: DivideRepo.DB 为 nil"))
+	}
+	if tx := TxFromContext(ctx); tx != nil {
+		return tx, nil
+	}
+	if ctx == nil {
+		return r.DB, nil
+	}
+	return r.DB.WithContext(ctx), nil
+}
+
+// GetByUserID 取分账树节点(主键 user_id)。
+//
+// 入参:ctx 请求上下文;userID 节点用户(ULID;空 → ErrBadParam)。
+// 出参:
+//   - *model.UserDivideInto:节点(left_val/right_val/layer/quota 原样返回,金额为字符串);
+//   - ErrNotFound(3001):该用户尚未进入分账体系(**不是**错误路径,调用方按业务分支处理);
+//   - ErrBadParam / ErrDBError。
+func (r *DivideRepo) GetByUserID(ctx context.Context, userID string) (*model.UserDivideInto, error) {
+	if strings.TrimSpace(userID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetByUserID 需要 user_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var out model.UserDivideInto
+	if err := db.Where("user_id = ?", userID).First(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "GetByUserID(t_user_divide_into)")
+	}
+	return &out, nil
+}
+
+// GetSubtree 取节点子树(严格位于 (leftVal, rightVal) 区间内的后代)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - leftVal/rightVal int32:父节点的左右值(Nested Set 区间;rightVal <= leftVal → ErrBadParam);
+//   - divideType int8:分成类型过滤(model.DivideTypeOneOff / DivideTypePerLesson;0 = 不筛类型)。
+//
+// 出参:
+//   - []model.UserDivideInto:后代节点,按 left_val 升序(= 树的深度优先前序,便于逐层遍历);
+//   - ErrBadParam / ErrDBError。
+//
+// 用途:额度级联下降(update_quota)/ 子树统计。**不含**父节点自身。
+func (r *DivideRepo) GetSubtree(ctx context.Context, leftVal, rightVal int32, divideType int8) ([]model.UserDivideInto, error) {
+	if leftVal < 0 || rightVal <= leftVal {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: GetSubtree 左右值区间非法"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := db.Model(&model.UserDivideInto{}).
+		Where("left_val > ? AND right_val < ?", leftVal, rightVal)
+	if divideType > 0 {
+		q = q.Where("type = ?", divideType)
+	}
+	var rows []model.UserDivideInto
+	if err := q.Order("left_val asc").Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// ShiftRight 右值扩张:`right_val = right_val + delta WHERE right_val >= threshold`。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - 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)
+}
+
+// ShiftLeft 左值扩张:`left_val = left_val + delta WHERE 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)
+}
+
+// shift 左右值扩张的公共实现(列名由内部常量传入,**不接受外部字符串**,防注入)。
+//
+// 入参:col 列名("left_val"/"right_val");threshold/delta 语义见 ShiftRight。
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+func (r *DivideRepo) shift(ctx context.Context, tx *gorm.DB, col 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 threshold < 0 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: shift 阈值不能为负"))
+	}
+	if delta == 0 {
+		return 0, nil
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+		Where(col+" >= ?", threshold).
+		UpdateColumn(col, gorm.Expr(col+" + ?", delta))
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// InsertNode 插入分账树节点(新节点左右值由 Service 用嵌套集算法算出)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - node 节点(UserID 非空;必须满足 LeftVal < RightVal,否则 ErrBadParam)。
+//
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 红线:与 ShiftRight/ShiftLeft **必须同事务**;已存在同 user_id 节点 → 唯一键冲突(ErrDBError,
+// 调用方按"已存在分支"重入,不得重试插入)。
+func (r *DivideRepo) InsertNode(ctx context.Context, tx *gorm.DB, node *model.UserDivideInto) error {
+	if err := requireTx(tx, "InsertNode"); err != nil {
+		return err
+	}
+	if node == nil || strings.TrimSpace(node.UserID) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertNode 需要 user_id"))
+	}
+	if node.LeftVal >= node.RightVal {
+		return infraerrors.WithCause(infraerrors.ErrBadParam,
+			errString("repository: InsertNode 违反 Nested Set 不变式 left_val < right_val"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(node).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// InsertLog 写入分成明细(**只增**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - log 明细(Type∈{1,2}、UserID/StudentID 非空、Money 为元字符串)。
+//
+// 出参:nil 成功;ErrBadParam / ErrDBError。
+//
+// 红线:与节点 total_income 累加(IncTotalIncome)必须同事务(否则"明细有、收入没加")。
+func (r *DivideRepo) InsertLog(ctx context.Context, tx *gorm.DB, log *model.UserDivideIntoLog) error {
+	if err := requireTx(tx, "InsertLog"); err != nil {
+		return err
+	}
+	if log == nil || strings.TrimSpace(log.UserID) == "" || strings.TrimSpace(log.StudentID) == "" {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 需要 user_id/student_id"))
+	}
+	if log.Type != model.DivideTypeOneOff && log.Type != model.DivideTypePerLesson {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: InsertLog 分成类型非法"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Create(log).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// DeleteLog 物理删除分成明细(撤销分支二:回滚 total_income 后删行)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - tx 事务句柄(**必传**);
+//   - id 明细 ID(<=0 → ErrBadParam)。
+//
+// 出参:nil 成功/行不存在(幂等);ErrBadParam / ErrDBError。
+//
+// 红线(设计 §3.3 #23):旧语义允许物理删除本表行(与"流水只增"纪律的唯一例外),
+// 但必须与 total_income 回滚同事务;删除前须判定"是否已有首次扣费上课记录"(改绑分支优先)。
+func (r *DivideRepo) DeleteLog(ctx context.Context, tx *gorm.DB, id int64) error {
+	if err := requireTx(tx, "DeleteLog"); err != nil {
+		return err
+	}
+	if id <= 0 {
+		return infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: DeleteLog 需要 id"))
+	}
+	if err := tx.WithContext(ctxOrBackground(ctx)).Where("id = ?", id).
+		Delete(&model.UserDivideIntoLog{}).Error; err != nil {
+		return infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return nil
+}
+
+// FindLog 按**幂等键**查分成明细(两级幂等判定唯一入口)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 获得分成的用户(ULID);studentID 报课学生(ULID);divideType 分成类型;
+//   - orderNo 订单号(type=2 的上课记录 id 字符串;**type=1 时忽略**——一次付清不区分订单)。
+//
+// 出参:
+//   - *model.UserDivideIntoLog:命中行;
+//   - ErrNotFound:未命中(调用方据此判定"未生成",非异常);
+//   - ErrBadParam / ErrDBError。
+//
+// 幂等键(dump divide_into_log_add 口径,**不可改**):
+//   - type=1(一次付清):(user_id, student_id, type);
+//   - type=2(按课次)  :(user_id, student_id, type, order_no)。
+func (r *DivideRepo) FindLog(ctx context.Context, userID, studentID string, divideType int8, orderNo string) (*model.UserDivideIntoLog, error) {
+	if strings.TrimSpace(userID) == "" || strings.TrimSpace(studentID) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: FindLog 需要 user_id/student_id"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	q := db.Model(&model.UserDivideIntoLog{}).
+		Where("user_id = ? AND student_id = ? AND type = ?", userID, studentID, divideType)
+	if divideType == model.DivideTypePerLesson {
+		q = q.Where("order_no = ?", strings.TrimSpace(orderNo))
+	}
+	var out model.UserDivideIntoLog
+	if err := q.Order("id asc").First(&out).Error; err != nil {
+		return nil, wrapQueryErr(err, "FindLog(t_user_divide_into_log)")
+	}
+	return &out, nil
+}
+
+// ListLogsByStudentOrder 取某「学生 + 订单」产生的**全部**分成明细(撤销的两参入口)。
+//
+// 入参:ctx 请求上下文;studentID 报课学生(ULID);orderNo 订单号(上课记录 id 字符串)。
+// 出参:
+//   - []model.UserDivideIntoLog:按 id 升序(生成先后;空切片表示无记录,幂等空操作);
+//   - ErrBadParam / ErrDBError。
+//
+// 语义(dump `divide_into_log_delete(studentid, orderid)` 两参):同一订单的分成会沿上行链
+// 生成多行(本人 + 各级上线),撤销时必须**整组处理**,故按 (student_id, order_no) 取全量,
+// 而非只取一行。
+func (r *DivideRepo) ListLogsByStudentOrder(ctx context.Context, studentID, orderNo string) ([]model.UserDivideIntoLog, error) {
+	if strings.TrimSpace(studentID) == "" || strings.TrimSpace(orderNo) == "" {
+		return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: ListLogsByStudentOrder 需要 student_id/order_no"))
+	}
+	db, err := r.db(ctx)
+	if err != nil {
+		return nil, err
+	}
+	var rows []model.UserDivideIntoLog
+	if err := db.Model(&model.UserDivideIntoLog{}).
+		Where("student_id = ? AND order_no = ?", studentID, orderNo).
+		Order("id asc").
+		Find(&rows).Error; err != nil {
+		return nil, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return rows, nil
+}
+
+// UpdateNodeParent 改挂节点父链(`subid 已存在`分支:只改父/根/层级,**不重排左右值**)。
+//
+// 入参:
+//   - ctx 请求上下文;tx 事务句柄(**必传**);
+//   - userID 被改挂节点;parentUserID 新上级;rootUserID 所属树根;layer 新层级(根=1)。
+//
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+//
+// 红线(修正 dump 缺陷,模型注释 §UserDivideInto.root_user_id 已标注):
+// 旧 SP 在该分支**漏写 root_user_id**(留 NULL,导致子树查询/级联失效);本方法强制显式写根。
+// 遗留:不重排左右值(与原 SP 一致)——若被改挂节点自带子树,区间可能出现交叠,
+// 已在交付说明中登记为遗留项(AC-09 范围内不引入区间重排,避免与迁移期数据冲突)。
+func (r *DivideRepo) UpdateNodeParent(ctx context.Context, tx *gorm.DB, userID, parentUserID, rootUserID string, layer int32) (int64, error) {
+	if err := requireTx(tx, "UpdateNodeParent"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" || strings.TrimSpace(parentUserID) == "" || strings.TrimSpace(rootUserID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateNodeParent 需要 user/parent/root"))
+	}
+	if layer < 1 {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateNodeParent layer 必须 >= 1"))
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+		Where("user_id = ?", userID).
+		Updates(map[string]any{
+			"parent_user_id": parentUserID,
+			"root_user_id":   rootUserID,
+			"layer":          layer,
+		})
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// UpdateNodeQuota 更新节点分成额度(不触碰左右值/收入)。
+//
+// 入参:ctx;tx(**必传**);userID 节点;quota 新额度(单位:分;负 → ErrBadParam)。
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+//
+// 红线:**必须**与 ClampSubtreeQuota 同事务(否则出现"父降了、子仍高于父"的越权额度窗口)。
+func (r *DivideRepo) UpdateNodeQuota(ctx context.Context, tx *gorm.DB, userID string, quota money.Amount) (int64, error) {
+	if err := requireTx(tx, "UpdateNodeQuota"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: UpdateNodeQuota 需要 user_id"))
+	}
+	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{}).
+		Where("user_id = ?", userID).
+		UpdateColumn("quota", quota.String())
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// ClampSubtreeQuota 额度级联下降:把子树内**超过**上限的节点统一压到上限。
+//
+// 入参:
+//   - ctx;tx(**必传**);
+//   - leftVal/rightVal int32 父节点区间(rightVal <= leftVal → ErrBadParam);
+//   - quota 上限(单位:分;负 → ErrBadParam)。
+//
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+//
+// SQL:`UPDATE ... SET quota = ? WHERE left_val > ? AND right_val < ? AND quota > ?`
+// 语义("子不得超父"):只降不升——低于上限的节点保持原额度(分别配置的阶梯额度不被抹平)。
+func (r *DivideRepo) ClampSubtreeQuota(ctx context.Context, tx *gorm.DB, leftVal, rightVal int32, 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 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{}).
+		Where("left_val > ? AND right_val < ?", leftVal, rightVal).
+		Where("quota > ?", quota.String()).
+		UpdateColumn("quota", quota.String())
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// IncTotalIncome 累加节点分成总收入(上行分成 +,撤销回滚 −)。
+//
+// 入参:ctx;tx(**必传**);userID 节点;delta 增量(单位:分;0 → 空操作)。
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+//
+// 红线:与 InsertLog/DeleteLog 同事务;金额字符串入场(decimal 精确,零 float64)。
+func (r *DivideRepo) IncTotalIncome(ctx context.Context, tx *gorm.DB, userID string, delta money.Amount) (int64, error) {
+	if err := requireTx(tx, "IncTotalIncome"); err != nil {
+		return 0, err
+	}
+	if strings.TrimSpace(userID) == "" {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("repository: IncTotalIncome 需要 user_id"))
+	}
+	if delta.IsZero() {
+		return 0, nil
+	}
+	if err := delta.Validate(); err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err)
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideInto{}).
+		Where("user_id = ?", userID).
+		UpdateColumn("total_income", gorm.Expr("total_income + ?", delta.String()))
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// UpdateLogOrderNo 改绑分成明细的订单号(撤销分支一:保留分成,改指首次扣费上课记录)。
+//
+// 入参:ctx;tx(**必传**);id 明细 ID;orderNo 新订单号(上课记录 id 字符串);remark 备注(可空)。
+// 出参:int64 受影响行数;ErrBadParam / ErrDBError。
+//
+// 红线(dump 口径):改绑**不改 money**、**不回滚 total_income**(分成保留);
+// order_no 是 type=2 的幂等键组成列,改绑即改幂等语义 → 必须写审计备注(默认"推广分成")。
+func (r *DivideRepo) UpdateLogOrderNo(ctx context.Context, tx *gorm.DB, id int64, orderNo, remark string) (int64, error) {
+	if err := requireTx(tx, "UpdateLogOrderNo"); err != nil {
+		return 0, err
+	}
+	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}
+	if strings.TrimSpace(remark) != "" {
+		fields["remark"] = remark
+	}
+	res := tx.WithContext(ctxOrBackground(ctx)).Model(&model.UserDivideIntoLog{}).
+		Where("id = ?", id).
+		Updates(fields)
+	if res.Error != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, res.Error)
+	}
+	return res.RowsAffected, nil
+}
+
+// IsNotFound 判断错误是否为仓储 ErrNotFound(供 Service 分支判断,避免各处抄 errors.Is)。
+//
+// 入参:err error(可 nil)。出参:true 表示"记录不存在"。
+func IsNotFound(err error) bool { return err != nil && errors.Is(err, infraerrors.ErrNotFound) }

+ 490 - 0
internal/academic/repository/divide_repo_test.go

@@ -0,0 +1,490 @@
+// DivideRepo 单测(AC-09):左右值位移 / 嵌套集不变式守卫 / 两级幂等键 / 撤销原语 / 额度级联原语。
+//
+// 注意:本仓储**不继承 BaseRepo**(全局无作用域表),因此没有 scoping 用例;
+// 越权防线在 Handler 角色白名单(AC-14)+ Service 显式参数,本层只保证数据正确性。
+package repository
+
+import (
+	"context"
+	"errors"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// newDivideRepoDB 分账域真实库(文件库;2 表 AutoMigrate)。
+func newDivideRepoDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_divide_repo_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(分账文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil {
+		t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err)
+	}
+	return db
+}
+
+// seedDivideNode 插入分账树节点(显式左右值,用于原语级用例)。
+func seedDivideNode(t *testing.T, db *gorm.DB, userID string, typ int8, left, right, layer int32, quota string, parent, root *string) {
+	t.Helper()
+	row := &model.UserDivideInto{
+		UserID:           userID,
+		Type:             typ,
+		Quota:            quota,
+		TotalIncome:      "0.00",
+		TotalIncomePayed: "0.00",
+		RootUserID:       root,
+		ParentUserID:     parent,
+		LeftVal:          left,
+		RightVal:         right,
+		Layer:            layer,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 分账节点(%s) 失败: %v", userID, err)
+	}
+}
+
+// loadNode 读节点(断言辅助)。
+func loadNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto {
+	t.Helper()
+	var row model.UserDivideInto
+	if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
+		t.Fatalf("读节点(%s) 失败: %v", userID, err)
+	}
+	return row
+}
+
+// centsOf 把库里的金额字符串归一为「分」(sqlite NUMERIC 亲和回读会丢 "0.00" 尾巴,故不得比字符串)。
+func centsOf(t *testing.T, s string) int64 {
+	t.Helper()
+	amt, err := money.Parse(s)
+	if err != nil {
+		t.Fatalf("金额解析失败 %q: %v", s, err)
+	}
+	return amt.Cents()
+}
+
+// TestDivideGetByUserID 命中 / 未命中(ErrNotFound)/ 参数防御。
+func TestDivideGetByUserID(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000001"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root)
+	repo := NewDivideRepo(db)
+	ctx := context.Background()
+
+	got, err := repo.GetByUserID(ctx, root)
+	if err != nil {
+		t.Fatalf("GetByUserID 失败: %v", err)
+	}
+	if got.LeftVal != 1 || got.RightVal != 2 || got.Layer != 1 {
+		t.Fatalf("节点左右值/层级不符: %+v", got)
+	}
+	if _, err := repo.GetByUserID(ctx, "01HNONE000000000000000001"); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("不存在应 ErrNotFound,实得 %v", err)
+	}
+	if _, err := repo.GetByUserID(ctx, ""); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := (&DivideRepo{}).GetByUserID(ctx, root); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("DB 未装配应 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestDivideShiftLeftRight 左右值位移(+2/-2 与阈值语义)。
+func TestDivideShiftLeftRight(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000002"
+	a, b := "01HNODEA00000000000000002", "01HNODEB00000000000000002"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
+	seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root)
+	seedDivideNode(t, db, b, model.DivideTypeOneOff, 4, 5, 2, "5.00", &root, &root)
+
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	// 在 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)
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("ShiftRight(6,+2) 应影响 1 行(root),实得 %d", n)
+		}
+		_, e = repo.ShiftLeft(ctx, tx, 6, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("位移失败: %v", err)
+	}
+	if got := loadNode(t, db, root); got.RightVal != 8 {
+		t.Fatalf("root.right 应为 8,实得 %d", got.RightVal)
+	}
+	// 在 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 {
+			return e
+		}
+		_, e := repo.ShiftLeft(ctx, tx, 3, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("位移失败: %v", err)
+	}
+	if got := loadNode(t, db, root); got.LeftVal != 1 || got.RightVal != 10 {
+		t.Fatalf("root 应为 (1,10),实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+	if got := loadNode(t, db, a); got.LeftVal != 2 || got.RightVal != 5 {
+		t.Fatalf("a 应为 (2,5),实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+	if got := loadNode(t, db, b); got.LeftVal != 6 || got.RightVal != 7 {
+		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) {
+		t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
+	}
+	if _, err := repo.ShiftLeft(ctx, repo.DB, -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 {
+		t.Fatalf("delta=0 应空操作,实得 n=%d err=%v", n, err)
+	}
+}
+
+// TestDivideInsertNodeGuards 嵌套集不变式(left<right)与事务必传。
+func TestDivideInsertNodeGuards(t *testing.T) {
+	db := newDivideRepoDB(t)
+	repo := NewDivideRepo(db)
+	ctx := context.Background()
+
+	if err := repo.InsertNode(ctx, nil, &model.UserDivideInto{UserID: "u1", LeftVal: 1, RightVal: 2}); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
+	}
+	if err := repo.InsertNode(ctx, db, &model.UserDivideInto{UserID: "u1", LeftVal: 5, RightVal: 5}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("left>=right 应 ErrBadParam(不变式守卫),实得 %v", err)
+	}
+	if err := repo.InsertNode(ctx, db, &model.UserDivideInto{UserID: " ", LeftVal: 1, RightVal: 2}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+	if err := repo.InsertNode(ctx, db, &model.UserDivideInto{
+		UserID: "01HOK00000000000000000001", Type: model.DivideTypeOneOff, Quota: "1.00",
+		TotalIncome: "0.00", TotalIncomePayed: "0.00", LeftVal: 1, RightVal: 2, Layer: 1,
+	}); err != nil {
+		t.Fatalf("InsertNode 失败: %v", err)
+	}
+}
+
+// TestDivideGetSubtree 子树查询(严格区间 + 类型过滤 + 前序排序)。
+func TestDivideGetSubtree(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000003"
+	a, b := "01HNODEA00000000000000003", "01HNODEB00000000000000003"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
+	seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 3, 2, "5.00", &root, &root)
+	seedDivideNode(t, db, b, model.DivideTypePerLesson, 4, 5, 2, "5.00", &root, &root)
+
+	repo := NewDivideRepo(db)
+	ctx := context.Background()
+
+	all, err := repo.GetSubtree(ctx, 1, 6, 0)
+	if err != nil {
+		t.Fatalf("GetSubtree 失败: %v", err)
+	}
+	if len(all) != 2 || all[0].UserID != a || all[1].UserID != b {
+		t.Fatalf("子树应为 [a,b](left 升序),实得 %+v", all)
+	}
+	onlyOne, err := repo.GetSubtree(ctx, 1, 6, model.DivideTypePerLesson)
+	if err != nil {
+		t.Fatalf("GetSubtree(type) 失败: %v", err)
+	}
+	if len(onlyOne) != 1 || onlyOne[0].UserID != b {
+		t.Fatalf("type=2 子树应仅 [b],实得 %+v", onlyOne)
+	}
+	if _, err := repo.GetSubtree(ctx, 6, 1, 0); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestDivideFindLogKeys 两级幂等键(type=1 忽略订单;type=2 含订单)。
+func TestDivideFindLogKeys(t *testing.T) {
+	db := newDivideRepoDB(t)
+	repo := NewDivideRepo(db)
+	ctx := context.Background()
+	stu := "01HSTU00000000000000000001"
+	u := "01HUSER000000000000000021"
+
+	if err := db.Create(&model.UserDivideIntoLog{
+		Type: model.DivideTypeOneOff, UserID: u, StudentID: stu, OrderNo: ptrString("100"),
+		Money: "10.00", CreateTime: time.Now(),
+	}).Error; err != nil {
+		t.Fatalf("seed log 失败: %v", err)
+	}
+	if err := db.Create(&model.UserDivideIntoLog{
+		Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("200"),
+		Money: "5.00", CreateTime: time.Now(),
+	}).Error; err != nil {
+		t.Fatalf("seed log 失败: %v", err)
+	}
+
+	// type=1:任意 order 均命中同一行(一次付清口径)
+	got, err := repo.FindLog(ctx, u, stu, model.DivideTypeOneOff, "999")
+	if err != nil {
+		t.Fatalf("FindLog(type1) 失败: %v", err)
+	}
+	if c := centsOf(t, got.Money); c != 1000 {
+		t.Fatalf("type=1 应命中 10.00 的行,实得 %q(%d 分)", got.Money, c)
+	}
+	// type=2:order 必须匹配
+	if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "200"); err != nil {
+		t.Fatalf("FindLog(type2,200) 应命中: %v", err)
+	}
+	if _, err := repo.FindLog(ctx, u, stu, model.DivideTypePerLesson, "999"); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("type=2 不同 order 应 ErrNotFound,实得 %v", err)
+	}
+	if _, err := repo.FindLog(ctx, "", stu, model.DivideTypeOneOff, "1"); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestDivideListLogsByStudentOrder 两参撤销入口:按 (student, order) 取全量上行明细。
+func TestDivideListLogsByStudentOrder(t *testing.T) {
+	db := newDivideRepoDB(t)
+	repo := NewDivideRepo(db)
+	ctx := context.Background()
+	stu := "01HSTU00000000000000000002"
+	for _, u := range []string{"01HUSER000000000000000031", "01HUSER000000000000000032", "01HUSER000000000000000033"} {
+		if err := db.Create(&model.UserDivideIntoLog{
+			Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"),
+			Money: "1.00", CreateTime: time.Now(),
+		}).Error; err != nil {
+			t.Fatalf("seed log 失败: %v", err)
+		}
+	}
+	rows, err := repo.ListLogsByStudentOrder(ctx, stu, "777")
+	if err != nil {
+		t.Fatalf("ListLogsByStudentOrder 失败: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("应返回同一订单的 3 条上行明细,实得 %d", len(rows))
+	}
+	if rows[0].ID >= rows[1].ID || rows[1].ID >= rows[2].ID {
+		t.Fatalf("应按 id 升序返回,实得 %+v", rows)
+	}
+	empty, err := repo.ListLogsByStudentOrder(ctx, stu, "888")
+	if err != nil || len(empty) != 0 {
+		t.Fatalf("无匹配应返回空切片,实得 %v err=%v", empty, err)
+	}
+	if _, err := repo.ListLogsByStudentOrder(ctx, stu, ""); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 order_no 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestDivideQuotaPrimitives 额度更新 / 级联只降不升 / 收入累加。
+func TestDivideQuotaPrimitives(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000004"
+	a := "01HNODEA00000000000000004"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 6, 1, "10.00", nil, &root)
+	seedDivideNode(t, db, a, model.DivideTypeOneOff, 2, 5, 2, "8.00", &root, &root)
+
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	// 级联只降:子树限制 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))
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("应压制 1 个子节点,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("ClampSubtreeQuota 失败: %v", err)
+	}
+	if got := loadNode(t, db, a); centsOf(t, got.Quota) != 400 {
+		t.Fatalf("a.quota 应压到 4.00,实得 %q", got.Quota)
+	}
+	// 再压到 6.00(高于现值)→ 不回升(影响 0 行)
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.ClampSubtreeQuota(ctx, tx, 1, 6, money.FromCents(600))
+		if e != nil {
+			return e
+		}
+		if n != 0 {
+			t.Fatalf("压制只降不升,应 0 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("ClampSubtreeQuota(仅降) 失败: %v", err)
+	}
+
+	// 收入累加 + 冲正
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		if _, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(1250)); e != nil {
+			return e
+		}
+		_, e := repo.IncTotalIncome(ctx, tx, a, money.FromCents(-250))
+		return e
+	}); err != nil {
+		t.Fatalf("IncTotalIncome 失败: %v", err)
+	}
+	if got := loadNode(t, db, a); centsOf(t, got.TotalIncome) != 1000 {
+		t.Fatalf("a.total_income 应为 10.00,实得 %q", got.TotalIncome)
+	}
+
+	// 额度更新 + 负额度拒绝
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.UpdateNodeQuota(ctx, tx, a, money.FromCents(1500))
+		return e
+	}); err != nil {
+		t.Fatalf("UpdateNodeQuota 失败: %v", err)
+	}
+	if got := loadNode(t, db, a); centsOf(t, got.Quota) != 1500 {
+		t.Fatalf("a.quota 应为 15.00,实得 %q", got.Quota)
+	}
+	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) {
+		t.Fatalf("非法区间应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestDivideLogPrimitives InsertLog 类型校验 / UpdateLogOrderNo 改绑 / DeleteLog 删行。
+func TestDivideLogPrimitives(t *testing.T) {
+	db := newDivideRepoDB(t)
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+	stu := "01HSTU00000000000000000003"
+	u := "01HUSER000000000000000041"
+
+	if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: 9, UserID: u, StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("非法 type 应 ErrBadParam,实得 %v", err)
+	}
+	if err := repo.InsertLog(ctx, db, &model.UserDivideIntoLog{Type: model.DivideTypeOneOff, UserID: "", StudentID: stu}); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)
+	}
+
+	var logID int64
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		row := &model.UserDivideIntoLog{
+			Type: model.DivideTypePerLesson, UserID: u, StudentID: stu, OrderNo: ptrString("777"),
+			Money: "3.50", CreateTime: time.Now(),
+		}
+		if e := repo.InsertLog(ctx, tx, row); e != nil {
+			return e
+		}
+		logID = row.ID
+		return nil
+	}); err != nil {
+		t.Fatalf("InsertLog 失败: %v", err)
+	}
+
+	// 改绑:order_no=888 + 备注
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		n, e := repo.UpdateLogOrderNo(ctx, tx, logID, "888", "推广分成")
+		if e != nil {
+			return e
+		}
+		if n != 1 {
+			t.Fatalf("改绑应影响 1 行,实得 %d", n)
+		}
+		return nil
+	}); err != nil {
+		t.Fatalf("UpdateLogOrderNo 失败: %v", err)
+	}
+	var row model.UserDivideIntoLog
+	if err := db.Where("id = ?", logID).First(&row).Error; err != nil {
+		t.Fatalf("读 log 失败: %v", err)
+	}
+	if row.OrderNo == nil || *row.OrderNo != "888" {
+		t.Fatalf("order_no 应改为 888,实得 %v", row.OrderNo)
+	}
+	if row.Remark == nil || *row.Remark != "推广分成" {
+		t.Fatalf("remark 应写推广分成,实得 %v", row.Remark)
+	}
+
+	// 删除
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error { return repo.DeleteLog(ctx, tx, logID) }); err != nil {
+		t.Fatalf("DeleteLog 失败: %v", err)
+	}
+	var count int64
+	if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
+		t.Fatalf("count 失败: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("删除后应为 0 行,实得 %d", count)
+	}
+	if err := repo.DeleteLog(ctx, nil, 1); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("tx nil 应 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestDivideUpdateNodeParentFillsRoot 改挂分支显式写 root(修正 dump 的 NULL 缺陷)。
+func TestDivideUpdateNodeParentFillsRoot(t *testing.T) {
+	db := newDivideRepoDB(t)
+	root := "01HROOT000000000000000005"
+	orphan := "01HORPH000000000000000005"
+	seedDivideNode(t, db, root, model.DivideTypeOneOff, 1, 2, 1, "10.00", nil, &root)
+	seedDivideNode(t, db, orphan, model.DivideTypeOneOff, 100, 101, 1, "0.00", nil, nil) // root 为 NULL(dump 脏数据形态)
+
+	repo := NewDivideRepo(db)
+	runner := NewTxRunner(db)
+	ctx := context.Background()
+
+	if err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		_, e := repo.UpdateNodeParent(ctx, tx, orphan, root, root, 2)
+		return e
+	}); err != nil {
+		t.Fatalf("UpdateNodeParent 失败: %v", err)
+	}
+	got := loadNode(t, db, orphan)
+	if got.ParentUserID == nil || *got.ParentUserID != root {
+		t.Fatalf("parent 应为 %s,实得 %v", root, got.ParentUserID)
+	}
+	if got.RootUserID == nil || *got.RootUserID != root {
+		t.Fatalf("root_user_id 必须显式补齐(dump 缺陷修正),实得 %v", got.RootUserID)
+	}
+	if got.Layer != 2 {
+		t.Fatalf("layer 应为 2,实得 %d", got.Layer)
+	}
+	if _, err := repo.UpdateNodeParent(ctx, db, orphan, "", root, 2); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("空 parent 应 ErrBadParam,实得 %v", err)
+	}
+	if _, err := repo.UpdateNodeParent(ctx, db, orphan, root, root, 0); !errors.Is(err, infraerrors.ErrBadParam) {
+		t.Fatalf("layer<1 应 ErrBadParam,实得 %v", err)
+	}
+}
+
+// TestIsNotFound 便捷判定(避免各处抄 errors.Is)。
+func TestIsNotFound(t *testing.T) {
+	if IsNotFound(nil) {
+		t.Fatal("nil 不应判为 NotFound")
+	}
+	if !IsNotFound(infraerrors.WithCause(infraerrors.ErrNotFound, errors.New("x"))) {
+		t.Fatal("WithCause(ErrNotFound) 应判为 NotFound")
+	}
+	if !strings.Contains(infraerrors.ErrNotFound.Error(), "不存在") {
+		t.Fatal("ErrNotFound 文案契约被改动")
+	}
+}

+ 508 - 0
internal/academic/service/divide_service.go

@@ -0,0 +1,508 @@
+// 分账服务(Nested Set 树维护 + 上行分成 + 撤销 + 额度级联)——AC-09。
+//
+// 依据:unified/03-migration/academic-domain-design.md §3.3 #22~#25(分账 4 SP)
+// / §1.7(全局无作用域表)/ §10.3(事务边界只在 Service)/ §11.1 拍板 11-12。
+//
+// 四个 SP 的 Go 化落点:
+//
+//	divide_into_add          → AddSubordinate(**服务端算左右值**,dump 2 参语义:只传上下级)
+//	divide_into_log_add      → AddLog(上行链入账;**递归改迭代 BFS**,深度上限 100)
+//	divide_into_log_delete   → DeleteLog(两分支:改绑 / 回滚删除,dump 2 参语义:studentid + orderid)
+//	divide_into_update_quota → UpdateQuota(改额度 + 子树级联下降,子不得超父 → 3111)
+//
+// 事务纪律:所有写路径走 `repository.TxRunner.WithinTx`;事务内下游调用一律传
+// `repository.WithTxContext(ctx, tx)` 注入的 ctx(读方法自动复用同一 tx,见仓储 db(ctx))。
+// 禁止在本服务内直接调用 `db.Transaction`(嵌套只允许经 TxRunner 透传)。
+//
+// 金额纪律:库里 quota / total_income / money 均为 decimal(12,2) 字符串列,
+// 一律 `money.Parse` 转 money.Amount(int64 分)后运算,**零 float64**。
+package service
+
+import (
+	"context"
+	"errors"
+	"strconv"
+	"strings"
+
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// MaxDivideDepth 分账树深度上限(根 layer=1;超限 → apperr.ErrDivideQuotaExceeded 3111)。
+//
+// 依据:模型注释 layer「深度上限 100(超限 → 3111,防递归栈溢出)」+ dump 的层数保护。
+// BFS 迭代在本上限内必然终止(即使数据成环也不会死循环)。
+const MaxDivideDepth int32 = 100
+
+// remarkRebind 撤销改绑分支写入的备注文案(dump 原样:"推广分成")。
+const remarkRebind = "推广分成"
+
+// FirstChargedRecordReader 首次扣费上课记录只读端口(跨聚合依赖,本服务**只定义契约**)。
+//
+// 为什么用端口而不是直接 JOIN:`log_delete` 的改绑分支需要判断"学生是否已有首次扣费上课记录",
+// 该判定属于课次/打卡聚合(AC-05/AC-10 范围)。AC-09 不实现它,只声明只读契约 + 允许注入;
+// 未注入时**所有撤销走回滚删除分支**(保守:不让分成悬空在多订单上)。
+//
+// 契约:
+//   - 只读,禁止写任何表;
+//   - 返回 (recordID, found, err):found=false 表示学生尚无扣费记录 → 走回滚删除分支;
+//   - err 仅 DB 异常(infraerrors.ErrDBError),不得返回业务错误。
+type FirstChargedRecordReader interface {
+	// FirstChargedRecordID 取该学生"首次扣费上课记录"的 ID。
+	//
+	// 入参:ctx 请求上下文;studentID 报课学生(canonical_user_id ULID)。
+	// 出参:recordID int64(上课记录 ID);found bool;error。
+	FirstChargedRecordID(ctx context.Context, studentID string) (recordID int64, found bool, err error)
+}
+
+// DivideService 分账服务(全局推广体系;越权防线在 Handler 角色白名单,本服务不做租户裁剪)。
+type DivideService struct {
+	// repo 分账树仓储(全局数据,不继承 BaseRepo)。
+	repo *repository.DivideRepo
+	// runner 事务运行器(顶层开事务,事务内透传)。
+	runner *repository.TxRunner
+	// firstCharged 首次扣费记录端口(可 nil:未注入时撤销一律走回滚删除)。
+	firstCharged FirstChargedRecordReader
+}
+
+// NewDivideService 构造分账服务(repo 与 runner 绑定同一 DB 句柄,避免跨连接池事务失效)。
+//
+// 入参:
+//   - db *gorm.DB:数据库句柄(必填)。
+//   - firstCharged FirstChargedRecordReader:首次扣费记录端口(可 nil)。
+//
+// 出参:*DivideService。
+func NewDivideService(db *gorm.DB, firstCharged FirstChargedRecordReader) *DivideService {
+	return &DivideService{
+		repo:         repository.NewDivideRepo(db),
+		runner:       repository.NewTxRunner(db),
+		firstCharged: firstCharged,
+	}
+}
+
+// AddSubordinate 把 subUserID 挂为 parentUserID 的下级(dump `divide_into_add` 2 参语义)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - parentUserID 上级用户(ULID;必须已是树节点,否则 3001);
+//   - subUserID 下级用户(ULID;== parentUserID → 3140,禁止自环)。
+//
+// 出参:
+//   - nil:成功(新建节点,或"已存在节点"分支改挂成功);
+//   - ErrNotFound(3001):上级不存在或不在分账体系;
+//   - ErrDivideQuotaExceeded(3111):层级超上限(父 layer+1 > 100);
+//   - ErrBusinessParam(3140):入参非法。
+//
+// 左右值算法(Nested Set 插入最右子节点,**服务端计算**,调用方不传 left/right):
+//
+//	① 父节点右值 R = parent.right_val(插入点)
+//	② ShiftRight(R, +2):right_val >= R 全体 +2 → 父节点及其**全部祖先**区间右移
+//	③ ShiftLeft (R, +2):left_val  >= R 全体 +2 → 位于插入点右侧的兄弟及其后代右移
+//	④ 新节点 (left, right) = (R, R+1),layer = parent.layer+1,root = 父的树根
+//
+// 不变式:①②③④ 必须在同一事务内完成(否则中间态树不可用);完成后恒有 left < right。
+//
+// 已存在节点分支(dump `if exists(select 1 from ... where userid = subid)`):
+//
+//	dump 仅改 parent,**漏写 root_user_id**(留 NULL → 子树/级联查询失效)。
+//	本实现按模型注释的修正要求显式写入父树根,且**不重排左右值**(与原 SP 一致,
+//	区间重排会与迁移期数据冲突,登记为遗留项)。
+func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUserID string) error {
+	parentUserID = strings.TrimSpace(parentUserID)
+	subUserID = strings.TrimSpace(subUserID)
+	if parentUserID == "" || subUserID == "" {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: AddSubordinate 需要 parent/sub user id"))
+	}
+	if parentUserID == subUserID {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 下级不能是自身(禁止成环)"))
+	}
+	if err := s.assertReady(); err != nil {
+		return err
+	}
+
+	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		parent, err := s.repo.GetByUserID(txCtx, parentUserID)
+		if err != nil {
+			return err // ErrNotFound(3001) 原样上抛:上级未加入分账体系
+		}
+		nextLayer := parent.Layer + 1
+		if nextLayer > MaxDivideDepth {
+			return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+				errString("service: 分账层级超上限 "+strconv.Itoa(int(MaxDivideDepth))))
+		}
+
+		rootID := resolveRoot(parent)
+
+		existing, err := s.repo.GetByUserID(txCtx, subUserID)
+		switch {
+		case err == nil:
+			// ── 已存在节点分支:只改挂父链(补齐 root,dump 缺陷修正)
+			if existing.UserID == parentUserID {
+				return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 上下级相同"))
+			}
+			if _, uerr := s.repo.UpdateNodeParent(txCtx, tx, subUserID, parentUserID, rootID, nextLayer); uerr != nil {
+				return uerr
+			}
+			return nil
+		case !errors.Is(err, infraerrors.ErrNotFound):
+			return err
+		}
+
+		// ── 新建节点分支:嵌套集"插入最右子节点"
+		insertAt := parent.RightVal
+		if _, serr := s.repo.ShiftRight(txCtx, tx, insertAt, 2); serr != nil {
+			return serr
+		}
+		if _, serr := s.repo.ShiftLeft(txCtx, tx, insertAt, 2); serr != nil {
+			return serr
+		}
+		parentID := parentUserID
+		node := &model.UserDivideInto{
+			UserID:           subUserID,
+			Type:             parent.Type,
+			Quota:            "0.00", // 新节点额度由后台 UpdateQuota 配置(默认 0,且不得超父)
+			TotalIncome:      "0.00",
+			TotalIncomePayed: "0.00",
+			RootUserID:       &rootID,
+			ParentUserID:     &parentID,
+			LeftVal:          insertAt,
+			RightVal:         insertAt + 1,
+			Layer:            nextLayer,
+		}
+		if ierr := s.repo.InsertNode(txCtx, tx, node); ierr != nil {
+			return ierr
+		}
+		return nil
+	})
+}
+
+// AddLog 上行分成入账(dump `divide_into_log_add`;**递归改迭代 BFS**)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - findUserID 起始用户(ULID;通常是直接推荐人);
+//   - stuID 报课学生(ULID;幂等键组成列);
+//   - recordID 上课记录 ID(int64;写入 order_no 作为 type=2 的幂等键,dump 传 orderid 语义);
+//   - remark 备注(可空;空则落 NULL)。
+//
+// 出参:
+//   - nil:成功(含"起始用户不是树节点 → 不入账"的空操作、以及幂等命中);
+//   - ErrDivideQuotaExceeded(3111):上行层数超 100(数据成环/异常链);
+//   - ErrBusinessParam / ErrDBError;
+//   - 若节点 quota 金额被写坏(非十进制)→ ErrDBError(数据异常,不静默归零)。
+//
+// 上行链语义(dump):从 findUserID 开始,逐级上溯 parent_user_id,每级生成一条明细
+// (money = 该级节点的 quota 快照),并累加该节点 total_income;到根(parent 为 NULL)终止。
+//
+// 幂等(两级,设计 §3.3 #22,**同事务内逐级判定**):
+//   - 节点 type=1(一次付清):同 (user, student) 只生成一条 → 后续不同 recordID 也不重复入账;
+//   - 节点 type=2(按课次)  :同 (user, student, order_no) 只生成一条。
+//
+// 为什么用迭代:链深可达 100,递归在极端脏数据下会栈溢出;迭代 + 显式层数计数可兜底成环。
+func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, recordID int64, remark string) error {
+	findUserID = strings.TrimSpace(findUserID)
+	stuID = strings.TrimSpace(stuID)
+	if findUserID == "" || stuID == "" {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: AddLog 需要 findUserID/stuID"))
+	}
+	if recordID <= 0 {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: AddLog 需要 recordID > 0"))
+	}
+	if err := s.assertReady(); err != nil {
+		return err
+	}
+	orderNo := strconv.FormatInt(recordID, 10)
+
+	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		cur, err := s.repo.GetByUserID(txCtx, findUserID)
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			// 红线(模型注释):必须是分账树节点才入账;非节点 → 静默不入账(不是错误)
+			return nil
+		}
+		if err != nil {
+			return err
+		}
+
+		for depth := int32(0); cur != nil; depth++ {
+			if depth >= MaxDivideDepth {
+				return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+					errString("service: 上行分成层级超上限(疑似成环) "+strconv.Itoa(int(MaxDivideDepth))))
+			}
+			if err := s.appendShare(txCtx, tx, cur, stuID, orderNo, remark); err != nil {
+				return err
+			}
+			if cur.ParentUserID == nil || strings.TrimSpace(*cur.ParentUserID) == "" {
+				break // 到根
+			}
+			parentID := strings.TrimSpace(*cur.ParentUserID)
+			if parentID == cur.UserID {
+				return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+					errString("service: 分账树自环(parent 指向自身)"))
+			}
+			next, perr := s.repo.GetByUserID(txCtx, parentID)
+			if errors.Is(perr, infraerrors.ErrNotFound) {
+				break // 断链(上级已不在树中):终止上行,已入账部分保留
+			}
+			if perr != nil {
+				return perr
+			}
+			cur = next
+		}
+		return nil
+	})
+}
+
+// appendShare 为单个节点生成分成明细并累加 total_income(AddLog 的迭代体)。
+//
+// 入参:txCtx(含 tx 的 ctx);tx;node 当前节点;stuID 学生;orderNo 订单号;remark 备注。
+// 出参:nil 成功/幂等命中;ErrDBError。
+//
+// 幂等判定经 repository.FindLog(两级键口径唯一入口),命中即跳过(**不返回错误**,
+// 对外语义是"重复请求幂等成功",与设计 §10.1 的 3114 仅内部日志口径一致)。
+func (s *DivideService) appendShare(txCtx context.Context, tx *gorm.DB, node *model.UserDivideInto, stuID, orderNo, remark string) error {
+	if node == nil {
+		return nil
+	}
+	_, err := s.repo.FindLog(txCtx, node.UserID, stuID, node.Type, orderNo)
+	switch {
+	case err == nil:
+		return nil // 幂等命中:该 (user, student[, order]) 已有分成
+	case !errors.Is(err, infraerrors.ErrNotFound):
+		return err
+	}
+
+	amount, perr := parseAmount(node.Quota)
+	if perr != nil {
+		return perr
+	}
+	row := &model.UserDivideIntoLog{
+		Type:      node.Type,
+		UserID:    node.UserID,
+		StudentID: stuID,
+		OrderNo:   stringPtr(orderNo),
+		Money:     amount.String(),
+		Remark:    optionalString(remark),
+	}
+	if ierr := s.repo.InsertLog(txCtx, tx, row); ierr != nil {
+		return ierr
+	}
+	if _, ierr := s.repo.IncTotalIncome(txCtx, tx, node.UserID, amount); ierr != nil {
+		return ierr
+	}
+	return nil
+}
+
+// DeleteLog 撤销某「学生 + 订单」产生的全部分成(dump `divide_into_log_delete` 2 参语义)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - stuID 报课学生(ULID);
+//   - orderID 订单/上课记录 ID(int64;dump 传 orderid)。
+//
+// 出参:
+//   - nil:成功(含"无对应明细"的幂等空操作);
+//   - ErrBusinessParam / ErrDBError(含端口返回的 DB 异常)。
+//
+// 两分支(设计 §3.3 #23,**顺序不可颠倒**):
+//
+//	① 改绑(保留分成):学生已有首次扣费上课记录 first,且 first != orderID
+//	   → 该组明细的 order_no 改为 first,备注写"推广分成",total_income **不回滚**;
+//	② 回滚删除:否则 → total_income -= money(冲正)并物理删除明细行。
+//
+// 说明:同一订单的分成会沿上行链生成多行(本人 + 各级上线),故按 (student, order) 取全量
+// 逐行处理,而不是只处理一行(否则上级分成会悬空)。
+func (s *DivideService) DeleteLog(ctx context.Context, stuID string, orderID int64) error {
+	stuID = strings.TrimSpace(stuID)
+	if stuID == "" || orderID <= 0 {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: DeleteLog 需要 stuID/orderID"))
+	}
+	if err := s.assertReady(); err != nil {
+		return err
+	}
+	orderNo := strconv.FormatInt(orderID, 10)
+
+	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		rows, err := s.repo.ListLogsByStudentOrder(txCtx, stuID, orderNo)
+		if err != nil {
+			return err
+		}
+		if len(rows) == 0 {
+			return nil // 已撤销/从未生成:幂等空操作
+		}
+
+		// 首次扣费记录(端口未注入 → 一律走回滚删除分支)
+		var firstID int64
+		var hasFirst bool
+		if s.firstCharged != nil {
+			firstID, hasFirst, err = s.firstCharged.FirstChargedRecordID(txCtx, stuID)
+			if err != nil {
+				return err
+			}
+		}
+		rebindTo := ""
+		if hasFirst && firstID > 0 && firstID != orderID {
+			rebindTo = strconv.FormatInt(firstID, 10)
+		}
+
+		for i := range rows {
+			row := &rows[i]
+			if rebindTo != "" {
+				// ① 改绑分支:保留分成,仅改幂等键指向首次扣费订单
+				if _, uerr := s.repo.UpdateLogOrderNo(txCtx, tx, row.ID, rebindTo, remarkRebind); uerr != nil {
+					return uerr
+				}
+				continue
+			}
+			// ② 回滚删除分支:冲正 total_income 后物理删除
+			amount, perr := parseAmount(row.Money)
+			if perr != nil {
+				return perr
+			}
+			if _, ierr := s.repo.IncTotalIncome(txCtx, tx, row.UserID, amount.Neg()); ierr != nil {
+				return ierr
+			}
+			if derr := s.repo.DeleteLog(txCtx, tx, row.ID); derr != nil {
+				return derr
+			}
+		}
+		return nil
+	})
+}
+
+// UpdateQuota 修改节点分成额度并级联下降子树(dump `divide_into_update_quota`)。
+//
+// 入参:
+//   - ctx 请求上下文;
+//   - userID 节点用户(ULID;必须是树节点,否则 3001);
+//   - quota 新额度(单位:分;负值/越界 → 3140)。
+//
+// 出参:
+//   - nil:成功(含"子树内无超标节点"的空更新);
+//   - ErrNotFound(3001):节点不存在;
+//   - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父);
+//   - ErrBusinessParam / ErrDBError。
+//
+// 级联规则("只降不升"):
+//
+//	本节点 quota := 新值;
+//	子树中 quota > 新值的节点 → 压到新值(低于新值的阶梯额度保持不变)。
+//
+// 事务:本节点更新与子树压制**同事务**(否则出现"父已降、子仍高于父"的越权窗口)。
+func (s *DivideService) UpdateQuota(ctx context.Context, userID string, quota money.Amount) error {
+	userID = strings.TrimSpace(userID)
+	if userID == "" {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: UpdateQuota 需要 userID"))
+	}
+	if quota.IsNegative() {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 分成额度不能为负"))
+	}
+	if verr := quota.Validate(); verr != nil {
+		return infraerrors.WithCause(apperr.ErrBusinessParam, verr)
+	}
+	if err := s.assertReady(); err != nil {
+		return err
+	}
+
+	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+
+		node, err := s.repo.GetByUserID(txCtx, userID)
+		if err != nil {
+			return err // ErrNotFound(3001) 原样上抛
+		}
+
+		// 上限校验:子不得超父(父额度是硬上限,超限 → 3111)
+		if node.ParentUserID != nil && strings.TrimSpace(*node.ParentUserID) != "" {
+			parent, perr := s.repo.GetByUserID(txCtx, strings.TrimSpace(*node.ParentUserID))
+			if perr == nil {
+				parentQuota, aerr := parseAmount(parent.Quota)
+				if aerr != nil {
+					return aerr
+				}
+				if quota.GreaterThan(parentQuota) {
+					return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+						errString("service: 分成额度不得超过上级("+quota.String()+" > "+parentQuota.String()+")"))
+				}
+			} else if !errors.Is(perr, infraerrors.ErrNotFound) {
+				return perr
+			}
+			// 上级不在树中(断链)→ 不做上限校验(不阻断后台修复数据)
+		}
+
+		if _, uerr := s.repo.UpdateNodeQuota(txCtx, tx, userID, quota); uerr != nil {
+			return uerr
+		}
+		if _, cerr := s.repo.ClampSubtreeQuota(txCtx, tx, node.LeftVal, node.RightVal, quota); cerr != nil {
+			return cerr
+		}
+		return nil
+	})
+}
+
+// assertReady 装配期自检(repo/runner 未构造 → ErrInternal,避免 nil 解引用)。
+func (s *DivideService) assertReady() error {
+	if s == nil || s.repo == nil || s.runner == nil {
+		return infraerrors.WithCause(infraerrors.ErrInternal, errString("service: DivideService 未装配 repo/runner"))
+	}
+	return nil
+}
+
+// resolveRoot 解析节点的所属树根(自身为根时返回自身 user_id)。
+//
+// 入参:node 分账节点(可 nil)。出参:根 user_id 字符串。
+//
+// 修正点(模型注释 ★):旧 SP 在"subid 已存在"分支可能留下 root_user_id = NULL,
+// 因此这里必须容忍 nil/空串并回退到节点自身,再回退到 parent(由调用方兜底)。
+func resolveRoot(node *model.UserDivideInto) string {
+	if node == nil {
+		return ""
+	}
+	if node.RootUserID != nil && strings.TrimSpace(*node.RootUserID) != "" {
+		return strings.TrimSpace(*node.RootUserID)
+	}
+	return node.UserID
+}
+
+// parseAmount 把库里的 decimal(12,2) 字符串转 money.Amount(空串视为 0.00,容错迁移期脏值)。
+//
+// 入参:s 十进制"元"字符串。出参:money.Amount;解析失败 → ErrDBError(数据异常不静默归零)。
+func parseAmount(s string) (money.Amount, error) {
+	trimmed := strings.TrimSpace(s)
+	if trimmed == "" {
+		return 0, nil
+	}
+	amount, err := money.Parse(trimmed)
+	if err != nil {
+		return 0, infraerrors.WithCause(infraerrors.ErrDBError, err)
+	}
+	return amount, nil
+}
+
+// stringPtr 返回字符串指针(空串返回 nil,便于落 NULL)。
+func stringPtr(s string) *string {
+	trimmed := strings.TrimSpace(s)
+	if trimmed == "" {
+		return nil
+	}
+	return &trimmed
+}
+
+// optionalString 空串返回 nil 的指针构造(备注列语义:空 → NULL)。
+func optionalString(s string) *string { return stringPtr(s) }
+
+// errString 实现 error 的最小字符串类型(本包不引入额外依赖,语义同 repository.errString)。
+type errString string
+
+// Error 实现 error 接口。
+func (e errString) Error() string { return string(e) }

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

@@ -0,0 +1,675 @@
+// DivideService 单测(AC-09):100 节点随机树不变式 / BFS 上游入账 / 两级幂等 / 撤销两分支 /
+// 额度级联下降。
+//
+// 测试栈:glebarez/sqlite 文件库(真实事务)。每个用例独立 DB,避免树区间互相污染。
+package service
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"math/rand"
+	"path/filepath"
+	"testing"
+
+	gormsqlite "github.com/glebarez/sqlite"
+	"gorm.io/gorm"
+
+	"yuansiwei/unified-backend/internal/academic/apperr"
+	"yuansiwei/unified-backend/internal/academic/model"
+	"yuansiwei/unified-backend/internal/academic/money"
+	"yuansiwei/unified-backend/internal/academic/repository"
+	infraerrors "yuansiwei/unified-backend/internal/infra/errors"
+)
+
+// newDivideServiceDB 分账域真实库(2 表 AutoMigrate)。
+func newDivideServiceDB(t *testing.T) *gorm.DB {
+	t.Helper()
+	dsn := filepath.Join(t.TempDir(), "academic_divide_service_test.db")
+	db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("gorm.Open(分账服务文件库) 失败: %v", err)
+	}
+	t.Cleanup(func() {
+		if sqlDB, e := db.DB(); e == nil {
+			_ = sqlDB.Close()
+		}
+	})
+	if err := db.AutoMigrate(&model.UserDivideInto{}, &model.UserDivideIntoLog{}); err != nil {
+		t.Fatalf("AutoMigrate(分账 2 表) 失败: %v", err)
+	}
+	return db
+}
+
+// nodeID 生成 26 位类 ULID 的测试用户 ID(t_user_divide_into.user_id 为 VARCHAR(26))。
+func nodeID(i int) string { return fmt.Sprintf("01H%023d", i) }
+
+// seedRoot 直接落一棵树的根节点(dump 中根节点由后台/迁移建立,不在 4 个 SP 范围内)。
+func seedRoot(t *testing.T, db *gorm.DB, userID string, typ int8, quota string) {
+	t.Helper()
+	root := userID
+	row := &model.UserDivideInto{
+		UserID:           userID,
+		Type:             typ,
+		Quota:            quota,
+		TotalIncome:      "0.00",
+		TotalIncomePayed: "0.00",
+		RootUserID:       &root,
+		LeftVal:          1,
+		RightVal:         2,
+		Layer:            1,
+	}
+	if err := db.Create(row).Error; err != nil {
+		t.Fatalf("seed 根节点(%s) 失败: %v", userID, err)
+	}
+}
+
+// loadDivideNode 读节点。
+func loadDivideNode(t *testing.T, db *gorm.DB, userID string) model.UserDivideInto {
+	t.Helper()
+	var row model.UserDivideInto
+	if err := db.Where("user_id = ?", userID).First(&row).Error; err != nil {
+		t.Fatalf("读节点(%s) 失败: %v", userID, err)
+	}
+	return row
+}
+
+// cents 金额字符串 → 分(sqlite NUMERIC 回读会丢 ".00",不得比字符串)。
+func cents(t *testing.T, s string) int64 {
+	t.Helper()
+	amt, err := money.Parse(s)
+	if err != nil {
+		t.Fatalf("金额解析失败 %q: %v", s, err)
+	}
+	return amt.Cents()
+}
+
+// fakeFirstCharged 首次扣费记录端口的测试替身。
+type fakeFirstCharged struct {
+	recordID int64
+	found    bool
+	err      error
+	calls    int
+}
+
+// FirstChargedRecordID 实现 FirstChargedRecordReader。
+func (f *fakeFirstCharged) FirstChargedRecordID(_ context.Context, _ string) (int64, bool, error) {
+	f.calls++
+	return f.recordID, f.found, f.err
+}
+
+// TestAddSubordinateNestedSetInvariants 100 节点随机树:left<right、区间嵌套无交叉、
+// 节点数 = 宽度/2、值集合恰好 1..2N、父区间包含子区间。
+func TestAddSubordinateNestedSetInvariants(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+
+	root := nodeID(0)
+	seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
+	created := []string{root}
+
+	rnd := rand.New(rand.NewSource(20260109))
+	const total = 100
+	for i := 1; i < total; i++ {
+		parent := created[rnd.Intn(len(created))]
+		child := nodeID(i)
+		if err := svc.AddSubordinate(ctx, parent, child); err != nil {
+			t.Fatalf("AddSubordinate(%s→%s) 失败: %v", parent, child, err)
+		}
+		created = append(created, child)
+	}
+
+	var nodes []model.UserDivideInto
+	if err := db.Find(&nodes).Error; err != nil {
+		t.Fatalf("读全树失败: %v", err)
+	}
+	if len(nodes) != total {
+		t.Fatalf("节点数应为 %d,实得 %d", total, len(nodes))
+	}
+
+	// ① left < right 且值集合 = {1..2N}(每值恰好一次)
+	seen := make(map[int32]string, total*2)
+	maxRight := int32(0)
+	for i := range nodes {
+		n := nodes[i]
+		if n.LeftVal >= n.RightVal {
+			t.Fatalf("节点 %s 违反 left<right:(%d,%d)", n.UserID, n.LeftVal, n.RightVal)
+		}
+		for _, v := range []int32{n.LeftVal, n.RightVal} {
+			if prev, dup := seen[v]; dup {
+				t.Fatalf("左/右值 %d 被重复使用(%s 与 %s),嵌套集区间损坏", v, prev, n.UserID)
+			}
+			seen[v] = n.UserID
+		}
+		if n.RightVal > maxRight {
+			maxRight = n.RightVal
+		}
+	}
+	if len(seen) != total*2 || maxRight != int32(total*2) {
+		t.Fatalf("值集合应为 1..%d(共 %d 个),实得 %d 个、max=%d", total*2, total*2, len(seen), maxRight)
+	}
+
+	// ② 节点数 = 宽度/2(Nested Set 恒等式)
+	if got := int(maxRight / 2); got != total {
+		t.Fatalf("节点数应 = maxRight/2 = %d,实得 %d", total, got)
+	}
+
+	// ③ 任意两节点区间:包含或相离,**绝不交叉**
+	for i := 0; i < len(nodes); i++ {
+		for j := i + 1; j < len(nodes); j++ {
+			a, b := nodes[i], nodes[j]
+			nested := (a.LeftVal < b.LeftVal && b.RightVal < a.RightVal) ||
+				(b.LeftVal < a.LeftVal && a.RightVal < b.RightVal)
+			disjoint := a.RightVal < b.LeftVal || b.RightVal < a.LeftVal
+			if !nested && !disjoint {
+				t.Fatalf("区间交叉:%s(%d,%d) vs %s(%d,%d)", a.UserID, a.LeftVal, a.RightVal, b.UserID, b.LeftVal, b.RightVal)
+			}
+		}
+	}
+
+	// ④ 父区间严格包含子区间,且 layer = 父 layer+1
+	byID := make(map[string]model.UserDivideInto, len(nodes))
+	for i := range nodes {
+		byID[nodes[i].UserID] = nodes[i]
+	}
+	for i := range nodes {
+		n := nodes[i]
+		if n.ParentUserID == nil || *n.ParentUserID == "" {
+			if n.UserID != root {
+				t.Fatalf("非根节点 %s 缺少 parent", n.UserID)
+			}
+			continue
+		}
+		parent, ok := byID[*n.ParentUserID]
+		if !ok {
+			t.Fatalf("节点 %s 的 parent %s 不在树中", n.UserID, *n.ParentUserID)
+		}
+		if !(parent.LeftVal < n.LeftVal && n.RightVal < parent.RightVal) {
+			t.Fatalf("%s 未嵌套在父 %s 区间内:父(%d,%d) 子(%d,%d)",
+				n.UserID, parent.UserID, parent.LeftVal, parent.RightVal, n.LeftVal, n.RightVal)
+		}
+		if n.Layer != parent.Layer+1 {
+			t.Fatalf("%s layer 应为父+1(%d),实得 %d", n.UserID, parent.Layer+1, n.Layer)
+		}
+	}
+}
+
+// TestAddSubordinateGuards 入参/上级缺失/自环。
+func TestAddSubordinateGuards(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	root := nodeID(0)
+	seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
+
+	if err := svc.AddSubordinate(ctx, "", nodeID(1)); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("空上级应 3140,实得 %v", err)
+	}
+	if err := svc.AddSubordinate(ctx, root, root); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("自环应 3140,实得 %v", err)
+	}
+	if err := svc.AddSubordinate(ctx, nodeID(99), nodeID(1)); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("上级不存在应 3001,实得 %v", err)
+	}
+	var zero *DivideService
+	if err := zero.AddSubordinate(ctx, root, nodeID(1)); !errors.Is(err, infraerrors.ErrInternal) {
+		t.Fatalf("未装配服务应 ErrInternal,实得 %v", err)
+	}
+}
+
+// TestAddSubordinateReparentBranch 已存在节点分支:改挂父链并显式补齐 root(dump NULL 缺陷修正)。
+func TestAddSubordinateReparentBranch(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	root := nodeID(0)
+	orphan := nodeID(50)
+	seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
+	// 模拟 dump 脏数据:subid 已存在、parent/root 均为 NULL
+	if err := db.Create(&model.UserDivideInto{
+		UserID: orphan, Type: model.DivideTypeOneOff, Quota: "0.00",
+		TotalIncome: "0.00", TotalIncomePayed: "0.00",
+		LeftVal: 100, RightVal: 101, Layer: 1,
+	}).Error; err != nil {
+		t.Fatalf("seed 孤立节点失败: %v", err)
+	}
+
+	if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
+		t.Fatalf("改挂分支失败: %v", err)
+	}
+	got := loadDivideNode(t, db, orphan)
+	if got.ParentUserID == nil || *got.ParentUserID != root {
+		t.Fatalf("parent 应改为 %s,实得 %v", root, got.ParentUserID)
+	}
+	if got.RootUserID == nil || *got.RootUserID != root {
+		t.Fatalf("root_user_id 必须补齐为 %s(dump 缺陷修正),实得 %v", root, got.RootUserID)
+	}
+	if got.Layer != 2 {
+		t.Fatalf("layer 应为 2,实得 %d", got.Layer)
+	}
+	// 不重排左右值(与原 SP 对齐)→ 值保持不变
+	if got.LeftVal != 100 || got.RightVal != 101 {
+		t.Fatalf("改挂分支不应重排左右值,实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+}
+
+// TestAddSubordinateDepthLimit 层级上限 100 → 3111。
+func TestAddSubordinateDepthLimit(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+
+	root := nodeID(0)
+	seedRoot(t, db, root, model.DivideTypeOneOff, "1.00")
+	prev := root
+	// 链到 layer=100
+	for i := 1; i <= 99; i++ {
+		child := nodeID(i)
+		if err := svc.AddSubordinate(ctx, prev, child); err != nil {
+			t.Fatalf("建链 AddSubordinate(%s→%s) 失败: %v", prev, child, err)
+		}
+		prev = child
+	}
+	if got := loadDivideNode(t, db, prev); got.Layer != 100 {
+		t.Fatalf("链尾 layer 应为 100,实得 %d", got.Layer)
+	}
+	if err := svc.AddSubordinate(ctx, prev, nodeID(1000)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
+		t.Fatalf("超过 100 层应 3111,实得 %v", err)
+	}
+}
+
+// TestAddLogBFSThreeLevels 上行链 BFS:本人 + 两级上线各生成一条明细并累加 total_income。
+func TestAddLogBFSThreeLevels(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a, b, c := nodeID(0), nodeID(1), nodeID(2)
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	if err := svc.AddSubordinate(ctx, b, c); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
+		t.Fatalf("设额度 B 失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, c, money.FromCents(200)); err != nil {
+		t.Fatalf("设额度 C 失败: %v", err)
+	}
+
+	stu := nodeID(900)
+	if err := svc.AddLog(ctx, c, stu, 777, "推荐分成"); err != nil {
+		t.Fatalf("AddLog 失败: %v", err)
+	}
+
+	var rows []model.UserDivideIntoLog
+	if err := db.Find(&rows).Error; err != nil {
+		t.Fatalf("读明细失败: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("三级链应生成 3 条明细,实得 %d", len(rows))
+	}
+	want := map[string]int64{c: 200, b: 500, a: 1000}
+	for i := range rows {
+		expect, ok := want[rows[i].UserID]
+		if !ok {
+			t.Fatalf("出现意外分成用户 %s", rows[i].UserID)
+		}
+		if got := cents(t, rows[i].Money); got != expect {
+			t.Fatalf("用户 %s 分成应为 %d 分,实得 %d", rows[i].UserID, expect, got)
+		}
+		if rows[i].OrderNo == nil || *rows[i].OrderNo != "777" {
+			t.Fatalf("order_no 应为 777,实得 %v", rows[i].OrderNo)
+		}
+		if rows[i].Remark == nil || *rows[i].Remark != "推荐分成" {
+			t.Fatalf("remark 应为推荐分成,实得 %v", rows[i].Remark)
+		}
+		if rows[i].Type != model.DivideTypePerLesson {
+			t.Fatalf("type 应继承节点(2),实得 %d", rows[i].Type)
+		}
+	}
+	// total_income 各自累加自己的 quota
+	for user, expect := range want {
+		if got := cents(t, loadDivideNode(t, db, user).TotalIncome); got != expect {
+			t.Fatalf("%s.total_income 应为 %d 分,实得 %d", user, expect, got)
+		}
+	}
+}
+
+// TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
+func TestAddLogTwoLevelIdempotency(t *testing.T) {
+	ctx := context.Background()
+	stu := nodeID(901)
+
+	t.Run("type=1 一次付清(同 user+student 只一条)", func(t *testing.T) {
+		db := newDivideServiceDB(t)
+		svc := NewDivideService(db, nil)
+		u := nodeID(0)
+		seedRoot(t, db, u, model.DivideTypeOneOff, "10.00")
+
+		if err := svc.AddLog(ctx, u, stu, 777, ""); err != nil {
+			t.Fatalf("AddLog 失败: %v", err)
+		}
+		if err := svc.AddLog(ctx, u, stu, 888, ""); err != nil { // 不同订单:应与 type=1 无关
+			t.Fatalf("AddLog(第二次) 失败: %v", err)
+		}
+		var count int64
+		if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
+			t.Fatalf("count 失败: %v", err)
+		}
+		if count != 1 {
+			t.Fatalf("type=1 应仅 1 条明细,实得 %d", count)
+		}
+		if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 1000 {
+			t.Fatalf("total_income 应只累加一次(1000 分),实得 %d", got)
+		}
+	})
+
+	t.Run("type=2 按课次(同 user+student+order 只一条)", func(t *testing.T) {
+		db := newDivideServiceDB(t)
+		svc := NewDivideService(db, nil)
+		u := nodeID(0)
+		seedRoot(t, db, u, model.DivideTypePerLesson, "10.00")
+
+		for i := 0; i < 2; i++ { // 同一订单重复请求
+			if err := svc.AddLog(ctx, u, stu, 777, ""); err != nil {
+				t.Fatalf("AddLog(重复) 失败: %v", err)
+			}
+		}
+		if count := countLogs(t, db); count != 1 {
+			t.Fatalf("同订单重复请求应幂等(1 条),实得 %d", count)
+		}
+		if err := svc.AddLog(ctx, u, stu, 888, ""); err != nil { // 新订单:应新增
+			t.Fatalf("AddLog(新订单) 失败: %v", err)
+		}
+		if count := countLogs(t, db); count != 2 {
+			t.Fatalf("新订单应新增明细(2 条),实得 %d", count)
+		}
+		if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 2000 {
+			t.Fatalf("total_income 应为 2000 分,实得 %d", got)
+		}
+	})
+}
+
+// countLogs 统计分成明细行数(测试辅助)。
+func countLogs(t *testing.T, db *gorm.DB) int64 {
+	t.Helper()
+	var count int64
+	if err := db.Model(&model.UserDivideIntoLog{}).Count(&count).Error; err != nil {
+		t.Fatalf("count 失败: %v", err)
+	}
+	return count
+}
+
+// TestAddLogNonTreeNodeNoop 非分账树节点 → 静默不入账(不报错)。
+func TestAddLogNonTreeNodeNoop(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	if err := svc.AddLog(ctx, nodeID(404), nodeID(901), 777, ""); err != nil {
+		t.Fatalf("非树节点应静默不入账,实得 %v", err)
+	}
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("非树节点不应生成明细,实得 %d", count)
+	}
+	if err := svc.AddLog(ctx, nodeID(1), "", 777, ""); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("空学生应 3140,实得 %v", err)
+	}
+	if err := svc.AddLog(ctx, nodeID(1), nodeID(901), 0, ""); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("recordID<=0 应 3140,实得 %v", err)
+	}
+}
+
+// TestAddLogCycleGuard 数据成环 → 迭代层数兜底 3111(不死循环、不栈溢出)。
+func TestAddLogCycleGuard(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a, b := nodeID(0), nodeID(1)
+	seedRoot(t, db, a, model.DivideTypeOneOff, "10.00")
+	seedRoot(t, db, b, model.DivideTypeOneOff, "10.00")
+	// 人为造自环(脏数据)
+	if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", a).
+		Updates(map[string]any{"parent_user_id": b}).Error; err != nil {
+		t.Fatalf("造环失败: %v", err)
+	}
+	if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
+		Updates(map[string]any{"parent_user_id": a}).Error; err != nil {
+		t.Fatalf("造环失败: %v", err)
+	}
+
+	err := svc.AddLog(ctx, a, nodeID(901), 777, "")
+	if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
+		t.Fatalf("成环应 3111(层级兜底),实得 %v", err)
+	}
+	// 事务回滚:环上的入账不应留下
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("报错后应整体回滚(0 条明细),实得 %d", count)
+	}
+}
+
+// TestDeleteLogRollbackBranch 撤销分支②:无首次扣费记录 → 回滚 total_income 并删行。
+func TestDeleteLogRollbackBranch(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil) // 端口未注入 → 一律走回滚
+	ctx := context.Background()
+	a, b := nodeID(0), nodeID(1)
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(500)); err != nil {
+		t.Fatalf("设额度失败: %v", err)
+	}
+	stu := nodeID(901)
+	if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
+		t.Fatalf("AddLog 失败: %v", err)
+	}
+	if count := countLogs(t, db); count != 2 {
+		t.Fatalf("应为 2 条明细,实得 %d", count)
+	}
+
+	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
+		t.Fatalf("DeleteLog 失败: %v", err)
+	}
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("回滚删除后应 0 条明细,实得 %d", count)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
+		t.Fatalf("a.total_income 应回滚为 0,实得 %d 分", got)
+	}
+	if got := cents(t, loadDivideNode(t, db, b).TotalIncome); got != 0 {
+		t.Fatalf("b.total_income 应回滚为 0,实得 %d 分", got)
+	}
+	// 幂等:再次撤销 → 空操作
+	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
+		t.Fatalf("重复撤销应幂等成功,实得 %v", err)
+	}
+}
+
+// TestDeleteLogRebindBranch 撤销分支①:已有首次扣费记录(≠当前订单)→ 改绑保留分成。
+func TestDeleteLogRebindBranch(t *testing.T) {
+	db := newDivideServiceDB(t)
+	port := &fakeFirstCharged{recordID: 888, found: true}
+	svc := NewDivideService(db, port)
+	ctx := context.Background()
+	a := nodeID(0)
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	stu := nodeID(901)
+	if err := svc.AddLog(ctx, a, stu, 777, ""); err != nil {
+		t.Fatalf("AddLog 失败: %v", err)
+	}
+
+	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
+		t.Fatalf("DeleteLog 失败: %v", err)
+	}
+	var rows []model.UserDivideIntoLog
+	if err := db.Find(&rows).Error; err != nil {
+		t.Fatalf("读明细失败: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("改绑分支应保留明细(1 条),实得 %d", len(rows))
+	}
+	if rows[0].OrderNo == nil || *rows[0].OrderNo != "888" {
+		t.Fatalf("order_no 应改绑为 888,实得 %v", rows[0].OrderNo)
+	}
+	if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
+		t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 {
+		t.Fatalf("改绑不回滚收入(应仍 1000 分),实得 %d", got)
+	}
+	if port.calls == 0 {
+		t.Fatal("改绑判定应调用首次扣费端口")
+	}
+}
+
+// TestDeleteLogFirstEqualsOrder 首次扣费记录 = 当前订单 → 不属改绑场景,走回滚删除。
+func TestDeleteLogFirstEqualsOrder(t *testing.T) {
+	db := newDivideServiceDB(t)
+	port := &fakeFirstCharged{recordID: 777, found: true}
+	svc := NewDivideService(db, port)
+	ctx := context.Background()
+	a := nodeID(0)
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	stu := nodeID(901)
+	if err := svc.AddLog(ctx, a, stu, 777, ""); err != nil {
+		t.Fatalf("AddLog 失败: %v", err)
+	}
+	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
+		t.Fatalf("DeleteLog 失败: %v", err)
+	}
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("首次记录即当前订单 → 应回滚删除(0 条),实得 %d", count)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
+		t.Fatalf("total_income 应回滚为 0,实得 %d 分", got)
+	}
+	if err := svc.DeleteLog(ctx, stu, 0); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("orderID<=0 应 3140,实得 %v", err)
+	}
+}
+
+// TestUpdateQuotaCascade 额度级联下降(子不得超父)+ 参数/缺失分支。
+func TestUpdateQuotaCascade(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+	a, b, c := nodeID(0), nodeID(1), nodeID(2)
+	seedRoot(t, db, a, model.DivideTypeOneOff, "20.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	if err := svc.AddSubordinate(ctx, b, c); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(1500)); err != nil {
+		t.Fatalf("设额度 B 失败: %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, c, money.FromCents(1200)); err != nil {
+		t.Fatalf("设额度 C 失败: %v", err)
+	}
+
+	// 父 A 降到 10.00 → B(15)、C(12) 一并压到 10.00
+	if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
+		t.Fatalf("UpdateQuota(A,10) 失败: %v", err)
+	}
+	for _, id := range []string{a, b, c} {
+		if got := cents(t, loadDivideNode(t, db, id).Quota); got != 1000 {
+			t.Fatalf("%s.quota 应级联压到 1000 分,实得 %d", id, got)
+		}
+	}
+
+	// 子节点超额(> 父)→ 3111
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(9999)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
+		t.Fatalf("超过上级应 3111,实得 %v", err)
+	}
+	// 参数/缺失
+	if err := svc.UpdateQuota(ctx, b, money.FromCents(-1)); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("负额度应 3140,实得 %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, nodeID(404), money.FromCents(100)); !errors.Is(err, infraerrors.ErrNotFound) {
+		t.Fatalf("节点不存在应 3001,实得 %v", err)
+	}
+	if err := svc.UpdateQuota(ctx, "", money.FromCents(100)); !errors.Is(err, apperr.ErrBusinessParam) {
+		t.Fatalf("空 userID 应 3140,实得 %v", err)
+	}
+}
+
+// TestDivideServiceNestedTxPassThrough 事务透传纪律:本服务在**外层事务内**被调用时
+// 必须经 WithTxContext 复用同一 tx(不重复 BEGIN),且随外层回滚一并撤销。
+func TestDivideServiceNestedTxPassThrough(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	runner := repository.NewTxRunner(db)
+	ctx := context.Background()
+
+	a, b := nodeID(0), nodeID(1)
+	seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	stu := nodeID(901)
+	boom := errors.New("outer-rollback")
+
+	err := runner.WithinTx(ctx, func(tx *gorm.DB) error {
+		txCtx := repository.WithTxContext(ctx, tx)
+		if aerr := svc.AddLog(txCtx, b, stu, 777, ""); aerr != nil {
+			return aerr
+		}
+		// 事务内应可见刚写入的明细(说明确实跑在同一 tx 上)
+		var inside int64
+		if cerr := tx.Model(&model.UserDivideIntoLog{}).Count(&inside).Error; cerr != nil {
+			return cerr
+		}
+		if inside != 2 {
+			t.Fatalf("外层事务内应可见 2 条明细,实得 %d", inside)
+		}
+		return boom
+	})
+	if !errors.Is(err, infraerrors.ErrDBError) {
+		t.Fatalf("非业务错误应归一为 5002,实得 %v", err)
+	}
+	if count := countLogs(t, db); count != 0 {
+		t.Fatalf("外层回滚后明细应全部消失(无独立事务提前提交),实得 %d", count)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
+		t.Fatalf("回滚后 total_income 应为 0,实得 %d 分", got)
+	}
+}
+
+// TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完 100 级
+// (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
+func TestAddLogBFSDepthBoundary(t *testing.T) {
+	db := newDivideServiceDB(t)
+	svc := NewDivideService(db, nil)
+	ctx := context.Background()
+
+	root := nodeID(0)
+	seedRoot(t, db, root, model.DivideTypePerLesson, "1.00")
+	prev := root
+	for i := 1; i <= 99; i++ {
+		child := nodeID(i)
+		if err := svc.AddSubordinate(ctx, prev, child); err != nil {
+			t.Fatalf("建链失败(%s→%s): %v", prev, child, err)
+		}
+		prev = child
+	}
+	tail := prev
+	if got := loadDivideNode(t, db, tail).Layer; got != MaxDivideDepth {
+		t.Fatalf("链尾 layer 应为 %d,实得 %d", MaxDivideDepth, got)
+	}
+
+	if err := svc.AddLog(ctx, tail, nodeID(901), 777, ""); err != nil {
+		t.Fatalf("%d 层链的 BFS 入账应在限内成功,实得 %v", MaxDivideDepth, err)
+	}
+	if count := countLogs(t, db); count != int64(MaxDivideDepth) {
+		t.Fatalf("%d 层链应生成 %d 条明细,实得 %d", MaxDivideDepth, MaxDivideDepth, count)
+	}
+	// 根节点收入应恰好为一次 quota
+	if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 100 {
+		t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got)
+	}
+}