Version: 0.1.2.dev.260207

feat: ⚠️ 批量导入课程接口支持冲突预检测与冲突提示

- 批量导入课程接口支持预先检测冲突
- 返回并展示具体发生冲突的课程信息 📚💥
- 补全此前规划的冲突提示功能(把大饼补上了 🍞)

refactor: 🧱 使用工作单元模式管理 dao 层事务

- 引入工作单元模式(Unit of Work)统一管理 dao 层
- 新建全局事务,使跨 repo 的 gorm 事务管理更加方便 🔁

fix: 🐛 修复将任务块添加进日程接口的多个问题

- 修复核心逻辑 bug(费了老大劲 😵‍💫)
- 补充并覆盖该接口的多种异常与错误场景测试 🧪
This commit is contained in:
LoveLosita
2026-02-07 22:08:13 +08:00
parent f4bea0576c
commit 75a44f2edd
15 changed files with 397 additions and 34 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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