From f934668838144a7769e0973555a9796a9c9ba149 Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Mon, 23 Feb 2026 21:49:46 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.3.6.dev.260223=20feat:=20?= =?UTF-8?q?=F0=9F=9A=80=20=E6=96=B0=E5=A2=9E=E6=99=BA=E8=83=BD=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E6=97=A5=E7=A8=8B=E6=8E=A5=E5=8F=A3=E4=B8=8E=E7=AE=97?= =?UTF-8?q?=E6=B3=95=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增智能编排日程接口,实现自动生成周维度课程安排 * 抽离核心算法至 `Logic` 包,统一存放调度与排课相关算法逻辑,优化项目结构分层 * 大多数用例测试通过,当前存在少量边界用例下“排课时间是否充足”的误判问题 * 返回的周视图数据在极端场景下存在数量偏差,待进一步完善边界控制 fix: 🐛 修复批量导入课程接口 500 错误 * 修复批量导入课程接口中未在 `event` 结构体填写时间字段的问题 * 解决因时间字段为空导致的服务端 500 错误,保证数据完整性 refactor: ♻️ 新增入参校验逻辑保障调度稳定性 * 在添加任务类时新增入参校验逻辑 * 避免非法数据进入调度流程,确保自动编排日程接口执行稳定 docs: 📚 更新 README 智能编排算法说明 * 补充智能编排日程算法的设计思路与实现说明 undo: ⚠️ 追加导入课程后缓存未自动失效 * 追加导入课程后未自动删除对应周安排缓存,存在数据不一致风险 * 当前未能稳定复现,计划后续定位缓存失效时序与触发条件问题 --- README.md | 57 ++++++ backend/api/schedule.go | 23 +++ backend/config.yaml | 2 +- backend/conv/schedule.go | 156 +++++++++++++++++ backend/conv/task-class.go | 11 +- backend/conv/time.go | 28 ++- backend/dao/course.go | 2 +- backend/dao/schedule.go | 16 ++ backend/logic/smart_planning.go | 300 ++++++++++++++++++++++++++++++++ backend/model/schedule.go | 3 +- backend/model/task-class.go | 37 +++- backend/respond/respond.go | 20 +++ backend/routers/routers.go | 1 + backend/service/course.go | 6 + backend/service/schedule.go | 29 +++ backend/service/task-class.go | 23 ++- 16 files changed, 703 insertions(+), 11 deletions(-) create mode 100644 backend/logic/smart_planning.go diff --git a/README.md b/README.md index 55464d2..ab31c51 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,64 @@ PS:截至v0.3.3。其中黑色箭头为请求数据链路,绿色箭头为返 ### 5.3.1 智能排课算法 +本系统采用 **“原子化时间网格(Atomic TimeGrid)”** 架构,实现了针对大学生复杂课表环境的智能任务填充。算法核心分为 **“沙盘模拟”、“边界感知探测”** 与 **“逻辑位移步进”** 三大模块。 +**1. 原子化时间沙盘 (Grid Sandboxing)** + +算法首先将物理时间窗口(StartDate 到 EndDate)抽象为一个三维矩阵 $Grid[Week][Day][Section]$。 + +- **多维状态标记**:每个格子(Slot)是携带 `Status` 和 `EventID` 的 `slotNode` 节点。 +- **优先级注水(Hydration)**: + 1. **Blocked(屏蔽区)**:根据用户配置的 `ExcludedSlots` 强制锁定,优先级最高。 + 2. **Filler(嵌入区)**:识别“水课”,标记为可利用资源。 + 3. **Occupied(占用区)**:映射既有硬核课程,确保调度不产生物理冲突。 + +**2. 边界感知探测 (Boundary-Sensing Detection)** + +为了解决“任务块跨课分身”的 Bug,算法引入了 **EventID 校验机制**。 + +- **容器自适应长度**:当算法探测到一个 `Filler` 槽位时,会向后贪心扫描,只有当相邻槽位的 `EventID` 相同且同为 `Filler` 时,才允许任务块拉伸。 +- **逻辑闭环**:这保证了任务块(TaskItem)要么完美嵌入单门水课,要么占据空地,绝不会出现一个任务横跨两门不同课程的情况。 + +**3. 稳扎稳打:逻辑位移步进 (Logical-Offset Skipping)** + +在 `Steady`(稳扎稳打)模式下,为了实现负载均衡,算法弃用了传统的“物理时间跳跃”,改用 **“逻辑坑位跳跃”**。 + +$$Gap = \frac{TotalAvailableSlots - (TaskCount \times 2)}{TaskCount + 1}$$ + +- **物理跳跃(旧版/错误)**:直接 $Time + Gap$,容易因遇到屏蔽时段或硬核课而导致游标溢出,从而“吞掉”后续任务。 +- **逻辑跳跃(现行/优化)**:调用 `skipAvailableSlots` 函数,在 Grid 中沿时间轴向后数出 $Gap$ 个**真正可用**的格子作为下一个起点。 +- **价值**:确保了在有限的 **2C4G** 服务器资源下,任务能像“等距列队”一样均匀分布在学期空隙中。 + +------ + +**🛠️ 算法运行流程** + +1. **Build**:调用 `buildTimeGrid`,将数据库的离散 `Schedules` 映射为内存状态网格。 +2. **Count**:统计当前窗口内所有 `Free` 与 `Filler` 的原子位总数。 +3. **Allocate**: + - 通过 `FindNextAvailable` 锁定首个合法坑位。 + - 进行 **容器探测** 决定任务块长度。 + - 执行 `skipAvailableSlots` 寻找下一个负载均衡点。 +4. **Preview**:输出 DTO 到前端,标记 `status: "suggested"` 供用户预览高亮。 + +------ + +**⚡ 性能表现 (Optimization)** + +- **时间复杂度**:$O(W \times D \times S)$,其中 $W$ 为任务类跨度周数。在处理典型的 16 周排程时,计算量仅在数千次操作级别,单机响应达毫秒级。 +- **空间复杂度**:由于采用了按需创建周 Map 的策略,内存占用随任务跨度动态伸缩,极大地减轻了重庆邮电大学校园服务器环境下的 GC 压力。 + +------ + +**数据回填** + +在执行完上述算法后,将任务块分成两类数据: + +1. 需要新建`ScheduleEvent`的,插入纯空闲时段的数据; +2. 直接嵌入现有课程中的任务块; + +然后分别调用不同的业务逻辑,开启大事务,批量插入,使得只需要连接2次数据库,并且若插入出错,支持批量回滚,不会存在任何脏数据。 ## 5.4 Agent范式实现细节 diff --git a/backend/api/schedule.go b/backend/api/schedule.go index 99dce4a..d203ded 100644 --- a/backend/api/schedule.go +++ b/backend/api/schedule.go @@ -143,3 +143,26 @@ func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) { //4.返回撤销成功的响应给前端 c.JSON(http.StatusOK, respond.Ok) } + +func (s *ScheduleAPI) SmartPlanning(c *gin.Context) { + // 1. 从请求上下文中获取用户ID + userID := c.GetInt("user_id") + // 2. 从请求体中获取智能规划的参数 + taskClassID := c.Query("task_class_id") + intTaskClassID, err := strconv.Atoi(taskClassID) + if err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + return + } + //3.调用服务层方法进行智能规划 + /*ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second)*/ + ctx := context.Background() + /*defer cancel() // 记得释放资源*/ + res, err := s.scheduleService.SmartPlanning(ctx, userID, intTaskClassID) + if err != nil { + respond.DealWithError(c, err) + return + } + //4.返回智能规划成功的响应给前端 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, res)) +} diff --git a/backend/config.yaml b/backend/config.yaml index 1833183..30eb5d2 100644 --- a/backend/config.yaml +++ b/backend/config.yaml @@ -35,5 +35,5 @@ redis: time: zone: "Asia/Shanghai" semesterStartDate: "2026-03-02" #学期开始日期,一定要设定为周一,以便于计算周数 - semesterEndDate: "2026-06-30" #学期结束日期,一定要设定为周日,确保最后一周完整 + semesterEndDate: "2026-07-19" #学期结束日期,一定要设定为周日,确保最后一周完整 diff --git a/backend/conv/schedule.go b/backend/conv/schedule.go index 49f0fdb..0cadcc3 100644 --- a/backend/conv/schedule.go +++ b/backend/conv/schedule.go @@ -164,6 +164,12 @@ func SchedulesToUserTodaySchedule(schedules []model.Schedule) []model.UserTodayS } func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSchedule { + if len(schedules) == 0 { + return &model.UserWeekSchedule{ + Week: 0, + Events: []model.WeeklyEventBrief{}, + } + } // 1. 初始化返回结构 (默认取第一条数据的周次) weekDTO := &model.UserWeekSchedule{ Week: schedules[0].Week, @@ -322,3 +328,153 @@ func SchedulesToUserOngoingSchedule(schedules []model.Schedule) *model.OngoingSc EndTime: ongoing.Event.EndTime, } } + +// 这里我们使用一个临时的内部结构来兼容“实日程”和“虚计划” +type slotInfo struct { + schedule *model.Schedule + plan *model.TaskClassItem +} + +func PlanningResultToUserWeekSchedules(userSchedule []model.Schedule, plans []model.TaskClassItem) []model.UserWeekSchedule { + // 1. 周次范围探测与数据分桶 (保持高效的 O(N) 复杂度) + minW, maxW := 25, 1 + weekMap := make(map[int][]model.Schedule) + for _, s := range userSchedule { + if s.Week < minW { + minW = s.Week + } + if s.Week > maxW { + maxW = s.Week + } + weekMap[s.Week] = append(weekMap[s.Week], s) + } + + planMap := make(map[int][]model.TaskClassItem) + for _, p := range plans { + if p.EmbeddedTime == nil { + continue + } + w := p.EmbeddedTime.Week + if w < minW { + minW = w + } + if w > maxW { + maxW = w + } + planMap[w] = append(planMap[w], p) + } + + var results []model.UserWeekSchedule + + for w := minW; w <= maxW; w++ { + // 构建当前周的逻辑网格 + indexMap := make(map[int]map[int]slotInfo) + for d := 1; d <= 7; d++ { + indexMap[d] = make(map[int]slotInfo) + } + + for _, s := range weekMap[w] { + indexMap[s.DayOfWeek][s.Section] = slotInfo{schedule: &s} + } + for _, p := range planMap[w] { + for sec := p.EmbeddedTime.SectionFrom; sec <= p.EmbeddedTime.SectionTo; sec++ { + info := indexMap[p.EmbeddedTime.DayOfWeek][sec] + info.plan = &p + indexMap[p.EmbeddedTime.DayOfWeek][sec] = info + } + } + + weekDTO := &model.UserWeekSchedule{Week: w, Events: []model.WeeklyEventBrief{}} + + for day := 1; day <= 7; day++ { + order := 1 + for curr := 1; curr <= 12; { + slot := indexMap[day][curr] + + if slot.schedule != nil || slot.plan != nil { + end := curr + // 🚀 修复逻辑 A:精准探测合并边界 + for next := curr + 1; next <= 12; next++ { + nextSlot := indexMap[day][next] + isSame := false + + if slot.schedule != nil && nextSlot.schedule != nil { + // 场景:都是课,且是同一门课 + isSame = slot.schedule.EventID == nextSlot.schedule.EventID + } else if slot.schedule == nil && nextSlot.schedule == nil && slot.plan != nil && nextSlot.plan != nil { + // 场景:都是新排任务,且是同一个 TaskItem (修复了之前会合并不同任务的 Bug) + isSame = slot.plan.ID == nextSlot.plan.ID + } + + if isSame { + end = next + } else { + break + } + } + + // 🚀 修复逻辑 B:直接计算 span 并传值,消除重复计算 + span := end - curr + 1 + brief := buildBrief(slot, day, curr, end, span, order) + + weekDTO.Events = append(weekDTO.Events, brief) + curr = end + 1 + order++ + } else { + // 场景 B:留空处理 (逻辑保持原子化) + emptyEnd := curr + if curr%2 != 0 && curr < 12 { + if next := indexMap[day][curr+1]; next.schedule == nil && next.plan == nil { + emptyEnd = curr + 1 + } + } + weekDTO.Events = append(weekDTO.Events, model.WeeklyEventBrief{ + Name: "无课", Type: "empty", DayOfWeek: day, Order: order, + StartTime: sectionTimeMap[curr][0], EndTime: sectionTimeMap[emptyEnd][1], + Span: emptyEnd - curr + 1, + }) + curr = emptyEnd + 1 + order++ + } + } + } + results = append(results, *weekDTO) + } + return results +} + +func buildBrief(slot slotInfo, day, start, end, span, order int) model.WeeklyEventBrief { + brief := model.WeeklyEventBrief{ + DayOfWeek: day, + Order: order, + StartTime: sectionTimeMap[start][0], + EndTime: sectionTimeMap[end][1], + Span: span, + Status: "normal", // 默认设为正常状态 + } + + if slot.schedule != nil { + // 场景 A:它是数据库里原有的课 (实日程) + brief.ID = slot.schedule.EventID + brief.Name = slot.schedule.Event.Name + brief.Location = *slot.schedule.Event.Location + brief.Type = slot.schedule.Event.Type + + // 如果这节课里被算法“塞”进了一个计划任务 + if slot.plan != nil { + brief.Status = "suggested" // 标记为建议状态,前端据此高亮整块 + brief.EmbeddedTaskInfo = model.TaskBrief{ + ID: slot.plan.ID, + Name: *slot.plan.Content, + Type: "task", + } + } + } else if slot.plan != nil { + // 场景 B:它是算法在空地新建的任务块 (虚日程) + brief.Name = *slot.plan.Content + brief.Type = "task" + brief.Status = "suggested" // 标记为建议状态 + } + + return brief +} diff --git a/backend/conv/task-class.go b/backend/conv/task-class.go index 31176a2..7046e19 100644 --- a/backend/conv/task-class.go +++ b/backend/conv/task-class.go @@ -1,7 +1,6 @@ package conv import ( - "encoding/json" "errors" "time" @@ -43,7 +42,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i taskClass.TotalSlots = &req.Config.TotalSlots taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse taskClass.Strategy = &req.Config.Strategy - //处理 ExcludedSlots 切片为 JSON 字符串 + /*//处理 ExcludedSlots 切片为 JSON 字符串 if len(req.Config.ExcludedSlots) > 0 { //转换为 JSON 字符串 excludedSlotsJSON := "[" @@ -58,7 +57,8 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i } else { emptyJSON := "[]" taskClass.ExcludedSlots = &emptyJSON - } + }*/ + taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int //3.开始构建 items var items []model.TaskClassItem for _, itemReq := range req.Items { @@ -114,14 +114,15 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model. AllowFillerCourse: safeBool(taskClass.AllowFillerCourse), Strategy: safeStr(taskClass.Strategy), } - // 3. 处理 ExcludedSlots JSON 字符串 -> []int + /*// 3. 处理 ExcludedSlots JSON 字符串 -> []int if taskClass.ExcludedSlots != nil && *taskClass.ExcludedSlots != "" { var excluded []int // 直接使用标准反序列化,比手动处理 rune 字符要健壮得多 if err := json.Unmarshal([]byte(*taskClass.ExcludedSlots), &excluded); err == nil { req.Config.ExcludedSlots = excluded } - } + }*/ + req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int // 4. 映射子项信息 (Items Section) // 此时 items 已经通过 Preload 加载到了 taskClass.Items 中 req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items)) diff --git a/backend/conv/time.go b/backend/conv/time.go index 45d7bb3..76ed0e3 100644 --- a/backend/conv/time.go +++ b/backend/conv/time.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/viper" ) -// DateFormat 此处定义基准学期的开始和结束日期 +// DateFormat 此处定义一个全局常量,确保在整个代码中使用统一的日期格式解析和格式化 const DateFormat = "2006-01-02" // RealDateToRelativeDate 将绝对日期转换为相对日期(格式: "week-day") @@ -121,3 +121,29 @@ func RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection int) (time return startTime, endTime, nil } + +func CalculateFirstDayOfWeek(date time.Time) time.Time { + // 计算当前日期是周几(0-6,0表示周日) + weekday := int(date.Weekday()) + if weekday == 0 { + weekday = 7 // 将周日调整为7,方便计算 + } + // 计算距离周一的天数偏移 + offset := weekday - 1 + // 计算本周一的日期 + firstDayOfWeek := date.AddDate(0, 0, -offset) + return firstDayOfWeek +} + +func CalculateLastDayOfWeek(date time.Time) time.Time { + // 计算当前日期是周几(0-6,0表示周日) + weekday := int(date.Weekday()) + if weekday == 0 { + weekday = 7 // 将周日调整为7,方便计算 + } + // 计算距离周日的天数偏移 + offset := 7 - weekday + // 计算本周日的日期 + lastDayOfWeek := date.AddDate(0, 0, offset) + return lastDayOfWeek +} diff --git a/backend/dao/course.go b/backend/dao/course.go index d304926..30782b5 100644 --- a/backend/dao/course.go +++ b/backend/dao/course.go @@ -35,7 +35,7 @@ func (r *CourseDAO) AddUserCoursesIntoScheduleEvents(ctx context.Context, events } ids := make([]int, 0, len(events)) for i := range events { - ids = append(ids, int(events[i].ID)) + ids = append(ids, events[i].ID) } return ids, nil } diff --git a/backend/dao/schedule.go b/backend/dao/schedule.go index 14c60f4..e75ac00 100644 --- a/backend/dao/schedule.go +++ b/backend/dao/schedule.go @@ -557,3 +557,19 @@ func (d *ScheduleDAO) GetRelIDByScheduleEventID(ctx context.Context, eventID int } return *r.RelID, nil } + +func (d *ScheduleDAO) GetUserSchedulesByTimeRange(ctx context.Context, userID int, startTime, endTime time.Time) ([]model.Schedule, error) { + var schedules []model.Schedule + err := d.db.WithContext(ctx). + Preload("Event"). + Preload("EmbeddedTask"). + Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id"). + Where("schedules.user_id = ? AND schedule_events.start_time >= ? AND schedule_events.end_time <= ?", + userID, startTime, endTime). + Order("schedule_events.start_time ASC"). // 命中索引 + Find(&schedules).Error + if err != nil { + return nil, err + } + return schedules, nil +} diff --git a/backend/logic/smart_planning.go b/backend/logic/smart_planning.go new file mode 100644 index 0000000..d44dde5 --- /dev/null +++ b/backend/logic/smart_planning.go @@ -0,0 +1,300 @@ +package logic + +import ( + "fmt" + + "github.com/LoveLosita/smartflow/backend/conv" + "github.com/LoveLosita/smartflow/backend/model" + "github.com/LoveLosita/smartflow/backend/respond" +) + +type slotStatus int + +const ( + Free slotStatus = iota // 0: 纯空闲 + Occupied // 1: 已有课/任务,不可动 + Blocked // 2: 用户屏蔽时段 + Filler // 3: 水课,允许嵌入 +) + +type slotNode struct { + Status slotStatus + EventID uint // 🚀 关键:记录课程 ID,用于识别水课边界 +} + +type grid struct { + data map[int]map[int][13]slotNode + startWeek int + startDay int // 🚀 新增:精确的开始星期 + endWeek int + endDay int // 🚀 新增:精确的结束星期 +} + +func (g *grid) getNode(w, d, s int) slotNode { + if dayMap, ok := g.data[w]; ok { + return dayMap[d][s] + } + return slotNode{Status: Free, EventID: 0} +} + +func (g *grid) setNode(w, d, s int, node slotNode) { + if _, ok := g.data[w]; !ok { + g.data[w] = make(map[int][13]slotNode) + } + dayData := g.data[w][d] + dayData[s] = node + g.data[w][d] = dayData +} + +// 检查是否可用 (Free 或 Filler 且不在 Blocked 时段内) +func (g *grid) isAvailable(w, d, s int) bool { + node := g.getNode(w, d, s) + return node.Status == Free || node.Status == Filler +} + +// countAvailableSlots 统计指定周次范围内所有可用的原子节次总数 +func (g *grid) countAvailableSlots(startW, startD, startS int) int { + count := 0 + for w := startW; w <= g.endWeek; w++ { + dayMap, hasData := g.data[w] + for d := 1; d <= 7; d++ { + // 🚀 头部裁剪:过滤开始日期前的天数 + if w == startW && d < startD { + continue + } + // 🚀 尾部裁剪:过滤结束日期后的天数 + if w == g.endWeek && d > g.endDay { + break + } + + var dayData [13]slotNode + if hasData { + dayData = dayMap[d] + } + + for s := 1; s <= 12; s++ { + // 🚀 头部裁剪:过滤开始节次前的槽位 + if w == startW && d == startD && s < startS { + continue + } + // 🚀 尾部裁剪:过滤结束节次后的槽位 + if w == g.endWeek && d == g.endDay { + break + } + + if dayData[s].Status == Free || dayData[s].Status == Filler { + count++ + } + } + } + } + return count +} + +// FindNextAvailable 从当前时间点开始,按周、天、节次顺序查找下一个可用格子 +func (g *grid) FindNextAvailable(currW, currD, currS int) (int, int, int) { + // 基础越界检查 + if currW > g.endWeek || (currW == g.endWeek && currD > g.endDay) { + return -1, -1, -1 + } + + for w := currW; w <= g.endWeek; w++ { + dayMap, hasData := g.data[w] + for d := 1; d <= 7; d++ { + if w == currW && d < currD { + continue + } + if w == g.endWeek && d > g.endDay { + break + } // 🚀 守住结束天 + + var dayData [13]slotNode + if hasData { + dayData = dayMap[d] + } + + for s := 1; s <= 12; s++ { + if w == currW && d == currD && s < currS { + continue + } + if w == g.endWeek && d == g.endDay { + break + } // 🚀 守住结束节 + + if dayData[s].Status == Free || dayData[s].Status == Filler { + return w, d, s + } + } + } + } + return -1, -1, -1 +} + +func SmartPlanningMainLogic(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.UserWeekSchedule, error) { + //1.先构建时间格子 + g := buildTimeGrid(schedules, taskClass) + //2.根据时间格子和排课策略计算每个任务块的具体安排时间 + allocatedItems, err := computeAllocation(g, taskClass.Items, *taskClass.Strategy) + if err != nil { + return nil, err + } + //3.把这些时间通过DTO函数回填到涉及周的 UserWeekSchedule 结构中,供前端展示 + return conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems), nil +} + +// buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课 + +func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid { + // 🚀 核心修正:获取精确的起始坐标 + startW, startD, _ := conv.RealDateToRelativeDate(taskClass.StartDate.Format(conv.DateFormat)) + endW, _, _ := conv.RealDateToRelativeDate(taskClass.EndDate.Format(conv.DateFormat)) + + g := &grid{ + data: make(map[int]map[int][13]slotNode), + startWeek: startW, + startDay: startD, + endWeek: endW, + } + + for _, blockIdx := range taskClass.ExcludedSlots { + sFrom, sTo := (blockIdx-1)*2+1, blockIdx*2 + for w := startW; w <= endW; w++ { + for d := 1; d <= 7; d++ { + for s := sFrom; s <= sTo; s++ { + g.setNode(w, d, s, slotNode{Status: Blocked}) + } + } + } + } + + // 映射日程 (尊重 Blocked 且只处理范围内的数据) + for _, s := range schedules { + if s.Week >= startW && s.Week <= endW { + if g.getNode(s.Week, s.DayOfWeek, s.Section).Status == Blocked { + continue + } + status := Occupied + if *taskClass.AllowFillerCourse && s.Event.CanBeEmbedded { + status = Filler + } + g.setNode(s.Week, s.DayOfWeek, s.Section, slotNode{Status: status, EventID: uint(s.EventID)}) + } + } + return g +} + +// computeAllocation 是核心函数,负责根据当前的时间格子状态和排课策略,计算出每个任务块的具体安排时间 +func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([]model.TaskClassItem, error) { + if len(items) == 0 { + return items, nil + } + + // 🚀 核心修正 1:获取真正的开始坐标(周、天、节) + // 这里假设你已经通过 conv 把 StartDate 换成了 w1, d1, s1 + startW := g.startWeek + startD := 1 // 建议从 conv 传入具体的 DayOfWeek + startS := 1 + + // 1. 获取可用资源总量 + totalAvailable := g.countAvailableSlots(g.startWeek, 1, 1) + // 假设每个任务块至少占用 2 个原子槽位 + totalRequired := len(items) * 2 + + // 🚀 核心改进:容量预判 + if totalAvailable < totalRequired { + // 如果连最基本的坑位都不够,直接报错,不进行任何编排 + return nil, respond.TimeNotEnoughForAutoScheduling + } + + // 🚀 核心修正 2:步长改为“逻辑间隔”,不再是物理跳跃 + // gap 表示:每两个任务之间,我们要故意空出多少个“可用位” + gap := 0 + if strategy == "steady" && totalAvailable > totalRequired { + gap = (totalAvailable - totalRequired) / (len(items) + 1) + } + + currW, currD, currS := startW, startD, startS + lastPlacedIndex := -1 + for i := range items { + w, d, s := g.FindNextAvailable(currW, currD, currS) + if w == -1 || w > g.endWeek { + break + } + + node := g.getNode(w, d, s) + slotLen := 2 + if node.Status == Filler { + slotLen = 1 + currID := node.EventID + for checkS := s + 1; checkS <= 12; checkS++ { + if next := g.getNode(w, d, checkS); next.Status == Filler && next.EventID == currID { + slotLen++ + } else { + break + } + } + } + + endS := s + slotLen - 1 + items[i].EmbeddedTime = &model.TargetTime{ + SectionFrom: s, SectionTo: endS, + Week: w, DayOfWeek: d, + } + + for sec := s; sec <= endS; sec++ { + g.setNode(w, d, sec, slotNode{Status: Occupied}) + } + + // 🚀 核心修正 3:基于“可用位”推进指针,而非物理索引 + // 我们要在 grid 中向后数出 gap 个可用位置,作为下一个任务的起点 + currW, currD, currS = g.skipAvailableSlots(w, d, endS, gap) + + lastPlacedIndex = i // 记录最后一个成功安放的任务索引 + } + // 🚀 核心改进:结果完整性校验 + if lastPlacedIndex < len(items)-1 { + return nil, fmt.Errorf("排程中断:由于时间片碎片化,仅成功安排了 %d/%d 个任务块,请尝试扩充时间范围或删减屏蔽位", lastPlacedIndex+1, len(items)) + } + + return items, nil +} + +// 辅助函数:向后跳过指定数量的可用坑位 +func (g *grid) skipAvailableSlots(w, d, s, skipCount int) (int, int, int) { + if skipCount <= 0 { + // 即使 gap 为 0,也要至少移到下一节 + s++ + if s > 12 { + s = 1 + d++ + if d > 7 { + d = 1 + w++ + } + } + return w, d, s + } + + found := 0 + currW, currD, currS := w, d, s+1 + for currW <= g.endWeek { + if currS > 12 { + currS = 1 + currD++ + if currD > 7 { + currD = 1 + currW++ + } + continue + } + + if g.isAvailable(currW, currD, currS) { + found++ + if found > skipCount { + return currW, currD, currS + } + } + currS++ + } + return currW, currD, currS +} diff --git a/backend/model/schedule.go b/backend/model/schedule.go index 8628607..19f9556 100644 --- a/backend/model/schedule.go +++ b/backend/model/schedule.go @@ -24,7 +24,7 @@ type Schedule struct { 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"` + Event *ScheduleEvent `gorm:"foreignKey:EventID" json:"event"` EmbeddedTask *TaskClassItem `gorm:"foreignKey:EmbeddedTaskID" json:"embedded_task"` } @@ -87,6 +87,7 @@ type WeeklyEventBrief struct { Location string `json:"location"` Type string `json:"type"` Span int `json:"span"` // 跨越的节数,给前端用来渲染宽度/高度 + Status string `json:"status"` EmbeddedTaskInfo TaskBrief `json:"embedded_task_info,omitempty"` } diff --git a/backend/model/task-class.go b/backend/model/task-class.go index 5e480a5..cd0d95e 100644 --- a/backend/model/task-class.go +++ b/backend/model/task-class.go @@ -21,10 +21,45 @@ type TaskClass struct { TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` - ExcludedSlots *string `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` + ExcludedSlots IntSlice `gorm:"column:excluded_slots;type:json;comment:不想要的时段切片"` Items []TaskClassItem `gorm:"foreignKey:CategoryID;references:ID"` // 一对多关联:一个 TaskClass 有多个 TaskClassItem } +// IntSlice 用于把 []int 以 JSON 形式存入/读出数据库 json 字段 +type IntSlice []int + +func (s IntSlice) Value() (driver.Value, error) { + // nil -> NULL;空切片 -> "[]" + if s == nil { + return nil, nil + } + return json.Marshal([]int(s)) +} + +func (s *IntSlice) Scan(value any) error { + if value == nil { + *s = nil + return nil + } + + var data []byte + switch v := value.(type) { + case []byte: + data = v + case string: + data = []byte(v) + default: + return fmt.Errorf("IntSlice: 不支持的扫描类型: %T", value) + } + + var out []int + if err := json.Unmarshal(data, &out); err != nil { + return err + } + *s = IntSlice(out) + return nil +} + // TaskClassItem 用于和数据库中的 task_items 表进行映射 type TaskClassItem struct { //section 1 diff --git a/backend/respond/respond.go b/backend/respond/respond.go index 5f9dd73..dc8ea9a 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -284,4 +284,24 @@ var ( //请求相关的响应 Status: "40043", Info: "invalid section range, start_section should be less than or equal to end_section", } + + MissingParamForAutoScheduling = Response{ //自动排课缺少参数 + Status: "40044", + Info: "missing param for auto scheduling", + } + + InvalidDateRange = Response{ //无效的日期范围 + Status: "40045", + Info: "invalid date range, start_date should be before or equal to end_date", + } + + TaskClassModeNotAuto = Response{ //任务类模式不是自动 + Status: "40046", + Info: "task class mode is not auto", + } + + TimeNotEnoughForAutoScheduling = Response{ //自动排课时间不足 + Status: "40047", + Info: "time not enough for auto scheduling", + } ) diff --git a/backend/routers/routers.go b/backend/routers/routers.go index 6e3724c..d6c6253 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -81,6 +81,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk scheduleGroup.GET("/recent-completed", handlers.ScheduleHandler.GetUserRecentCompletedSchedules) scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule) scheduleGroup.DELETE("/undo-task-item", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule) + scheduleGroup.GET("/smart-planning", handlers.ScheduleHandler.SmartPlanning) } } // 初始化Gin引擎 diff --git a/backend/service/course.go b/backend/service/course.go index 730c3af..5793cc6 100644 --- a/backend/service/course.go +++ b/backend/service/course.go @@ -78,6 +78,10 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor continue } //2.转换为 Schedule_event 切片 + st, ed, err := conv.RelativeTimeToRealTime(week, arrangement.DayOfWeek, arrangement.StartSection, arrangement.EndSection) + if err != nil { + return nil, err + } scheduleEvent := model.ScheduleEvent{ UserID: userID, Name: course.CourseName, @@ -85,6 +89,8 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor Type: "course", RelID: nil, CanBeEmbedded: course.IsAllowTasks, + StartTime: st, + EndTime: ed, } finalScheduleEvents = append(finalScheduleEvents, scheduleEvent) //3.转换为 Schedule 切片 diff --git a/backend/service/schedule.go b/backend/service/schedule.go index 0cbbbf1..bba4dbf 100644 --- a/backend/service/schedule.go +++ b/backend/service/schedule.go @@ -8,6 +8,7 @@ import ( "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" + "github.com/LoveLosita/smartflow/backend/logic" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" "github.com/go-redis/redis/v8" @@ -100,6 +101,7 @@ func (ss *ScheduleService) GetUserWeeklySchedule(ctx context.Context, userID, we } //3.转换为前端需要的格式 weeklySchedule := conv.SchedulesToUserWeeklySchedule(schedules) + weeklySchedule.Week = week //4.将查询结果存入缓存,设置过期时间为一周(或者根据实际情况调整) err = ss.cacheDAO.SetUserWeeklyScheduleToCache(ctx, userID, weeklySchedule) return weeklySchedule, nil @@ -375,3 +377,30 @@ func (ss *ScheduleService) RevocateUserTaskClassItem(ctx context.Context, userID } return nil } + +func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, error) { + //1.通过任务类id获取任务类详情 + taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID) + if err != nil { + return nil, err + } + //2.校验任务类的参数是否合法 + if taskClass == nil { + return nil, respond.WrongTaskClassID + } + if *taskClass.Mode != "auto" { + return nil, respond.TaskClassModeNotAuto + } + //3.获取任务类安排的时间范围内的全部周数信息(左右边界不足一周的情况也要算作一周) + schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate)) + if err != nil { + return nil, err + } + //4.将多个周的信息传入智能排课算法,获取推荐的时间安排(周+周内的天+节次) + result, err := logic.SmartPlanningMainLogic(schedules, taskClass) + if err != nil { + return nil, err + } + //5.将推荐的时间安排转换为前端需要的格式返回 + return result, nil +} diff --git a/backend/service/task-class.go b/backend/service/task-class.go index 82125ec..8cff78c 100644 --- a/backend/service/task-class.go +++ b/backend/service/task-class.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "time" "github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/dao" @@ -32,7 +33,27 @@ func NewTaskClassService(taskClassRepo *dao.TaskClassDAO, cacheRepo *dao.CacheDA // AddOrUpdateTaskClass 为指定用户添加任务类 func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model.UserAddTaskClassRequest, userID int, method int, targetTaskClassID int) error { - // 1) 先写数据库(事务内) + //1.先校验参数 + if req.Mode == "auto" { + if req.StartDate == "" || req.EndDate == "" { + return respond.MissingParamForAutoScheduling + } + st, err := time.Parse("2006-01-02", req.StartDate) + if err != nil { + return respond.WrongParamType + } + ed, err := time.Parse("2006-01-02", req.EndDate) + if err != nil { + return respond.WrongParamType + } + if st.After(ed) { + return respond.InvalidDateRange + } + } + if req.Mode == "" || req.Name == "" || len(req.Items) == 0 { + return respond.MissingParam + } + //2.写数据库(事务内) if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error { taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID) if err != nil {