Kaynağa Gözat

fix(academic): 修复审计 P0(老带新判据判错 status + 分成 BFS 起点多算一代)

两处均由 read-only 审计用 dump 原文复核确认,都会导致**金额错发**。

## P0-1 · CountSignedByUser 的 type=4 判据(dump `yuansiwei_finance.sql:762947`)
dump 原文:
    SELECT COUNT(1) FROM t_courses_activity_signed t1
      INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
     WHERE t1.userid=inputUserid AND t2.status != 0

修复前:`WHERE signed.status <> 0 ... AND activity.type = 4`
→ **取消过的 type=4 报名不计入**,用户取消后可重新报名并**再次领取老带新奖励**(重复发钱)。

修复后(两分支语义不同,禁止合并):
- `type4Only=true`(3106 限一次):判**活动** `activity.status <> 0`,**不过滤报名行 status**
  (取消过的报名也算"参加过");
- `type4Only=false`(signed_cancel 第一道守卫):仍只看报名行 `signed.status <> 0`。

## P0-2 · AddLog 上行分成 BFS 起点(dump `yuansiwei_finance.sql:763744`)
dump 原文:
    DECLARE cur CURSOR FOR SELECT userid,type,parent_userid,quota FROM t_user_divide_into
     WHERE userid=(SELECT parent_userid FROM t_user_divide_into WHERE userid=findUserid);

修复前:从 `GetByUserID(findUserID)` 开始 → **本人也记一份**,整整多算一代。
修复后:
- 先取本人节点,`parent_user_id` 为空(本人是根)→ 直接返回 nil(幂等空操作,对应子查询 NULL);
- 否则从**父节点**开始上溯;上级已断链(不在树中)→ 同样静默无明细;
- 深度上限 100 与自环/成环检测保持(自环改为在起点即判,报 3111)。

## 单测(新增/更新)
- A1(repository):`TestCountSignedByUserType4Dedup` 改造为 dump 口径 ——
  「取消过 type=4 报名仍计数(→ 仍拒绝重报)」+「活动停用后可重报(计数 0)」+「守卫口径取消行不计」。
- D1(service):新增 `TestAddLogStartFromParentDumpSemantics` ——
  「根用户无明细」「普通用户只给上级及更高层分成、本人无明细且 total_income 保持 0」「上级断链无明细」。
- 既有 BFS / 幂等 / 撤销 / 事务透传 / 100 层边界用例按新语义修正期望值
  (本人不参与 → 明细数 = 链条级数 - 1),并补充"本人不得出现在明细中"的显式断言。

`go build ./... && go vet ./... && go test ./... -short` 全绿。
Q 20 saat önce
ebeveyn
işleme
9d8de5af82

+ 25 - 13
internal/academic/repository/activity_repo.go

@@ -265,26 +265,34 @@ func (r *ActivityRepo) UpsertSigned(ctx context.Context, tx *gorm.DB, signed *mo
 	return nil
 }
 
-// CountSignedByUser 统计用户有效报名数(**type=4 跨活动去重判定**,3106 老带新限一次)。
+// CountSignedByUser 统计用户报名数(**type=4 老带新限一次判定** / 取消报名的守卫条件)。
 //
 // 入参:
 //   - ctx 请求上下文;
 //   - userID 报名用户(canonical_user_id ULID;空 → ErrBadParam);
-//   - type4Only true=只统计老带新(t_courses_activity.type=4)活动;false=统计全部活动类型。
+//   - type4Only 判定口径(**两分支语义不同,禁止合并**):
+//     true  = 老带新限一次(3106):只统计老带新活动(activity.type=4)**且活动未停用**(activity.status<>0),
+//     **不过滤报名行状态**——取消过的报名也算"参加过";
+//     false = 取消报名前置守卫(signed_cancel):统计该用户**当前有效**报名(signed.status<>0),
+//     不限活动类型、不看活动状态。
 //
 // 出参:
