Эх сурвалжийг харах

fix(academic): 服务层按 dump 对齐(D2 零额度不入账 / D4 已存在 no-op / D6 新节点 type / A6 原额度前置 / D11 改绑 / 契约注释)

承接 `2a8fc32`(仓储层),本提交为服务层与契约注释部分。

## D2 · quota<=0 不入账(dump `763760`)
`divide_service.go:appendShare`:`quota<=0` 直接跳过(既不写明细,也不累加 total_income)。
否则会给 0 元分成写一条"流水",打破「明细条数 = 实际发钱次数」的对账恒等式。
- `TestAddLogBFSDepthBoundary` 因此需显式给链上每个节点配额度(否则只有根会入账)。

## D4 · 已存在 subid → 完全 no-op(dump `763695`)
`AddSubordinate` 删掉"改挂父链"分支:已存在即幂等成功返回 nil,parent/root/layer/左右值一律不动。
旧实现改挂会凭空移动整棵子树且不重排左右值 → 破坏嵌套集不变式。
- `TestAddSubordinateReparentBranch` **改名并反转期望** → `TestAddSubordinateExistingNodeNoop`
  (parent/root 保持 NULL、layer 不变、左右值不重排、重复调用幂等)。

## D6 · 新节点 Type 固定 1(dump `763714`)
`AddSubordinate` 新建节点 `Type: model.DivideTypeOneOff`,不再继承父。
继承父会把"按课次(2)"传染整条下级链 → 幂等维度(type=1 忽略订单 / type=2 含订单)跟着错。
- `TestAddLogBFSThreeLevels` 的 type 断言改为逐用户校验:预置节点 a 保持 type=2、新建节点 b 为 type=1。

## A6 · 级联两个前置条件(dump `763870-763874`,服务侧)
`UpdateQuota`:更新本节点**之前**取原 quota;**仅当原 quota>0** 才调 `ClampSubtreeQuota`,
并传入 `node.Type` 做同类型过滤。
- 新增 `TestUpdateQuotaNoCascadeWhenOrigZero`:父原 quota=0 → 设 10.00 **不级联**(子保持 5.00);
  父原 quota=10.00 → 降到 3.00 **级联**(子压到 3.00)。
- `TestUpdateQuotaCascade` 起始态本就是父 20.00(>0),级联不被前置跳过,期望不变(已确认)。

## D11 · 撤销售回:有首扣记录就改绑(dump `762547`)
`DeleteLog` 去掉 `firstID != orderID` 条件:**只要**存在首扣记录就改绑(order_no=首扣记录、
create_time 由仓储重置为 now()、remark="推广分成"、total_income 不回滚);仅无首扣记录才回滚删除。
带旧条件会把"撤当前这单且它自己就是首扣记录"误判成无可改绑 → 整组冲正(少发钱)。
- `TestDeleteLogFirstEqualsOrder` → `TestDeleteLogFirstEqualsOrderRebind`:断言仍保留 1 条、
  order_no 不变、remark 改绑文案、total_income 不回滚、create_time 被重置(用 2020 作参照绕开秒级精度)。

## 契约注释(不改逻辑,dump `763811`)
`FirstChargedRecordReader` 注释写死 4 个谓词(`student_id` / `cost>0` / `end_time IS NOT NULL` / `ORDER BY start_time ASC LIMIT 1`)
与「**返回 recordid 列而非 record_student.id**」,并附参考 SQL——否则 AC-05/11 取错列会让撤销改绑匹配不上。

## 测试期望变更点(全部从 dump 重推,未照抄清单)
1. `TestAddSubordinateReparentBranch` → `ExistingNodeNoop`(no-op 语义反转)
2. `TestAddLogBFSThreeLevels`(明细 type 逐节点校验 + 本人不入账)
3. `TestAddLogBFSDepthBoundary`(99 条 + 显式配额度以隔离 D2 影响)
4. `TestUpdateQuotaCascade`(不变,确认未被"原值>0"前置跳过)
5. `TestDeleteLogFirstEqualsOrder`(→ 改绑保留 + create_time 重置)
6. 新增 `TestUpdateQuotaNoCascadeWhenOrigZero`

