course_service_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. // CourseService 单测(AC-11):排班生成 week 展开 / 幂等(重复生成不新增)/ 参数校验。
  2. //
  3. // 测试栈:glebarez/sqlite 文件库(真实事务);与 divide_service_test.go 同一 package,
  4. // 复用 cents() 金额断言助手。
  5. package service
  6. import (
  7. "context"
  8. "errors"
  9. "path/filepath"
  10. "testing"
  11. "time"
  12. gormsqlite "github.com/glebarez/sqlite"
  13. "gorm.io/gorm"
  14. "yuansiwei/unified-backend/internal/academic/apperr"
  15. "yuansiwei/unified-backend/internal/academic/model"
  16. "yuansiwei/unified-backend/internal/academic/money"
  17. infraerrors "yuansiwei/unified-backend/internal/infra/errors"
  18. appscope "yuansiwei/unified-backend/internal/infra/scope"
  19. )
  20. // newLearningDB 课程域真实库(课程/分类/规则/报课/排班/打卡/资金账户/活动迁移 12 表)。
  21. func newLearningDB(t *testing.T) *gorm.DB {
  22. t.Helper()
  23. dsn := filepath.Join(t.TempDir(), "academic_learning_test.db")
  24. db, err := gorm.Open(gormsqlite.Open(dsn), &gorm.Config{})
  25. if err != nil {
  26. t.Fatalf("gorm.Open(课程域文件库) 失败: %v", err)
  27. }
  28. t.Cleanup(func() {
  29. if sqlDB, e := db.DB(); e == nil {
  30. _ = sqlDB.Close()
  31. }
  32. })
  33. migrateLearningTables(t, db,
  34. &model.Courses{}, &model.CoursesClassify{}, &model.CoursesClass{}, &model.CoursesStudent{},
  35. &model.CoursesRecord{}, &model.CoursesRecordStudent{},
  36. &model.UserExpense{}, &model.UserExpenseSchool{},
  37. &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{},
  38. &model.CoursesActivityUsedLog{}, &model.CoursesActivitySigned{}, &model.UserBalanceRequest{},
  39. )
  40. return db
  41. }
  42. // migrateLearningTables 逐表 AutoMigrate 并删除该表索引(SQLite 索引名库级唯一,MySQL 表级)。
  43. //
  44. // 见 repository/record_repo_test.go 同名助手的长注释:academic model 多表共用
  45. // `idx_inst_campus` / `uk_biz_no` 等索引名,一次性迁移会报 "index already exists"。
  46. func migrateLearningTables(t *testing.T, db *gorm.DB, tables ...any) {
  47. t.Helper()
  48. for _, tbl := range tables {
  49. if err := db.AutoMigrate(tbl); err != nil {
  50. t.Fatalf("AutoMigrate(%T) 失败: %v", tbl, err)
  51. }
  52. stmt := &gorm.Statement{DB: db}
  53. if err := stmt.Parse(tbl); err != nil {
  54. t.Fatalf("解析表名(%T) 失败: %v", tbl, err)
  55. }
  56. var names []string
  57. if err := db.Raw("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
  58. stmt.Schema.Table).Scan(&names).Error; err != nil {
  59. t.Fatalf("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err)
  60. }
  61. for _, name := range names {
  62. if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil {
  63. t.Fatalf("删除索引(%s) 失败: %v", name, err)
  64. }
  65. }
  66. }
  67. }
  68. // campusScope 普通校区角色作用域(机构 1 / 校区 10)。
  69. func campusScope() *appscope.Scope {
  70. return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "academic"}
  71. }
  72. // superScope 平台超管作用域(跨机构,维护型 SP 用)。
  73. func superScope() *appscope.Scope {
  74. return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true}
  75. }
  76. // requireAppCode 断言错误码(业务错误统一走 *infraerrors.AppError)。
  77. func requireAppCode(t *testing.T, err error, want int) {
  78. t.Helper()
  79. if err == nil {
  80. t.Fatalf("期望错误码 %d,实际 err=nil", want)
  81. }
  82. var appErr *infraerrors.AppError
  83. if !errors.As(err, &appErr) {
  84. t.Fatalf("期望 *AppError(code=%d),实际 %T: %v", want, err, err)
  85. }
  86. if appErr.Code != want {
  87. t.Fatalf("错误码=%d,期望 %d(%v)", appErr.Code, want, err)
  88. }
  89. }
  90. // seedCourse 插入课程模板(直接落库,避开 CreateCourse 的分类校验)。
  91. func seedCourse(t *testing.T, db *gorm.DB, name string, week, duration string, number int32) *model.Courses {
  92. t.Helper()
  93. row := &model.Courses{
  94. Name: name, Week: week, Duration: duration, Number: number,
  95. InstitutionID: 1, CampusID: 10, Status: model.CoursesStatusPublished,
  96. Type: 1000, OriginalCost: "1000.00", RealPrice: "900.00",
  97. }
  98. if err := db.Create(row).Error; err != nil {
  99. t.Fatalf("seed 课程(%s) 失败: %v", name, err)
  100. }
  101. return row
  102. }
  103. // seedClassRule 插入排班规则(时分用 1970-01-01 承载,对齐 model 约定)。
  104. func seedClassRule(t *testing.T, db *gorm.DB, coursesID, campusID int64, week string, startHM, endHM time.Time, lessonCost string) *model.CoursesClass {
  105. t.Helper()
  106. row := &model.CoursesClass{
  107. Status: model.CoursesClassStatusOngoing, CoursesID: coursesID, ClassID: 1,
  108. InstitutionID: 1, CampusID: campusID, Week: week,
  109. StartTime: &startHM, EndTime: &endHM, LessonCost: lessonCost, StandardLessonCost: lessonCost,
  110. Number: 20,
  111. }
  112. if err := db.Create(row).Error; err != nil {
  113. t.Fatalf("seed 排班规则失败: %v", err)
  114. }
  115. return row
  116. }
  117. // hm 构造"时分"(1970-01-01 HH:MM)。
  118. func hm(hour, minute int) time.Time { return time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC) }
  119. // loadRecords 取课程全部排班实例(按 start_time 升序)。
  120. func loadRecords(t *testing.T, db *gorm.DB, coursesID int64) []model.CoursesRecord {
  121. t.Helper()
  122. rows := make([]model.CoursesRecord, 0, 8)
  123. if err := db.Where("courses_id = ?", coursesID).Order("start_time ASC").Find(&rows).Error; err != nil {
  124. t.Fatalf("读取排班实例失败: %v", err)
  125. }
  126. return rows
  127. }
  128. // TestGenerateRecordsWeekExpansion 按 week 规则展开:只生成命中的星期,起止时分与规则一致。
  129. func TestGenerateRecordsWeekExpansion(t *testing.T) {
  130. db := newLearningDB(t)
  131. svc := NewCourseService(db, campusScope())
  132. course := seedCourse(t, db, "周一数学", "-1", "1.5", 20)
  133. seedClassRule(t, db, course.ID, 10, "1", hm(9, 0), hm(10, 30), "100.00") // 周一 09:00-10:30
  134. // 2026-01-05 是周一 → 生成 7 天窗口只应命中 1 次
  135. from := time.Date(2026, 1, 5, 0, 0, 0, 0, time.Local)
  136. out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from})
  137. if err != nil {
  138. t.Fatalf("GenerateRecords 失败: %v", err)
  139. }
  140. if out.Created != 1 {
  141. t.Fatalf("Created=%d,期望 1(仅周一命中)", out.Created)
  142. }
  143. if out.SkippedRule < 6 {
  144. t.Fatalf("SkippedRule=%d,期望 >= 6(其余 6 天 week 不命中)", out.SkippedRule)
  145. }
  146. rows := loadRecords(t, db, course.ID)
  147. if len(rows) != 1 {
  148. t.Fatalf("落库排班实例=%d 条,期望 1", len(rows))
  149. }
  150. if rows[0].StartTime == nil || rows[0].StartTime.Hour() != 9 || rows[0].StartTime.Minute() != 0 {
  151. t.Fatalf("上课时间=%v,期望 09:00", rows[0].StartTime)
  152. }
  153. if rows[0].StartTime.Day() != 5 {
  154. t.Fatalf("上课日期=%d,期望 5(2026-01-05 周一)", rows[0].StartTime.Day())
  155. }
  156. if rows[0].EndTime == nil || rows[0].EndTime.Hour() != 10 || rows[0].EndTime.Minute() != 30 {
  157. t.Fatalf("下课时间=%v,期望 10:30", rows[0].EndTime)
  158. }
  159. if rows[0].Status != model.CoursesRecordStatusNotStarted {
  160. t.Fatalf("新生成实例 status=%d,期望 0(未开始)", rows[0].Status)
  161. }
  162. if rows[0].CampusID != 10 {
  163. t.Fatalf("实例 campus_id=%d,期望 10(规则校区优先)", rows[0].CampusID)
  164. }
  165. }
  166. // TestGenerateRecordsIdempotent 幂等:同 courses_id + start_time 不重复插入(AC-11 ① 反向断言)。
  167. func TestGenerateRecordsIdempotent(t *testing.T) {
  168. db := newLearningDB(t)
  169. svc := NewCourseService(db, campusScope())
  170. course := seedCourse(t, db, "周一英语", "-1", "1.0", 20)
  171. seedClassRule(t, db, course.ID, 10, "1", hm(14, 0), hm(15, 0), "80.00")
  172. from := time.Date(2026, 1, 5, 0, 0, 0, 0, time.Local)
  173. first, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from})
  174. if err != nil {
  175. t.Fatalf("首次 GenerateRecords 失败: %v", err)
  176. }
  177. if first.Created != 1 {
  178. t.Fatalf("首次 Created=%d,期望 1", first.Created)
  179. }
  180. // 重复生成(运维重跑)→ 一条都不新增
  181. second, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from})
  182. if err != nil {
  183. t.Fatalf("二次 GenerateRecords 失败: %v", err)
  184. }
  185. if second.Created != 0 {
  186. t.Fatalf("二次 Created=%d,期望 0(幂等:同 courses_id+start_time 不重复插)", second.Created)
  187. }
  188. if second.SkippedExisting != 1 {
  189. t.Fatalf("二次 SkippedExisting=%d,期望 1", second.SkippedExisting)
  190. }
  191. if rows := loadRecords(t, db, course.ID); len(rows) != 1 {
  192. t.Fatalf("重跑后落库条数=%d,期望仍为 1", len(rows))
  193. }
  194. }
  195. // TestGenerateRecordsWeekAllAndSameDayMultiRule 全部周 + 同一天两条不同时分的规则 → 各生成一条,
  196. // 同一天同一时分(两条规则撞同一时刻)只生成一条(本次循环内 seen 去重)。
  197. func TestGenerateRecordsWeekAllAndSameDayMultiRule(t *testing.T) {
  198. db := newLearningDB(t)
  199. svc := NewCourseService(db, campusScope())
  200. course := seedCourse(t, db, "全天课", model.CoursesWeekAll, "2.0", 50)
  201. seedClassRule(t, db, course.ID, 10, "", hm(9, 0), hm(11, 0), "100.00") // 空 week → 回落课程 "-1"(全部周)
  202. seedClassRule(t, db, course.ID, 10, "-1", hm(15, 0), hm(17, 0), "100.00") // 全部周,另一时段
  203. seedClassRule(t, db, course.ID, 10, "-1", hm(9, 0), hm(11, 0), "100.00") // 与第一条撞同一时刻
  204. from := time.Date(2026, 2, 2, 0, 0, 0, 0, time.Local) // 周一
  205. out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 3, From: &from})
  206. if err != nil {
  207. t.Fatalf("GenerateRecords 失败: %v", err)
  208. }
  209. if out.Created != 6 {
  210. t.Fatalf("Created=%d,期望 6(3 天 × 2 个不同时段,撞车的第三条被去重)", out.Created)
  211. }
  212. if out.SkippedExisting != 3 {
  213. t.Fatalf("SkippedExisting=%d,期望 3(每天被撞车的那条)", out.SkippedExisting)
  214. }
  215. if rows := loadRecords(t, db, course.ID); len(rows) != 6 {
  216. t.Fatalf("落库条数=%d,期望 6", len(rows))
  217. }
  218. }
  219. // TestGenerateRecordsWithoutRule 无生效规则 → 3140(不静默生成 0 条)。
  220. func TestGenerateRecordsWithoutRule(t *testing.T) {
  221. db := newLearningDB(t)
  222. svc := NewCourseService(db, campusScope())
  223. course := seedCourse(t, db, "无规则课程", "-1", "1.0", 10)
  224. _, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7})
  225. requireAppCode(t, err, apperr.CodeBusinessParam)
  226. }
  227. // TestGenerateRecordsCourseWindow 课程未开始 → 窗口内一条都不生成(SkippedRule 全命中)。
  228. func TestGenerateRecordsCourseWindow(t *testing.T) {
  229. db := newLearningDB(t)
  230. svc := NewCourseService(db, campusScope())
  231. course := seedCourse(t, db, "下月开课", "-1", "1.0", 10)
  232. windowStart := time.Date(2026, 3, 1, 0, 0, 0, 0, time.Local)
  233. course.StartTime = &windowStart
  234. if err := db.Model(&model.Courses{}).Where("id = ?", course.ID).Update("start_time", windowStart).Error; err != nil {
  235. t.Fatalf("更新课程起始时间失败: %v", err)
  236. }
  237. seedClassRule(t, db, course.ID, 10, "-1", hm(9, 0), hm(10, 0), "100.00")
  238. from := time.Date(2026, 2, 2, 0, 0, 0, 0, time.Local)
  239. out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 3, From: &from})
  240. if err != nil {
  241. t.Fatalf("GenerateRecords 失败: %v", err)
  242. }
  243. if out.Created != 0 {
  244. t.Fatalf("Created=%d,期望 0(课程 3 月才开始,窗口在 2 月)", out.Created)
  245. }
  246. }
  247. // TestParseWeekRule 周规则解析("-1"/空/单值/多值/非法)。
  248. func TestParseWeekRule(t *testing.T) {
  249. cases := []struct {
  250. week string
  251. all bool
  252. days []int
  253. wantErr bool
  254. }{
  255. {week: "", all: true},
  256. {week: "-1", all: true},
  257. {week: "1", days: []int{1}},
  258. {week: "0,6", days: []int{0, 6}},
  259. {week: "1, 3 ,5", days: []int{1, 3, 5}},
  260. {week: "7", wantErr: true},
  261. {week: "-2", wantErr: true},
  262. {week: "a", wantErr: true},
  263. }
  264. for _, c := range cases {
  265. got, err := parseWeekRule(c.week)
  266. if c.wantErr {
  267. if err == nil {
  268. t.Fatalf("week=%q 期望报错,实际通过", c.week)
  269. }
  270. continue
  271. }
  272. if err != nil {
  273. t.Fatalf("week=%q 解析失败: %v", c.week, err)
  274. }
  275. if got.all != c.all {
  276. t.Fatalf("week=%q all=%v,期望 %v", c.week, got.all, c.all)
  277. }
  278. for _, d := range c.days {
  279. if !got.days[d] {
  280. t.Fatalf("week=%q 应包含星期 %d", c.week, d)
  281. }
  282. }
  283. }
  284. }
  285. // TestParseLessonMinutesAndDuration 课时解析与还原(零 float64)。
  286. func TestParseLessonMinutesAndDuration(t *testing.T) {
  287. cases := []struct {
  288. raw string
  289. minutes int
  290. wantErr bool
  291. }{
  292. {raw: "", minutes: 0},
  293. {raw: "1", minutes: 60},
  294. {raw: "1.5", minutes: 90},
  295. {raw: "0.5", minutes: 30},
  296. {raw: "2.25", minutes: 135},
  297. {raw: "-1", wantErr: true},
  298. {raw: "1.234", wantErr: true},
  299. {raw: "x", wantErr: true},
  300. }
  301. for _, c := range cases {
  302. got, err := parseLessonMinutes(c.raw)
  303. if c.wantErr {
  304. if err == nil {
  305. t.Fatalf("duration=%q 期望报错", c.raw)
  306. }
  307. continue
  308. }
  309. if err != nil {
  310. t.Fatalf("duration=%q 解析失败: %v", c.raw, err)
  311. }
  312. if got != c.minutes {
  313. t.Fatalf("duration=%q → %d 分钟,期望 %d", c.raw, got, c.minutes)
  314. }
  315. }
  316. if durationFromMinutes(90) != "1.5" || durationFromMinutes(60) != "1.0" || durationFromMinutes(0) != "0.0" {
  317. t.Fatalf("durationFromMinutes 还原错误: %s/%s/%s",
  318. durationFromMinutes(90), durationFromMinutes(60), durationFromMinutes(0))
  319. }
  320. }
  321. // TestCreateCourseValidation 课程入参校验:空名称/非法周规则/负价/超上限 → 2002。
  322. func TestCreateCourseValidation(t *testing.T) {
  323. db := newLearningDB(t)
  324. svc := NewCourseService(db, campusScope())
  325. ctx := context.Background()
  326. _, err := svc.CreateCourse(ctx, CreateCourseInput{Name: " ", Duration: "1.0", Week: "-1"})
  327. requireAppCode(t, err, infraerrors.ErrBadParam.Code)
  328. _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "9"})
  329. requireAppCode(t, err, infraerrors.ErrBadParam.Code)
  330. _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "0", Week: "-1"})
  331. requireAppCode(t, err, infraerrors.ErrBadParam.Code)
  332. _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "-1", RealPrice: "-1.00"})
  333. requireAppCode(t, err, infraerrors.ErrBadParam.Code)
  334. _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "-1", RealPrice: "999999999.00"})
  335. requireAppCode(t, err, infraerrors.ErrBadParam.Code)
  336. ok, err := svc.CreateCourse(ctx, CreateCourseInput{
  337. Name: "正常课", Duration: "1.5", Week: "1,3", Number: 20,
  338. OriginalCost: "1200.00", RealPrice: "1000.00", DiscountCash: "-50.00",
  339. })
  340. if err != nil {
  341. t.Fatalf("合法入参应成功,实际: %v", err)
  342. }
  343. if ok.InstitutionID != 1 || ok.CampusID != 10 {
  344. t.Fatalf("作用域列 institution/campus=(%d,%d),期望 (1,10)", ok.InstitutionID, ok.CampusID)
  345. }
  346. if ok.Duration != "1.5" {
  347. t.Fatalf("duration=%s,期望 1.5", ok.Duration)
  348. }
  349. if ok.DiscountCash != "-50.00" {
  350. t.Fatalf("discount_cash=%s,期望 -50.00(允许负数)", ok.DiscountCash)
  351. }
  352. }
  353. // TestDistributeAmount 课酬均分:Σ 恒等于总额(余数入首行)。
  354. func TestDistributeAmount(t *testing.T) {
  355. total, err := money.Parse("100.00")
  356. if err != nil {
  357. t.Fatalf("金额解析失败: %v", err)
  358. }
  359. parts := distributeAmount(total, 3)
  360. if len(parts) != 3 {
  361. t.Fatalf("均分行数=%d,期望 3", len(parts))
  362. }
  363. sum := money.Amount(0)
  364. for _, p := range parts {
  365. sum = sum.Add(p)
  366. }
  367. if sum != total {
  368. t.Fatalf("均分合计=%s,期望 %s(对账恒等式 Σ=record.teacher_reward)", sum.String(), total.String())
  369. }
  370. if parts[0] != money.FromCents(3334) {
  371. t.Fatalf("首行=%s,期望 33.34(余数入首行)", parts[0].String())
  372. }
  373. if len(distributeAmount(total, 0)) != 0 {
  374. t.Fatal("行数为 0 时应返回空切片")
  375. }
  376. if got := distributeAmount(0, 2); len(got) != 2 || got[0] != 0 || got[1] != 0 {
  377. t.Fatalf("金额为 0 时应返回全 0,实际 %v", got)
  378. }
  379. }