// DivideService 单测(AC-09):100 节点随机树不变式 / BFS 上游入账 / 两级幂等 / 撤销两分支 / // 额度级联下降。 // // 测试栈:glebarez/sqlite 文件库(真实事务)。每个用例独立 DB,避免树区间互相污染。 package service import ( "context" "errors" "fmt" "math/rand" "path/filepath" "testing" "time" 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= n.RightVal { t.Fatalf("节点 %s 违反 left 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 缺陷修正)。 // 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() 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("已存在节点应幂等成功(no-op),实得 %v", err) } got := loadDivideNode(t, db, orphan) if got.ParentUserID != nil { t.Fatalf("已存在节点不得改挂父链(dump 763695 分支体无写动作),实得 parent=%v", *got.ParentUserID) } if got.RootUserID != nil { t.Fatalf("已存在节点不得写 root_user_id,实得 %v", *got.RootUserID) } if got.Layer != 1 { t.Fatalf("layer 不应变化(应 1),实得 %d", got.Layer) } if got.LeftVal != 100 || got.RightVal != 101 { t.Fatalf("左右值不应重排,实得 (%d,%d)", got.LeftVal, got.RightVal) } // 重复调用同样 no-op(幂等) if err := svc.AddSubordinate(ctx, root, orphan); err != nil { t.Fatalf("重复调用应幂等成功,实得 %v", err) } } // 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:**从上级起算**(dump `:763744` 子查询口径), // 本人不参与分成 —— c 报课,只有 b(直接上级)与 a(根)各得一条明细。 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) != 2 { t.Fatalf("上行链应生成 2 条明细(本人不参与),实得 %d", len(rows)) } want := map[string]int64{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) } // 明细 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 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) } } // 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 的上级)") } } } // TestAddLogBFSZeroQuotaMiddle 上行链**中间层 quota=0**:只跳该层,**不中断**上行(audit D2)。 // // 链:A(quota 10.00, type=2) > B(quota 0.00, type=1) > C(quota 2.00, type=1),find=C。 // // 期望(dump `yuansiwei_finance.sql:763760`:额度<=0 的节点不生成明细): // - 起点是 C 的上级 B(本人不参与)→ B.quota=0 → **不生成明细**、total_income 不累加; // - B 的 0 额度**不得当作断链**:上行必须继续到 A,A 照常入账 10.00。 // → 明细**恰 1 条**(仅 A),B/C 的 total_income 均为 0。 // // 为什么单独需要这条用例:TestAddLogBFSThreeLevels / TestAddLogBFSDepthBoundary 给链上**每个**节点 // 都配了额度,恰好绕过 D2 的"零额度跳层"分支;若实现把 `quota<=0` 写成 `return err`/`break`, // 上述两条仍全绿,只有本用例会红。 func TestAddLogBFSZeroQuotaMiddle(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("建链失败(%s→%s): %v", a, b, err) } if err := svc.AddSubordinate(ctx, b, c); err != nil { t.Fatalf("建链失败(%s→%s): %v", b, c, err) } // 中间层 B 保持新建节点的默认额度 0.00(不配置)——正是本用例要覆盖的场景 if got := cents(t, loadDivideNode(t, db, b).Quota); got != 0 { t.Fatalf("前置条件:B 额度应为 0.00(AddSubordinate 默认值),实得 %d 分", got) } // 链尾 C 的额度与入账无关(本人不参与分成),仅为贴合"末级也配了额度"的真实形态; // 这里直接落库而不走 UpdateQuota:后者有"子不得超父"硬上限(C 的父 B=0.00), // 配 2.00 会被 3111 拒绝,与本用例要验证的 D2 跳层逻辑无关。 if err := db.Model(&model.UserDivideInto{}).Where("user_id = ?", c). UpdateColumn("quota", "2.00").Error; 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) } // 恰 1 条明细(仅 A):B 被 D2 跳过,且跳过不得终止上行 if count := countLogs(t, db); count != 1 { t.Fatalf("中间层 quota=0 时应恰 1 条明细(仅 A),实得 %d", count) } 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].UserID != a { t.Fatalf("唯一明细应属于 A(跨过零额度中间层后的最上级),实得 %s", rows[0].UserID) } if got := cents(t, rows[0].Money); got != 1000 { t.Fatalf("A 的分成应为 10.00(1000 分),实得 %d", got) } if rows[0].OrderNo == nil || *rows[0].OrderNo != "777" { t.Fatalf("order_no 应为 777,实得 %v", rows[0].OrderNo) } // 收入口径:只有 A 累加;B(零额度被跳过)与 C(本人)都必须为 0 if got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 1000 { t.Fatalf("A.total_income 应为 1000 分,实得 %d", got) } for _, u := range []string{b, c} { if got := cents(t, loadDivideNode(t, db, u).TotalIncome); got != 0 { t.Fatalf("%s.total_income 应为 0(B 零额度不入账 / C 本人不参与),实得 %d 分", u, got) } } // 幂等不因"跳过 B"而失效:重复调用不得为 A 再写一条 if err := svc.AddLog(ctx, c, stu, 777, ""); err != nil { t.Fatalf("AddLog 重复调用应幂等成功,实得 %v", err) } if count := countLogs(t, db); count != 1 { t.Fatalf("重复调用后仍应 1 条明细(幂等),实得 %d", count) } } // TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。 // // 结构:root(拿分成的一方)+ child(报课/被推荐人)—— **分成只入 root**(本人不参与,dump 口径)。 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, 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, child, stu, 777, ""); err != nil { t.Fatalf("AddLog 失败: %v", err) } if err := svc.AddLog(ctx, child, 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, 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, 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, child, 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 } // 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) 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) } // b 报课 → 只有上级 a 得一条(本人不参与分成,dump 口径) if count := countLogs(t, db); count != 1 { t.Fatalf("应为 1 条明细(仅上级),实得 %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, 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, b, 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("改绑判定应调用首次扣费端口") } } // 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) 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) 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 != 1 { t.Fatalf("首扣记录=当前订单 → 应改绑保留(1 条),实得 %d", count) } 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) } } // 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) } } // 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) { 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 != 1 { t.Fatalf("外层事务内应可见 1 条明细(仅上级,本人不参与),实得 %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 必须完整走完到根 // (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。 // // 明细条数 = 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 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) } // 链尾本人不参与 → 明细 = 层数 - 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 { t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got) } }