|
|
@@ -0,0 +1,675 @@
|
|
|
+// DivideService 单测(AC-09):100 节点随机树不变式 / BFS 上游入账 / 两级幂等 / 撤销两分支 /
|
|
|
+// 额度级联下降。
|
|
|
+//
|
|
|
+// 测试栈:glebarez/sqlite 文件库(真实事务)。每个用例独立 DB,避免树区间互相污染。
|
|
|
+package service
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "errors"
|
|
|
+ "fmt"
|
|
|
+ "math/rand"
|
|
|
+ "path/filepath"
|
|
|
+ "testing"
|
|
|
+
|
|
|
+ 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<right、区间嵌套无交叉、
|
|
|
+// 节点数 = 宽度/2、值集合恰好 1..2N、父区间包含子区间。
|
|
|
+func TestAddSubordinateNestedSetInvariants(t *testing.T) {
|
|
|
+ db := newDivideServiceDB(t)
|
|
|
+ svc := NewDivideService(db, nil)
|
|
|
+ ctx := context.Background()
|
|
|
+
|
|
|
+ root := nodeID(0)
|
|
|
+ seedRoot(t, db, root, model.DivideTypeOneOff, "10.00")
|
|
|
+ created := []string{root}
|
|
|
+
|
|
|
+ rnd := rand.New(rand.NewSource(20260109))
|
|
|
+ const total = 100
|
|
|
+ for i := 1; i < total; i++ {
|
|
|
+ parent := created[rnd.Intn(len(created))]
|
|
|
+ child := nodeID(i)
|
|
|
+ if err := svc.AddSubordinate(ctx, parent, child); err != nil {
|
|
|
+ t.Fatalf("AddSubordinate(%s→%s) 失败: %v", parent, child, err)
|
|
|
+ }
|
|
|
+ created = append(created, child)
|
|
|
+ }
|
|
|
+
|
|
|
+ var nodes []model.UserDivideInto
|
|
|
+ if err := db.Find(&nodes).Error; err != nil {
|
|
|
+ t.Fatalf("读全树失败: %v", err)
|
|
|
+ }
|
|
|
+ if len(nodes) != total {
|
|
|
+ t.Fatalf("节点数应为 %d,实得 %d", total, len(nodes))
|
|
|
+ }
|
|
|
+
|
|
|
+ // ① left < right 且值集合 = {1..2N}(每值恰好一次)
|
|
|
+ seen := make(map[int32]string, total*2)
|
|
|
+ maxRight := int32(0)
|
|
|
+ for i := range nodes {
|
|
|
+ n := nodes[i]
|
|
|
+ if n.LeftVal >= n.RightVal {
|
|
|
+ t.Fatalf("节点 %s 违反 left<right:(%d,%d)", n.UserID, n.LeftVal, n.RightVal)
|
|
|
+ }
|
|
|
+ for _, v := range []int32{n.LeftVal, n.RightVal} {
|
|
|
+ if prev, dup := seen[v]; dup {
|
|
|
+ t.Fatalf("左/右值 %d 被重复使用(%s 与 %s),嵌套集区间损坏", v, prev, n.UserID)
|
|
|
+ }
|
|
|
+ seen[v] = n.UserID
|
|
|
+ }
|
|
|
+ if n.RightVal > 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 缺陷修正)。
|
|
|
+func TestAddSubordinateReparentBranch(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("改挂分支失败: %v", err)
|
|
|
+ }
|
|
|
+ got := loadDivideNode(t, db, orphan)
|
|
|
+ if got.ParentUserID == nil || *got.ParentUserID != root {
|
|
|
+ t.Fatalf("parent 应改为 %s,实得 %v", root, got.ParentUserID)
|
|
|
+ }
|
|
|
+ if got.RootUserID == nil || *got.RootUserID != root {
|
|
|
+ t.Fatalf("root_user_id 必须补齐为 %s(dump 缺陷修正),实得 %v", root, got.RootUserID)
|
|
|
+ }
|
|
|
+ if got.Layer != 2 {
|
|
|
+ t.Fatalf("layer 应为 2,实得 %d", got.Layer)
|
|
|
+ }
|
|
|
+ // 不重排左右值(与原 SP 对齐)→ 值保持不变
|
|
|
+ if got.LeftVal != 100 || got.RightVal != 101 {
|
|
|
+ t.Fatalf("改挂分支不应重排左右值,实得 (%d,%d)", got.LeftVal, got.RightVal)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 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:本人 + 两级上线各生成一条明细并累加 total_income。
|
|
|
+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) != 3 {
|
|
|
+ t.Fatalf("三级链应生成 3 条明细,实得 %d", len(rows))
|
|
|
+ }
|
|
|
+ want := map[string]int64{c: 200, 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)
|
|
|
+ }
|
|
|
+ if rows[i].Type != model.DivideTypePerLesson {
|
|
|
+ t.Fatalf("type 应继承节点(2),实得 %d", 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)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestAddLogTwoLevelIdempotency 两级幂等:type=1 忽略订单;type=2 含订单。
|
|
|
+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 := nodeID(0)
|
|
|
+ seedRoot(t, db, u, model.DivideTypeOneOff, "10.00")
|
|
|
+
|
|
|
+ if err := svc.AddLog(ctx, u, stu, 777, ""); err != nil {
|
|
|
+ t.Fatalf("AddLog 失败: %v", err)
|
|
|
+ }
|
|
|
+ if err := svc.AddLog(ctx, u, 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 := nodeID(0)
|
|
|
+ seedRoot(t, db, u, model.DivideTypePerLesson, "10.00")
|
|
|
+
|
|
|
+ for i := 0; i < 2; i++ { // 同一订单重复请求
|
|
|
+ if err := svc.AddLog(ctx, u, 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 { // 新订单:应新增
|
|
|
+ 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
|
|
|
+}
|
|
|
+
|
|
|
+// 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)
|
|
|
+ }
|
|
|
+ if count := countLogs(t, db); count != 2 {
|
|
|
+ t.Fatalf("应为 2 条明细,实得 %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 := nodeID(0)
|
|
|
+ seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
|
|
|
+ stu := nodeID(901)
|
|
|
+ if err := svc.AddLog(ctx, a, 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("改绑判定应调用首次扣费端口")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// TestDeleteLogFirstEqualsOrder 首次扣费记录 = 当前订单 → 不属改绑场景,走回滚删除。
|
|
|
+func TestDeleteLogFirstEqualsOrder(t *testing.T) {
|
|
|
+ db := newDivideServiceDB(t)
|
|
|
+ port := &fakeFirstCharged{recordID: 777, found: true}
|
|
|
+ svc := NewDivideService(db, port)
|
|
|
+ ctx := context.Background()
|
|
|
+ a := nodeID(0)
|
|
|
+ seedRoot(t, db, a, model.DivideTypePerLesson, "10.00")
|
|
|
+ stu := nodeID(901)
|
|
|
+ if err := svc.AddLog(ctx, a, stu, 777, ""); err != nil {
|
|
|
+ t.Fatalf("AddLog 失败: %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 got := cents(t, loadDivideNode(t, db, a).TotalIncome); got != 0 {
|
|
|
+ t.Fatalf("total_income 应回滚为 0,实得 %d 分", got)
|
|
|
+ }
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 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 != 2 {
|
|
|
+ t.Fatalf("外层事务内应可见 2 条明细,实得 %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 必须完整走完 100 级
|
|
|
+// (101 级才触发 3111,见 TestAddLogCycleGuard)。同时验证"迭代不递归"在大深度下不栈溢出。
|
|
|
+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)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+ // 根节点收入应恰好为一次 quota
|
|
|
+ if got := cents(t, loadDivideNode(t, db, root).TotalIncome); got != 100 {
|
|
|
+ t.Fatalf("根节点 total_income 应为 100 分,实得 %d", got)
|
|
|
+ }
|
|
|
+}
|