-//   - int64:有效报名记录数(**status != 0**,已取消/过期行不计);
+//   - int64:命中条数(>0 即命中对应业务规则);
 //   - ErrBadParam / ErrDBError。
 //
-// SQL 口径(对齐 dump,表名/列名限定防 JOIN ambiguous):
+// SQL 口径(**逐字对齐 dump**):
 //
-//	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)   -- 作用域
+//	type4Only=true(dump `yuansiwei_finance.sql:762947`,老带新限一次):
+//	  SELECT COUNT(1) FROM t_courses_activity_signed t1
+//	    INNER JOIN t_courses_activity t2 ON t1.activityid=t2.id AND t2.type=4
+//	   WHERE t1.userid=? AND t2.status != 0
+//	→ **判活动 t2.status**,且对报名 t1.status **不做过滤**(取消过也算参加过,防"取消后重报再领奖励");
 //
-// 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行),故 COUNT(*) 即
-// "该用户参加过多少个 type=4 活动";>0 即可返回 3106(设计 §3.2 signed_add)。
+//	type4Only=false(signed_cancel 第一道守卫):
+//	  SELECT count(*) FROM t_courses_activity_signed WHERE user_id=? AND status <> 0
+//	→ 只看报名行是否仍然有效。
+//
+// 去重语义:跨活动去重由 UK(user_id, activity_id) 保证(一活动一行)。
 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"))
@@ -294,11 +302,15 @@ func (r *ActivityRepo) CountSignedByUser(ctx context.Context, userID string, typ
 		return 0, err
 	}
 	q := db.Table("t_courses_activity_signed AS signed").
-		Where("signed.user_id = ?", userID).
-		Where("signed.status <> ?", model.ActivitySignedCancelled)
+		Where("signed.user_id = ?", userID)
 	if type4Only {
+		// dump 口径:判**活动** status(停用=0 的活动不计),报名行状态不参与过滤
 		q = q.Joins("JOIN t_courses_activity AS activity ON activity.id = signed.activity_id").
-			Where("activity.type = ?", model.ActivityTypeRecommend)
+			Where("activity.type = ?", model.ActivityTypeRecommend).
+			Where("activity.status <> ?", model.ActivityStatusDisabled)
+	} else {
+		// signed_cancel 守卫:只看报名行是否有效
+		q = q.Where("signed.status <> ?", model.ActivitySignedCancelled)
 	}
 	q = scopedCampusRows(q, r.Sc, "signed")
 	var count int64

+ 35 - 7
internal/academic/repository/activity_repo_test.go

@@ -301,19 +301,27 @@ func TestUpsertSignedGuards(t *testing.T) {
 	}
 }
 
-// TestCountSignedByUserType4Dedup type=4 跨活动去重计数;status=0 不计。
+// TestCountSignedByUserType4Dedup type=4 判据(**逐字对齐 dump `yuansiwei_finance.sql:762947`**)。
+//
+// dump 口径:`WHERE t1.userid=? AND t2.status != 0`(判**活动** status,**不过滤报名行 status**)。
+// 审计 P0-1 修复前本实现筛的是 `signed.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")
+	// 活动 3 已停用(status=0)→ dump 口径不计(停用活动可重报)
+	if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 3).
+		UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil {
+		t.Fatalf("停用活动 3 失败: %v", err)
+	}
 
 	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, 2, 2, 1, 2, user, model.ActivitySignedCancelled) // **已取消也计**(参加过)
