| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- // 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)
- }
- }
|