| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- // RecordService 单测(AC-11):开始/结束上课状态机、结课单事务联动、幂等、失败整体回滚。
- //
- // 测试栈:glebarez/sqlite 文件库(真实事务)。分账与课酬用可注入的 fake(断言调用参数与 tx 透传)。
- package service
- import (
- "context"
- "errors"
- "testing"
- "time"
- "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"
- )
- // dividerCall 一次分账调用记录。
- //
- // 字段:findUserID 起点用户;stuID 学生;recordID 上课记录;hasTx ctx 中是否带事务(§10.3 红线断言)。
- type dividerCall struct {
- findUserID string
- stuID string
- recordID int64
- hasTx bool
- }
- // fakeDivider 分账入口测试替身(可注入失败,用于验证"整体回滚")。
- type fakeDivider struct {
- calls []dividerCall
- err error
- }
- // AddLog 实现 DivideLogGranter。
- func (f *fakeDivider) AddLog(ctx context.Context, findUserID, stuID string, recordID int64, _ string) error {
- f.calls = append(f.calls, dividerCall{
- findUserID: findUserID,
- stuID: stuID,
- recordID: recordID,
- hasTx: repository.TxFromContext(ctx) != nil,
- })
- return f.err
- }
- // fakeQuoter 课酬试算测试替身。
- type fakeQuoter struct {
- reward money.Amount
- err error
- calls int
- }
- // QuoteRecordReward 实现 TeacherRewardQuoter。
- func (f *fakeQuoter) QuoteRecordReward(_ context.Context, _ RewardQuoteInput) (money.Amount, error) {
- f.calls++
- return f.reward, f.err
- }
- // learningFixture 结课用例的公共数据(课程 + 2 学生 + 账户 + 一节课)。
- type learningFixture struct {
- course *model.Courses
- record *model.CoursesRecord
- student1 *model.CoursesStudent // 有推荐人 R1,课费 100.00
- student2 *model.CoursesStudent // 无推荐人,课费 50.00
- }
- // seedLearningFixture 造结课数据。
- //
- // 数据口径:S1 lesson_cost=100.00 / 剩余 10 次 / 剩余费用 1000.00,推荐人 R1;
- // S2 lesson_cost=50.00 / 剩余 10 次 / 剩余费用 500.00,无推荐人。
- func seedLearningFixture(t *testing.T, db *gorm.DB) *learningFixture {
- t.Helper()
- course := seedCourse(t, db, "结课数学", "-1", "1.0", 20)
- start := time.Date(2026, 4, 6, 9, 0, 0, 0, time.Local)
- end := start.Add(time.Hour)
- teacher := "01HTEACHER0000000000001"
- record := &model.CoursesRecord{
- BizNo: "REC-FINISH-1", CoursesID: course.ID, ClassID: 1, TeacherID: &teacher,
- InstitutionID: 1, CampusID: 10, Status: model.CoursesRecordStatusNotStarted,
- StartTime: &start, EndTime: &end,
- }
- if err := db.Create(record).Error; err != nil {
- t.Fatalf("seed 排班实例失败: %v", err)
- }
- recommender := "01HRECOMMENDER000000001"
- s1 := &model.CoursesStudent{
- BizNo: "CS-1", CoursesID: course.ID, ClassID: 1, InstitutionID: 1, CampusID: 10,
- StudentID: "01HSTUDENT0000000000001", Type: model.CoursesStudentTypeNormal,
- Status: model.CoursesStudentStatusNormal,
- Receivable: "1000.00", NetReceipts: "1000.00", LessonCost: "100.00",
- Number: 10, RemainderNumber: 10, RemainderCost: "1000.00", RemainderReceivable: "0.00",
- RecommenderUserID: &recommender,
- }
- s2 := &model.CoursesStudent{
- BizNo: "CS-2", CoursesID: course.ID, ClassID: 1, InstitutionID: 1, CampusID: 10,
- StudentID: "01HSTUDENT0000000000002", Type: model.CoursesStudentTypeNormal,
- Status: model.CoursesStudentStatusNormal,
- Receivable: "500.00", NetReceipts: "500.00", LessonCost: "50.00",
- Number: 10, RemainderNumber: 10, RemainderCost: "500.00", RemainderReceivable: "0.00",
- }
- for _, row := range []*model.CoursesStudent{s1, s2} {
- if err := db.Create(row).Error; err != nil {
- t.Fatalf("seed 报课记录(%s) 失败: %v", row.BizNo, err)
- }
- // 资金账户(spend 累加需要账户已建户;全局 + 校区各一行)
- if err := db.Create(&model.UserExpense{UserID: row.StudentID}).Error; err != nil {
- t.Fatalf("seed 全局账户(%s) 失败: %v", row.StudentID, err)
- }
- if err := db.Create(&model.UserExpenseSchool{
- UserID: row.StudentID, InstitutionID: 1, CampusID: 10,
- }).Error; err != nil {
- t.Fatalf("seed 校区账户(%s) 失败: %v", row.StudentID, err)
- }
- }
- return &learningFixture{course: course, record: record, student1: s1, student2: s2}
- }
- // loadRecord 读排班实例。
- func loadRecord(t *testing.T, db *gorm.DB, id int64) model.CoursesRecord {
- t.Helper()
- var row model.CoursesRecord
- if err := db.Where("id = ?", id).First(&row).Error; err != nil {
- t.Fatalf("读取排班实例失败: %v", err)
- }
- return row
- }
- // loadStudentCourse 读报课记录。
- func loadStudentCourse(t *testing.T, db *gorm.DB, id int64) model.CoursesStudent {
- t.Helper()
- var row model.CoursesStudent
- if err := db.Where("id = ?", id).First(&row).Error; err != nil {
- t.Fatalf("读取报课记录失败: %v", err)
- }
- return row
- }
- // loadSpend 读账户 spend 列(全局 / 校区)。
- func loadSpend(t *testing.T, db *gorm.DB, userID string) (global, campus int64) {
- t.Helper()
- var g model.UserExpense
- if err := db.Where("user_id = ?", userID).First(&g).Error; err != nil {
- t.Fatalf("读取全局账户失败: %v", err)
- }
- var s model.UserExpenseSchool
- if err := db.Where("user_id = ? AND campus_id = ?", userID, 10).First(&s).Error; err != nil {
- t.Fatalf("读取校区账户失败: %v", err)
- }
- return cents(t, g.Spend), cents(t, s.Spend)
- }
- // countRecordStudents 统计某节课的打卡明细行数。
- func countRecordStudents(t *testing.T, db *gorm.DB, recordID int64) int64 {
- t.Helper()
- var n int64
- if err := db.Model(&model.CoursesRecordStudent{}).Where("record_id = ?", recordID).Count(&n).Error; err != nil {
- t.Fatalf("统计打卡明细失败: %v", err)
- }
- return n
- }
- // TestStartRecordStateMachine 开始上课状态机:0→1 成功;重复开始 3121;已结束再开始 3121。
- func TestStartRecordStateMachine(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
- ctx := context.Background()
- first, err := svc.StartRecord(ctx, fx.record.ID)
- if err != nil {
- t.Fatalf("首次开始上课失败: %v", err)
- }
- if first.Status != model.CoursesRecordStatusOngoing || first.Repeated {
- t.Fatalf("首次开始上课 status=%d repeated=%v,期望 (1,false)", first.Status, first.Repeated)
- }
- rec := loadRecord(t, db, fx.record.ID)
- if rec.ActualStartTime == nil {
- t.Fatal("开始上课后 actual_start_time 应非空")
- }
- // 反向断言:重复开始 → 3121(不是幂等成功:actual_start_time 只写一次)
- _, err = svc.StartRecord(ctx, fx.record.ID)
- requireAppCode(t, err, apperr.CodeRecordStateInvalid)
- // 结束后再开始 → 3121
- if _, ferr := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); ferr != nil {
- t.Fatalf("结课失败: %v", ferr)
- }
- _, err = svc.StartRecord(ctx, fx.record.ID)
- requireAppCode(t, err, apperr.CodeRecordStateInvalid)
- }
- // TestFinishRecordLinkedTransaction 结束上课 = 单事务联动(AC-11 ② 正向断言)。
- //
- // 断言链(同一事务内的 7 个动作全部生效):
- // 1. record:status=2 + actual_end_time + student_number + income(=Σcost) + teacher_reward;
- // 2. record_student:逐学生一行,cost/give_cost/teacher_reward/end_time 落库;
- // 3. courses_student:finished_number+1、remainder_number-1、remainder_cost-cost;
- // 4. spend:全局 + 校区双写(统计口径,**不动 balance**);
- // 5. 分账:仅"有推荐人且真扣费"的学生触发一次,orderID=recordID,且 ctx 带事务;
- // 6. 课酬:按行均分,Σ record_student.teacher_reward == record.teacher_reward。
- func TestFinishRecordLinkedTransaction(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- divide := &fakeDivider{}
- quoter := &fakeQuoter{reward: money.FromCents(10000)} // 100.00 一节课
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide, Quoter: quoter})
- ctx := context.Background()
- if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
- t.Fatalf("开始上课失败: %v", err)
- }
- out, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
- if err != nil {
- t.Fatalf("结课失败: %v", err)
- }
- // ① record 汇总
- rec := loadRecord(t, db, fx.record.ID)
- if rec.Status != model.CoursesRecordStatusFinished {
- t.Fatalf("status=%d,期望 2(已结束)", rec.Status)
- }
- if rec.ActualEndTime == nil {
- t.Fatal("actual_end_time 应非空")
- }
- if rec.StudentNumber != 2 {
- t.Fatalf("student_number=%d,期望 2", rec.StudentNumber)
- }
- if got := cents(t, rec.Income); got != 15000 {
- t.Fatalf("income=%d 分,期望 15000(100.00+50.00)", got)
- }
- if got := cents(t, rec.TeacherReward); got != 10000 {
- t.Fatalf("teacher_reward=%d 分,期望 10000", got)
- }
- if out.Repeated {
- t.Fatal("首次结课 Repeated=true,期望 false")
- }
- // ② 打卡明细
- if n := countRecordStudents(t, db, fx.record.ID); n != 2 {
- t.Fatalf("打卡明细=%d 行,期望 2", n)
- }
- var rows []model.CoursesRecordStudent
- if err := db.Where("record_id = ?", fx.record.ID).Order("student_id ASC").Find(&rows).Error; err != nil {
- t.Fatalf("读取打卡明细失败: %v", err)
- }
- rewardSum := int64(0)
- for i := range rows {
- if rows[i].EndTime == nil {
- t.Fatalf("打卡明细(%s) end_time 应非空", rows[i].StudentID)
- }
- rewardSum += cents(t, rows[i].TeacherReward)
- }
- if rewardSum != 10000 {
- t.Fatalf("Σ record_student.teacher_reward=%d 分,期望 10000(= record.teacher_reward)", rewardSum)
- }
- // ③ 扣课时
- s1 := loadStudentCourse(t, db, fx.student1.ID)
- if s1.FinishedNumber != 1 {
- t.Fatalf("S1 finished_number=%d,期望 1", s1.FinishedNumber)
- }
- if s1.RemainderNumber != 9 {
- t.Fatalf("S1 remainder_number=%d,期望 9", s1.RemainderNumber)
- }
- if got := cents(t, s1.RemainderCost); got != 90000 {
- t.Fatalf("S1 remainder_cost=%d 分,期望 90000(1000.00-100.00)", got)
- }
- s2 := loadStudentCourse(t, db, fx.student2.ID)
- if s2.FinishedNumber != 1 || s2.RemainderNumber != 9 {
- t.Fatalf("S2 课次未扣:finished=%d remainder=%d", s2.FinishedNumber, s2.RemainderNumber)
- }
- if got := cents(t, s2.RemainderCost); got != 45000 {
- t.Fatalf("S2 remainder_cost=%d 分,期望 45000(500.00-50.00)", got)
- }
- // ④ spend:全局 + 校区双写,balance 不动
- if g, c := loadSpend(t, db, fx.student1.StudentID); g != 10000 || c != 10000 {
- t.Fatalf("S1 spend 全局/校区=(%d,%d) 分,期望 (10000,10000)", g, c)
- }
- if g, c := loadSpend(t, db, fx.student2.StudentID); g != 5000 || c != 5000 {
- t.Fatalf("S2 spend 全局/校区=(%d,%d) 分,期望 (5000,5000)", g, c)
- }
- var acc model.UserExpense
- if err := db.Where("user_id = ?", fx.student1.StudentID).First(&acc).Error; err != nil {
- t.Fatalf("读取全局账户失败: %v", err)
- }
- if cents(t, acc.Balance) != 0 {
- t.Fatalf("spend 累加不应改 balance,实际 balance=%s", acc.Balance)
- }
- // ⑤ 分账:只有 S1 有推荐人 → 恰好 1 次,orderID=recordID,ctx 带 tx
- if len(divide.calls) != 1 {
- t.Fatalf("分账调用=%d 次,期望 1(仅 S1 有推荐人)", len(divide.calls))
- }
- call := divide.calls[0]
- if call.stuID != fx.student1.StudentID || call.findUserID != "01HRECOMMENDER000000001" || call.recordID != fx.record.ID {
- t.Fatalf("分账入参错误: %+v", call)
- }
- if !call.hasTx {
- t.Fatal("分账调用 ctx 未携带事务(违反 §10.3:事务内跨服务必须 WithTxContext 透传)")
- }
- if quoter.calls != 1 {
- t.Fatalf("课酬试算=%d 次,期望 1(一节课只算一次,再按行均分)", quoter.calls)
- }
- }
- // TestFinishRecordIdempotent 重复结课 → 幂等成功(不重复扣课时/不重复分账/不新增明细)。
- func TestFinishRecordIdempotent(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- divide := &fakeDivider{}
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
- ctx := context.Background()
- if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
- t.Fatalf("开始上课失败: %v", err)
- }
- if _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); err != nil {
- t.Fatalf("首次结课失败: %v", err)
- }
- second, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
- if err != nil {
- t.Fatalf("重复结课应成功(幂等),实际: %v", err)
- }
- if !second.Repeated {
- t.Fatal("重复结课 Repeated=false,期望 true")
- }
- if second.StudentNumber != 2 || second.Income.Cents() != 15000 {
- t.Fatalf("幂等回读 student_number=%d income=%d,期望 (2,15000)", second.StudentNumber, second.Income.Cents())
- }
- // 反向断言:不重复扣课时、不重复分账、不新增明细
- if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 1 {
- t.Fatalf("重复结课后 S1 finished_number=%d,期望仍为 1", s1.FinishedNumber)
- }
- if n := countRecordStudents(t, db, fx.record.ID); n != 2 {
- t.Fatalf("重复结课后打卡明细=%d 行,期望仍为 2", n)
- }
- if len(divide.calls) != 1 {
- t.Fatalf("重复结课后分账调用=%d 次,期望仍为 1", len(divide.calls))
- }
- if g, _ := loadSpend(t, db, fx.student1.StudentID); g != 10000 {
- t.Fatalf("重复结课后 S1 spend=%d 分,期望仍为 10000", g)
- }
- }
- // TestFinishRecordRollbackOnDivideFailure 分账失败 → **整笔回滚**(AC-11 ② 反向断言)。
- //
- // 关键:分账在链路末端(课时已扣、spend 已加、record 已置 2),一旦失败必须全部回滚,
- // 否则出现"课时扣了、分成没给"的静默不一致。
- func TestFinishRecordRollbackOnDivideFailure(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- boom := errors.New("divide boom")
- divide := &fakeDivider{err: boom}
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
- ctx := context.Background()
- if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
- t.Fatalf("开始上课失败: %v", err)
- }
- _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
- if err == nil {
- t.Fatal("分账失败时结课应返回错误")
- }
- if !errors.Is(err, boom) {
- t.Fatalf("错误应包含底层原因,实际 %v", err)
- }
- rec := loadRecord(t, db, fx.record.ID)
- if rec.Status != model.CoursesRecordStatusOngoing {
- t.Fatalf("回滚后 status=%d,期望 1(仍在上课中)", rec.Status)
- }
- if rec.ActualEndTime != nil {
- t.Fatal("回滚后 actual_end_time 应为空")
- }
- if n := countRecordStudents(t, db, fx.record.ID); n != 0 {
- t.Fatalf("回滚后打卡明细=%d 行,期望 0", n)
- }
- if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 {
- t.Fatalf("回滚后 S1 finished_number=%d,期望 0(课时未扣)", s1.FinishedNumber)
- }
- if got := cents(t, loadStudentCourse(t, db, fx.student1.ID).RemainderCost); got != 100000 {
- t.Fatalf("回滚后 S1 remainder_cost=%d 分,期望 100000(未扣)", got)
- }
- if g, c := loadSpend(t, db, fx.student1.StudentID); g != 0 || c != 0 {
- t.Fatalf("回滚后 S1 spend=(%d,%d),期望 (0,0)", g, c)
- }
- }
- // TestFinishRecordExplicitAttendance 显式打卡名单:插班生(未报课)可上课但不扣课时。
- func TestFinishRecordExplicitAttendance(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
- ctx := context.Background()
- if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
- t.Fatalf("开始上课失败: %v", err)
- }
- cost := money.FromCents(2000)
- zero := money.Amount(0)
- out, err := svc.FinishRecord(ctx, FinishRecordInput{
- RecordID: fx.record.ID,
- Items: []AttendanceItem{
- {StudentID: "01HSTUDENT0000000000001", Cost: &cost},
- {StudentID: "01HSTUDENT0000000000003", Type: model.CoursesRecordStudentTypeTransfer, Cost: &zero},
- },
- })
- if err != nil {
- t.Fatalf("结课失败: %v", err)
- }
- if out.StudentNumber != 2 || out.Income.Cents() != 2000 {
- t.Fatalf("student_number=%d income=%d,期望 (2,2000)", out.StudentNumber, out.Income.Cents())
- }
- // 插班生(未报课)不扣课时:只有 S1 的 finished_number 变化
- if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 1 {
- t.Fatalf("S1 finished_number=%d,期望 1", s1.FinishedNumber)
- }
- // 未出现在名单里的 S2 不扣课时
- if s2 := loadStudentCourse(t, db, fx.student2.ID); s2.FinishedNumber != 0 {
- t.Fatalf("S2 未上课却被扣课时:finished_number=%d", s2.FinishedNumber)
- }
- }
- // TestFinishRecordDuplicatedStudentRejected 同一学生在名单里出现两次 → 3140(不静默合并)。
- func TestFinishRecordDuplicatedStudentRejected(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
- _, err := svc.FinishRecord(context.Background(), FinishRecordInput{
- RecordID: fx.record.ID,
- Items: []AttendanceItem{
- {StudentID: fx.student1.StudentID},
- {StudentID: fx.student1.StudentID},
- },
- })
- requireAppCode(t, err, apperr.CodeBusinessParam)
- }
- // TestMarkAttendanceUpsertAndGuard 打卡幂等 upsert;已结课不可补打卡(3121)。
- func TestMarkAttendanceUpsertAndGuard(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
- ctx := context.Background()
- cost := money.FromCents(10000)
- out, err := svc.MarkAttendance(ctx, MarkAttendanceInput{
- RecordID: fx.record.ID,
- Items: []AttendanceItem{{StudentID: fx.student1.StudentID, Cost: &cost}},
- })
- if err != nil {
- t.Fatalf("打卡失败: %v", err)
- }
- if out.StudentNumber != 1 || out.Income.Cents() != 10000 {
- t.Fatalf("打卡汇总 student_number=%d income=%d,期望 (1,10000)", out.StudentNumber, out.Income.Cents())
- }
- // 重复打卡 → 覆盖同行(不新增)
- cost2 := money.FromCents(12000)
- if _, err := svc.MarkAttendance(ctx, MarkAttendanceInput{
- RecordID: fx.record.ID,
- Items: []AttendanceItem{{StudentID: fx.student1.StudentID, Cost: &cost2}},
- }); err != nil {
- t.Fatalf("重复打卡失败: %v", err)
- }
- if n := countRecordStudents(t, db, fx.record.ID); n != 1 {
- t.Fatalf("重复打卡后行数=%d,期望 1(按 record+student 幂等)", n)
- }
- // 打卡**不扣课时**(扣课时只发生在结课)
- if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 {
- t.Fatalf("打卡不应扣课时,finished_number=%d", s1.FinishedNumber)
- }
- // 结课后补打卡 → 3121
- if _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); err != nil {
- t.Fatalf("结课失败: %v", err)
- }
- _, err = svc.MarkAttendance(ctx, MarkAttendanceInput{
- RecordID: fx.record.ID,
- Items: []AttendanceItem{{StudentID: fx.student2.StudentID, Cost: &cost}},
- })
- requireAppCode(t, err, apperr.CodeRecordStateInvalid)
- }
- // TestQuoteRewardPlanMissingDoesNotBlock 课酬方案未配置(3112)→ 不阻断结课,课酬记 0.00。
- func TestQuoteRewardPlanMissingDoesNotBlock(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- quoter := &fakeQuoter{err: apperr.ErrPayrollPlanMissing}
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{Quoter: quoter})
- out, err := svc.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx.record.ID})
- if err != nil {
- t.Fatalf("3112 不应阻断结课,实际: %v", err)
- }
- if out.TeacherReward.Cents() != 0 {
- t.Fatalf("课酬=%d 分,期望 0", out.TeacherReward.Cents())
- }
- // 反向断言:其他错误必须阻断(整体回滚)
- boom := infraerrors.WithCause(infraerrors.ErrDBError, errors.New("quote db down"))
- db2 := newLearningDB(t)
- fx2 := seedLearningFixture(t, db2)
- svc2 := NewRecordService(db2, campusScope(), RecordServiceOptions{Quoter: &fakeQuoter{err: boom}})
- if _, err := svc2.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx2.record.ID}); err == nil {
- t.Fatal("课酬试算 DB 异常时结课应失败(整体回滚)")
- }
- if n := countRecordStudents(t, db2, fx2.record.ID); n != 0 {
- t.Fatalf("回滚后打卡明细=%d 行,期望 0", n)
- }
- }
- // TestRecordServiceSingleTransaction 单事务形状:内层失败 → 外层整体回滚(§10.3 单测要求)。
- //
- // 用一个"分账失败"的注入点验证:若 Repository/下游 service 各自开事务,
- // 结课前的扣课时与 spend 会先提交、无法回滚;能全回滚即证明**只开 1 个事务**。
- func TestRecordServiceSingleTransaction(t *testing.T) {
- db := newLearningDB(t)
- fx := seedLearningFixture(t, db)
- divide := &fakeDivider{err: errors.New("boom")}
- svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
- _, err := svc.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx.record.ID})
- if err == nil {
- t.Fatal("期望结课失败")
- }
- // 断言"全部无残留"(任何一项非零都说明有过独立提交)
- if n := countRecordStudents(t, db, fx.record.ID); n != 0 {
- t.Fatalf("残留打卡明细 %d 行", n)
- }
- if got := loadRecord(t, db, fx.record.ID); got.Status != model.CoursesRecordStatusNotStarted || got.ActualEndTime != nil {
- t.Fatalf("残留状态 status=%d actual_end=%v", got.Status, got.ActualEndTime)
- }
- if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 || cents(t, s1.RemainderCost) != 100000 {
- t.Fatalf("残留课时扣减: finished=%d remainder_cost=%s", s1.FinishedNumber, s1.RemainderCost)
- }
- if g, c := loadSpend(t, db, fx.student1.StudentID); g != 0 || c != 0 {
- t.Fatalf("残留 spend=(%d,%d)", g, c)
- }
- if !divide.calls[0].hasTx {
- t.Fatal("分账调用未携带事务")
- }
- }
|