record_service_test.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. // RecordService 单测(AC-11):开始/结束上课状态机、结课单事务联动、幂等、失败整体回滚。
  2. //
  3. // 测试栈:glebarez/sqlite 文件库(真实事务)。分账与课酬用可注入的 fake(断言调用参数与 tx 透传)。
  4. package service
  5. import (
  6. "context"
  7. "errors"
  8. "testing"
  9. "time"
  10. "gorm.io/gorm"
  11. "yuansiwei/unified-backend/internal/academic/apperr"
  12. "yuansiwei/unified-backend/internal/academic/model"
  13. "yuansiwei/unified-backend/internal/academic/money"
  14. "yuansiwei/unified-backend/internal/academic/repository"
  15. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  16. )
  17. // dividerCall 一次分账调用记录。
  18. //
  19. // 字段:findUserID 起点用户;stuID 学生;recordID 上课记录;hasTx ctx 中是否带事务(§10.3 红线断言)。
  20. type dividerCall struct {
  21. findUserID string
  22. stuID string
  23. recordID int64
  24. hasTx bool
  25. }
  26. // fakeDivider 分账入口测试替身(可注入失败,用于验证"整体回滚")。
  27. type fakeDivider struct {
  28. calls []dividerCall
  29. err error
  30. }
  31. // AddLog 实现 DivideLogGranter。
  32. func (f *fakeDivider) AddLog(ctx context.Context, findUserID, stuID string, recordID int64, _ string) error {
  33. f.calls = append(f.calls, dividerCall{
  34. findUserID: findUserID,
  35. stuID: stuID,
  36. recordID: recordID,
  37. hasTx: repository.TxFromContext(ctx) != nil,
  38. })
  39. return f.err
  40. }
  41. // fakeQuoter 课酬试算测试替身。
  42. type fakeQuoter struct {
  43. reward money.Amount
  44. err error
  45. calls int
  46. }
  47. // QuoteRecordReward 实现 TeacherRewardQuoter。
  48. func (f *fakeQuoter) QuoteRecordReward(_ context.Context, _ RewardQuoteInput) (money.Amount, error) {
  49. f.calls++
  50. return f.reward, f.err
  51. }
  52. // learningFixture 结课用例的公共数据(课程 + 2 学生 + 账户 + 一节课)。
  53. type learningFixture struct {
  54. course *model.Courses
  55. record *model.CoursesRecord
  56. student1 *model.CoursesStudent // 有推荐人 R1,课费 100.00
  57. student2 *model.CoursesStudent // 无推荐人,课费 50.00
  58. }
  59. // seedLearningFixture 造结课数据。
  60. //
  61. // 数据口径:S1 lesson_cost=100.00 / 剩余 10 次 / 剩余费用 1000.00,推荐人 R1;
  62. // S2 lesson_cost=50.00 / 剩余 10 次 / 剩余费用 500.00,无推荐人。
  63. func seedLearningFixture(t *testing.T, db *gorm.DB) *learningFixture {
  64. t.Helper()
  65. course := seedCourse(t, db, "结课数学", "-1", "1.0", 20)
  66. start := time.Date(2026, 4, 6, 9, 0, 0, 0, time.Local)
  67. end := start.Add(time.Hour)
  68. teacher := "01HTEACHER0000000000001"
  69. record := &model.CoursesRecord{
  70. BizNo: "REC-FINISH-1", CoursesID: course.ID, ClassID: 1, TeacherID: &teacher,
  71. InstitutionID: 1, CampusID: 10, Status: model.CoursesRecordStatusNotStarted,
  72. StartTime: &start, EndTime: &end,
  73. }
  74. if err := db.Create(record).Error; err != nil {
  75. t.Fatalf("seed 排班实例失败: %v", err)
  76. }
  77. recommender := "01HRECOMMENDER000000001"
  78. s1 := &model.CoursesStudent{
  79. BizNo: "CS-1", CoursesID: course.ID, ClassID: 1, InstitutionID: 1, CampusID: 10,
  80. StudentID: "01HSTUDENT0000000000001", Type: model.CoursesStudentTypeNormal,
  81. Status: model.CoursesStudentStatusNormal,
  82. Receivable: "1000.00", NetReceipts: "1000.00", LessonCost: "100.00",
  83. Number: 10, RemainderNumber: 10, RemainderCost: "1000.00", RemainderReceivable: "0.00",
  84. RecommenderUserID: &recommender,
  85. }
  86. s2 := &model.CoursesStudent{
  87. BizNo: "CS-2", CoursesID: course.ID, ClassID: 1, InstitutionID: 1, CampusID: 10,
  88. StudentID: "01HSTUDENT0000000000002", Type: model.CoursesStudentTypeNormal,
  89. Status: model.CoursesStudentStatusNormal,
  90. Receivable: "500.00", NetReceipts: "500.00", LessonCost: "50.00",
  91. Number: 10, RemainderNumber: 10, RemainderCost: "500.00", RemainderReceivable: "0.00",
  92. }
  93. for _, row := range []*model.CoursesStudent{s1, s2} {
  94. if err := db.Create(row).Error; err != nil {
  95. t.Fatalf("seed 报课记录(%s) 失败: %v", row.BizNo, err)
  96. }
  97. // 资金账户(spend 累加需要账户已建户;全局 + 校区各一行)
  98. if err := db.Create(&model.UserExpense{UserID: row.StudentID}).Error; err != nil {
  99. t.Fatalf("seed 全局账户(%s) 失败: %v", row.StudentID, err)
  100. }
  101. if err := db.Create(&model.UserExpenseSchool{
  102. UserID: row.StudentID, InstitutionID: 1, CampusID: 10,
  103. }).Error; err != nil {
  104. t.Fatalf("seed 校区账户(%s) 失败: %v", row.StudentID, err)
  105. }
  106. }
  107. return &learningFixture{course: course, record: record, student1: s1, student2: s2}
  108. }
  109. // loadRecord 读排班实例。
  110. func loadRecord(t *testing.T, db *gorm.DB, id int64) model.CoursesRecord {
  111. t.Helper()
  112. var row model.CoursesRecord
  113. if err := db.Where("id = ?", id).First(&row).Error; err != nil {
  114. t.Fatalf("读取排班实例失败: %v", err)
  115. }
  116. return row
  117. }
  118. // loadStudentCourse 读报课记录。
  119. func loadStudentCourse(t *testing.T, db *gorm.DB, id int64) model.CoursesStudent {
  120. t.Helper()
  121. var row model.CoursesStudent
  122. if err := db.Where("id = ?", id).First(&row).Error; err != nil {
  123. t.Fatalf("读取报课记录失败: %v", err)
  124. }
  125. return row
  126. }
  127. // loadSpend 读账户 spend 列(全局 / 校区)。
  128. func loadSpend(t *testing.T, db *gorm.DB, userID string) (global, campus int64) {
  129. t.Helper()
  130. var g model.UserExpense
  131. if err := db.Where("user_id = ?", userID).First(&g).Error; err != nil {
  132. t.Fatalf("读取全局账户失败: %v", err)
  133. }
  134. var s model.UserExpenseSchool
  135. if err := db.Where("user_id = ? AND campus_id = ?", userID, 10).First(&s).Error; err != nil {
  136. t.Fatalf("读取校区账户失败: %v", err)
  137. }
  138. return cents(t, g.Spend), cents(t, s.Spend)
  139. }
  140. // countRecordStudents 统计某节课的打卡明细行数。
  141. func countRecordStudents(t *testing.T, db *gorm.DB, recordID int64) int64 {
  142. t.Helper()
  143. var n int64
  144. if err := db.Model(&model.CoursesRecordStudent{}).Where("record_id = ?", recordID).Count(&n).Error; err != nil {
  145. t.Fatalf("统计打卡明细失败: %v", err)
  146. }
  147. return n
  148. }
  149. // TestStartRecordStateMachine 开始上课状态机:0→1 成功;重复开始 3121;已结束再开始 3121。
  150. func TestStartRecordStateMachine(t *testing.T) {
  151. db := newLearningDB(t)
  152. fx := seedLearningFixture(t, db)
  153. svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
  154. ctx := context.Background()
  155. first, err := svc.StartRecord(ctx, fx.record.ID)
  156. if err != nil {
  157. t.Fatalf("首次开始上课失败: %v", err)
  158. }
  159. if first.Status != model.CoursesRecordStatusOngoing || first.Repeated {
  160. t.Fatalf("首次开始上课 status=%d repeated=%v,期望 (1,false)", first.Status, first.Repeated)
  161. }
  162. rec := loadRecord(t, db, fx.record.ID)
  163. if rec.ActualStartTime == nil {
  164. t.Fatal("开始上课后 actual_start_time 应非空")
  165. }
  166. // 反向断言:重复开始 → 3121(不是幂等成功:actual_start_time 只写一次)
  167. _, err = svc.StartRecord(ctx, fx.record.ID)
  168. requireAppCode(t, err, apperr.CodeRecordStateInvalid)
  169. // 结束后再开始 → 3121
  170. if _, ferr := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); ferr != nil {
  171. t.Fatalf("结课失败: %v", ferr)
  172. }
  173. _, err = svc.StartRecord(ctx, fx.record.ID)
  174. requireAppCode(t, err, apperr.CodeRecordStateInvalid)
  175. }
  176. // TestFinishRecordLinkedTransaction 结束上课 = 单事务联动(AC-11 ② 正向断言)。
  177. //
  178. // 断言链(同一事务内的 7 个动作全部生效):
  179. // 1. record:status=2 + actual_end_time + student_number + income(=Σcost) + teacher_reward;
  180. // 2. record_student:逐学生一行,cost/give_cost/teacher_reward/end_time 落库;
  181. // 3. courses_student:finished_number+1、remainder_number-1、remainder_cost-cost;
  182. // 4. spend:全局 + 校区双写(统计口径,**不动 balance**);
  183. // 5. 分账:仅"有推荐人且真扣费"的学生触发一次,orderID=recordID,且 ctx 带事务;
  184. // 6. 课酬:按行均分,Σ record_student.teacher_reward == record.teacher_reward。
  185. func TestFinishRecordLinkedTransaction(t *testing.T) {
  186. db := newLearningDB(t)
  187. fx := seedLearningFixture(t, db)
  188. divide := &fakeDivider{}
  189. quoter := &fakeQuoter{reward: money.FromCents(10000)} // 100.00 一节课
  190. svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide, Quoter: quoter})
  191. ctx := context.Background()
  192. if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
  193. t.Fatalf("开始上课失败: %v", err)
  194. }
  195. out, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
  196. if err != nil {
  197. t.Fatalf("结课失败: %v", err)
  198. }
  199. // ① record 汇总
  200. rec := loadRecord(t, db, fx.record.ID)
  201. if rec.Status != model.CoursesRecordStatusFinished {
  202. t.Fatalf("status=%d,期望 2(已结束)", rec.Status)
  203. }
  204. if rec.ActualEndTime == nil {
  205. t.Fatal("actual_end_time 应非空")
  206. }
  207. if rec.StudentNumber != 2 {
  208. t.Fatalf("student_number=%d,期望 2", rec.StudentNumber)
  209. }
  210. if got := cents(t, rec.Income); got != 15000 {
  211. t.Fatalf("income=%d 分,期望 15000(100.00+50.00)", got)
  212. }
  213. if got := cents(t, rec.TeacherReward); got != 10000 {
  214. t.Fatalf("teacher_reward=%d 分,期望 10000", got)
  215. }
  216. if out.Repeated {
  217. t.Fatal("首次结课 Repeated=true,期望 false")
  218. }
  219. // ② 打卡明细
  220. if n := countRecordStudents(t, db, fx.record.ID); n != 2 {
  221. t.Fatalf("打卡明细=%d 行,期望 2", n)
  222. }
  223. var rows []model.CoursesRecordStudent
  224. if err := db.Where("record_id = ?", fx.record.ID).Order("student_id ASC").Find(&rows).Error; err != nil {
  225. t.Fatalf("读取打卡明细失败: %v", err)
  226. }
  227. rewardSum := int64(0)
  228. for i := range rows {
  229. if rows[i].EndTime == nil {
  230. t.Fatalf("打卡明细(%s) end_time 应非空", rows[i].StudentID)
  231. }
  232. rewardSum += cents(t, rows[i].TeacherReward)
  233. }
  234. if rewardSum != 10000 {
  235. t.Fatalf("Σ record_student.teacher_reward=%d 分,期望 10000(= record.teacher_reward)", rewardSum)
  236. }
  237. // ③ 扣课时
  238. s1 := loadStudentCourse(t, db, fx.student1.ID)
  239. if s1.FinishedNumber != 1 {
  240. t.Fatalf("S1 finished_number=%d,期望 1", s1.FinishedNumber)
  241. }
  242. if s1.RemainderNumber != 9 {
  243. t.Fatalf("S1 remainder_number=%d,期望 9", s1.RemainderNumber)
  244. }
  245. if got := cents(t, s1.RemainderCost); got != 90000 {
  246. t.Fatalf("S1 remainder_cost=%d 分,期望 90000(1000.00-100.00)", got)
  247. }
  248. s2 := loadStudentCourse(t, db, fx.student2.ID)
  249. if s2.FinishedNumber != 1 || s2.RemainderNumber != 9 {
  250. t.Fatalf("S2 课次未扣:finished=%d remainder=%d", s2.FinishedNumber, s2.RemainderNumber)
  251. }
  252. if got := cents(t, s2.RemainderCost); got != 45000 {
  253. t.Fatalf("S2 remainder_cost=%d 分,期望 45000(500.00-50.00)", got)
  254. }
  255. // ④ spend:全局 + 校区双写,balance 不动
  256. if g, c := loadSpend(t, db, fx.student1.StudentID); g != 10000 || c != 10000 {
  257. t.Fatalf("S1 spend 全局/校区=(%d,%d) 分,期望 (10000,10000)", g, c)
  258. }
  259. if g, c := loadSpend(t, db, fx.student2.StudentID); g != 5000 || c != 5000 {
  260. t.Fatalf("S2 spend 全局/校区=(%d,%d) 分,期望 (5000,5000)", g, c)
  261. }
  262. var acc model.UserExpense
  263. if err := db.Where("user_id = ?", fx.student1.StudentID).First(&acc).Error; err != nil {
  264. t.Fatalf("读取全局账户失败: %v", err)
  265. }
  266. if cents(t, acc.Balance) != 0 {
  267. t.Fatalf("spend 累加不应改 balance,实际 balance=%s", acc.Balance)
  268. }
  269. // ⑤ 分账:只有 S1 有推荐人 → 恰好 1 次,orderID=recordID,ctx 带 tx
  270. if len(divide.calls) != 1 {
  271. t.Fatalf("分账调用=%d 次,期望 1(仅 S1 有推荐人)", len(divide.calls))
  272. }
  273. call := divide.calls[0]
  274. if call.stuID != fx.student1.StudentID || call.findUserID != "01HRECOMMENDER000000001" || call.recordID != fx.record.ID {
  275. t.Fatalf("分账入参错误: %+v", call)
  276. }
  277. if !call.hasTx {
  278. t.Fatal("分账调用 ctx 未携带事务(违反 §10.3:事务内跨服务必须 WithTxContext 透传)")
  279. }
  280. if quoter.calls != 1 {
  281. t.Fatalf("课酬试算=%d 次,期望 1(一节课只算一次,再按行均分)", quoter.calls)
  282. }
  283. }
  284. // TestFinishRecordIdempotent 重复结课 → 幂等成功(不重复扣课时/不重复分账/不新增明细)。
  285. func TestFinishRecordIdempotent(t *testing.T) {
  286. db := newLearningDB(t)
  287. fx := seedLearningFixture(t, db)
  288. divide := &fakeDivider{}
  289. svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
  290. ctx := context.Background()
  291. if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
  292. t.Fatalf("开始上课失败: %v", err)
  293. }
  294. if _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); err != nil {
  295. t.Fatalf("首次结课失败: %v", err)
  296. }
  297. second, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
  298. if err != nil {
  299. t.Fatalf("重复结课应成功(幂等),实际: %v", err)
  300. }
  301. if !second.Repeated {
  302. t.Fatal("重复结课 Repeated=false,期望 true")
  303. }
  304. if second.StudentNumber != 2 || second.Income.Cents() != 15000 {
  305. t.Fatalf("幂等回读 student_number=%d income=%d,期望 (2,15000)", second.StudentNumber, second.Income.Cents())
  306. }
  307. // 反向断言:不重复扣课时、不重复分账、不新增明细
  308. if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 1 {
  309. t.Fatalf("重复结课后 S1 finished_number=%d,期望仍为 1", s1.FinishedNumber)
  310. }
  311. if n := countRecordStudents(t, db, fx.record.ID); n != 2 {
  312. t.Fatalf("重复结课后打卡明细=%d 行,期望仍为 2", n)
  313. }
  314. if len(divide.calls) != 1 {
  315. t.Fatalf("重复结课后分账调用=%d 次,期望仍为 1", len(divide.calls))
  316. }
  317. if g, _ := loadSpend(t, db, fx.student1.StudentID); g != 10000 {
  318. t.Fatalf("重复结课后 S1 spend=%d 分,期望仍为 10000", g)
  319. }
  320. }
  321. // TestFinishRecordRollbackOnDivideFailure 分账失败 → **整笔回滚**(AC-11 ② 反向断言)。
  322. //
  323. // 关键:分账在链路末端(课时已扣、spend 已加、record 已置 2),一旦失败必须全部回滚,
  324. // 否则出现"课时扣了、分成没给"的静默不一致。
  325. func TestFinishRecordRollbackOnDivideFailure(t *testing.T) {
  326. db := newLearningDB(t)
  327. fx := seedLearningFixture(t, db)
  328. boom := errors.New("divide boom")
  329. divide := &fakeDivider{err: boom}
  330. svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
  331. ctx := context.Background()
  332. if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
  333. t.Fatalf("开始上课失败: %v", err)
  334. }
  335. _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID})
  336. if err == nil {
  337. t.Fatal("分账失败时结课应返回错误")
  338. }
  339. if !errors.Is(err, boom) {
  340. t.Fatalf("错误应包含底层原因,实际 %v", err)
  341. }
  342. rec := loadRecord(t, db, fx.record.ID)
  343. if rec.Status != model.CoursesRecordStatusOngoing {
  344. t.Fatalf("回滚后 status=%d,期望 1(仍在上课中)", rec.Status)
  345. }
  346. if rec.ActualEndTime != nil {
  347. t.Fatal("回滚后 actual_end_time 应为空")
  348. }
  349. if n := countRecordStudents(t, db, fx.record.ID); n != 0 {
  350. t.Fatalf("回滚后打卡明细=%d 行,期望 0", n)
  351. }
  352. if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 {
  353. t.Fatalf("回滚后 S1 finished_number=%d,期望 0(课时未扣)", s1.FinishedNumber)
  354. }
  355. if got := cents(t, loadStudentCourse(t, db, fx.student1.ID).RemainderCost); got != 100000 {
  356. t.Fatalf("回滚后 S1 remainder_cost=%d 分,期望 100000(未扣)", got)
  357. }
  358. if g, c := loadSpend(t, db, fx.student1.StudentID); g != 0 || c != 0 {
  359. t.Fatalf("回滚后 S1 spend=(%d,%d),期望 (0,0)", g, c)
  360. }
  361. }
  362. // TestFinishRecordExplicitAttendance 显式打卡名单:插班生(未报课)可上课但不扣课时。
  363. func TestFinishRecordExplicitAttendance(t *testing.T) {
  364. db := newLearningDB(t)
  365. fx := seedLearningFixture(t, db)
  366. svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
  367. ctx := context.Background()
  368. if _, err := svc.StartRecord(ctx, fx.record.ID); err != nil {
  369. t.Fatalf("开始上课失败: %v", err)
  370. }
  371. cost := money.FromCents(2000)
  372. zero := money.Amount(0)
  373. out, err := svc.FinishRecord(ctx, FinishRecordInput{
  374. RecordID: fx.record.ID,
  375. Items: []AttendanceItem{
  376. {StudentID: "01HSTUDENT0000000000001", Cost: &cost},
  377. {StudentID: "01HSTUDENT0000000000003", Type: model.CoursesRecordStudentTypeTransfer, Cost: &zero},
  378. },
  379. })
  380. if err != nil {
  381. t.Fatalf("结课失败: %v", err)
  382. }
  383. if out.StudentNumber != 2 || out.Income.Cents() != 2000 {
  384. t.Fatalf("student_number=%d income=%d,期望 (2,2000)", out.StudentNumber, out.Income.Cents())
  385. }
  386. // 插班生(未报课)不扣课时:只有 S1 的 finished_number 变化
  387. if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 1 {
  388. t.Fatalf("S1 finished_number=%d,期望 1", s1.FinishedNumber)
  389. }
  390. // 未出现在名单里的 S2 不扣课时
  391. if s2 := loadStudentCourse(t, db, fx.student2.ID); s2.FinishedNumber != 0 {
  392. t.Fatalf("S2 未上课却被扣课时:finished_number=%d", s2.FinishedNumber)
  393. }
  394. }
  395. // TestFinishRecordDuplicatedStudentRejected 同一学生在名单里出现两次 → 3140(不静默合并)。
  396. func TestFinishRecordDuplicatedStudentRejected(t *testing.T) {
  397. db := newLearningDB(t)
  398. fx := seedLearningFixture(t, db)
  399. svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
  400. _, err := svc.FinishRecord(context.Background(), FinishRecordInput{
  401. RecordID: fx.record.ID,
  402. Items: []AttendanceItem{
  403. {StudentID: fx.student1.StudentID},
  404. {StudentID: fx.student1.StudentID},
  405. },
  406. })
  407. requireAppCode(t, err, apperr.CodeBusinessParam)
  408. }
  409. // TestMarkAttendanceUpsertAndGuard 打卡幂等 upsert;已结课不可补打卡(3121)。
  410. func TestMarkAttendanceUpsertAndGuard(t *testing.T) {
  411. db := newLearningDB(t)
  412. fx := seedLearningFixture(t, db)
  413. svc := NewRecordService(db, campusScope(), RecordServiceOptions{})
  414. ctx := context.Background()
  415. cost := money.FromCents(10000)
  416. out, err := svc.MarkAttendance(ctx, MarkAttendanceInput{
  417. RecordID: fx.record.ID,
  418. Items: []AttendanceItem{{StudentID: fx.student1.StudentID, Cost: &cost}},
  419. })
  420. if err != nil {
  421. t.Fatalf("打卡失败: %v", err)
  422. }
  423. if out.StudentNumber != 1 || out.Income.Cents() != 10000 {
  424. t.Fatalf("打卡汇总 student_number=%d income=%d,期望 (1,10000)", out.StudentNumber, out.Income.Cents())
  425. }
  426. // 重复打卡 → 覆盖同行(不新增)
  427. cost2 := money.FromCents(12000)
  428. if _, err := svc.MarkAttendance(ctx, MarkAttendanceInput{
  429. RecordID: fx.record.ID,
  430. Items: []AttendanceItem{{StudentID: fx.student1.StudentID, Cost: &cost2}},
  431. }); err != nil {
  432. t.Fatalf("重复打卡失败: %v", err)
  433. }
  434. if n := countRecordStudents(t, db, fx.record.ID); n != 1 {
  435. t.Fatalf("重复打卡后行数=%d,期望 1(按 record+student 幂等)", n)
  436. }
  437. // 打卡**不扣课时**(扣课时只发生在结课)
  438. if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 {
  439. t.Fatalf("打卡不应扣课时,finished_number=%d", s1.FinishedNumber)
  440. }
  441. // 结课后补打卡 → 3121
  442. if _, err := svc.FinishRecord(ctx, FinishRecordInput{RecordID: fx.record.ID}); err != nil {
  443. t.Fatalf("结课失败: %v", err)
  444. }
  445. _, err = svc.MarkAttendance(ctx, MarkAttendanceInput{
  446. RecordID: fx.record.ID,
  447. Items: []AttendanceItem{{StudentID: fx.student2.StudentID, Cost: &cost}},
  448. })
  449. requireAppCode(t, err, apperr.CodeRecordStateInvalid)
  450. }
  451. // TestQuoteRewardPlanMissingDoesNotBlock 课酬方案未配置(3112)→ 不阻断结课,课酬记 0.00。
  452. func TestQuoteRewardPlanMissingDoesNotBlock(t *testing.T) {
  453. db := newLearningDB(t)
  454. fx := seedLearningFixture(t, db)
  455. quoter := &fakeQuoter{err: apperr.ErrPayrollPlanMissing}
  456. svc := NewRecordService(db, campusScope(), RecordServiceOptions{Quoter: quoter})
  457. out, err := svc.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx.record.ID})
  458. if err != nil {
  459. t.Fatalf("3112 不应阻断结课,实际: %v", err)
  460. }
  461. if out.TeacherReward.Cents() != 0 {
  462. t.Fatalf("课酬=%d 分,期望 0", out.TeacherReward.Cents())
  463. }
  464. // 反向断言:其他错误必须阻断(整体回滚)
  465. boom := infraerrors.WithCause(infraerrors.ErrDBError, errors.New("quote db down"))
  466. db2 := newLearningDB(t)
  467. fx2 := seedLearningFixture(t, db2)
  468. svc2 := NewRecordService(db2, campusScope(), RecordServiceOptions{Quoter: &fakeQuoter{err: boom}})
  469. if _, err := svc2.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx2.record.ID}); err == nil {
  470. t.Fatal("课酬试算 DB 异常时结课应失败(整体回滚)")
  471. }
  472. if n := countRecordStudents(t, db2, fx2.record.ID); n != 0 {
  473. t.Fatalf("回滚后打卡明细=%d 行,期望 0", n)
  474. }
  475. }
  476. // TestRecordServiceSingleTransaction 单事务形状:内层失败 → 外层整体回滚(§10.3 单测要求)。
  477. //
  478. // 用一个"分账失败"的注入点验证:若 Repository/下游 service 各自开事务,
  479. // 结课前的扣课时与 spend 会先提交、无法回滚;能全回滚即证明**只开 1 个事务**。
  480. func TestRecordServiceSingleTransaction(t *testing.T) {
  481. db := newLearningDB(t)
  482. fx := seedLearningFixture(t, db)
  483. divide := &fakeDivider{err: errors.New("boom")}
  484. svc := NewRecordService(db, campusScope(), RecordServiceOptions{Divide: divide})
  485. _, err := svc.FinishRecord(context.Background(), FinishRecordInput{RecordID: fx.record.ID})
  486. if err == nil {
  487. t.Fatal("期望结课失败")
  488. }
  489. // 断言"全部无残留"(任何一项非零都说明有过独立提交)
  490. if n := countRecordStudents(t, db, fx.record.ID); n != 0 {
  491. t.Fatalf("残留打卡明细 %d 行", n)
  492. }
  493. if got := loadRecord(t, db, fx.record.ID); got.Status != model.CoursesRecordStatusNotStarted || got.ActualEndTime != nil {
  494. t.Fatalf("残留状态 status=%d actual_end=%v", got.Status, got.ActualEndTime)
  495. }
  496. if s1 := loadStudentCourse(t, db, fx.student1.ID); s1.FinishedNumber != 0 || cents(t, s1.RemainderCost) != 100000 {
  497. t.Fatalf("残留课时扣减: finished=%d remainder_cost=%s", s1.FinishedNumber, s1.RemainderCost)
  498. }
  499. if g, c := loadSpend(t, db, fx.student1.StudentID); g != 0 || c != 0 {
  500. t.Fatalf("残留 spend=(%d,%d)", g, c)
  501. }
  502. if !divide.calls[0].hasTx {
  503. t.Fatal("分账调用未携带事务")
  504. }
  505. }