Version: 0.1.2.dev.260207
feat: ⚠️ 批量导入课程接口支持冲突预检测与冲突提示 - 批量导入课程接口支持预先检测冲突 - 返回并展示具体发生冲突的课程信息 📚💥 - 补全此前规划的冲突提示功能(把大饼补上了 🍞) refactor: 🧱 使用工作单元模式管理 dao 层事务 - 引入工作单元模式(Unit of Work)统一管理 dao 层 - 新建全局事务,使跨 repo 的 gorm 事务管理更加方便 🔁 fix: 🐛 修复将任务块添加进日程接口的多个问题 - 修复核心逻辑 bug(费了老大劲 😵💫) - 补充并覆盖该接口的多种异常与错误场景测试 🧪
This commit is contained in:
@@ -56,11 +56,14 @@ func (sa *CourseHandler) AddUserCourses(c *gin.Context) {
|
|||||||
// 创建一个带 1 秒超时的上下文
|
// 创建一个带 1 秒超时的上下文
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)
|
||||||
defer cancel() // 记得释放资源
|
defer cancel() // 记得释放资源
|
||||||
err = sa.service.AddUserCourses(ctx, req, userIDInterface)
|
conflicts, err := sa.service.AddUserCourses(ctx, req, userIDInterface)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
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)
|
c.JSON(http.StatusBadRequest, err)
|
||||||
|
case errors.Is(err, respond.ScheduleConflict):
|
||||||
|
c.JSON(http.StatusConflict, respond.RespWithData(respond.ScheduleConflict, conflicts))
|
||||||
default:
|
default:
|
||||||
c.JSON(http.StatusInternalServerError, respond.InternalError(err))
|
c.JSON(http.StatusInternalServerError, respond.InternalError(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ func Start() {
|
|||||||
courseRepo := dao.NewCourseDAO(db)
|
courseRepo := dao.NewCourseDAO(db)
|
||||||
taskClassRepo := dao.NewTaskClassDAO(db)
|
taskClassRepo := dao.NewTaskClassDAO(db)
|
||||||
scheduleRepo := dao.NewScheduleDAO(db)
|
scheduleRepo := dao.NewScheduleDAO(db)
|
||||||
|
manager := dao.NewManager(db)
|
||||||
//service 层
|
//service 层
|
||||||
userService := service.NewUserService(userRepo, cacheRepo)
|
userService := service.NewUserService(userRepo, cacheRepo)
|
||||||
taskSv := service.NewTaskService(taskRepo)
|
taskSv := service.NewTaskService(taskRepo)
|
||||||
courseService := service.NewCourseService(courseRepo)
|
courseService := service.NewCourseService(courseRepo, scheduleRepo)
|
||||||
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo)
|
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
|
||||||
//api 层
|
//api 层
|
||||||
userApi := api.NewUserHandler(userService)
|
userApi := api.NewUserHandler(userService)
|
||||||
taskApi := api.NewTaskHandler(taskSv)
|
taskApi := api.NewTaskHandler(taskSv)
|
||||||
|
|||||||
61
backend/conv/schedule.go
Normal file
61
backend/conv/schedule.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -137,12 +137,12 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UserInsertTaskItemRequestToModel 用于将填入空闲时段日程的请求转换为 Schedule 模型
|
// 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
|
var schedules []model.Schedule
|
||||||
for section := startSection; section <= endSection; section++ {
|
for section := startSection; section <= endSection; section++ {
|
||||||
req1 := &model.Schedule{
|
req1 := &model.Schedule{
|
||||||
UserID: userID, // 由调用方填充
|
UserID: userID,
|
||||||
EmbeddedTaskID: &taskID,
|
EmbeddedTaskID: taskID,
|
||||||
Week: req.Week,
|
Week: req.Week,
|
||||||
DayOfWeek: req.DayOfWeek,
|
DayOfWeek: req.DayOfWeek,
|
||||||
Section: section,
|
Section: section,
|
||||||
|
|||||||
44
backend/dao/base.go
Normal file
44
backend/dao/base.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
func (dao *CourseDAO) AddUserCoursesIntoSchedule(ctx context.Context, courses []model.Schedule) error {
|
||||||
if err := dao.db.WithContext(ctx).Create(&courses).Error; err != nil {
|
if err := dao.db.WithContext(ctx).Create(&courses).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package dao
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
"github.com/LoveLosita/smartflow/backend/respond"
|
"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) {
|
func (d *ScheduleDAO) WithTx(tx *gorm.DB) *ScheduleDAO {
|
||||||
if err := dao.db.Create(&schedules).Error; err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
ids := make([]int, len(schedules))
|
ids := make([]int, len(schedules))
|
||||||
@@ -31,9 +36,9 @@ func (dao *ScheduleDAO) AddSchedules(schedules []model.Schedule) ([]int, error)
|
|||||||
return ids, nil
|
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
|
// 仅更新指定:用户/周/星期/节次区间 的记录,将 embedded_task_id 精准写入 taskID
|
||||||
res := dao.db.
|
res := d.db.
|
||||||
Table("schedules").
|
Table("schedules").
|
||||||
Where("user_id = ? AND week = ? AND day_of_week = ? AND section BETWEEN ? AND ?", userID, week, dayOfWeek, startSection, endSection).
|
Where("user_id = ? AND week = ? AND day_of_week = ? AND section BETWEEN ? AND ?", userID, week, dayOfWeek, startSection, endSection).
|
||||||
Update("embedded_task_id", taskID)
|
Update("embedded_task_id", taskID)
|
||||||
@@ -41,13 +46,13 @@ func (dao *ScheduleDAO) EmbedTaskIntoSchedule(startSection, endSection, dayOfWee
|
|||||||
return res.Error
|
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 {
|
type row struct {
|
||||||
UserID *int `gorm:"column:user_id"`
|
UserID *int `gorm:"column:user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var r row
|
var r row
|
||||||
err := dao.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Table("schedule_events").
|
Table("schedule_events").
|
||||||
Select("user_id").
|
Select("user_id").
|
||||||
Where("id = ?", courseScheduleEventID).
|
Where("id = ?", courseScheduleEventID).
|
||||||
@@ -65,14 +70,14 @@ func (dao *ScheduleDAO) GetCourseUserIDByID(ctx context.Context, courseScheduleE
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsCourseEmbeddedByOtherTaskBlock 判断课程在给定节次区间内是否已被其他任务块嵌入(用于业务限制)
|
// 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 {
|
if startSection <= 0 || endSection <= 0 || startSection > endSection {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var cnt int64
|
var cnt int64
|
||||||
err := dao.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Table("schedules").
|
Table("schedules").
|
||||||
Where("id = ?", courseID).
|
Where("id = ?", courseID).
|
||||||
Where("section BETWEEN ? AND ?", startSection, endSection).
|
Where("section BETWEEN ? AND ?", startSection, endSection).
|
||||||
@@ -84,7 +89,7 @@ func (dao *ScheduleDAO) IsCourseEmbeddedByOtherTaskBlock(ctx context.Context, co
|
|||||||
return cnt > 0, nil
|
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 {
|
if len(sections) == 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -92,7 +97,7 @@ func (dao *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, wee
|
|||||||
// 统计同一用户、同一周、同一天、且节次有交集的排程数量
|
// 统计同一用户、同一周、同一天、且节次有交集的排程数量
|
||||||
// 约定表字段:user_id, week, day_of_week, section
|
// 约定表字段:user_id, week, day_of_week, section
|
||||||
var cnt int64
|
var cnt int64
|
||||||
err := dao.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Table("schedules").
|
Table("schedules").
|
||||||
Where("user_id = ? AND week = ? AND day_of_week = ?", userID, week, dayOfWeek).
|
Where("user_id = ? AND week = ? AND day_of_week = ?", userID, week, dayOfWeek).
|
||||||
Where("section IN ?", sections).
|
Where("section IN ?", sections).
|
||||||
@@ -103,7 +108,7 @@ func (dao *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, wee
|
|||||||
return cnt > 0, nil
|
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 {
|
if startSection <= 0 || endSection <= 0 || startSection > endSection {
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -113,7 +118,7 @@ func (dao *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEve
|
|||||||
// 说明:此处按你当前表结构的用法(schedule\_events 存事件,schedules 存节次明细)来写:
|
// 说明:此处按你当前表结构的用法(schedule\_events 存事件,schedules 存节次明细)来写:
|
||||||
// schedules 里通过 schedule\_event\_id 关联到 schedule\_events.id
|
// schedules 里通过 schedule\_event\_id 关联到 schedule\_events.id
|
||||||
var cnt int64
|
var cnt int64
|
||||||
err := dao.db.WithContext(ctx).
|
err := d.db.WithContext(ctx).
|
||||||
Table("schedules").
|
Table("schedules").
|
||||||
Where("event_id = ?", courseScheduleEventID).
|
Where("event_id = ?", courseScheduleEventID).
|
||||||
Where("week = ? AND day_of_week = ?", week, dayOfWeek).
|
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
|
return cnt == int64(endSection-startSection+1), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dao *ScheduleDAO) AddScheduleEvent(scheduleEvent *model.ScheduleEvent) (int, error) {
|
func (d *ScheduleDAO) AddScheduleEvent(scheduleEvent *model.ScheduleEvent) (int, error) {
|
||||||
if err := dao.db.Create(&scheduleEvent).Error; err != nil {
|
if err := d.db.Create(&scheduleEvent).Error; err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return scheduleEvent.ID, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
// AddOrUpdateTaskClass 为指定用户添加/更新任务类(防越权:更新时限定 user_id)
|
||||||
func (dao *TaskClassDAO) AddOrUpdateTaskClass(userID int, taskClass *model.TaskClass) (int, error) {
|
func (dao *TaskClassDAO) AddOrUpdateTaskClass(userID int, taskClass *model.TaskClass) (int, error) {
|
||||||
// 不信任入参里的 UserID,强制使用当前登录用户
|
// 不信任入参里的 UserID,强制使用当前登录用户
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ func NewTaskDAO(db *gorm.DB) *TaskDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *TaskDAO) WithTx(tx *gorm.DB) *TaskDAO {
|
||||||
|
return &TaskDAO{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
// AddTask 为指定用户添加任务
|
// AddTask 为指定用户添加任务
|
||||||
func (dao *TaskDAO) AddTask(req *model.Task) (*model.Task, error) {
|
func (dao *TaskDAO) AddTask(req *model.Task) (*model.Task, error) {
|
||||||
if err := dao.db.Create(req).Error; err != nil {
|
if err := dao.db.Create(req).Error; err != nil {
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ func NewUserDAO(db *gorm.DB) *UserDAO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *UserDAO) WithTx(tx *gorm.DB) *UserDAO {
|
||||||
|
return &UserDAO{db: tx}
|
||||||
|
}
|
||||||
|
|
||||||
// Create 创建新用户
|
// Create 创建新用户
|
||||||
// 插入新用户信息到数据库
|
// 插入新用户信息到数据库
|
||||||
func (dao *UserDAO) Create(username, phoneNumber, password string) (*model.User, error) {
|
func (dao *UserDAO) Create(username, phoneNumber, password string) (*model.User, error) {
|
||||||
|
|||||||
@@ -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"`
|
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"`
|
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"`
|
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" }
|
func (ScheduleEvent) TableName() string { return "schedule_events" }
|
||||||
|
|||||||
@@ -183,4 +183,9 @@ var ( //请求相关的响应
|
|||||||
Status: "40028",
|
Status: "40028",
|
||||||
Info: "course time not match",
|
Info: "course time not match",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InsertCourseTwice = Response{ //重复插入课程
|
||||||
|
Status: "40029",
|
||||||
|
Info: "insert course twice",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/LoveLosita/smartflow/backend/conv"
|
||||||
"github.com/LoveLosita/smartflow/backend/dao"
|
"github.com/LoveLosita/smartflow/backend/dao"
|
||||||
"github.com/LoveLosita/smartflow/backend/model"
|
"github.com/LoveLosita/smartflow/backend/model"
|
||||||
"github.com/LoveLosita/smartflow/backend/respond"
|
"github.com/LoveLosita/smartflow/backend/respond"
|
||||||
@@ -10,16 +12,34 @@ import (
|
|||||||
|
|
||||||
type CourseService struct {
|
type CourseService struct {
|
||||||
// 伸出手:准备接住 DAO
|
// 伸出手:准备接住 DAO
|
||||||
dao *dao.CourseDAO
|
courseDAO *dao.CourseDAO
|
||||||
|
scheduleDAO *dao.ScheduleDAO
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCourseService 创建 CourseService 实例
|
// NewCourseService 创建 CourseService 实例
|
||||||
func NewCourseService(dao *dao.CourseDAO) *CourseService {
|
func NewCourseService(courseDAO *dao.CourseDAO, scheduleDAO *dao.ScheduleDAO) *CourseService {
|
||||||
return &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 {
|
func CheckSingleCourse(req model.UserCheckCourseRequest) bool {
|
||||||
for _, arrangement := range req.Arrangements {
|
for _, arrangement := range req.Arrangements {
|
||||||
if arrangement.StartWeek > arrangement.EndWeek ||
|
if arrangement.StartWeek > arrangement.EndWeek ||
|
||||||
@@ -33,14 +53,15 @@ func CheckSingleCourse(req model.UserCheckCourseRequest) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AddUserCourses 添加用户课程表
|
// 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.先校验参数是否正确
|
//1.先校验参数是否正确
|
||||||
for _, course := range req.Courses {
|
for _, course := range req.Courses {
|
||||||
result := CheckSingleCourse(course)
|
result := CheckSingleCourse(course)
|
||||||
if !result {
|
if !result {
|
||||||
return respond.WrongCourseInfo
|
return nil, respond.WrongCourseInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//2.将前端传来的课程信息转换为 Schedule 和 ScheduleEvent 切片
|
||||||
var finalSchedules []model.Schedule
|
var finalSchedules []model.Schedule
|
||||||
var finalScheduleEvents []model.ScheduleEvent
|
var finalScheduleEvents []model.ScheduleEvent
|
||||||
var pos []int
|
var pos []int
|
||||||
@@ -82,9 +103,25 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//TODO 冲突处理、重复检测...预计0.2.0版本之前完成
|
//3.先检测是否重复插入了课程(同一周、同一天、同一节已有课程)
|
||||||
//4.事务:插入两个表要么都成功,要么都回滚
|
exists, err := ss.scheduleDAO.CheckScheduleConflict(ctx, finalSchedules)
|
||||||
return ss.dao.Transaction(func(txDAO *dao.CourseDAO) error {
|
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)
|
ids, err := txDAO.AddUserCoursesIntoScheduleEvents(ctx, finalScheduleEvents)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -98,4 +135,11 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return nil, respond.InsertCourseTwice
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,15 @@ type TaskClassService struct {
|
|||||||
taskClassRepo *dao.TaskClassDAO
|
taskClassRepo *dao.TaskClassDAO
|
||||||
cacheRepo *dao.CacheDAO
|
cacheRepo *dao.CacheDAO
|
||||||
scheduleRepo *dao.ScheduleDAO
|
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{
|
return &TaskClassService{
|
||||||
taskClassRepo: taskClassRepo,
|
taskClassRepo: taskClassRepo,
|
||||||
cacheRepo: cacheRepo,
|
cacheRepo: cacheRepo,
|
||||||
scheduleRepo: scheduleRepo,
|
scheduleRepo: scheduleRepo,
|
||||||
|
repoManager: manager,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +167,11 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re
|
|||||||
}
|
}
|
||||||
//4.否则构造Schedule模型
|
//4.否则构造Schedule模型
|
||||||
sections := make([]int, 0, req.EndSection-req.StartSection+1)
|
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 统一检查冲突(避免逐条查库)
|
//4.1 统一检查冲突(避免逐条查库)
|
||||||
conflict, err := sv.scheduleRepo.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections)
|
conflict, err := sv.scheduleRepo.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -175,7 +181,7 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re
|
|||||||
return respond.ScheduleConflict
|
return respond.ScheduleConflict
|
||||||
}
|
}
|
||||||
//5.写入数据库(事务)
|
//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表有外键关联)
|
//5.1 先将任务块插入scheduleEvent表,获取生成的ID后再插入schedule表(因为schedule表有外键关联)
|
||||||
id, err := sv.scheduleRepo.AddScheduleEvent(scheduleEvent)
|
id, err := sv.scheduleRepo.AddScheduleEvent(scheduleEvent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,6 +203,34 @@ func (sv *TaskClassService) AddTaskClassItemIntoSchedule(ctx context.Context, re
|
|||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques
|
|||||||
if taskModel.Priority < 1 || taskModel.Priority >= 5 {
|
if taskModel.Priority < 1 || taskModel.Priority >= 5 {
|
||||||
return nil, respond.InvalidPriority
|
return nil, respond.InvalidPriority
|
||||||
}
|
}
|
||||||
//3. 调用 dao 层进行数据持久化
|
//3. 调用 courseDAO 层进行数据持久化
|
||||||
createdTask, err := ts.dao.AddTask(taskModel)
|
createdTask, err := ts.dao.AddTask(taskModel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (ts *TaskService) GetUserTasks(ctx context.Context, userID int) ([]model.GetUserTaskResp, error) {
|
||||||
//1. 调用 dao 层获取数据
|
//1. 调用 courseDAO 层获取数据
|
||||||
tasks, err := ts.dao.GetTasksByUserID(userID)
|
tasks, err := ts.dao.GetTasksByUserID(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user