Преглед изворни кода

feat(ac20): AddSubordinate 落 3 层业务上限校验(先 3 后 100)

行为变化(唯一一处):
- AddSubordinate 在原有 100 判定**之前**插入 nextLayer > MaxDivideBizDepth(3)
  ⇒ 3111,文案含「业务上限(最多 3 层:超管→校区主任→老师),目标层级 N」。
- 100 那条保留为纵深防御,文案改为「结构上限」,两者可由 message 区分。
- 判序:先 3 后 100(3 是用户真会撞到的常规失败点,100 只在脏数据下触发)。

单测:
- TestAddSubordinateDepthLimit → TestAddSubordinateBizDepthLimit:建到 3 层,
  断言第 4 层 3111 + 文案指向业务上限 + 被拒后 right_val 未位移、节点数未变。
  **常量值写死 3**(不用 MaxDivideBizDepth):实测过用常量写法的版本在常量被改成
  100 时仍 PASS —— 自指断言,等于没测;写法与理由都写在用例注释里。
- 新增 TestDivideStructuralDepthConstant:只钉 MaxDivideDepth==100 / BizDepth==3 /
  3<100,注释说明为何不能删 100,且**不为它构造 100 层链**。
- TestAddLogBFSDepthBoundary:100 层链改由新增 seedChain 直接写库
  (F20 后公开接口建不出这么深),语义变清晰 =「库里已有历史超长链,读路径不能崩」。
  seedChain 必须自己连 parent_user_id —— 只写 layer/left/right 会造出 100 个孤点。
- TestAddSubordinateNestedSetInvariants:随机挂父改为只在 layer<3 的节点里选,
  否则随机撞 3111(本用例测嵌套集不变量,不是测上限)。

变异验证(已回滚,工作树无残留):
- 去掉 3 层判定(if false && ...)⇒ TestAddSubordinateBizDepthLimit
  FAIL「超过 3 层应 3111,实得 <nil>」。
- 常量改 100 ⇒ 上述用例 FAIL + TestDivideStructuralDepthConstant FAIL。

handler 注释:端点 39/40/41 补全 3111 的**四义**(层级超3 / 层级超100 /
上行超100或成环 / 新额度超父额度),并强调前端不能只按错误码区分文案。
Q пре 8 часа
родитељ
комит
092613bccd

+ 24 - 3
internal/academic/handler/divide_handler.go