`go build ./... && go vet ./... && go test ./... -short` 全绿。
Q 19 цаг өмнө
parent
commit
85cd5e6e38

+ 62 - 26
internal/academic/service/divide_service.go

@@ -48,10 +48,20 @@ const remarkRebind = "推广分成"
 // 该判定属于课次/打卡聚合(AC-05/AC-10 范围)。AC-09 不实现它,只声明只读契约 + 允许注入;
 // 未注入时**所有撤销走回滚删除分支**(保守:不让分成悬空在多订单上)。
 //
-// 契约:
+// 契约(**取数口径已写死,实现方不得自选**,dump `yuansiwei_finance.sql:763811`):
+//   - **4 个谓词缺一不可**:`student_id`(本人)、`cost > 0`(真扣费,排除免费/试听)、
+//     `end_time IS NOT NULL`(已结课,未结课不算"首次扣费")、`ORDER BY start_time ASC LIMIT 1`(最早);
+//   - **返回的是 `recordid` 列,不是 `record_student.id`**(dump 原文取 recordid;
+//     取错列会让撤销时的 order_no 与明细侧对不上 → 改绑失效、分成悬空);
 //   - 只读,禁止写任何表;
 //   - 返回 (recordID, found, err):found=false 表示学生尚无扣费记录 → 走回滚删除分支;
 //   - err 仅 DB 异常(infraerrors.ErrDBError),不得返回业务错误。
+//
+// 参考 SQL(口径以此为准):
+//
+//	SELECT recordid FROM t_courses_record_student
+//	 WHERE student_id = ? AND cost > 0 AND end_time IS NOT NULL
+//	 ORDER BY start_time ASC LIMIT 1;
 type FirstChargedRecordReader interface {
 	// FirstChargedRecordID 取该学生"首次扣费上课记录"的 ID。
 	//
@@ -107,11 +117,12 @@ func NewDivideService(db *gorm.DB, firstCharged FirstChargedRecordReader) *Divid
 //
 // 不变式:①②③④ 必须在同一事务内完成(否则中间态树不可用);完成后恒有 left < right。
 //
-// 已存在节点分支(dump `if exists(select 1 from ... where userid = subid)`):
+// 已存在节点分支(audit D4,dump `yuansiwei_finance.sql:763695`):
 //
-//	dump 仅改 parent,**漏写 root_user_id**(留 NULL → 子树/级联查询失效)。
-//	本实现按模型注释的修正要求显式写入父树根,且**不重排左右值**(与原 SP 一致,
-//	区间重排会与迁移期数据冲突,登记为遗留项)。
+//	dump 的判断是 `if exists(select 1 from t_user_divide_into where userid = subid) then ... end if;`
+//	分支体内**没有**任何改挂父链的动作 —— 即"已存在 = 完全 no-op(幂等成功,返回 nil)"。
+//	本实现按 dump 对齐:不再改 parent/root/layer(改挂会凭空移动整棵子树且不重排左右值,
+//	直接破坏嵌套集不变式)。
 func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUserID string) error {
 	parentUserID = strings.TrimSpace(parentUserID)
 	subUserID = strings.TrimSpace(subUserID)
@@ -140,16 +151,10 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 
 		rootID := resolveRoot(parent)
 
-		existing, err := s.repo.GetByUserID(txCtx, subUserID)
+		_, 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
-			}
+			// ── 已存在节点:完全 no-op(dump 763695 的分支体没有任何写动作)
 			return nil
 		case !errors.Is(err, infraerrors.ErrNotFound):
 			return err
@@ -157,16 +162,19 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 
 		// ── 新建节点分支:嵌套集"插入最右子节点"
 		insertAt := parent.RightVal