+	seedSigned(t, db, 3, 3, 1, 2, user, model.ActivitySignedSigned)    // 活动已停用 → 不计
+	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})
@@ -323,15 +331,35 @@ func TestCountSignedByUserType4Dedup(t *testing.T) {
 	if err != nil {
 		t.Fatalf("CountSignedByUser(type4) 失败: %v", err)
 	}
+	// 活动 1(有效报名)+ 活动 2(**取消过的报名**)= 2;活动 3(停用)与活动 4(非 type=4)不计
 	if type4 != 2 {
-		t.Fatalf("type=4 有效报名数应为 2(取消行不计),实得 %d", type4)
+		t.Fatalf("type=4 计数应为 2(取消过也算、停用活动不计),实得 %d", type4)
+	}
+
+	// A1-1:取消过 type=4 报名后**仍拒绝**(3106 语义)——只留活动 2 的取消行
+	if err := db.Where("id IN ?", []int64{1, 3, 4, 5}).Delete(&model.CoursesActivitySigned{}).Error; err != nil {
+		t.Fatalf("清理报名行失败: %v", err)
+	}
+	if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 1 {
+		t.Fatalf("取消过报名仍应计数 1(防取消后重报重复领奖),实得 %d err=%v", again, err)
 	}
+
+	// A1-2:活动停用后可重报(停用活动不再计入 type=4 判据)
+	if err := db.Model(&model.CoursesActivity{}).Where("id = ?", 2).
+		UpdateColumn("status", model.ActivityStatusDisabled).Error; err != nil {
+		t.Fatalf("停用活动 2 失败: %v", err)
+	}
+	if again, err := repo.CountSignedByUser(ctx, user, true); err != nil || again != 0 {
+		t.Fatalf("活动停用后应可重报(计数 0),实得 %d err=%v", again, err)
+	}
+
+	// 守卫口径(type4Only=false):只看报名行是否有效 → 已取消行不计
 	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 all != 0 {
+		t.Fatalf("signed_cancel 守卫口径下已取消行应为 0,实得 %d", all)
 	}
 	if _, err := repo.CountSignedByUser(ctx, "  ", true); !errors.Is(err, infraerrors.ErrBadParam) {
 		t.Fatalf("空 user_id 应 ErrBadParam,实得 %v", err)

+ 28 - 3
internal/academic/service/divide_service.go

@@ -198,8 +198,15 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 //   - ErrBusinessParam / ErrDBError;
 //   - 若节点 quota 金额被写坏(非十进制)→ ErrDBError(数据异常,不静默归零)。
 //
-// 上行链语义(dump):从 findUserID 开始,逐级上溯 parent_user_id,每级生成一条明细
-// (money = 该级节点的 quota 快照),并累加该节点 total_income;到根(parent 为 NULL)终止。
+// 上行链语义(**逐字对齐 dump `yuansiwei_finance.sql:763744`**):
+//
+//	DECLARE cur CURSOR FOR SELECT userid,type,parent_userid,quota FROM t_user_divide_into
+//	 WHERE userid = (SELECT parent_userid FROM t_user_divide_into WHERE userid = findUserid);
+//
+// 起点 = **findUserid 的上级**(本人不参与分成,本人只是"被推荐人"),随后逐级上溯 parent_user_id,
+// 每级生成一条明细(money = 该级节点的 quota 快照)并累加该节点 total_income,到根(parent 为 NULL)终止。
+// findUserid 是根(其 parent_user_id 为 NULL)或本身不是树节点 → 子查询为 NULL → **无明细且不报错**
+// (静默不入账,与 dump 的子查询语义一致)。
 //
 // 幂等(两级,设计 §3.3 #22,**同事务内逐级判定**):
 //   - 节点 type=1(一次付清):同 (user, student) 只生成一条 → 后续不同 recordID 也不重复入账;
@@ -223,7 +230,8 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
 	return s.runner.WithinTx(ctx, func(tx *gorm.DB) error {
 		txCtx := repository.WithTxContext(ctx, tx)
 
-		cur, err := s.repo.GetByUserID(txCtx, findUserID)
+		// ① 定位 findUserid 的**上级**(dump 子查询口径):本人不参与分成
+		self, err := s.repo.GetByUserID(txCtx, findUserID)
 		if errors.Is(err, infraerrors.ErrNotFound) {
 			// 红线(模型注释):必须是分账树节点才入账;非节点 → 静默不入账(不是错误)
 			return nil
@@ -231,6 +239,23 @@ func (s *DivideService) AddLog(ctx context.Context, findUserID, stuID string, re
 		if err != nil {
 			return err
 		}
+		if self.ParentUserID == nil || strings.TrimSpace(*self.ParentUserID) == "" {
+			// findUserid 是根:dump 子查询返回 NULL → 无明细(幂等空操作)
+			return nil
+		}
+		startID := strings.TrimSpace(*self.ParentUserID)
+		if startID == self.UserID {
+			return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+				errString("service: 分账树自环(parent 指向自身)"))
+		}
+
+		cur, err := s.repo.GetByUserID(txCtx, startID)
+		if errors.Is(err, infraerrors.ErrNotFound) {
+			return nil // 断链(上级已不在树中):无明细(与 dump 子查询 NULL 同效)
+		}
+		if err != nil {
+			return err
+		}
 
 		for depth := int32(0); cur != nil; depth++ {
 			if depth >= MaxDivideDepth {

+ 139 - 21
internal/academic/service/divide_service_test.go

@@ -279,7 +279,8 @@ func TestAddSubordinateDepthLimit(t *testing.T) {
 	}
 }
 
-// TestAddLogBFSThreeLevels 上行链 BFS:本人 + 两级上线各生成一条明细并累加 total_income。
+// TestAddLogBFSThreeLevels 上行链 BFS:**从上级起算**(dump `:763744` 子查询口径),
+// 本人不参与分成 —— c 报课,只有 b(直接上级)与 a(根)各得一条明细。
 func TestAddLogBFSThreeLevels(t *testing.T) {
 	db := newDivideServiceDB(t)
 	svc := NewDivideService(db, nil)
@@ -308,10 +309,10 @@ func TestAddLogBFSThreeLevels(t *testing.T) {
 	if err := db.Find(&rows).Error; err != nil {
 		t.Fatalf("读明细失败: %v", err)
 	}
-	if len(rows) != 3 {
-		t.Fatalf("三级链应生成 3 条明细,实得 %d", len(rows))
+	if len(rows) != 2 {
+		t.Fatalf("上行链应生成 2 条明细(本人不参与),实得 %d", len(rows))
 	}
-	want := map[string]int64{c: 200, b: 500, a: 1000}
+	want := map[string]int64{b: 500, a: 1000}
 	for i := range rows {
 		expect, ok := want[rows[i].UserID]
 		if !ok {
@@ -336,9 +337,20 @@ func TestAddLogBFSThreeLevels(t *testing.T) {
 			t.Fatalf("%s.total_income 应为 %d 分,实得 %d", user, expect, got)
 		}
 	}
+	// D1 红线:本人(c)不参与分成,任何列都不得被写入
+	if got := cents(t, loadDivideNode(t, db, c).TotalIncome); got != 0 {
+		t.Fatalf("本人 %s 不应获得分成(total_income 应 0),实得 %d 分", c, got)
+	}
+	for i := range rows {
+		if rows[i].UserID == c {
+			t.Fatal("本人不得生成分成明细(dump 起点是 findUserid 的上级)")
+		}
+	}
 }
 
 // TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
+//
+// 结构:root(拿分成的一方)+ child(报课/被推荐人)—— **分成只入 root**(本人不参与,dump 口径)。
 func TestAddLogTwoLevelIdempotency(t *testing.T) {
 	ctx := context.Background()
 	stu := nodeID(901)
@@ -346,13 +358,16 @@ func TestAddLogTwoLevelIdempotency(t *testing.T) {
 	t.Run("type=1 一次付清(同 user+student 只一条)", func(t *testing.T) {
 		db := newDivideServiceDB(t)
 		svc := NewDivideService(db, nil)
-		u := nodeID(0)
+		u, child := nodeID(0), nodeID(1)
 		seedRoot(t, db, u, model.DivideTypeOneOff, "10.00")
+		if err := svc.AddSubordinate(ctx, u, child); err != nil {
+			t.Fatalf("建链失败: %v", err)
+		}
 
-		if err := svc.AddLog(ctx, u, stu, 777, ""); err != nil {
+		if err := svc.AddLog(ctx, child, stu, 777, ""); err != nil {
 			t.Fatalf("AddLog 失败: %v", err)
 		}
-		if err := svc.AddLog(ctx, u, stu, 888, ""); err != nil { // 不同订单:应与 type=1 无关
+		if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 不同订单:应与 type=1 无关
 			t.Fatalf("AddLog(第二次) 失败: %v", err)
 		}
 		var count int64
@@ -370,18 +385,21 @@ func TestAddLogTwoLevelIdempotency(t *testing.T) {
 	t.Run("type=2 按课次(同 user+student+order 只一条)", func(t *testing.T) {
 		db := newDivideServiceDB(t)
 		svc := NewDivideService(db, nil)
-		u := nodeID(0)
+		u, child := nodeID(0), nodeID(1)
 		seedRoot(t, db, u, model.DivideTypePerLesson, "10.00")
+		if err := svc.AddSubordinate(ctx, u, child); err != nil {
+			t.Fatalf("建链失败: %v", err)
+		}
 
 		for i := 0; i < 2; i++ { // 同一订单重复请求
-			if err := svc.AddLog(ctx, u, stu, 777, ""); err != nil {
+			if err := svc.AddLog(ctx, child, 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 { // 新订单:应新增
+		if err := svc.AddLog(ctx, child, stu, 888, ""); err != nil { // 新订单:应新增
 			t.Fatalf("AddLog(新订单) 失败: %v", err)
 		}
 		if count := countLogs(t, db); count != 2 {
@@ -403,6 +421,96 @@ func countLogs(t *testing.T, db *gorm.DB) int64 {
 	return count
 }
 
+// TestAddLogStartFromParentDumpSemantics 审计 P0-2(dump `yuansiwei_finance.sql:763744`):
+// 上行分成的起点是 **findUserid 的上级**,本人不参与;findUserid 是根 → 无明细(幂等空操作)。
+//
+// 修复前从本人开始 → 本人也记一份,整整多算一代(金额错发)。
+func TestAddLogStartFromParentDumpSemantics(t *testing.T) {
+	ctx := context.Background()
+	stu := nodeID(901)
+
+	t.Run("根用户无明细(子查询为 NULL)", func(t *testing.T) {
+		db := newDivideServiceDB(t)
+		svc := NewDivideService(db, nil)
+		root := nodeID(0)
+		seedRoot(t, db, root, model.DivideTypePerLesson, "10.00")
+
+		if err := svc.AddLog(ctx, root, stu, 777, ""); err != nil {
+			t.Fatalf("根用户报课应静默不入账(不报错),实得 %v", err)
+		}
+		if count := countLogs(t, db); count != 0 {
+			t.Fatalf("根用户无上级 → 不应生成明细,实得 %d", count)
+		}
+		if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 0 {
+			t.Fatalf("根用户 total_income 应保持 0,实得 %d 分", got)
+		}
+	})
+
+	t.Run("只给上级及更高层分成、本人无明细", func(t *testing.T) {
+		db := newDivideServiceDB(t)
+		svc := NewDivideService(db, nil)
+		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)
+		}
+
+		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)
+		}
+		got := make(map[string]int64, len(rows))
+		for i := range rows {
+			got[rows[i].UserID] = cents(t, rows[i].Money)
+		}
+		if len(got) != 2 {
+			t.Fatalf("应只有上级 b 与根 a 两条明细,实得 %d:%v", len(got), got)
+		}
+		if _, ok := got[c]; ok {
+			t.Fatal("本人 c 不得出现在分成明细中(多算一代 = 金额错发)")
+		}
+		if got[b] != 500 || got[a] != 1000 {
+			t.Fatalf("分成金额不符:b=%d a=%d(期望 500/1000)", got[b], got[a])
+		}
+		if cents(t, loadDivideNode(t, db, c).TotalIncome) != 0 {
+			t.Fatal("本人 c 的 total_income 必须保持 0")
+		}
+	})
+
+	t.Run("上级已断链(不在树中)→ 无明细", func(t *testing.T) {
+		db := newDivideServiceDB(t)
+		svc := NewDivideService(db, nil)
+		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)
+		}
+		// 人为把 b 的上级指向一个不存在的节点(脏数据 / 上级已退网)
+		if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", b).
+			UpdateColumn("parent_user_id", nodeID(777)).Error; err != nil {
+			t.Fatalf("造断链失败: %v", err)
+		}
+		if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
+			t.Fatalf("断链应静默不入账,实得 %v", err)
+		}
+		if count := countLogs(t, db); count != 0 {
+			t.Fatalf("断链不应生成明细,实得 %d", count)
+		}
+	})
+}
+
 // TestAddLogNonTreeNodeNoop 非分账树节点 → 静默不入账(不报错)。
 func TestAddLogNonTreeNodeNoop(t *testing.T) {
 	db := newDivideServiceDB(t)
@@ -467,8 +575,9 @@ func TestDeleteLogRollbackBranch(t *testing.T) {
 	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)
+	// b 报课 → 只有上级 a 得一条(本人不参与分成,dump 口径)
+	if count := countLogs(t, db); count != 1 {
+		t.Fatalf("应为 1 条明细(仅上级),实得 %d", count)
 	}
 
 	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
@@ -495,10 +604,13 @@ func TestDeleteLogRebindBranch(t *testing.T) {
 	port := &fakeFirstCharged{recordID: 888, found: true}
 	svc := NewDivideService(db, port)
 	ctx := context.Background()
-	a := nodeID(0)
+	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)
-	if err := svc.AddLog(ctx, a, stu, 777, ""); err != nil {
+	if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
 		t.Fatalf("AddLog 失败: %v", err)
 	}
 
@@ -532,10 +644,13 @@ func TestDeleteLogFirstEqualsOrder(t *testing.T) {
 	port := &fakeFirstCharged{recordID: 777, found: true}
 	svc := NewDivideService(db, port)
 	ctx := context.Background()
-	a := nodeID(0)
+	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)
-	if err := svc.AddLog(ctx, a, stu, 777, ""); err != nil {
+	if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
 		t.Fatalf("AddLog 失败: %v", err)
 	}
 	if err := svc.DeleteLog(ctx, stu, 777); err != nil {
@@ -624,8 +739,8 @@ func TestDivideServiceNestedTxPassThrough(t *testing.T) {
 		if cerr := tx.Model(&model.UserDivideIntoLog{}).Count(&inside).Error; cerr != nil {
 			return cerr
 		}
-		if inside != 2 {
-			t.Fatalf("外层事务内应可见 2 条明细,实得 %d", inside)
+		if inside != 1 {
+			t.Fatalf("外层事务内应可见 1 条明细(仅上级,本人不参与),实得 %d", inside)
 		}
 		return boom
 	})
@@ -640,8 +755,10 @@ func TestDivideServiceNestedTxPassThrough(t *testing.T) {
 	}
 }
 
-// TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完 100 级
+// TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完到根
 // (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
+//
+// 明细条数 = 99(链尾本人不参与分成,dump 起点是其上级,故比全链少 1 条)。
 func TestAddLogBFSDepthBoundary(t *testing.T) {
 	db := newDivideServiceDB(t)
 	svc := NewDivideService(db, nil)
@@ -665,8 +782,9 @@ func TestAddLogBFSDepthBoundary(t *testing.T) {
 	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)
+	// 链尾本人不参与 → 明细 = 层数 - 1
+	if count := countLogs(t, db); count != int64(MaxDivideDepth-1) {
+		t.Fatalf("%d 层链应生成 %d 条明细(本人不参与),实得 %d", MaxDivideDepth, MaxDivideDepth-1, count)
 	}
 	// 根节点收入应恰好为一次 quota
 	if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 100 {