From 75a44f2edd1a4367b962874902a6e5362a7927c5 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Sat, 7 Feb 2026 22:08:13 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.1.2.dev.260207=20feat:=20=E2=9A=A0?= =?UTF-8?q?=EF=B8=8F=20=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E8=AF=BE?= =?UTF-8?q?=E7=A8=8B=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E9=A2=84=E6=A3=80=E6=B5=8B=E4=B8=8E=E5=86=B2=E7=AA=81=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量导入课程接口支持预先检测冲突 - 返回并展示具体发生冲突的课程信息 📚💥 - 补全此前规划的冲突提示功能(把大饼补上了 🍞) refactor: 🧱 使用工作单元模式管理 dao 层事务 - 引入工作单元模式(Unit of Work)统一管理 dao 层 - 新建全局事务,使跨 repo 的 gorm 事务管理更加方便 🔁 fix: 🐛 修复将任务块添加进日程接口的多个问题 - 修复核心逻辑 bug(费了老大劲 😵‍💫) - 补充并覆盖该接口的多种异常与错误场景测试 🧪 --- backend/api/course.go | 7 +- backend/cmd/start.go | 5 +- backend/conv/schedule.go | 61 +++++++++++++ backend/conv/task-class.go | 6 +- backend/dao/base.go | 44 ++++++++++ backend/dao/course.go | 4 + backend/dao/schedule.go | 161 +++++++++++++++++++++++++++++++--- backend/dao/task-class.go | 6 ++ backend/dao/task.go | 4 + backend/dao/user.go | 4 + backend/model/schedule.go | 20 +++++ backend/respond/respond.go | 5 ++ backend/service/course.go | 60 +++++++++++-- backend/service/task-class.go | 40 ++++++++- backend/service/task.go | 4 +- 15 files changed, 397 insertions(+), 34 deletions(-) create mode 100644 backend/conv/schedule.go create mode 100644 backend/dao/base.go diff --git a/backend/api/course.go b/backend/api/course.go index d0635bc..67bb6b8 100644 --- a/backend/api/course.go +++ b/backend/api/course.go @@ -56,11 +56,14 @@ func (sa *CourseHandler) AddUserCourses(c *gin.Context) { // 创建一个带 1 秒超时的上下文 ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) defer cancel() // 记得释放资源 - err = sa.service.AddUserCourses(ctx, req, userIDInterface) + conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface) if err != nil { switch { - case errors.Is(err, respond.WrongParamType), errors.Is(err, respond.WrongCourseInfo): + case errors.Is(err, respond.WrongParamType), errors.Is(err, respond.WrongCourseInfo), + errors.Is(err, respond.InsertCourseTwice): c.JSON(http.StatusBadRequest, err) + case errors.Is(err, respond.ScheduleConflict): + c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts)) default: c.JSON(http.StatusInternalServerError, respond.InternalError(err)) } diff --git a/backend/cmd/start.go b/backend/cmd/start.go index ced8946..c7dd499 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -46,11 +46,12 @@ func Start() { courseRepo := dao.NewCourseDAO(db) taskClassRepo := dao.NewTaskClassDAO(db) scheduleRepo := dao.NewScheduleDAO(db) + manager := dao.NewManager(db) //service 层 userService := service.NewUserService(userRepo, cacheRepo) taskSv := service.NewTaskService(taskRepo) - courseService := service.NewCourseService(courseRepo) - taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo) + courseService := service.NewCourseService(courseRepo, scheduleRepo) + taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) //api 层 userApi := api.NewUserHandler(userService) taskApi := api.NewTaskHandler(taskSv) diff --git a/backend/conv/schedule.go b/backend/conv/schedule.go new file mode 100644 index 0000000..69b4cbb --- /dev/null +++ b/backend/conv/schedule.go @@ -0,0 +1,61 @@ +package conv + +import ( + "fmt" + + "github.com/LoveLosita/smartflow/backend/model" +) + +import "sort" + +func SchedulesToScheduleConflictDetail(schedules []model.Schedule) []model.ScheduleConflictDetail { + if len(schedules) == 0 { + return []model.ScheduleConflictDetail{} + } + // 1. 使用 Map 进行逻辑分组 + // Key 格式: EventID-Week-Day (防止同一事件在不同天出现时被混为一谈) + groups := make(map[string]*model.ScheduleConflictDetail) + for _, s := range schedules { + key := fmt.Sprintf("%d-%d-%d", s.EventID, s.Week, s.DayOfWeek) + + if _, ok := groups[key]; !ok { + // 初始化该分组 + groups[key] = &model.ScheduleConflictDetail{ + EventID: s.EventID, + Name: s.Event.Name, + Location: *s.Event.Location, // 假设字段是 *string + Type: s.Event.Type, + Week: s.Week, + DayOfWeek: s.DayOfWeek, + } + } + // 将当前节次加入数组 + groups[key].Sections = append(groups[key].Sections, s.Section) + } + + // 2. 处理每个分组的区间逻辑 + res := make([]model.ScheduleConflictDetail, 0, len(groups)) + for _, detail := range groups { + // 排序节次,例如把 [3, 1, 2] 变成 [1, 2, 3] + sort.Ints(detail.Sections) + + // 最小值即起始,最大值即结束 + detail.StartSection = detail.Sections[0] + detail.EndSection = detail.Sections[len(detail.Sections)-1] + + res = append(res, *detail) + } + + // 3. 可选:对结果集按时间排序,让前端收到的 DTO 也是有序的 + sort.Slice(res, func(i, j int) bool { + if res[i].Week != res[j].Week { + return res[i].Week < res[j].Week + } + if res[i].DayOfWeek != res[j].DayOfWeek { + return res[i].DayOfWeek < res[j].DayOfWeek + } + return res[i].StartSection < res[j].StartSection + }) + + return res +} diff --git a/backend/conv/task-class.go b/backend/conv/task-class.go index 521fb02..31176a2 100644 --- a/backend/conv/task-class.go +++ b/backend/conv/task-class.go @@ -137,12 +137,12 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model. } // UserInsertTaskItemRequestToModel 用于将填入空闲时段日程的请求转换为 Schedule 模型 -func UserInsertTaskItemRequestToModel(req *model.UserInsertTaskClassItemToScheduleRequest, item *model.TaskClassItem, taskID, userID, startSection, endSection int) ([]model.Schedule, *model.ScheduleEvent) { +func UserInsertTaskItemRequestToModel(req *model.UserInsertTaskClassItemToScheduleRequest, item *model.TaskClassItem, taskID *int, userID, startSection, endSection int) ([]model.Schedule, *model.ScheduleEvent) { var schedules []model.Schedule for section := startSection; section <= endSection; section++ { req1 := &model.Schedule{ - UserID: userID, // 由调用方填充 - EmbeddedTaskID: &taskID, + UserID: userID, + EmbeddedTaskID: taskID, Week: req.Week, DayOfWeek: req.DayOfWeek, Section: section, diff --git a/backend/dao/base.go b/backend/dao/base.go new file mode 100644 index 0000000..ca6f046 --- /dev/null +++ b/backend/dao/base.go @@ -0,0 +1,44 @@ +package dao + +import ( + "context" + + "gorm.io/gorm" +) + +// RepoManager 囊括了所有的 Repo +type RepoManager struct { + db *gorm.DB + Schedule *ScheduleDAO + Task *TaskDAO + Course *CourseDAO + TaskClass *TaskClassDAO + User *UserDAO +} + +func NewManager(db *gorm.DB) *RepoManager { + return &RepoManager{ + db: db, + Schedule: NewScheduleDAO(db), + Task: NewTaskDAO(db), + Course: NewCourseDAO(db), + TaskClass: NewTaskClassDAO(db), + User: NewUserDAO(db), + } +} + +// Transaction 核心函数:开启一个带事务的“新管理器” +func (m *RepoManager) Transaction(ctx context.Context, fn func(txM *RepoManager) error) error { + return m.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 💡 关键:创建一个新的 RepoManager,里面的 Repo 全部注入这个 tx 句柄 + txM := &RepoManager{ + db: tx, + Schedule: m.Schedule.WithTx(tx), + Task: m.Task.WithTx(tx), + TaskClass: m.TaskClass.WithTx(tx), + Course: m.Course.WithTx(tx), + User: m.User.WithTx(tx), + } + return fn(txM) + }) +} diff --git a/backend/dao/course.go b/backend/dao/course.go index 94b2524..46cf308 100644 --- a/backend/dao/course.go +++ b/backend/dao/course.go @@ -18,6 +18,10 @@ func NewCourseDAO(db *gorm.DB) *CourseDAO { } } +func (r *CourseDAO) WithTx(tx *gorm.DB) *CourseDAO { + return &CourseDAO{db: tx} +} + func (dao *CourseDAO) AddUserCoursesIntoSchedule(ctx context.Context, courses []model.Schedule) error { if err := dao.db.WithContext(ctx).Create(&courses).Error; err != nil { return err diff --git a/backend/dao/schedule.go b/backend/dao/schedule.go index 6cb561e..f1a8a82 100644 --- a/backend/dao/schedule.go +++ b/backend/dao/schedule.go @@ -3,6 +3,7 @@ package dao import ( "context" "errors" + "fmt" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" @@ -20,8 +21,12 @@ func NewScheduleDAO(db *gorm.DB) *ScheduleDAO { } } -func (dao *ScheduleDAO) AddSchedules(schedules []model.Schedule) ([]int, error) { - if err := dao.db.Create(&schedules).Error; err != nil { +func (d *ScheduleDAO) WithTx(tx *gorm.DB) *ScheduleDAO { + return &ScheduleDAO{db: tx} +} + +func (d *ScheduleDAO) AddSchedules(schedules []model.Schedule) ([]int, error) { + if err := d.db.Create(&schedules).Error; err != nil { return nil, err } ids := make([]int, len(schedules)) @@ -31,9 +36,9 @@ func (dao *ScheduleDAO) AddSchedules(schedules []model.Schedule) ([]int, error) return ids, nil } -func (dao *ScheduleDAO) EmbedTaskIntoSchedule(startSection, endSection, dayOfWeek, week, userID, taskID int) error { +func (d *ScheduleDAO) EmbedTaskIntoSchedule(startSection, endSection, dayOfWeek, week, userID, taskID int) error { // 仅更新指定:用户/周/星期/节次区间 的记录,将 embedded_task_id 精准写入 taskID - res := dao.db. + res := d.db. Table("schedules"). Where("user_id = ? AND week = ? AND day_of_week = ? AND section BETWEEN ? AND ?", userID, week, dayOfWeek, startSection, endSection). Update("embedded_task_id", taskID) @@ -41,13 +46,13 @@ func (dao *ScheduleDAO) EmbedTaskIntoSchedule(startSection, endSection, dayOfWee return res.Error } -func (dao *ScheduleDAO) GetCourseUserIDByID(ctx context.Context, courseScheduleEventID int) (int, error) { +func (d *ScheduleDAO) GetCourseUserIDByID(ctx context.Context, courseScheduleEventID int) (int, error) { type row struct { UserID *int `gorm:"column:user_id"` } var r row - err := dao.db.WithContext(ctx). + err := d.db.WithContext(ctx). Table("schedule_events"). Select("user_id"). Where("id = ?", courseScheduleEventID). @@ -65,14 +70,14 @@ func (dao *ScheduleDAO) GetCourseUserIDByID(ctx context.Context, courseScheduleE } // IsCourseEmbeddedByOtherTaskBlock 判断课程在给定节次区间内是否已被其他任务块嵌入(用于业务限制) -func (dao *ScheduleDAO) IsCourseEmbeddedByOtherTaskBlock(ctx context.Context, courseID, startSection, endSection int) (bool, error) { +func (d *ScheduleDAO) IsCourseEmbeddedByOtherTaskBlock(ctx context.Context, courseID, startSection, endSection int) (bool, error) { // 若区间非法,视为不冲突 if startSection <= 0 || endSection <= 0 || startSection > endSection { return false, nil } var cnt int64 - err := dao.db.WithContext(ctx). + err := d.db.WithContext(ctx). Table("schedules"). Where("id = ?", courseID). Where("section BETWEEN ? AND ?", startSection, endSection). @@ -84,7 +89,7 @@ func (dao *ScheduleDAO) IsCourseEmbeddedByOtherTaskBlock(ctx context.Context, co return cnt > 0, nil } -func (dao *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, week, dayOfWeek int, sections []int) (bool, error) { +func (d *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, week, dayOfWeek int, sections []int) (bool, error) { // 无节次则视为无冲突 if len(sections) == 0 { return false, nil @@ -92,7 +97,7 @@ func (dao *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, wee // 统计同一用户、同一周、同一天、且节次有交集的排程数量 // 约定表字段:user_id, week, day_of_week, section var cnt int64 - err := dao.db.WithContext(ctx). + err := d.db.WithContext(ctx). Table("schedules"). Where("user_id = ? AND week = ? AND day_of_week = ?", userID, week, dayOfWeek). Where("section IN ?", sections). @@ -103,7 +108,7 @@ func (dao *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, wee return cnt > 0, nil } -func (dao *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEventID, week, dayOfWeek, startSection, endSection int) (bool, error) { +func (d *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEventID, week, dayOfWeek, startSection, endSection int) (bool, error) { // 区间非法直接不匹配 if startSection <= 0 || endSection <= 0 || startSection > endSection { return false, nil @@ -113,7 +118,7 @@ func (dao *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEve // 说明:此处按你当前表结构的用法(schedule\_events 存事件,schedules 存节次明细)来写: // schedules 里通过 schedule\_event\_id 关联到 schedule\_events.id var cnt int64 - err := dao.db.WithContext(ctx). + err := d.db.WithContext(ctx). Table("schedules"). Where("event_id = ?", courseScheduleEventID). Where("week = ? AND day_of_week = ?", week, dayOfWeek). @@ -127,9 +132,137 @@ func (dao *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEve return cnt == int64(endSection-startSection+1), nil } -func (dao *ScheduleDAO) AddScheduleEvent(scheduleEvent *model.ScheduleEvent) (int, error) { - if err := dao.db.Create(&scheduleEvent).Error; err != nil { +func (d *ScheduleDAO) AddScheduleEvent(scheduleEvent *model.ScheduleEvent) (int, error) { + if err := d.db.Create(&scheduleEvent).Error; err != nil { return 0, err } return scheduleEvent.ID, nil } + +// CheckScheduleConflict 检查给定的 Schedule 切片中是否存在课程的冲突(即同一用户、同一周、同一天、且节次有交集的记录,并且只管课程,不管其它任务类型) +func (d *ScheduleDAO) CheckScheduleConflict(ctx context.Context, schedules []model.Schedule) (bool, error) { + if len(schedules) == 0 { + return false, nil + } + + // 聚合:同一 user/week/day 的节次去重后一次性查库 + type key struct { + UserID int + Week int + DayOfWeek int + } + groups := make(map[key]map[int]struct{}) + + for _, s := range schedules { + // 基础字段不合法直接跳过(按不冲突处理) + if s.UserID <= 0 || s.Week <= 0 || s.DayOfWeek <= 0 || s.Section <= 0 { + continue + } + k := key{UserID: s.UserID, Week: s.Week, DayOfWeek: s.DayOfWeek} + if _, ok := groups[k]; !ok { + groups[k] = make(map[int]struct{}) + } + groups[k][s.Section] = struct{}{} + } + + for k, set := range groups { + if len(set) == 0 { + continue + } + + sections := make([]int, 0, len(set)) + for sec := range set { + sections = append(sections, sec) + } + + // 仅判断“课程(type=course)”是否冲突: + // schedules.event_id -> schedule_events.id,再用 schedule_events.type 过滤 + var cnt int64 + err := d.db.WithContext(ctx). + Table("schedules s"). + Joins("JOIN schedule_events e ON e.id = s.event_id"). + Where("s.user_id = ? AND s.week = ? AND s.day_of_week = ?", k.UserID, k.Week, k.DayOfWeek). + Where("s.section IN ?", sections). + Where("e.type = ?", "course"). + Count(&cnt).Error + if err != nil { + return false, err + } + if cnt > 0 { + return true, nil + } + } + + return false, nil +} + +func (d *ScheduleDAO) GetNonCourseScheduleConflicts(ctx context.Context, newSchedules []model.Schedule) ([]model.Schedule, error) { + if len(newSchedules) == 0 { + return nil, nil + } + + // 1. 构建指纹图:用于快速比对坐标 + userID := newSchedules[0].UserID + weeksMap := make(map[int]bool) + newSlotsFingerprints := make(map[string]bool) + + for _, s := range newSchedules { + weeksMap[s.Week] = true + key := fmt.Sprintf("%d-%d-%d", s.Week, s.DayOfWeek, s.Section) + newSlotsFingerprints[key] = true + } + + weeks := make([]int, 0, len(weeksMap)) + for w := range weeksMap { + weeks = append(weeks, w) + } + + // 2. 第一步:定义一个临时小结构体,精准捞取坐标和 EventID + type simpleSlot struct { + EventID int + Week int + DayOfWeek int + Section int + } + var candidates []simpleSlot + + // 💡 这里的逻辑:只查索引覆盖到的字段,速度极快 + err := d.db.WithContext(ctx). + Table("schedules"). + Select("schedules.event_id, schedules.week, schedules.day_of_week, schedules.section"). + Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id"). + Where("schedules.user_id = ? AND schedules.week IN ? AND schedule_events.type != ?", userID, weeks, "course"). + Scan(&candidates).Error + + if err != nil { + return nil, err + } + + // 3. 筛选出真正碰撞的 EventID + eventIDMap := make(map[int]bool) + for _, s := range candidates { + key := fmt.Sprintf("%d-%d-%d", s.Week, s.DayOfWeek, s.Section) + if newSlotsFingerprints[key] { + eventIDMap[s.EventID] = true + } + } + + if len(eventIDMap) == 0 { + return nil, nil + } + + // 4. 第二步:“抄全家”——根据碰撞到的 ID 捞出这些任务的所有原子槽位 + var ids []int + for id := range eventIDMap { + ids = append(ids, id) + } + + var fullConflicts []model.Schedule + // 💡 关键:这里必须 Preload("Event"),这样 DTO 才有名称显示 + err = d.db.WithContext(ctx). + Preload("Event"). + Where("event_id IN ?", ids). + Find(&fullConflicts).Error + + return fullConflicts, err +} diff --git a/backend/dao/task-class.go b/backend/dao/task-class.go index a18fbcd..9b0bad5 100644 --- a/backend/dao/task-class.go +++ b/backend/dao/task-class.go @@ -21,6 +21,12 @@ func NewTaskClassDAO(db *gorm.DB) *TaskClassDAO { } } +func (dao *TaskClassDAO) WithTx(tx *gorm.DB) *TaskClassDAO { + return &TaskClassDAO{ + db: tx, + } +} + // AddOrUpdateTaskClass 为指定用户添加/更新任务类(防越权:更新时限定 user_id) func (dao *TaskClassDAO) AddOrUpdateTaskClass(userID int, taskClass *model.TaskClass) (int, error) { // 不信任入参里的 UserID,强制使用当前登录用户 diff --git a/backend/dao/task.go b/backend/dao/task.go index 5ce2c9f..17f696a 100644 --- a/backend/dao/task.go +++ b/backend/dao/task.go @@ -19,6 +19,10 @@ func NewTaskDAO(db *gorm.DB) *TaskDAO { } } +func (r *TaskDAO) WithTx(tx *gorm.DB) *TaskDAO { + return &TaskDAO{db: tx} +} + // AddTask 为指定用户添加任务 func (dao *TaskDAO) AddTask(req *model.Task) (*model.Task, error) { if err := dao.db.Create(req).Error; err != nil { diff --git a/backend/dao/user.go b/backend/dao/user.go index 2bbe96e..8af172e 100644 --- a/backend/dao/user.go +++ b/backend/dao/user.go @@ -23,6 +23,10 @@ func NewUserDAO(db *gorm.DB) *UserDAO { } } +func (r *UserDAO) WithTx(tx *gorm.DB) *UserDAO { + return &UserDAO{db: tx} +} + // Create 创建新用户 // 插入新用户信息到数据库 func (dao *UserDAO) Create(username, phoneNumber, password string) (*model.User, error) { diff --git a/backend/model/schedule.go b/backend/model/schedule.go index c381e64..33e4fb0 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -19,6 +19,26 @@ type Schedule struct { Section int `gorm:"column:section;uniqueIndex:idx_user_slot_atomic,priority:4;not null;comment:原子化节次 (1-12)" json:"section"` EmbeddedTaskID *int `gorm:"column:embedded_task_id;comment:若为水课嵌入,记录具体的任务项ID" json:"embedded_task_id"` Status string `gorm:"column:status;type:enum('normal','interrupted');default:'normal';comment:状态: 正常/因故中断" json:"status"` + // 💡 必须加上这一行,告诉 GORM 如何关联元数据 + Event ScheduleEvent `gorm:"foreignKey:EventID" json:"event"` +} + +type ScheduleConflictDetail struct { + EventID int `json:"event_id"` + Name string `json:"name"` + Location string `json:"location"` + DayOfWeek int `json:"day_of_week"` + Week int `json:"week"` + Sections []int `json:"sections"` + StartSection int `json:"start_section"` + EndSection int `json:"end_section"` + Type string `json:"type"` + EmbeddedTasks []ScheduleEmbeddedTask `json:"embedded_tasks"` +} + +type ScheduleEmbeddedTask struct { + Section int `json:"section"` + TaskID int `json:"task_id"` } func (ScheduleEvent) TableName() string { return "schedule_events" } diff --git a/backend/respond/respond.go b/backend/respond/respond.go index 7c51b84..61f6b00 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -183,4 +183,9 @@ var ( //请求相关的响应 Status: "40028", Info: "course time not match", } + + InsertCourseTwice = Response{ //重复插入课程 + Status: "40029", + Info: "insert course twice", + } ) diff --git a/backend/service/course.go b/backend/service/course.go index 20e3daa..730c3af 100644 --- a/backend/service/course.go +++ b/backend/service/course.go @@ -2,7 +2,9 @@ package service import ( "context" + "strings" + "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" @@ -10,16 +12,34 @@ import ( type CourseService struct { // 伸出手:准备接住 DAO - dao *dao.CourseDAO + courseDAO *dao.CourseDAO + scheduleDAO *dao.ScheduleDAO } // NewCourseService 创建 CourseService 实例 -func NewCourseService(dao *dao.CourseDAO) *CourseService { +func NewCourseService(courseDAO *dao.CourseDAO, scheduleDAO *dao.ScheduleDAO) *CourseService { return &CourseService{ - dao: dao, + courseDAO: courseDAO, + scheduleDAO: scheduleDAO, } } +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + // 兼容常见 MySQL / PostgreSQL / SQLite 的报错关键字 + // 也可以进一步精确到你的索引名 idx_user_slot_atomic + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "duplicate entry") || + strings.Contains(msg, "unique constraint") || + strings.Contains(msg, "unique violation") || + strings.Contains(msg, "duplicate key") { + return true + } + return false +} + func CheckSingleCourse(req model.UserCheckCourseRequest) bool { for _, arrangement := range req.Arrangements { if arrangement.StartWeek > arrangement.EndWeek || @@ -33,14 +53,15 @@ func CheckSingleCourse(req model.UserCheckCourseRequest) bool { } // AddUserCourses 添加用户课程表 -func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImportCoursesRequest, userID int) error { +func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImportCoursesRequest, userID int) ([]model.ScheduleConflictDetail, error) { //1.先校验参数是否正确 for _, course := range req.Courses { result := CheckSingleCourse(course) if !result { - return respond.WrongCourseInfo + return nil, respond.WrongCourseInfo } } + //2.将前端传来的课程信息转换为 Schedule 和 ScheduleEvent 切片 var finalSchedules []model.Schedule var finalScheduleEvents []model.ScheduleEvent var pos []int @@ -82,9 +103,25 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor } } } - //TODO 冲突处理、重复检测...预计0.2.0版本之前完成 - //4.事务:插入两个表要么都成功,要么都回滚 - return ss.dao.Transaction(func(txDAO *dao.CourseDAO) error { + //3.先检测是否重复插入了课程(同一周、同一天、同一节已有课程) + exists, err := ss.scheduleDAO.CheckScheduleConflict(ctx, finalSchedules) + if err != nil { + return nil, err + } + if exists { + return nil, respond.InsertCourseTwice + } + //4.再检查是否和某些非课程的日程冲突(同一周、同一天、同一节已有非课程日程),并给出具体的冲突信息 + conflicts, err := ss.scheduleDAO.GetNonCourseScheduleConflicts(ctx, finalSchedules) + if err != nil { + return nil, err + } + if len(conflicts) > 0 { + ret := conv.SchedulesToScheduleConflictDetail(conflicts) + return ret, respond.ScheduleConflict + } + //5.事务:插入两个表要么都成功,要么都回滚 + err = ss.courseDAO.Transaction(func(txDAO *dao.CourseDAO) error { ids, err := txDAO.AddUserCoursesIntoScheduleEvents(ctx, finalScheduleEvents) if err != nil { return err @@ -98,4 +135,11 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor } return nil }) + if err != nil { + if isUniqueViolation(err) { + return nil, respond.InsertCourseTwice + } + return nil, err + } + return nil, nil } diff --git a/backend/service/task-class.go b/backend/service/task-class.go index 5dc46e7..11de787 100644 --- a/backend/service/task-class.go +++ b/backend/service/task-class.go @@ -17,13 +17,15 @@ type TaskClassService struct { taskClassRepo *dao.TaskClassDAO cacheRepo *dao.CacheDAO scheduleRepo *dao.ScheduleDAO + repoManager *dao.RepoManager // 统一管理多个 DAO 的事务 } -func NewTaskClassService(taskClassRepo *dao.TaskClassDAO, cacheRepo *dao.CacheDAO, scheduleRepo *dao.ScheduleDAO) *TaskClassService { +func NewTaskClassService(taskClassRepo *dao.TaskClassDAO, cacheRepo *dao.CacheDAO, scheduleRepo *dao.ScheduleDAO, manager *dao.RepoManager) *TaskClassService { return &TaskClassService{ taskClassRepo: taskClassRepo, cacheRepo: cacheRepo, scheduleRepo: scheduleRepo, + repoManager: manager, } } @@ -165,7 +167,11 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re } //4.否则构造Schedule模型 sections := make([]int, 0, req.EndSection-req.StartSection+1) - schedules, scheduleEvent := conv.UserInsertTaskItemRequestToModel(req, taskItem, taskID, userID, req.StartSection, req.EndSection) + schedules, scheduleEvent := conv.UserInsertTaskItemRequestToModel(req, taskItem, nil, userID, req.StartSection, req.EndSection) + //将节次区间转换为节次切片,方便后续检查冲突 + for section := req.StartSection; section <= req.EndSection; section++ { + sections = append(sections, section) + } //4.1 统一检查冲突(避免逐条查库) conflict, err := sv.scheduleRepo.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections) if err != nil { @@ -175,7 +181,7 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re return respond.ScheduleConflict } //5.写入数据库(事务) - if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { + /*if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { //5.1 先将任务块插入scheduleEvent表,获取生成的ID后再插入schedule表(因为schedule表有外键关联) id, err := sv.scheduleRepo.AddScheduleEvent(scheduleEvent) if err != nil { @@ -197,6 +203,34 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re return nil }); err != nil { return err + }*/ + // 5. 写入数据库(通过 RepoManager 统一管理事务) + // 这里的 sv.daoManager 是你在初始化 Service 时注入的全局 RepoManager 实例 + if err := sv.repoManager.Transaction(ctx, func(txM *dao.RepoManager) error { + // 5.1 使用事务中的 ScheduleRepo 插入 Event + // 💡 这里的 txM.Schedule 已经注入了事务句柄 + eventID, err := txM.Schedule.AddScheduleEvent(scheduleEvent) + if err != nil { + return err // 触发回滚 + } + // 5.2 关联 ID(纯内存操作,无需 tx) + for i := range schedules { + schedules[i].EventID = eventID + } + // 5.3 使用事务中的 ScheduleRepo 批量插入原子槽位 + // 💡 如果这里因为外键或唯一索引报错,5.1 的 Event 也会被撤回 + if _, err = txM.Schedule.AddSchedules(schedules); err != nil { + return err // 触发回滚 + } + // 5.4 使用事务中的 TaskRepo 更新任务状态 + // 💡 这里的 txM.Task 取代了你原来的 txDAO + if err := txM.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, taskID, taskItem.EmbeddedTime); err != nil { + return err // 触发回滚 + } + return nil + }); err != nil { + // 这里处理最终的错误返回,比如 respond.Error + return err } return nil } diff --git a/backend/service/task.go b/backend/service/task.go index 5767c82..6e85953 100644 --- a/backend/service/task.go +++ b/backend/service/task.go @@ -28,7 +28,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques if taskModel.Priority < 1 || taskModel.Priority >= 5 { return nil, respond.InvalidPriority } - //3. 调用 dao 层进行数据持久化 + //3. 调用 courseDAO 层进行数据持久化 createdTask, err := ts.dao.AddTask(taskModel) if err != nil { return nil, err @@ -39,7 +39,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques } func (ts *TaskService) GetUserTasks(ctx context.Context, userID int) ([]model.GetUserTaskResp, error) { - //1. 调用 dao 层获取数据 + //1. 调用 courseDAO 层获取数据 tasks, err := ts.dao.GetTasksByUserID(userID) if err != nil { return nil, err