-		if _, serr := s.repo.ShiftRight(txCtx, tx, insertAt, 2); serr != nil {
+		// root 限定(audit V-02):位移只作用于本树,否则会串改同库其它树的左右值区间
+		if _, serr := s.repo.ShiftRight(txCtx, tx, rootID, insertAt, 2); serr != nil {
 			return serr
 		}
-		if _, serr := s.repo.ShiftLeft(txCtx, tx, insertAt, 2); serr != nil {
+		if _, serr := s.repo.ShiftLeft(txCtx, tx, rootID, insertAt, 2); serr != nil {
 			return serr
 		}
 		parentID := parentUserID
 		node := &model.UserDivideInto{
-			UserID:           subUserID,
-			Type:             parent.Type,
+			UserID: subUserID,
+			// audit D6:新节点固定「一次付清(1)」,**不继承父**(dump 763714 的 INSERT 不写 type,
+			// 落 DDL 默认值 1)。继承父会把"按课次"格式传染整条下级链 → 幂等维度(1 vs 2)跟着错。
+			Type:             model.DivideTypeOneOff,
 			Quota:            "0.00", // 新节点额度由后台 UpdateQuota 配置(默认 0,且不得超父)
 			TotalIncome:      "0.00",
 			TotalIncomePayed: "0.00",
@@ -309,6 +317,11 @@ func (s *DivideService) appendShare(txCtx context.Context, tx *gorm.DB, node *mo
 	if perr != nil {
 		return perr
 	}
+	// audit D2(dump 763760):额度 <= 0 的节点**不生成明细**(也不累加 total_income)。
+	// 否则会给 0 元分成写一条"流水",把"明细条数 = 实际发钱次数"这个对账恒等式打破。
+	if !amount.GreaterThan(0) {
+		return nil
+	}
 	row := &model.UserDivideIntoLog{
 		Type:      node.Type,
 		UserID:    node.UserID,
@@ -337,11 +350,16 @@ func (s *DivideService) appendShare(txCtx context.Context, tx *gorm.DB, node *mo
 //   - nil:成功(含"无对应明细"的幂等空操作);
 //   - ErrBusinessParam / ErrDBError(含端口返回的 DB 异常)。
 //
-// 两分支(设计 §3.3 #23,**顺序不可颠倒**):
+// 两分支(audit D11 按 dump 定稿,**顺序不可颠倒**):
 //
-//	① 改绑(保留分成):学生已有首次扣费上课记录 first,且 first != orderID
-//	   → 该组明细的 order_no 改为 first,备注写"推广分成",total_income **不回滚**;
-//	② 回滚删除:否则 → total_income -= money(冲正)并物理删除明细行。
+//	① 改绑(保留分成):**只要**学生存在首次扣费上课记录 first
+//	   → 该组明细的 order_no 一律改为 first、create_time 重置为 now()、备注写"推广分成",
+//	   total_income **不回滚**;
+//	② 回滚删除:**仅**在无 first 记录时 → total_income -= money(冲正)并物理删除明细行。
+//
+// 关键改动(D11):去掉旧实现里的 `first != orderID` 条件 —— dump 762547 的改绑分支不做这个比较,
+// 撤的正是"当前这单",而它自己就是首扣记录;带条件会把它判成"无可改绑"→ 误走回滚删除,
+// 把真实产生的分成整组冲掉(少发钱)。
 //
 // 说明:同一订单的分成会沿上行链生成多行(本人 + 各级上线),故按 (student, order) 取全量
 // 逐行处理,而不是只处理一行(否则上级分成会悬空)。
@@ -376,7 +394,8 @@ func (s *DivideService) DeleteLog(ctx context.Context, stuID string, orderID int
 			}
 		}
 		rebindTo := ""
-		if hasFirst && firstID > 0 && firstID != orderID {
+		if hasFirst && firstID > 0 {
+			// D11:有首扣记录就改绑,**不与当前 orderID 比较**(dump 762547)
 			rebindTo = strconv.FormatInt(firstID, 10)
 		}
 
@@ -418,10 +437,17 @@ func (s *DivideService) DeleteLog(ctx context.Context, stuID string, orderID int
 //   - ErrDivideQuotaExceeded(3111):新额度**超过上级**额度(子不得超父);
 //   - ErrBusinessParam / ErrDBError。
 //
-// 级联规则("只降不升"):
+// 级联规则(audit A6,dump `yuansiwei_finance.sql:763870-763874` **两个前置条件**):
+//
+//	UPDATE ... SET quota=updateQuota WHERE userid=findUserid;          -- 本节点先落新值
+//	IF (uQuota > 0) THEN                                               -- ① 节点**原** quota>0 才级联
+//	  UPDATE ... WHERE left_val>uLeft AND right_val<uRight
+//	              AND quota>updateQuota AND type=uType;               -- ② 只压**同分成类型**的子节点
+//	END IF;
+//
+// 即:① 原 quota=0 的节点不级联(尚未配置额度的节点不该"顺手"压低子树);
 //
-//	本节点 quota := 新值;
-//	子树中 quota > 新值的节点 → 压到新值(低于新值的阶梯额度保持不变)。
+//	② 级联只在同一分成类型内传播(一次付清/按课次两条线的额度互不影响)。
 //
 // 事务:本节点更新与子树压制**同事务**(否则出现"父已降、子仍高于父"的越权窗口)。
 func (s *DivideService) UpdateQuota(ctx context.Context, userID string, quota money.Amount) error {
@@ -465,10 +491,20 @@ func (s *DivideService) UpdateQuota(ctx context.Context, userID string, quota mo
 			// 上级不在树中(断链)→ 不做上限校验(不阻断后台修复数据)
 		}
 
+		// A6 前置①:取**原** quota(必须在更新本节点之前读,否则拿到的已是新值)
+		origQuota, aerr := parseAmount(node.Quota)
+		if aerr != nil {
+			return aerr
+		}
 		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 {
+		if !origQuota.GreaterThan(0) {
+			// 原额度为 0 → dump 不级联(只落本节点新值)
+			return nil
+		}
+		// A6 前置②:只压同一分成类型的子节点
+		if _, cerr := s.repo.ClampSubtreeQuota(txCtx, tx, node.LeftVal, node.RightVal, node.Type, quota); cerr != nil {
 			return cerr
 		}
 		return nil

+ 103 - 18
internal/academic/service/divide_service_test.go

@@ -11,6 +11,7 @@ import (
 	"math/rand"
 	"path/filepath"
 	"testing"
+	"time"
 
 	gormsqlite "github.com/glebarez/sqlite"
 	"gorm.io/gorm"
@@ -219,7 +220,12 @@ func TestAddSubordinateGuards(t *testing.T) {
 }
 
 // TestAddSubordinateReparentBranch 已存在节点分支:改挂父链并显式补齐 root(dump NULL 缺陷修正)。
-func TestAddSubordinateReparentBranch(t *testing.T) {
+// TestAddSubordinateExistingNodeNoop 已存在 subid → **完全 no-op**(audit D4,dump 763695)。
+//
+// dump 的 `if exists(... where userid = subid) then ... end if;` 分支体内**没有任何写动作**:
+// 已存在即视为幂等成功,parent/root/layer/左右值一律不动。
+// 旧实现会"改挂父链",等于凭空移动整棵子树且不重排左右值 → 破坏嵌套集不变式(本次删除)。
+func TestAddSubordinateExistingNodeNoop(t *testing.T) {
 	db := newDivideServiceDB(t)
 	svc := NewDivideService(db, nil)
 	ctx := context.Background()
@@ -236,21 +242,24 @@ func TestAddSubordinateReparentBranch(t *testing.T) {
 	}
 
 	if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
-		t.Fatalf("改挂分支失败: %v", err)
+		t.Fatalf("已存在节点应幂等成功(no-op),实得 %v", err)
 	}
 	got := loadDivideNode(t, db, orphan)
-	if got.ParentUserID == nil || *got.ParentUserID != root {
-		t.Fatalf("parent 应改为 %s,实得 %v", root, got.ParentUserID)
+	if got.ParentUserID != nil {
+		t.Fatalf("已存在节点不得改挂父链(dump 763695 分支体无写动作),实得 parent=%v", *got.ParentUserID)
 	}
-	if got.RootUserID == nil || *got.RootUserID != root {
-		t.Fatalf("root_user_id 必须补齐为 %s(dump 缺陷修正),实得 %v", root, got.RootUserID)
+	if got.RootUserID != nil {
+		t.Fatalf("已存在节点不得写 root_user_id,实得 %v", *got.RootUserID)
 	}
-	if got.Layer != 2 {
-		t.Fatalf("layer 应为 2,实得 %d", got.Layer)
+	if got.Layer != 1 {
+		t.Fatalf("layer 不应变化(应 1),实得 %d", got.Layer)
 	}
-	// 不重排左右值(与原 SP 对齐)→ 值保持不变
 	if got.LeftVal != 100 || got.RightVal != 101 {
-		t.Fatalf("改挂分支不应重排左右值,实得 (%d,%d)", got.LeftVal, got.RightVal)
+		t.Fatalf("左右值不应重排,实得 (%d,%d)", got.LeftVal, got.RightVal)
+	}
+	// 重复调用同样 no-op(幂等)
+	if err := svc.AddSubordinate(ctx, root, orphan); err != nil {
+		t.Fatalf("重复调用应幂等成功,实得 %v", err)
 	}
 }
 
@@ -327,8 +336,12 @@ func TestAddLogBFSThreeLevels(t *testing.T) {
 		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)
+		// 明细 type = **该节点自己的 type**:a 是预置节点(type=2),b 是 AddSubordinate 新建节点
+		// → audit D6 规定新节点固定 1(不继承父的 2),故这里逐用户校验
+		wantType := map[string]int8{a: model.DivideTypePerLesson, b: model.DivideTypeOneOff}
+		if rows[i].Type != wantType[rows[i].UserID] {
+			t.Fatalf("用户 %s 的明细 type 应为 %d(D6:新节点固定 1),实得 %d",
+				rows[i].UserID, wantType[rows[i].UserID], rows[i].Type)
 		}
 	}
 	// total_income 各自累加自己的 quota
@@ -638,8 +651,11 @@ func TestDeleteLogRebindBranch(t *testing.T) {
 	}
 }
 
-// TestDeleteLogFirstEqualsOrder 首次扣费记录 = 当前订单 → 不属改绑场景,走回滚删除。
-func TestDeleteLogFirstEqualsOrder(t *testing.T) {
+// TestDeleteLogFirstEqualsOrderRebind 首扣记录**就是**当前订单 → 仍然改绑保留(audit D11,dump 762547)。
+//
+// dump 的改绑分支不与当前 orderID 比较:撤的正是"当前这单"而它本身就是首扣记录,
+// 带 `first != orderID` 条件会误判成"无可改绑"→ 回滚删除,把真实产生的分成整组冲掉(少发钱)。
+func TestDeleteLogFirstEqualsOrderRebind(t *testing.T) {
 	db := newDivideServiceDB(t)
 	port := &fakeFirstCharged{recordID: 777, found: true}
 	svc := NewDivideService(db, port)
@@ -653,14 +669,36 @@ func TestDeleteLogFirstEqualsOrder(t *testing.T) {
 	if err := svc.AddLog(ctx, b, stu, 777, ""); err != nil {
 		t.Fatalf("AddLog 失败: %v", err)
 	}
+	before := loadDivideNode(t, db, a).TotalIncome
+	// 把改绑前的 create_time 拨到 2020:改绑要求"重置为 now()",用 2020 作参照可绕开
+	// sqlite CURRENT_TIMESTAMP 的秒级精度(同秒内 After() 无法区分)
+	old2020 := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
+	if err := db.Model(&model.UserDivideIntoLog{}).Where("user_id = ?", a).
+		UpdateColumn("create_time", old2020).Error; err != nil {
+		t.Fatalf("回拨 create_time 失败: %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 count := countLogs(t, db); count != 1 {
+		t.Fatalf("首扣记录=当前订单 → 应改绑保留(1 条),实得 %d", count)
 	}
-	if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
-		t.Fatalf("total_income 应回滚为 0,实得 %d 分", got)
+	var rows []model.UserDivideIntoLog
+	if err := db.Find(&rows).Error; err != nil {
+		t.Fatalf("读明细失败: %v", err)
+	}
+	if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" {
+		t.Fatalf("order_no 应仍为 777(改绑到首扣记录),实得 %v", rows[0].OrderNo)
+	}
+	if rows[0].Remark == nil || *rows[0].Remark != remarkRebind {
+		t.Fatalf("remark 应为 %q,实得 %v", remarkRebind, rows[0].Remark)
+	}
+	if got := loadDivideNode(t, db, a).TotalIncome; got != before {
+		t.Fatalf("改绑不回滚 total_income(应仍 %q),实得 %q", before, got)
+	}
+	if !rows[0].CreateTime.After(old2020) {
+		t.Fatalf("改绑后 create_time 应重置为 now()(> 2020),实得 %s", rows[0].CreateTime)
 	}
 	if err := svc.DeleteLog(ctx, stu, 0); !errors.Is(err, apperr.ErrBusinessParam) {
 		t.Fatalf("orderID<=0 应 3140,实得 %v", err)
@@ -713,6 +751,47 @@ func TestUpdateQuotaCascade(t *testing.T) {
 	}
 }
 
+// TestUpdateQuotaNoCascadeWhenOrigZero 原 quota=0 时不级联(audit A6,dump 763872 `IF (uQuota>0)`)。
+//
+// 语义:还没配置过额度的节点,被"设成某个值"时不该顺手压低子树(子树额度是独立配置的阶梯)。
+// 对照:节点原额度 > 0 时级联照旧生效(后半段断言)。
+func TestUpdateQuotaNoCascadeWhenOrigZero(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, "0.00") // 原 quota = 0
+	if err := svc.AddSubordinate(ctx, a, b); err != nil {
+		t.Fatalf("建链失败: %v", err)
+	}
+	// 直接把子节点额度设成 5.00(绕过服务,构造"父 0 / 子 5"的起始态)
+	if err := db.Exec("UPDATE t_user_divide_into SET quota = ? WHERE user_id = ?", "5.00", b).Error; err != nil {
+		t.Fatalf("配置子额度失败: %v", err)
+	}
+
+	// 父原 quota=0 → 设成 10.00 **不级联**:子保持 5.00
+	if err := svc.UpdateQuota(ctx, a, money.FromCents(1000)); err != nil {
+		t.Fatalf("UpdateQuota 失败: %v", err)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).Quota); got != 1000 {
+		t.Fatalf("父额度应为 10.00,实得 %d 分", got)
+	}
+	if got := cents(t, loadDivideNode(t, db, b).Quota); got != 500 {
+		t.Fatalf("原 quota=0 不应级联:子应保持 5.00,实得 %d 分", got)
+	}
+
+	// 父原 quota=10.00 > 0 → 降到 3.00 **会级联**:子被压到 3.00
+	if err := svc.UpdateQuota(ctx, a, money.FromCents(300)); err != nil {
+		t.Fatalf("UpdateQuota(第二次) 失败: %v", err)
+	}
+	if got := cents(t, loadDivideNode(t, db, a).Quota); got != 300 {
+		t.Fatalf("父额度应为 3.00,实得 %d 分", got)
+	}
+	if got := cents(t, loadDivideNode(t, db, b).Quota); got != 300 {
+		t.Fatalf("原 quota>0 应级联压到 3.00,实得 %d 分", got)
+	}
+}
+
 // TestDivideServiceNestedTxPassThrough 事务透传纪律:本服务在**外层事务内**被调用时
 // 必须经 WithTxContext 复用同一 tx(不重复 BEGIN),且随外层回滚一并撤销。
 func TestDivideServiceNestedTxPassThrough(t *testing.T) {
@@ -779,6 +858,12 @@ func TestAddLogBFSDepthBoundary(t *testing.T) {
 		t.Fatalf("链尾 layer 应为 %d,实得 %d", MaxDivideDepth, got)
 	}
 
+	// audit D2:quota<=0 的节点不生成明细 → 给链上**每个节点**配上 1.00 额度,
+	// 否则只有根节点会入账(本用例要测的是 BFS 深度边界,不是 D2)
+	if err := db.Exec("UPDATE t_user_divide_into SET quota = ?", "1.00").Error; err != nil {
+		t.Fatalf("统一配置额度失败: %v", err)
+	}
+
 	if err := svc.AddLog(ctx, tail, nodeID(901), 777, ""); err != nil {
 		t.Fatalf("%d 层链的 BFS 入账应在限内成功,实得 %v", MaxDivideDepth, err)
 	}