// CourseService 单测(AC-11):排班生成 week 展开 / 幂等(重复生成不新增)/ 参数校验。 // // 测试栈:glebarez/sqlite 文件库(真实事务);与 divide_service_test.go 同一 package, // 复用 cents() 金额断言助手。 package service import ( "context" "errors" "path/filepath" "testing" "time" gormsqlite "github.com/glebarez/sqlite" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/apperr" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // newLearningDB 课程域真实库(课程/分类/规则/报课/排班/打卡/资金账户/活动迁移 12 表)。 func newLearningDB(t *testing.T) *gorm.DB { t.Helper() dsn := filepath.Join(t.TempDir(), "academic_learning_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() } }) migrateLearningTables(t, db, &model.Courses{}, &model.CoursesClassify{}, &model.CoursesClass{}, &model.CoursesStudent{}, &model.CoursesRecord{}, &model.CoursesRecordStudent{}, &model.UserExpense{}, &model.UserExpenseSchool{}, &model.CoursesActivityUserDiscount{}, &model.CoursesActivityUserDiscountLog{}, &model.CoursesActivityUsedLog{}, &model.CoursesActivitySigned{}, &model.UserBalanceRequest{}, ) return db } // migrateLearningTables 逐表 AutoMigrate 并删除该表索引(SQLite 索引名库级唯一,MySQL 表级)。 // // 见 repository/record_repo_test.go 同名助手的长注释:academic model 多表共用 // `idx_inst_campus` / `uk_biz_no` 等索引名,一次性迁移会报 "index already exists"。 func migrateLearningTables(t *testing.T, db *gorm.DB, tables ...any) { t.Helper() for _, tbl := range tables { 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("读取索引清单(%s) 失败: %v", stmt.Schema.Table, err) } for _, name := range names { if err := db.Exec("DROP INDEX IF EXISTS `" + name + "`").Error; err != nil { t.Fatalf("删除索引(%s) 失败: %v", name, err) } } } } // campusScope 普通校区角色作用域(机构 1 / 校区 10)。 func campusScope() *appscope.Scope { return &appscope.Scope{InstitutionID: 1, CampusID: 10, UserID: "01HOPERATOR0000000000001", Role: "academic"} } // superScope 平台超管作用域(跨机构,维护型 SP 用)。 func superScope() *appscope.Scope { return &appscope.Scope{InstitutionID: 1, CampusID: 0, UserID: "01HSUPERADMIN0000000001", Role: "super_admin", SuperAdmin: true} } // requireAppCode 断言错误码(业务错误统一走 *infraerrors.AppError)。 func requireAppCode(t *testing.T, err error, want int) { t.Helper() if err == nil { t.Fatalf("期望错误码 %d,实际 err=nil", want) } var appErr *infraerrors.AppError if !errors.As(err, &appErr) { t.Fatalf("期望 *AppError(code=%d),实际 %T: %v", want, err, err) } if appErr.Code != want { t.Fatalf("错误码=%d,期望 %d(%v)", appErr.Code, want, err) } } // seedCourse 插入课程模板(直接落库,避开 CreateCourse 的分类校验)。 func seedCourse(t *testing.T, db *gorm.DB, name string, week, duration string, number int32) *model.Courses { t.Helper() row := &model.Courses{ Name: name, Week: week, Duration: duration, Number: number, InstitutionID: 1, CampusID: 10, Status: model.CoursesStatusPublished, Type: 1000, OriginalCost: "1000.00", RealPrice: "900.00", } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 课程(%s) 失败: %v", name, err) } return row } // seedClassRule 插入排班规则(时分用 1970-01-01 承载,对齐 model 约定)。 func seedClassRule(t *testing.T, db *gorm.DB, coursesID, campusID int64, week string, startHM, endHM time.Time, lessonCost string) *model.CoursesClass { t.Helper() row := &model.CoursesClass{ Status: model.CoursesClassStatusOngoing, CoursesID: coursesID, ClassID: 1, InstitutionID: 1, CampusID: campusID, Week: week, StartTime: &startHM, EndTime: &endHM, LessonCost: lessonCost, StandardLessonCost: lessonCost, Number: 20, } if err := db.Create(row).Error; err != nil { t.Fatalf("seed 排班规则失败: %v", err) } return row } // hm 构造"时分"(1970-01-01 HH:MM)。 func hm(hour, minute int) time.Time { return time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC) } // loadRecords 取课程全部排班实例(按 start_time 升序)。 func loadRecords(t *testing.T, db *gorm.DB, coursesID int64) []model.CoursesRecord { t.Helper() rows := make([]model.CoursesRecord, 0, 8) if err := db.Where("courses_id = ?", coursesID).Order("start_time ASC").Find(&rows).Error; err != nil { t.Fatalf("读取排班实例失败: %v", err) } return rows } // TestGenerateRecordsWeekExpansion 按 week 规则展开:只生成命中的星期,起止时分与规则一致。 func TestGenerateRecordsWeekExpansion(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) course := seedCourse(t, db, "周一数学", "-1", "1.5", 20) seedClassRule(t, db, course.ID, 10, "1", hm(9, 0), hm(10, 30), "100.00") // 周一 09:00-10:30 // 2026-01-05 是周一 → 生成 7 天窗口只应命中 1 次 from := time.Date(2026, 1, 5, 0, 0, 0, 0, time.Local) out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from}) if err != nil { t.Fatalf("GenerateRecords 失败: %v", err) } if out.Created != 1 { t.Fatalf("Created=%d,期望 1(仅周一命中)", out.Created) } if out.SkippedRule < 6 { t.Fatalf("SkippedRule=%d,期望 >= 6(其余 6 天 week 不命中)", out.SkippedRule) } rows := loadRecords(t, db, course.ID) if len(rows) != 1 { t.Fatalf("落库排班实例=%d 条,期望 1", len(rows)) } if rows[0].StartTime == nil || rows[0].StartTime.Hour() != 9 || rows[0].StartTime.Minute() != 0 { t.Fatalf("上课时间=%v,期望 09:00", rows[0].StartTime) } if rows[0].StartTime.Day() != 5 { t.Fatalf("上课日期=%d,期望 5(2026-01-05 周一)", rows[0].StartTime.Day()) } if rows[0].EndTime == nil || rows[0].EndTime.Hour() != 10 || rows[0].EndTime.Minute() != 30 { t.Fatalf("下课时间=%v,期望 10:30", rows[0].EndTime) } if rows[0].Status != model.CoursesRecordStatusNotStarted { t.Fatalf("新生成实例 status=%d,期望 0(未开始)", rows[0].Status) } if rows[0].CampusID != 10 { t.Fatalf("实例 campus_id=%d,期望 10(规则校区优先)", rows[0].CampusID) } } // TestGenerateRecordsIdempotent 幂等:同 courses_id + start_time 不重复插入(AC-11 ① 反向断言)。 func TestGenerateRecordsIdempotent(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) course := seedCourse(t, db, "周一英语", "-1", "1.0", 20) seedClassRule(t, db, course.ID, 10, "1", hm(14, 0), hm(15, 0), "80.00") from := time.Date(2026, 1, 5, 0, 0, 0, 0, time.Local) first, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from}) if err != nil { t.Fatalf("首次 GenerateRecords 失败: %v", err) } if first.Created != 1 { t.Fatalf("首次 Created=%d,期望 1", first.Created) } // 重复生成(运维重跑)→ 一条都不新增 second, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7, From: &from}) if err != nil { t.Fatalf("二次 GenerateRecords 失败: %v", err) } if second.Created != 0 { t.Fatalf("二次 Created=%d,期望 0(幂等:同 courses_id+start_time 不重复插)", second.Created) } if second.SkippedExisting != 1 { t.Fatalf("二次 SkippedExisting=%d,期望 1", second.SkippedExisting) } if rows := loadRecords(t, db, course.ID); len(rows) != 1 { t.Fatalf("重跑后落库条数=%d,期望仍为 1", len(rows)) } } // TestGenerateRecordsWeekAllAndSameDayMultiRule 全部周 + 同一天两条不同时分的规则 → 各生成一条, // 同一天同一时分(两条规则撞同一时刻)只生成一条(本次循环内 seen 去重)。 func TestGenerateRecordsWeekAllAndSameDayMultiRule(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) course := seedCourse(t, db, "全天课", model.CoursesWeekAll, "2.0", 50) seedClassRule(t, db, course.ID, 10, "", hm(9, 0), hm(11, 0), "100.00") // 空 week → 回落课程 "-1"(全部周) seedClassRule(t, db, course.ID, 10, "-1", hm(15, 0), hm(17, 0), "100.00") // 全部周,另一时段 seedClassRule(t, db, course.ID, 10, "-1", hm(9, 0), hm(11, 0), "100.00") // 与第一条撞同一时刻 from := time.Date(2026, 2, 2, 0, 0, 0, 0, time.Local) // 周一 out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 3, From: &from}) if err != nil { t.Fatalf("GenerateRecords 失败: %v", err) } if out.Created != 6 { t.Fatalf("Created=%d,期望 6(3 天 × 2 个不同时段,撞车的第三条被去重)", out.Created) } if out.SkippedExisting != 3 { t.Fatalf("SkippedExisting=%d,期望 3(每天被撞车的那条)", out.SkippedExisting) } if rows := loadRecords(t, db, course.ID); len(rows) != 6 { t.Fatalf("落库条数=%d,期望 6", len(rows)) } } // TestGenerateRecordsWithoutRule 无生效规则 → 3140(不静默生成 0 条)。 func TestGenerateRecordsWithoutRule(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) course := seedCourse(t, db, "无规则课程", "-1", "1.0", 10) _, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 7}) requireAppCode(t, err, apperr.CodeBusinessParam) } // TestGenerateRecordsCourseWindow 课程未开始 → 窗口内一条都不生成(SkippedRule 全命中)。 func TestGenerateRecordsCourseWindow(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) course := seedCourse(t, db, "下月开课", "-1", "1.0", 10) windowStart := time.Date(2026, 3, 1, 0, 0, 0, 0, time.Local) course.StartTime = &windowStart if err := db.Model(&model.Courses{}).Where("id = ?", course.ID).Update("start_time", windowStart).Error; err != nil { t.Fatalf("更新课程起始时间失败: %v", err) } seedClassRule(t, db, course.ID, 10, "-1", hm(9, 0), hm(10, 0), "100.00") from := time.Date(2026, 2, 2, 0, 0, 0, 0, time.Local) out, err := svc.GenerateRecords(context.Background(), GenerateRecordsInput{CoursesID: course.ID, Days: 3, From: &from}) if err != nil { t.Fatalf("GenerateRecords 失败: %v", err) } if out.Created != 0 { t.Fatalf("Created=%d,期望 0(课程 3 月才开始,窗口在 2 月)", out.Created) } } // TestParseWeekRule 周规则解析("-1"/空/单值/多值/非法)。 func TestParseWeekRule(t *testing.T) { cases := []struct { week string all bool days []int wantErr bool }{ {week: "", all: true}, {week: "-1", all: true}, {week: "1", days: []int{1}}, {week: "0,6", days: []int{0, 6}}, {week: "1, 3 ,5", days: []int{1, 3, 5}}, {week: "7", wantErr: true}, {week: "-2", wantErr: true}, {week: "a", wantErr: true}, } for _, c := range cases { got, err := parseWeekRule(c.week) if c.wantErr { if err == nil { t.Fatalf("week=%q 期望报错,实际通过", c.week) } continue } if err != nil { t.Fatalf("week=%q 解析失败: %v", c.week, err) } if got.all != c.all { t.Fatalf("week=%q all=%v,期望 %v", c.week, got.all, c.all) } for _, d := range c.days { if !got.days[d] { t.Fatalf("week=%q 应包含星期 %d", c.week, d) } } } } // TestParseLessonMinutesAndDuration 课时解析与还原(零 float64)。 func TestParseLessonMinutesAndDuration(t *testing.T) { cases := []struct { raw string minutes int wantErr bool }{ {raw: "", minutes: 0}, {raw: "1", minutes: 60}, {raw: "1.5", minutes: 90}, {raw: "0.5", minutes: 30}, {raw: "2.25", minutes: 135}, {raw: "-1", wantErr: true}, {raw: "1.234", wantErr: true}, {raw: "x", wantErr: true}, } for _, c := range cases { got, err := parseLessonMinutes(c.raw) if c.wantErr { if err == nil { t.Fatalf("duration=%q 期望报错", c.raw) } continue } if err != nil { t.Fatalf("duration=%q 解析失败: %v", c.raw, err) } if got != c.minutes { t.Fatalf("duration=%q → %d 分钟,期望 %d", c.raw, got, c.minutes) } } if durationFromMinutes(90) != "1.5" || durationFromMinutes(60) != "1.0" || durationFromMinutes(0) != "0.0" { t.Fatalf("durationFromMinutes 还原错误: %s/%s/%s", durationFromMinutes(90), durationFromMinutes(60), durationFromMinutes(0)) } } // TestCreateCourseValidation 课程入参校验:空名称/非法周规则/负价/超上限 → 2002。 func TestCreateCourseValidation(t *testing.T) { db := newLearningDB(t) svc := NewCourseService(db, campusScope()) ctx := context.Background() _, err := svc.CreateCourse(ctx, CreateCourseInput{Name: " ", Duration: "1.0", Week: "-1"}) requireAppCode(t, err, infraerrors.ErrBadParam.Code) _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "9"}) requireAppCode(t, err, infraerrors.ErrBadParam.Code) _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "0", Week: "-1"}) requireAppCode(t, err, infraerrors.ErrBadParam.Code) _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "-1", RealPrice: "-1.00"}) requireAppCode(t, err, infraerrors.ErrBadParam.Code) _, err = svc.CreateCourse(ctx, CreateCourseInput{Name: "课", Duration: "1.0", Week: "-1", RealPrice: "999999999.00"}) requireAppCode(t, err, infraerrors.ErrBadParam.Code) ok, err := svc.CreateCourse(ctx, CreateCourseInput{ Name: "正常课", Duration: "1.5", Week: "1,3", Number: 20, OriginalCost: "1200.00", RealPrice: "1000.00", DiscountCash: "-50.00", }) if err != nil { t.Fatalf("合法入参应成功,实际: %v", err) } if ok.InstitutionID != 1 || ok.CampusID != 10 { t.Fatalf("作用域列 institution/campus=(%d,%d),期望 (1,10)", ok.InstitutionID, ok.CampusID) } if ok.Duration != "1.5" { t.Fatalf("duration=%s,期望 1.5", ok.Duration) } if ok.DiscountCash != "-50.00" { t.Fatalf("discount_cash=%s,期望 -50.00(允许负数)", ok.DiscountCash) } } // TestDistributeAmount 课酬均分:Σ 恒等于总额(余数入首行)。 func TestDistributeAmount(t *testing.T) { total, err := money.Parse("100.00") if err != nil { t.Fatalf("金额解析失败: %v", err) } parts := distributeAmount(total, 3) if len(parts) != 3 { t.Fatalf("均分行数=%d,期望 3", len(parts)) } sum := money.Amount(0) for _, p := range parts { sum = sum.Add(p) } if sum != total { t.Fatalf("均分合计=%s,期望 %s(对账恒等式 Σ=record.teacher_reward)", sum.String(), total.String()) } if parts[0] != money.FromCents(3334) { t.Fatalf("首行=%s,期望 33.34(余数入首行)", parts[0].String()) } if len(distributeAmount(total, 0)) != 0 { t.Fatal("行数为 0 时应返回空切片") } if got := distributeAmount(0, 2); len(got) != 2 || got[0] != 0 || got[1] != 0 { t.Fatalf("金额为 0 时应返回全 0,实际 %v", got) } }