// cmd/api 装配单测(AC-11 硬要点 5):DivideService 必须注入「首次扣费上课记录」端口。 // // 这是 D11(撤销首扣订单 → 改绑保留分成)在生产生效的前提: // 未注入时 DivideService 只能走"回滚删除"分支 → 撤销首扣订单会把真实分成整组冲掉(少发钱)。 // 本用例用真实 sqlite + 真实 RecordRepo 跑一遍 DeleteLog 的两条分支,防止"注入被误删"回归。 package main import ( "context" "path/filepath" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" ) // newWiringDB 装配用例库(分账明细 + 打卡明细 2 表)。 // // 逐表迁移(SQLite 索引名库级唯一;两张表都有 uk_biz_no/index,见 service 测试同名助手注释)。 func newWiringDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_wiring_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() } }) for _, tbl := range []any{&model.UserDivideInto{}, &model.UserDivideIntoLog{}, &model.CoursesRecordStudent{}} { if err := db.AutoMigrate(tbl); err != nil { t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err) } stmt := &gorm.Statement{DB: db} if err := stmt.Parse(tbl); err != nil { t.Fatalf("解析表名(%T) 失败: %v", tbl, err) } var names []string if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'", stmt.Schema.Table).Scan(&names).Error; err != nil { t.Fatalf("读取索引清单失败: %v", err) } for _, name := range names { if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil { t.Fatalf("删除索引(%s) 失败: %v", name, err) } } } return db } // TestAcademicWiringFirstChargedInjected 装配断言:端口已注入,且 D11 改绑分支真实生效。 func TestAcademicWiringFirstChargedInjected(t *testing.T) { db := newWiringDB(t) svc := newAcademicServices(db) if svc == nil || svc.Divide == nil { t.Fatal("newAcademicServices 未装配 DivideService") } if !svc.FirstChargedInjected { t.Fatal("FirstChargedInjected=false —— 首次扣费端口未注入(D11 改绑分支将永远不生效)") } ctx := context.Background() student := "01HSTUDENT000000000000042" // ① 学生存在首扣记录(record_id=900,cost>0 且 end_time 非空) endTime := time.Date(2026, 4, 1, 10, 0, 0, 0, time.UTC) if err := db.Create(&model.CoursesRecordStudent{ BizNo: "RS-FIRST", CoursesID: 1, InstitutionID: 1, CampusID: 10, RecordID: 900, StudentID: student, Type: model.CoursesRecordStudentTypeNormal, StartTime: time.Date(2026, 4, 1, 9, 0, 0, 0, time.UTC), EndTime: &endTime, Cost: "50.00", }).Error; err != nil { t.Fatalf("seed 首扣打卡明细失败: %v", err) } // 分成明细(type=2 按课次,order_no=500) log := &model.UserDivideIntoLog{ Type: model.DivideTypePerLesson, UserID: "01HAGENT0000000000000001", StudentID: student, OrderNo: strPtr("500"), Money: "10.00", } if err := db.Create(log).Error; err != nil { t.Fatalf("seed 分成明细失败: %v", err) } if err := svc.Divide.DeleteLog(ctx, student, 500); err != nil { t.Fatalf("DeleteLog 失败: %v", err) } var rebound model.UserDivideIntoLog if err := db.Where("id = ?", log.ID).First(&rebound).Error; err != nil { t.Fatalf("改绑分支不应删除明细,读取失败: %v", err) } if rebound.OrderNo == nil || *rebound.OrderNo != "900" { t.Fatalf("order_no=%v,期望 900(改绑到首次扣费的 record_id)", rebound.OrderNo) } if got, _ := money.Parse(rebound.Money); got.Cents() != 1000 { t.Fatalf("改绑不应改金额:%s", rebound.Money) } // ② 反向断言:无首扣记录的学生 → 走回滚删除分支(明细被删) other := "01HSTUDENT000000000000043" orphan := &model.UserDivideIntoLog{ Type: model.DivideTypePerLesson, UserID: "01HAGENT0000000000000001", StudentID: other, OrderNo: strPtr("600"), Money: "20.00", } if err := db.Create(orphan).Error; err != nil { t.Fatalf("seed 无首扣分成明细失败: %v", err) } if err := svc.Divide.DeleteLog(ctx, other, 600); err != nil { t.Fatalf("DeleteLog(无首扣) 失败: %v", err) } var count int64 if err := db.Model(&model.UserDivideIntoLog{}).Where("id = ?", orphan.ID).Count(&count).Error; err != nil { t.Fatalf("统计失败: %v", err) } if count != 0 { t.Fatalf("无首扣记录时应回滚删除明细,实际仍存在 %d 行", count) } } // strPtr 字符串指针构造(空串 → nil)。 func strPtr(s string) *string { if s == "" { return nil } return &s }