// 课程 / 班级(排班规则)/ 排班生成服务——AC-11。 // // 依据:unified/03-migration/academic-domain-design.md §4.4(端点 44~49)/ §11-3(排班盘点结论) // / §1.4(事务模板)/ §10.3(事务内调用下游一律透传 tx)/ §10.7(金额量级断言)。 // // 职责边界(与 RecordService 的分工): // // CourseService 课程模板 CRUD、班级(= 排班规则 t_courses_class)CRUD、**排班生成**(端点 49) // RecordService 开始上课 / 结束上课(单事务联动)/ 打卡(端点 50~52) // // 排班生成算法(无 dump SP 覆盖,本期为旧 PHP `modules/courses/helpers` 的 Go 化最小闭环): // // 对 [from, from+Days) 的每一天 → // 对每条生效排班规则(t_courses_class.status=1)→ // week 规则命中?(class.week 优先,空则回落到 courses.week;"-1" = 全部周) // 课程时间窗命中?(courses.start_time/end_time 为课程起止,超出则跳过) // 组装 start_time = 当天日期 + 规则时分;end_time = 规则下课时分(缺则按课程 duration 推) // 幂等:同 courses_id + start_time 已存在 → 跳过(**不重复插入**,AC-11 ①) // // 幂等实现(硬要点 1):DDL 无 (courses_id, start_time) 唯一键(model/testdata 指纹已锁定,本期不改 DDL), // 故采用"同一事务内先查后插"+ 本次循环内 seen 集合双重去重。并发双击仍可能双插(无唯一键兜底), // 已登记为交付遗留项(运维重跑去重 / 后续补唯一键须同步 DDL 三处)。 package service import ( "context" "crypto/rand" "errors" "strconv" "strings" "time" "github.com/oklog/ulid" "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" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // 排班生成的参数边界。 const ( // DefaultGenerateDays 未指定天数时的默认生成窗口(一周)。 DefaultGenerateDays = 7 // MaxGenerateDays 单次生成窗口上限(防一次生成数十万条实例)。 MaxGenerateDays = 366 // MaxCourseMoneyYuan 单笔课程金额业务上限(元;§10.7 单笔量级断言,超限 → 2002)。 MaxCourseMoneyYuan = "1000000.00" ) // CourseService 课程/班级/排班生成服务(作用域在构造时绑定,见 scope 包注释)。 type CourseService struct { // courses 课程/分类/规则/报课账户仓储(多租户)。 courses *repository.CourseRepo // records 排班实例仓储(排班生成写入口 + 幂等判据)。 records *repository.RecordRepo // runner 事务运行器(顶层开事务;事务内透传)。 runner *repository.TxRunner // now 时钟(默认 time.Now,单测注入固定时刻)。 now func() time.Time // loc 生成排班时使用的时区(默认进程本地时区;DDL 全 datetime(3))。 loc *time.Location } // NewCourseService 构造课程服务(三件套共享同一 DB 句柄,避免跨连接池事务失效)。 // // 入参: // - db *gorm.DB:数据库句柄(必填); // - sc *appscope.Scope:请求作用域(nil 时任何查询 panic fail-fast,系统任务须显式构造)。 // // 出参:*CourseService。 func NewCourseService(db *gorm.DB, sc *appscope.Scope) *CourseService { return &CourseService{ courses: repository.NewCourseRepo(db, sc), records: repository.NewRecordRepo(db, sc), runner: repository.NewTxRunner(db), now: time.Now, loc: time.Local, } } // Get 取课程模板详情(端点 44/45 的读侧)。 // // 入参:ctx;id 课程 ID(<=0 → 2001)。 // 出参:*model.Courses;3001 不存在/不在作用域;5002 DB 异常。 func (s *CourseService) Get(ctx context.Context, id int64) (*model.Courses, error) { if id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } return s.courses.GetCourseByID(ctx, id) } // List 分页查询课程列表(端点 44)。 // // 入参:ctx;f 过滤条件;pg 分页(仓储内钳制 pageSize ∈ [1,200])。 // 出参:([]model.Courses, total int64, error)。 func (s *CourseService) List(ctx context.Context, f repository.CourseFilter, pg repository.PageQuery) ([]model.Courses, int64, error) { return s.courses.ListCourses(ctx, f, pg) } // CreateCourseInput 新建课程模板入参(端点 45 POST /admin/courses)。 // // 字段语义见 model.Courses 五维注释;金额一律**元字符串**(§10.6:入参/出参统一字符串)。 type CreateCourseInput struct { SubjectID int32 GradeID int32 Type int32 ClassifyID int64 Name string Number int32 Duration string // 单节课时长(课时,1 位小数,如 "1.5") Week string // 周规则("-1" 全部 / "0" 周日 / "1".."6" / 逗号分隔) StartTime *time.Time EndTime *time.Time Address *string OriginalCost string RealPrice string DiscountCash string DiscountRatio int32 MaxPeople int32 Remark *string } // CreateCourse 新建课程模板(作用域列取当前 Scope)。 // // 入参:ctx;in 入参。 // 出参:*model.Courses(含自增 ID);2001/2002 参数错;3001 分类不存在;5002 DB 异常。 // // 校验(顺序固定,先校验后落库): // 1. name 非空、week 合法、duration > 0、number >= 0; // 2. 三个金额列可解析且不超单笔上限(original_cost/real_price >= 0;discount_cash 可为负——模型红线); // 3. classify_id > 0 时必须存在且在同一作用域(防跨租户挂分类)。 func (s *CourseService) CreateCourse(ctx context.Context, in CreateCourseInput) (*model.Courses, error) { name := strings.TrimSpace(in.Name) if name == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空")) } if _, err := parseWeekRule(in.Week); err != nil { return nil, err } durationMinutes, err := parseLessonMinutes(in.Duration) if err != nil { return nil, err } if durationMinutes <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0")) } if in.Number < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负")) } if in.StartTime != nil && in.EndTime != nil && in.EndTime.Before(*in.StartTime) { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程结束时间不得早于开始时间")) } originalCost, err := parseCourseMoney(in.OriginalCost, "原价", false) if err != nil { return nil, err } realPrice, err := parseCourseMoney(in.RealPrice, "实价", false) if err != nil { return nil, err } discountCash, err := parseCourseMoney(in.DiscountCash, "现金折扣", true) if err != nil { return nil, err } institutionID, campusID := s.courses.GetScopedID() if institutionID <= 0 { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)")) } row := &model.Courses{ SubjectID: in.SubjectID, GradeID: in.GradeID, Type: normalizeCourseType(in.Type), InstitutionID: institutionID, CampusID: campusID, // 0 = 机构级(由 SP29 回填;多校区课程永久保持 0) ClassifyID: in.ClassifyID, Status: model.CoursesStatusUnpublished, Name: name, Number: in.Number, Duration: durationFromMinutes(durationMinutes), Week: normalizeWeek(in.Week), StartTime: in.StartTime, EndTime: in.EndTime, Address: normalizeOptString(in.Address), OriginalCost: originalCost.String(), RealPrice: realPrice.String(), DiscountCash: discountCash.String(), DiscountRatio: in.DiscountRatio, Remark: normalizeOptString(in.Remark), MaxPeople: in.MaxPeople, } err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if row.ClassifyID > 0 { if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil { return cerr // 3001:分类不存在/不在作用域 } } return s.courses.CreateCourse(txCtx, tx, row) }) if err != nil { return nil, err } return row, nil } // UpdateCourseInput 编辑课程模板入参(端点 45 PUT /admin/courses/:id)。 // // 字段语义:nil = 不修改(部分更新);非 nil = 落库(含零值语义,如 Number=0 表示改成 0 次)。 type UpdateCourseInput struct { ID int64 Name *string SubjectID *int32 GradeID *int32 Type *int32 ClassifyID *int64 Number *int32 Duration *string Week *string StartTime *time.Time EndTime *time.Time Address *string OriginalCost *string RealPrice *string DiscountCash *string DiscountRatio *int32 MaxPeople *int32 Remark *string } // UpdateCourse 部分更新课程模板(列白名单由本方法构造,不接受外部列名)。 // // 入参:ctx;in 入参(ID 必填)。 // 出参:*model.Courses(更新后回读);2001/2002/3001/5002。 // // 红线: // - **不改** institution_id / campus_id / status(作用域与发布状态机各有专用入口); // - **不改** created_at;改 update_time/update_user_id 由 Handler 层补(当前无操作人上下文则留 NULL)。 func (s *CourseService) UpdateCourse(ctx context.Context, in UpdateCourseInput) (*model.Courses, error) { if in.ID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } row := &model.Courses{ID: in.ID} columns := make([]string, 0, 16) if in.Name != nil { name := strings.TrimSpace(*in.Name) if name == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程名称不能为空")) } row.Name = name columns = append(columns, "name") } if in.SubjectID != nil { row.SubjectID = *in.SubjectID columns = append(columns, "subject_id") } if in.GradeID != nil { row.GradeID = *in.GradeID columns = append(columns, "grade_id") } if in.Type != nil { row.Type = normalizeCourseType(*in.Type) columns = append(columns, "type") } if in.ClassifyID != nil { if *in.ClassifyID < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: classify_id 不能为负")) } row.ClassifyID = *in.ClassifyID columns = append(columns, "classify_id") } if in.Number != nil { if *in.Number < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 上课次数不能为负")) } row.Number = *in.Number columns = append(columns, "number") } if in.Duration != nil { minutes, err := parseLessonMinutes(*in.Duration) if err != nil { return nil, err } if minutes <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 单节课时长必须 > 0")) } row.Duration = durationFromMinutes(minutes) columns = append(columns, "duration") } if in.Week != nil { if _, err := parseWeekRule(*in.Week); err != nil { return nil, err } row.Week = normalizeWeek(*in.Week) columns = append(columns, "week") } if in.StartTime != nil { row.StartTime = in.StartTime columns = append(columns, "start_time") } if in.EndTime != nil { row.EndTime = in.EndTime columns = append(columns, "end_time") } if in.Address != nil { row.Address = normalizeOptString(in.Address) columns = append(columns, "address") } if in.OriginalCost != nil { amt, err := parseCourseMoney(*in.OriginalCost, "原价", false) if err != nil { return nil, err } row.OriginalCost = amt.String() columns = append(columns, "original_cost") } if in.RealPrice != nil { amt, err := parseCourseMoney(*in.RealPrice, "实价", false) if err != nil { return nil, err } row.RealPrice = amt.String() columns = append(columns, "real_price") } if in.DiscountCash != nil { amt, err := parseCourseMoney(*in.DiscountCash, "现金折扣", true) if err != nil { return nil, err } row.DiscountCash = amt.String() columns = append(columns, "discount_cash") } if in.DiscountRatio != nil { row.DiscountRatio = *in.DiscountRatio columns = append(columns, "discount_ratio") } if in.MaxPeople != nil { row.MaxPeople = *in.MaxPeople columns = append(columns, "max_people") } if in.Remark != nil { row.Remark = normalizeOptString(in.Remark) columns = append(columns, "remark") } if len(columns) == 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 没有任何待更新字段")) } err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if _, cerr := s.courses.GetCourseByID(txCtx, in.ID); cerr != nil { return cerr // 3001:不存在/不在作用域 } if in.ClassifyID != nil && row.ClassifyID > 0 { if _, cerr := s.courses.GetClassifyByID(txCtx, row.ClassifyID); cerr != nil { return cerr } } affected, uerr := s.courses.UpdateCourse(txCtx, tx, row, columns) if uerr != nil { return uerr } if affected == 0 { return infraerrors.WithCause(infraerrors.ErrNotFound, errString("service: 课程不存在或不在作用域")) } return nil }) if err != nil { return nil, err } return s.courses.GetCourseByID(ctx, in.ID) } // ListClasses 取某课程的全部班级(排班规则)(端点 46)。 // // 入参:ctx;coursesID 课程 ID(<=0 → 2001)。 // 出参:[]model.CoursesClass(空结果非 nil);3001/5002。 func (s *CourseService) ListClasses(ctx context.Context, coursesID int64) ([]model.CoursesClass, error) { if coursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } // 先做作用域存在性校验:不存在的课程返回 3001,而不是空列表(避免前端误判"还没排班") if _, err := s.courses.GetCourseByID(ctx, coursesID); err != nil { return nil, err } return s.courses.ListClassesByCourses(ctx, coursesID, false) } // CreateClassInput 新建班级(排班规则)入参(端点 47)。 // // 时分入参用 `"HH:MM"` 字符串(而非 datetime):旧库该两列是"时分语义",date 部分无业务含义 // (model 注释红线),新写入统一 1970-01-01 + 时分,避免前端传时区导致跨天漂移。 type CreateClassInput struct { CoursesID int64 ClassID int64 TeacherID string Week string StartTime string // "HH:MM" EndTime string // "HH:MM"(空则按课程 duration 推算) StandardLessonCost string LessonCost string DiscountCash string Number int32 Address *string Remark *string } // CreateClass 新建排班规则(班级)。 // // 入参:ctx;in 入参。 // 出参:*model.CoursesClass(含自增 ID);2001/2002/3001/5002。 // // 校验:课程存在;week 合法;时分格式合法;下课时间 > 上课时间(跨天不支持 → 2002, // 排班跨天属旧库未覆盖场景,需先补规则再实现)。 func (s *CourseService) CreateClass(ctx context.Context, in CreateClassInput) (*model.CoursesClass, error) { if in.CoursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } if _, err := parseWeekRule(in.Week); err != nil { return nil, err } startHM, err := parseHHMM(in.StartTime, "上课时间") if err != nil { return nil, err } var endHM *time.Time if strings.TrimSpace(in.EndTime) != "" { endHM, err = parseHHMM(in.EndTime, "下课时间") if err != nil { return nil, err } if !endHM.After(*startHM) { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 下课时间必须晚于上课时间(跨天排班暂不支持)")) } } if in.Number < 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 开课总量不能为负")) } standardCost, err := parseCourseMoney(in.StandardLessonCost, "课程标准单价", false) if err != nil { return nil, err } lessonCost, err := parseCourseMoney(in.LessonCost, "单次课费", false) if err != nil { return nil, err } discountCash, err := parseCourseMoney(in.DiscountCash, "课程优惠", true) if err != nil { return nil, err } institutionID, campusID := s.courses.GetScopedID() if institutionID <= 0 { return nil, infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 缺少机构作用域(institution_id)")) } row := &model.CoursesClass{ Status: model.CoursesClassStatusOngoing, CoursesID: in.CoursesID, ClassID: in.ClassID, TeacherID: stringPtr(in.TeacherID), InstitutionID: institutionID, CampusID: campusID, StandardLessonCost: standardCost.String(), Number: in.Number, LessonCost: lessonCost.String(), DiscountCash: discountCash.String(), FinishedNumber: 0, Confirm: model.CoursesClassConfirmNo, Week: normalizeWeek(in.Week), StartTime: startHM, EndTime: endHM, Address: normalizeOptString(in.Address), Remark: normalizeOptString(in.Remark), } err = s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) if _, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID); cerr != nil { return cerr } return s.courses.CreateClass(txCtx, tx, row) }) if err != nil { return nil, err } return row, nil } // GenerateRecordsInput 排班生成入参(端点 49)。 // // 字段: // - CoursesID int64:课程 ID(必填); // - Days int:生成窗口天数(<=0 → 默认 7;>366 → 钳制 366); // - From *time.Time:窗口起点(nil → 服务端当前时刻所在日;只取日期部分)。 type GenerateRecordsInput struct { CoursesID int64 Days int From *time.Time } // GenerateRecordsResult 排班生成结果(幂等可重跑:重复调用 Created=0,SkippedExisting>0)。 // // 字段:CoursesID 课程;Created 新建条数;SkippedExisting 已存在跳过条数; // SkippedRule 规则不命中(week/时间窗/缺时分)跳过条数;RecordIDs 本次新建的实例 ID(升序)。 type GenerateRecordsResult struct { CoursesID int64 Created int SkippedExisting int SkippedRule int RecordIDs []int64 } // GenerateRecords 按 week 规则展开未来 N 天的排班实例(端点 49,AC-11 ①)。 // // 入参:ctx;in 入参。 // 出参:*GenerateRecordsResult;3001 课程不存在;3140 无生效排班规则;5002。 // // 幂等(同 courses_id + start_time 不重复插入): // 1. 事务内 `FindByCoursesStart` 先查后插; // 2. 本轮 `seen[start]` 集合拦截"同一天同一时刻两条规则"的自撞; // 3. 重复调用只增加 SkippedExisting,不产生新行(可安全重跑,运维修复利器)。 // // 事务:整批生成在**一个事务**内(部分失败不留半批数据,运维不会看到"生成了一半"的课表)。 func (s *CourseService) GenerateRecords(ctx context.Context, in GenerateRecordsInput) (*GenerateRecordsResult, error) { if in.CoursesID <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课程 id 必须 > 0")) } days := in.Days if days <= 0 { days = DefaultGenerateDays } if days > MaxGenerateDays { days = MaxGenerateDays } from := s.now() if in.From != nil && !in.From.IsZero() { from = *in.From } base := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, s.loc) out := &GenerateRecordsResult{CoursesID: in.CoursesID, RecordIDs: make([]int64, 0, days)} err := s.runner.WithinTx(ctx, func(tx *gorm.DB) error { txCtx := repository.WithTxContext(ctx, tx) course, cerr := s.courses.GetCourseByID(txCtx, in.CoursesID) if cerr != nil { return cerr } rules, rerr := s.courses.ListClassesByCourses(txCtx, in.CoursesID, true) if rerr != nil { return rerr } if len(rules) == 0 { return infraerrors.WithCause(apperr.ErrBusinessParam, errString("service: 该课程没有生效中的排班规则,无法生成排班")) } courseMinutes, derr := parseLessonMinutes(course.Duration) if derr != nil { return derr } seen := make(map[time.Time]struct{}, days*len(rules)) for dayIdx := 0; dayIdx < days; dayIdx++ { day := base.AddDate(0, 0, dayIdx) for i := range rules { rule := &rules[i] // ① week 命中判定(class.week 优先;空串回落到课程模板的 week) weekText := rule.Week if strings.TrimSpace(weekText) == "" { weekText = course.Week } week, werr := parseWeekRule(weekText) if werr != nil { return werr } if !week.all && !week.days[int(day.Weekday())] { out.SkippedRule++ continue } // ② 组装上课/下课时间(规则时分缺失 → 无法排课,跳过并注释) start, ok := combineTimeOnDay(day, rule.StartTime, s.loc) if !ok { out.SkippedRule++ continue } end, ok := combineTimeOnDay(day, rule.EndTime, s.loc) if !ok { // 规则未配下课时分:按课程 duration 推(duration 也缺失 → 下课时间 = 上课时间,仍可上课) end = start.Add(time.Duration(courseMinutes) * time.Minute) } // ③ 课程时间窗(课程尚未开始 / 已结束 → 跳过) if course.StartTime != nil && start.Before(*course.StartTime) { out.SkippedRule++ continue } if course.EndTime != nil && start.After(*course.EndTime) { out.SkippedRule++ continue } // ④ 幂等:本轮已生成 / 库中已存在 → 跳过 if _, dup := seen[start]; dup { out.SkippedExisting++ continue } if _, ferr := s.records.FindByCoursesStart(txCtx, in.CoursesID, start); ferr == nil { out.SkippedExisting++ continue } else if !errors.Is(ferr, infraerrors.ErrNotFound) { return ferr } bizNo, berr := newBizNo() if berr != nil { return berr } // 校区归属:规则校区 → 课程校区 → 当前作用域校区(三者皆 0 才落机构级 0)。 // 落 0 的实例对"校区角色"不可见(Scoped 的 campus_id 等值过滤),故必须兜到作用域校区。 campusID := pickCampusID(rule.CampusID, course.CampusID) if campusID <= 0 { _, scopeCampusID := s.courses.GetScopedID() campusID = scopeCampusID } row := &model.CoursesRecord{ BizNo: bizNo, CoursesID: in.CoursesID, ClassID: rule.ClassID, TeacherID: rule.TeacherID, InstitutionID: course.InstitutionID, CampusID: campusID, Status: model.CoursesRecordStatusNotStarted, StartTime: &start, EndTime: &end, Address: normalizeOptString(rule.Address), Remark: normalizeOptString(rule.Remark), } if cerr := s.records.CreateRecord(txCtx, tx, row); cerr != nil { return cerr } seen[start] = struct{}{} out.Created++ out.RecordIDs = append(out.RecordIDs, row.ID) } } return nil }) if err != nil { return nil, err } return out, nil } // --------------------------------------------------------------------------- // 内部工具(课程域共用) // --------------------------------------------------------------------------- // weekRule 解析后的周规则。 // // 字段:all 表示"全部周"("-1" 或空串);days[0..6] 对应 time.Weekday(0=周日)。 type weekRule struct { all bool days [7]bool } // parseWeekRule 解析 week 字符串("-1"/空 = 全部周;"0".."6";逗号分隔多值)。 // // 入参:week 原始字符串。出参:weekRule;非法值 → 2002(infraerrors.ErrBadParam)。 // // 口径(model.Courses.Week 注释):"-1" 全部周 / "0" 周日 / "1".."6" 周一..周六 / 多值逗号分隔。 // `time.Weekday` 的 0=Sunday 与旧库 "0"=周日 一致,故无需偏移(错位一天会让排班整体挪日)。 func parseWeekRule(week string) (weekRule, error) { text := strings.TrimSpace(week) if text == "" || text == model.CoursesWeekAll { return weekRule{all: true}, nil } var out weekRule matched := false for _, part := range strings.Split(text, ",") { item := strings.TrimSpace(part) if item == "" { continue } day, err := strconv.Atoi(item) if err != nil || day < 0 || day > 6 { return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 非法周规则 "+text+"(仅支持 -1 / 0~6 / 逗号分隔)")) } out.days[day] = true matched = true } if !matched { return weekRule{}, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 周规则为空 "+text)) } return out, nil } // normalizeWeek 归一 week 字符串(空 → "-1";其余 trim 后原样保留)。 // // 入参:week 原始值。出参:落库值(列 NOT NULL DEFAULT '-1')。 func normalizeWeek(week string) string { text := strings.TrimSpace(week) if text == "" { return model.CoursesWeekAll } return text } // normalizeCourseType 归一课程类型(0 → 1000 集体班,对齐 classify.class_type 默认)。 // // 入参:typ 入参类型。出参:落库类型(>0)。 func normalizeCourseType(typ int32) int32 { if typ <= 0 { return 1000 } return typ } // parseCourseMoney 解析课程域金额入参(元字符串 → money.Amount,含单笔上限断言)。 // // 入参:raw 元字符串;label 字段中文名(错误提示);allowNegative 是否允许负数(折扣列允许)。 // 出参:money.Amount;2002 解析失败/超上限/不允许的负数。 // // 红线(§10.7):入参一律字符串,禁止 float64;单笔 ≤ 1,000,000.00 元(超限 = 脏数据放大前拦截)。 func parseCourseMoney(raw, label string, allowNegative bool) (money.Amount, error) { text := strings.TrimSpace(raw) if text == "" { return 0, nil } amount, err := money.Parse(text) if err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"金额格式非法 "+text)) } if err := amount.Validate(); err != nil { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, err) } if amount.IsNegative() && !allowNegative { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"不能为负")) } limit, lerr := money.Parse(MaxCourseMoneyYuan) if lerr != nil { return 0, infraerrors.WithCause(infraerrors.ErrInternal, lerr) } if amount.GreaterThan(limit) || amount.LessThan(limit.Neg()) { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"超出单笔金额上限 "+MaxCourseMoneyYuan+" 元")) } return amount, nil } // parseLessonMinutes 解析"课时"字符串为分钟(decimal(5,1) 语义,如 "1.5" → 90 分钟,零 float64)。 // // 入参:raw 课时字符串(可空 → 0)。出参:分钟数;非法格式 → 2002。 // // 实现:按小数点切成 整数小时 + 小数位;小数位只支持 1~2 位(DDL 为 1 位,留 2 位兼容旧脏值), // 分钟 = frac × 60 / 10^位数(整数运算,0.25 → 15 分钟,0.5 → 30 分钟)。 func parseLessonMinutes(raw string) (int, error) { text := strings.TrimSpace(raw) if text == "" { return 0, nil } parts := strings.SplitN(text, ".", 2) hours, err := strconv.Atoi(strings.TrimSpace(parts[0])) if err != nil || hours < 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课时格式非法 "+text)) } minutes := hours * 60 if len(parts) == 2 { frac := strings.TrimSpace(parts[1]) if frac == "" { return minutes, nil } if len(frac) > 2 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课时最多 2 位小数 "+text)) } value, ferr := strconv.Atoi(frac) if ferr != nil || value < 0 { return 0, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: 课时格式非法 "+text)) } scale := 1 for i := 0; i < len(frac); i++ { scale *= 10 } minutes += value * 60 / scale } return minutes, nil } // durationFromMinutes 把分钟数还原为 decimal(5,1) 的"课时"字符串(60 分钟 → "1.0")。 // // 入参:minutes 分钟数(>=0)。出参:课时字符串(保留 1 位小数,如 "1.5")。 // // 说明:非 10 分钟整数倍的分钟会被**向下取整到 0.1 课时**(写入侧精度边界,由 Service 统一决定, // 避免 GORM 把 float64 写进 decimal 列产生精度噪声)。 func durationFromMinutes(minutes int) string { if minutes <= 0 { return "0.0" } tenths := minutes / 6 // 6 分钟 = 0.1 课时(1 课时 = 60 分钟) return strconv.FormatInt(int64(tenths/10), 10) + "." + strconv.Itoa(tenths%10) } // parseHHMM 解析 "HH:MM" 时分(date 固定 1970-01-01,对齐 model 的"时分语义"约定)。 // // 入参:raw 时分字符串;label 字段中文名。出参:*time.Time(UTC 承载 1970-01-01 HH:MM);非法 → 2002。 func parseHHMM(raw, label string) (*time.Time, error) { text := strings.TrimSpace(raw) if text == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"不能为空(格式 HH:MM)")) } parts := strings.Split(text, ":") if len(parts) < 2 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"格式非法 "+text+"(应为 HH:MM)")) } hour, herr := strconv.Atoi(strings.TrimSpace(parts[0])) minute, merr := strconv.Atoi(strings.TrimSpace(parts[1])) if herr != nil || merr != nil || hour < 0 || hour > 23 || minute < 0 || minute > 59 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: "+label+"超出范围 "+text+"(00:00~23:59)")) } at := time.Date(1970, 1, 1, hour, minute, 0, 0, time.UTC) return &at, nil } // combineTimeOnDay 把"时分"叠加到指定日期上(生成排班实例的 start/end)。 // // 入参:day 日期(只取年月日);hm 时分来源(nil → 返回 false);loc 时区。 // 出参:(time.Time, ok);ok=false 表示时分缺失,调用方需跳过或按课程时长推算。 func combineTimeOnDay(day time.Time, hm *time.Time, loc *time.Location) (time.Time, bool) { if hm == nil { return time.Time{}, false } if loc == nil { loc = time.Local } return time.Date(day.Year(), day.Month(), day.Day(), hm.Hour(), hm.Minute(), 0, 0, loc), true } // pickCampusID 取排班实例的归属校区(规则校区优先;否则课程校区)。 // // 入参:ruleCampusID / courseCampusID。出参:校区 ID(可能为 0 = 机构级)。 // // 说明:两处都为 0 时实例落 0,由 SP29 的课程回填 + 后续规则修正补齐(不在此处猜校区)。 func pickCampusID(ruleCampusID, courseCampusID int64) int64 { if ruleCampusID > 0 { return ruleCampusID } return courseCampusID } // normalizeOptString 可选字符串归一(trim 后空串 → nil,落 DB NULL)。 // // 入参:s *string(可 nil)。出参:*string(nil 或去空白后的非空指针)。 func normalizeOptString(s *string) *string { if s == nil { return nil } return stringPtr(*s) } // newBizNo 生成 26 位业务单号(ULID;用于 t_courses_record / t_courses_record_student 的 biz_no)。 // // 出参:(string, error);熵源故障 → ErrInternal 包装(不降级为 math/rand,防可预测单号)。 func newBizNo() (string, error) { v, err := ulid.New(ulid.Timestamp(time.Now()), rand.Reader) if err != nil { return "", infraerrors.WithCause(infraerrors.ErrInternal, err) } return v.String(), nil }