@@ -156,7 +156,21 @@ type DivideMemberReq struct {
 // AddDivideMember 加入下线(端点 39 `POST /admin/divide-into/members`)。
 //
 // 出参(成功):`{ancestorIds:[]}`(新节点的祖先链,由外向内、不含自身)。
-// 出参(失败):1001/2001/3111(层级超 100)/3001/5002。
+// 出参(失败):1001/2001/3111(层级超限,**两种语义见下**)/3001/5002。
+//
+// **3111 在本端点有两种判据,前端文案应区分**(F20,2026-11-10):
+//   - ① **业务规则:层级超 3 层**(超管 → 校区主任 → 老师)。第 4 层直接拒,
+//     这是产品拍板的硬规则(前端 mock `divide-into.ts:26 MAX_DIVIDE_DEPTH = 3` 的落地)。
+//     文案示例:「分账最多支持 3 层(超管 → 校区主任 → 老师)」。
+//   - ② 结构上限:层级超 100(防上行 BFS 不终止 / 递归爆栈)。正常业务撞不到,
+//     撞到通常意味着**库里已有脏数据**,不是用户操作问题,文案不宜照抄 ①。
+//
+// 判序固定「先 ① 后 ②」:① 是常规失败点,② 是兜底;反了会把业务限制误报成数据异常。
+// 全仓 3111 的**四义**(本批整理,勿只看本端点):
+//   - 层级超 3 层(业务规则,本端点 + 端点 39 建节点时);
+//   - 层级超 100(结构上限,本端点);
+//   - 上行链路超 100 层 / 成环(端点数流水时 BFS 兜底,见端点 41);
+//   - 新额度 > 父节点额度(端点 40 改额度,D9 有意增强)。
 //
 // ⚠ 新节点 `quota` 默认 **0.00**(D6)⇒ 前端应在成功后**引导设置额度**(端点 40),
 // 否则该链一分钱不分成且**不报错**(静默不入账)。
@@ -189,10 +203,13 @@ type DivideQuotaReq struct {
 // SetDivideQuota 改分成额度(端点 40 `PUT /admin/divide-into/members/:userId/quota`)。
 //
 // 出参(成功):`{affectedUserIds:[]}`(含自身;= 子树内 `quota >= 新额度` 的节点)。
-// 出参(失败):1001/2001/2002(quota 格式非法或为负)/3111(**新额度 > 父额度**)/3001/5002。
+// 出参(失败):1001/2001/2002(quota 格式非法或为负)/3111(新额度超父额度)/3001/5002。
 //
 // **3111 的判据是「新额度 > 父节点额度」**(D9 有意增强),不是 `quota > 1`:
 // 判据落在 `DivideService.UpdateQuota`(AC-09 已交付),本 handler 只做入参解析 + 幂等透传。
+// 注意:这是 3111 的**第 4 种语义**(金额超限),与端点 39 的层级超 3 层/超 100 层、
+// 端点 41 的上行超 100 层**共用同一个错误码**,前端不能只按错误码区分文案,
+// 必须拿 message 或按端点各自映射(详见 AddDivideMember 注释里的"3111 四义")。
 func (h *Handler) SetDivideQuota(c *fiber.Ctx) error {
 	sc, err := mustScope(c)
 	if err != nil {
@@ -238,7 +255,11 @@ type DivideLogReq struct {
 // CreateDivideLogRecord 记分账流水(端点 41 `POST /admin/divide-into/logs`)。
 //
 // 出参(成功):`{logged:true}`(dump 的 `log_add` 不返回 id;前端只关心"是否入账")。
-// 出参(失败):1001/2001/2002(orderNo 非数字)/3111(上行超 100 层)/3001/5002。
+// 出参(失败):1001/2001/2002(orderNo 非数字)/3111(上行链路超 100 层或成环)/3001/5002。
+//
+// 3111 在这里是**第 3 种语义**:不是"层级超 3 层",而是沿 `parent_user_id` 上行 BFS 时
+// 撞到结构上限(超过 100 级或成环)。撞到它说明**库里的链路本身有问题**(历史脏数据),
+// 前端文案应是"分账链路异常,请联系管理员",而不是"分账最多 3 层"。
 //
 // **无金额入参**:分成额取自节点 `quota`(客户端传金额 = 前端定分成额的资金漏洞)。
 func (h *Handler) CreateDivideLogRecord(c *fiber.Ctx) error {

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

@@ -129,9 +129,14 @@ func NewDivideService(db *gorm.DB, firstCharged FirstChargedRecordReader) *Divid
 // 出参:
 //   - nil:成功(新建节点,或"已存在节点"分支改挂成功);
 //   - ErrNotFound(3001):上级不存在或不在分账体系;
-//   - ErrDivideQuotaExceeded(3111):层级超上限(父 layer+1 > 100);
+//   - ErrDivideQuotaExceeded(3111):层级超限。**有两种语义,文案不同、判序不同**:
+//     ① 业务上限:父 layer+1 > MaxDivideBizDepth(3) —— 超管→校区主任→老师,第 4 层直接拒;
+//     ② 结构上限:父 layer+1 > MaxDivideDepth(100) —— 纯粹的数据结构自保,防止 BFS 不终止;
 //   - ErrBusinessParam(3140):入参非法。
 //
+// 层级判定严格按「先 ① 后 ②」:3 是业务规则的常规失败点(用户操作就会撞到),
+// 100 是异常数据的兜底(正常业务撞不到)。顺序反了会把"业务上不让建"错报成"数据异常"。
+//
 // 左右值算法(Nested Set 插入最右子节点,**服务端计算**,调用方不传 left/right):
 //
 //	① 父节点右值 R = parent.right_val(插入点)
@@ -168,9 +173,21 @@ func (s *DivideService) AddSubordinate(ctx context.Context, parentUserID, subUse
 			return err // ErrNotFound(3001) 原样上抛:上级未加入分账体系
 		}
 		nextLayer := parent.Layer + 1
+		// ① 业务规则(先判):最多 3 层 —— 超管 → 校区主任 → 老师。拍板 2026-11-10。
+		//    放前面是因为它才是**用户真会撞到**的那条;100 那条正常业务永远撞不到。
+		//    文案与 ② 必须可区分:① 是产品限制("你不能再往下建了"),
+		//    ② 是数据结构自保("链路长得不正常,可能是脏数据/成环")。
+		if nextLayer > MaxDivideBizDepth {
+			return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
+				errString("service: 分账层级超业务上限(最多 "+strconv.Itoa(int(MaxDivideBizDepth))+
+					" 层:超管→校区主任→老师),目标层级 "+strconv.Itoa(int(nextLayer))))
+		}
+		// ② 结构上限(纵深防御,保留不动):防上行 BFS 不终止 / 递归爆栈。
+		//    与 ① 不是同一个概念,见 MaxDivideDepth 的注释;删掉它会让脏数据把服务拖死。
 		if nextLayer > MaxDivideDepth {
 			return infraerrors.WithCause(apperr.ErrDivideQuotaExceeded,
-				errString("service: 分账层级超上限 "+strconv.Itoa(int(MaxDivideDepth))))
+				errString("service: 分账层级超结构上限 "+strconv.Itoa(int(MaxDivideDepth))+
+					",目标层级 "+strconv.Itoa(int(nextLayer))))
 		}
 
 		rootID := resolveRoot(parent)

+ 132 - 27
internal/academic/service/divide_service_test.go

@@ -10,6 +10,7 @@ import (
 	"fmt"
 	"math/rand"
 	"path/filepath"
+	"strings"
 	"testing"
 	"time"
 
@@ -112,12 +113,27 @@ func TestAddSubordinateNestedSetInvariants(t *testing.T) {
 
 	rnd := rand.New(rand.NewSource(20260109))
 	const total = 100
+	// 层高登记:F20 之后 AddSubordinate 有 3 层业务上限,**随机挂父会随机撞 3111**。
+	// 本用例测的是嵌套集不变量(100 个节点),不是层数上限,
+	// 所以把随机空间限制在「还能再挂子节点」的节点里(layer < MaxDivideBizDepth),
+	// 保持原意:树可以很宽,但不许超过业务层数。
+	layers := map[string]int32{root: 1}
 	for i := 1; i < total; i++ {
-		parent := created[rnd.Intn(len(created))]
+		candidates := make([]string, 0, len(created))
+		for _, u := range created {
+			if layers[u] < MaxDivideBizDepth {
+				candidates = append(candidates, u)
+			}
+		}
+		if len(candidates) == 0 { // 兜底:根节点 layer=1 恒可挂,理论上不会发生
+			t.Fatalf("没有可挂子节点的父节点(layer < %d)", MaxDivideBizDepth)
+		}
+		parent := candidates[rnd.Intn(len(candidates))]
 		child := nodeID(i)
 		if err := svc.AddSubordinate(ctx, parent, child); err != nil {
 			t.Fatalf("AddSubordinate(%s→%s) 失败: %v", parent, child, err)
 		}
+		layers[child] = layers[parent] + 1
 		created = append(created, child)
 	}
 
@@ -263,28 +279,79 @@ func TestAddSubordinateExistingNodeNoop(t *testing.T) {
 	}
 }
 
-// TestAddSubordinateDepthLimit 层级上限 100 → 3111。
-func TestAddSubordinateDepthLimit(t *testing.T) {
+// TestAddSubordinateBizDepthLimit 业务层级上限 3 层 → 3111(超管 → 校区主任 → 老师)。
+//
+// 边界取**恰好**:layer=1,2,3 允许(3 = MaxDivideBizDepth),第 4 层被拒。
+// 这条断言的产品含义:前端 `divide-into.ts:26 MAX_DIVIDE_DEPTH = 3` 只是"界面不让你点",
+// 若后端也允许第 4 层,绕过前端就能建出前端渲染不出来的树。
+func TestAddSubordinateBizDepthLimit(t *testing.T) {
 	db := newDivideServiceDB(t)
 	svc := NewDivideService(db, nil)
 	ctx := context.Background()
 
+	// ⚠ 这里**故意写死 3**,不用 MaxDivideBizDepth。
+	// 原因:这条用例验的是"产品拍板的规则",规则的**值本身就是被测对象**。
+	// 若写成 `for i := 1; i < int(MaxDivideBizDepth)`,常量被改成 100 时用例会跟着
+	// 建到 100 层、再断言第 101 层被拒 —— 仍然通过,看起来"守住了规则",其实什么也没守
+	// (实测过:常量改 100,用常量写法的版本仍 PASS)。这种自指断言正是空过断言的一种。
+	const bizDepth = 3 // 硬编码:超管 → 校区主任 → 老师(拍板 2026-11-10)
+	if MaxDivideBizDepth != bizDepth {
+		t.Fatalf("常量 MaxDivideBizDepth=%d 与拍板值 %d 不一致", MaxDivideBizDepth, bizDepth)
+	}
+
 	root := nodeID(0)
 	seedRoot(t, db, root, model.DivideTypeOneOff, "1.00")
 	prev := root
-	// 链到 layer=100
-	for i := 1; i <= 99; i++ {
+	// 建到 layer=3(3 层 = 超管 / 校区主任 / 老师)
+	for i := 1; i < bizDepth; 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 got := loadDivideNode(t, db, prev).Layer; got != bizDepth {
+		t.Fatalf("链尾 layer 应为 %d(业务上限内),实得 %d", bizDepth, got)
+	}
+
+	// 第 4 层:必须 3111,且**中文文案指向业务上限**(不能与结构上限混淆)
+	beforeTail := loadDivideNode(t, db, prev)
+	err := svc.AddSubordinate(ctx, prev, nodeID(1000))
+	if !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
+		t.Fatalf("超过 %d 层应 3111,实得 %v", bizDepth, err)
+	}
+	if msg := err.Error(); !strings.Contains(msg, "业务上限") {
+		t.Fatalf("第 4 层的错误文案应指向**业务上限**,实得 %q", msg)
+	}
+	// 反证:被拒后不得留下任何节点(事务回滚 + 左右值未位移)
+	if after := loadDivideNode(t, db, prev); after.RightVal != beforeTail.RightVal {
+		t.Fatalf("被拒后父节点 right_val 不应位移:前 %d,后 %d", beforeTail.RightVal, after.RightVal)
+	}
+	var nodeCount int64
+	if err := db.Model(&model.UserDivideInto{}).Count(&nodeCount).Error; err != nil {
+		t.Fatalf("count 失败: %v", err)
+	}
+	if nodeCount != int64(bizDepth) {
+		t.Fatalf("被拒后节点数应仍为 %d,实得 %d", bizDepth, nodeCount)
+	}
+}
+
+// TestDivideStructuralDepthConstant 只钉一个数字:结构上限仍是 100。
+//
+// 为什么单独写:MaxDivideBizDepth(3) 上线后,"100" 看起来像是多余的历史遗留,
+// 很容易被下一个人当成重复常量删掉。它不是 —— 它守的是"上行 BFS 必须终止 /
+// 递归不能爆栈",删掉后一棵脏数据(成环/超长链)就能把服务拖死。
+// 这里只断言常量值,**不**去构造 100 层链(那是 TestAddLogBFSDepthBoundary 的事)。
+func TestDivideStructuralDepthConstant(t *testing.T) {
+	if MaxDivideDepth != 100 {
+		t.Fatalf("结构上限 MaxDivideDepth 必须保持 100(防 BFS 不终止/递归爆栈),实得 %d", MaxDivideDepth)
+	}
+	if MaxDivideBizDepth != 3 {
+		t.Fatalf("业务上限 MaxDivideBizDepth 必须为 3(超管→校区主任→老师),实得 %d", MaxDivideBizDepth)
 	}
-	if err := svc.AddSubordinate(ctx, prev, nodeID(1000)); !errors.Is(err, apperr.ErrDivideQuotaExceeded) {
-		t.Fatalf("超过 100 层应 3111,实得 %v", err)
+	if MaxDivideBizDepth >= MaxDivideDepth {
+		t.Fatalf("业务上限(%d) 必须严格小于结构上限(%d):两者不是同一个概念,也不该相等",
+			MaxDivideBizDepth, MaxDivideDepth)
 	}
 }
 
@@ -915,36 +982,74 @@ func TestDivideServiceNestedTxPassThrough(t *testing.T) {
 	}
 }
 
+// seedChain 直接落一条 layer=1..n 的**单链**,绕过 AddSubordinate。
+//
+// 为什么必须绕过:F20 之后 AddSubordinate 有 3 层业务上限,用公开接口建不出 100 层链。
+// 但「结构上限 / 上行 BFS 必须终止」要验的恰恰是**脏数据**(成环、超长链)下的行为,
+// 这种数据在现实里只能由历史迁移产生 —— 所以这里直接写库,等价于"库里已经有这么一棵树",
+// 与"用户用接口建出来的树"是两件事,不能混。
+//
+// 嵌套集取值(单链,n 个节点):节点 i(1-based)left = 2i-1,right = 2(n-i+1)。
+// **parent_user_id 必须自己连上**:上行 BFS 的唯一依据就是这列(不是 layer、不是嵌套集区间),
+// 只写 layer/left/right 而漏掉 parent 会造出"看似 100 层、实际 100 个孤点"的假数据,
+// 断言就会以"0 条明细"的形式失败 —— 空过的反面:数据没连上,测试反而更红了。
+func seedChain(t *testing.T, db *gorm.DB, n int32, typ int8, quota string) []string {
+	t.Helper()
+	if n <= 0 {
+		t.Fatalf("seedChain 需要 n>0,实得 %d", n)
+	}
+	ids := make([]string, 0, n)
+	for i := int32(1); i <= n; i++ {
+		ids = append(ids, nodeID(int(i)))
+	}
+	root := ids[0]
+	rows := make([]model.UserDivideInto, 0, n)
+	for i := int32(1); i <= n; i++ {
+		rootCopy := root
+		var parent *string
+		if i > 1 {
+			p := ids[i-2]
+			parent = &p
+		}
+		rows = append(rows, model.UserDivideInto{
+			UserID:           ids[i-1],
+			Type:             typ,
+			Quota:            quota,
+			TotalIncome:      "0.00",
+			TotalIncomePayed: "0.00",
+			RootUserID:       &rootCopy,
+			ParentUserID:     parent,
+			LeftVal:          2*i - 1,
+			RightVal:         2 * (n - i + 1),
+			Layer:            i,
+		})
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("seed %d 层链失败: %v", n, err)
+	}
+	return ids
+}
+
 // TestAddLogBFSDepthBoundary 100 层链(layer 1..100)恰好在上限内:BFS 必须完整走完到根
 // (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
 //
+// 链由 seedChain 直接写库(F20 的 3 层业务上限不允许用 AddSubordinate 建这么深),
+// 语义是"库里已存在历史的超长链":**读路径不能因为链长就崩**,但**写路径不许再造新的**。
+//
 // 明细条数 = 99(链尾本人不参与分成,dump 起点是其上级,故比全链少 1 条)。
 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
+	// audit D2:quota<=0 的节点不生成明细 → 每节点自带 1.00 额度(seedChain 里已给),
+	// 否则只有根节点会入账(本用例要测的是 BFS 深度边界,不是 D2)
+	ids := seedChain(t, db, MaxDivideDepth, model.DivideTypePerLesson, "1.00")
+	tail := ids[len(ids)-1]
 	if got := loadDivideNode(t, db, tail).Layer; got != MaxDivideDepth {
 		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)
 	}
@@ -953,7 +1058,7 @@ func TestAddLogBFSDepthBoundary(t *testing.T) {
 		t.Fatalf("%d 层链应生成 %d 条明细(本人不参与),实得 %d", MaxDivideDepth, MaxDivideDepth-1, count)
 	}
 	// 根节点收入应恰好为一次 quota
-	if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 100 {
+	if got := cents(t, loadDivideNode(t, db, ids[0]).TotalIncome); got != 100 {
 		t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got)
 	}
 }