|
|
@@ -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)
|
|
|
}
|
|
|
}
|