// 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("分账调用未携带事务") } }