Version: 0.3.6.dev.260223

feat: 🚀 新增智能编排日程接口与算法模块

* 新增智能编排日程接口,实现自动生成周维度课程安排
* 抽离核心算法至 `Logic` 包,统一存放调度与排课相关算法逻辑,优化项目结构分层
* 大多数用例测试通过,当前存在少量边界用例下“排课时间是否充足”的误判问题
* 返回的周视图数据在极端场景下存在数量偏差,待进一步完善边界控制

fix: 🐛 修复批量导入课程接口 500 错误

* 修复批量导入课程接口中未在 `event` 结构体填写时间字段的问题
* 解决因时间字段为空导致的服务端 500 错误,保证数据完整性

refactor: ♻️ 新增入参校验逻辑保障调度稳定性

* 在添加任务类时新增入参校验逻辑
* 避免非法数据进入调度流程,确保自动编排日程接口执行稳定

docs: 📚 更新 README 智能编排算法说明

* 补充智能编排日程算法的设计思路与实现说明

undo: ⚠️ 追加导入课程后缓存未自动失效

* 追加导入课程后未自动删除对应周安排缓存,存在数据不一致风险
* 当前未能稳定复现,计划后续定位缓存失效时序与触发条件问题
This commit is contained in:
LoveLosita
2026-02-23 21:49:46 +08:00
parent 9cf288c49b
commit f934668838
16 changed files with 703 additions and 11 deletions

View File

@@ -291,7 +291,64 @@ PS截至v0.3.3。其中黑色箭头为请求数据链路,绿色箭头为返
### 5.3.1 智能排课算法 ### 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范式实现细节 ## 5.4 Agent范式实现细节

View File

@@ -143,3 +143,26 @@ func (s *ScheduleAPI) UserRevocateTaskItemFromSchedule(c *gin.Context) {
//4.返回撤销成功的响应给前端 //4.返回撤销成功的响应给前端
c.JSON(http.StatusOK, respond.Ok) 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))
}

View File

@@ -35,5 +35,5 @@ redis:
time: time:
zone: "Asia/Shanghai" zone: "Asia/Shanghai"
semesterStartDate: "2026-03-02" #学期开始日期,一定要设定为周一,以便于计算周数 semesterStartDate: "2026-03-02" #学期开始日期,一定要设定为周一,以便于计算周数
semesterEndDate: "2026-06-30" #学期结束日期,一定要设定为周日,确保最后一周完整 semesterEndDate: "2026-07-19" #学期结束日期,一定要设定为周日,确保最后一周完整

View File

@@ -164,6 +164,12 @@ func SchedulesToUserTodaySchedule(schedules []model.Schedule) []model.UserTodayS
} }
func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSchedule { func SchedulesToUserWeeklySchedule(schedules []model.Schedule) *model.UserWeekSchedule {
if len(schedules) == 0 {
return &model.UserWeekSchedule{
Week: 0,
Events: []model.WeeklyEventBrief{},
}
}
// 1. 初始化返回结构 (默认取第一条数据的周次) // 1. 初始化返回结构 (默认取第一条数据的周次)
weekDTO := &model.UserWeekSchedule{ weekDTO := &model.UserWeekSchedule{
Week: schedules[0].Week, Week: schedules[0].Week,
@@ -322,3 +328,153 @@ func SchedulesToUserOngoingSchedule(schedules []model.Schedule) *model.OngoingSc
EndTime: ongoing.Event.EndTime, 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
}

View File

@@ -1,7 +1,6 @@
package conv package conv
import ( import (
"encoding/json"
"errors" "errors"
"time" "time"
@@ -43,7 +42,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
taskClass.TotalSlots = &req.Config.TotalSlots taskClass.TotalSlots = &req.Config.TotalSlots
taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse taskClass.AllowFillerCourse = &req.Config.AllowFillerCourse
taskClass.Strategy = &req.Config.Strategy taskClass.Strategy = &req.Config.Strategy
//处理 ExcludedSlots 切片为 JSON 字符串 /*//处理 ExcludedSlots 切片为 JSON 字符串
if len(req.Config.ExcludedSlots) > 0 { if len(req.Config.ExcludedSlots) > 0 {
//转换为 JSON 字符串 //转换为 JSON 字符串
excludedSlotsJSON := "[" excludedSlotsJSON := "["
@@ -58,7 +57,8 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i
} else { } else {
emptyJSON := "[]" emptyJSON := "[]"
taskClass.ExcludedSlots = &emptyJSON taskClass.ExcludedSlots = &emptyJSON
} }*/
taskClass.ExcludedSlots = req.Config.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
//3.开始构建 items //3.开始构建 items
var items []model.TaskClassItem var items []model.TaskClassItem
for _, itemReq := range req.Items { for _, itemReq := range req.Items {
@@ -114,14 +114,15 @@ func ProcessUserGetCompleteTaskClassRequest(taskClass *model.TaskClass) (*model.
AllowFillerCourse: safeBool(taskClass.AllowFillerCourse), AllowFillerCourse: safeBool(taskClass.AllowFillerCourse),
Strategy: safeStr(taskClass.Strategy), Strategy: safeStr(taskClass.Strategy),
} }
// 3. 处理 ExcludedSlots JSON 字符串 -> []int /*// 3. 处理 ExcludedSlots JSON 字符串 -> []int
if taskClass.ExcludedSlots != nil && *taskClass.ExcludedSlots != "" { if taskClass.ExcludedSlots != nil && *taskClass.ExcludedSlots != "" {
var excluded []int var excluded []int
// 直接使用标准反序列化,比手动处理 rune 字符要健壮得多 // 直接使用标准反序列化,比手动处理 rune 字符要健壮得多
if err := json.Unmarshal([]byte(*taskClass.ExcludedSlots), &excluded); err == nil { if err := json.Unmarshal([]byte(*taskClass.ExcludedSlots), &excluded); err == nil {
req.Config.ExcludedSlots = excluded req.Config.ExcludedSlots = excluded
} }
} }*/
req.Config.ExcludedSlots = taskClass.ExcludedSlots // 直接复用 IntSlice 类型,前端也能正确解析为 []int
// 4. 映射子项信息 (Items Section) // 4. 映射子项信息 (Items Section)
// 此时 items 已经通过 Preload 加载到了 taskClass.Items 中 // 此时 items 已经通过 Preload 加载到了 taskClass.Items 中
req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items)) req.Items = make([]model.UserAddTaskClassItemRequest, 0, len(taskClass.Items))

View File

@@ -9,7 +9,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
// DateFormat 此处定义基准学期的开始和结束日期 // DateFormat 此处定义一个全局常量,确保在整个代码中使用统一的日期格式解析和格式化
const DateFormat = "2006-01-02" const DateFormat = "2006-01-02"
// RealDateToRelativeDate 将绝对日期转换为相对日期(格式: "week-day" // RealDateToRelativeDate 将绝对日期转换为相对日期(格式: "week-day"
@@ -121,3 +121,29 @@ func RelativeTimeToRealTime(week, dayOfWeek, startSection, endSection int) (time
return startTime, endTime, nil return startTime, endTime, nil
} }
func CalculateFirstDayOfWeek(date time.Time) time.Time {
// 计算当前日期是周几0-60表示周日
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-60表示周日
weekday := int(date.Weekday())
if weekday == 0 {
weekday = 7 // 将周日调整为7方便计算
}
// 计算距离周日的天数偏移
offset := 7 - weekday
// 计算本周日的日期
lastDayOfWeek := date.AddDate(0, 0, offset)
return lastDayOfWeek
}

View File

@@ -35,7 +35,7 @@ func (r *CourseDAO) AddUserCoursesIntoScheduleEvents(ctx context.Context, events
} }
ids := make([]int, 0, len(events)) ids := make([]int, 0, len(events))
for i := range events { for i := range events {
ids = append(ids, int(events[i].ID)) ids = append(ids, events[i].ID)
} }
return ids, nil return ids, nil
} }

View File

@@ -557,3 +557,19 @@ func (d *ScheduleDAO) GetRelIDByScheduleEventID(ctx context.Context, eventID int
} }
return *r.RelID, nil 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
}

View File

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

View File

@@ -24,7 +24,7 @@ type Schedule struct {
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 如何关联元数据 // 💡 必须加上这一行,告诉 GORM 如何关联元数据
Event ScheduleEvent `gorm:"foreignKey:EventID" json:"event"` Event *ScheduleEvent `gorm:"foreignKey:EventID" json:"event"`
EmbeddedTask *TaskClassItem `gorm:"foreignKey:EmbeddedTaskID" json:"embedded_task"` EmbeddedTask *TaskClassItem `gorm:"foreignKey:EmbeddedTaskID" json:"embedded_task"`
} }
@@ -87,6 +87,7 @@ type WeeklyEventBrief struct {
Location string `json:"location"` Location string `json:"location"`
Type string `json:"type"` Type string `json:"type"`
Span int `json:"span"` // 跨越的节数,给前端用来渲染宽度/高度 Span int `json:"span"` // 跨越的节数,给前端用来渲染宽度/高度
Status string `json:"status"`
EmbeddedTaskInfo TaskBrief `json:"embedded_task_info,omitempty"` EmbeddedTaskInfo TaskBrief `json:"embedded_task_info,omitempty"`
} }

View File

@@ -21,10 +21,45 @@ type TaskClass struct {
TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"` TotalSlots *int `gorm:"column:total_slots;comment:分配的总节数"`
AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"` AllowFillerCourse *bool `gorm:"column:allow_filler_course;default:true"`
Strategy *string `gorm:"column:strategy;type:enum('steady','rapid')"` 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 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 表进行映射 // TaskClassItem 用于和数据库中的 task_items 表进行映射
type TaskClassItem struct { type TaskClassItem struct {
//section 1 //section 1

View File

@@ -284,4 +284,24 @@ var ( //请求相关的响应
Status: "40043", Status: "40043",
Info: "invalid section range, start_section should be less than or equal to end_section", 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",
}
) )

View File

@@ -81,6 +81,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, limiter *pk
scheduleGroup.GET("/recent-completed", handlers.ScheduleHandler.GetUserRecentCompletedSchedules) scheduleGroup.GET("/recent-completed", handlers.ScheduleHandler.GetUserRecentCompletedSchedules)
scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule) scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule)
scheduleGroup.DELETE("/undo-task-item", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule) scheduleGroup.DELETE("/undo-task-item", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule)
scheduleGroup.GET("/smart-planning", handlers.ScheduleHandler.SmartPlanning)
} }
} }
// 初始化Gin引擎 // 初始化Gin引擎

View File

@@ -78,6 +78,10 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor
continue continue
} }
//2.转换为 Schedule_event 切片 //2.转换为 Schedule_event 切片
st, ed, err := conv.RelativeTimeToRealTime(week, arrangement.DayOfWeek, arrangement.StartSection, arrangement.EndSection)
if err != nil {
return nil, err
}
scheduleEvent := model.ScheduleEvent{ scheduleEvent := model.ScheduleEvent{
UserID: userID, UserID: userID,
Name: course.CourseName, Name: course.CourseName,
@@ -85,6 +89,8 @@ func (ss *CourseService) AddUserCourses(ctx context.Context, req model.UserImpor
Type: "course", Type: "course",
RelID: nil, RelID: nil,
CanBeEmbedded: course.IsAllowTasks, CanBeEmbedded: course.IsAllowTasks,
StartTime: st,
EndTime: ed,
} }
finalScheduleEvents = append(finalScheduleEvents, scheduleEvent) finalScheduleEvents = append(finalScheduleEvents, scheduleEvent)
//3.转换为 Schedule 切片 //3.转换为 Schedule 切片

View File

@@ -8,6 +8,7 @@ import (
"github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/logic"
"github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond" "github.com/LoveLosita/smartflow/backend/respond"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
@@ -100,6 +101,7 @@ func (ss *ScheduleService) GetUserWeeklySchedule(ctx context.Context, userID, we
} }
//3.转换为前端需要的格式 //3.转换为前端需要的格式
weeklySchedule := conv.SchedulesToUserWeeklySchedule(schedules) weeklySchedule := conv.SchedulesToUserWeeklySchedule(schedules)
weeklySchedule.Week = week
//4.将查询结果存入缓存,设置过期时间为一周(或者根据实际情况调整) //4.将查询结果存入缓存,设置过期时间为一周(或者根据实际情况调整)
err = ss.cacheDAO.SetUserWeeklyScheduleToCache(ctx, userID, weeklySchedule) err = ss.cacheDAO.SetUserWeeklyScheduleToCache(ctx, userID, weeklySchedule)
return weeklySchedule, nil return weeklySchedule, nil
@@ -375,3 +377,30 @@ func (ss *ScheduleService) RevocateUserTaskClassItem(ctx context.Context, userID
} }
return nil 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
}

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"log" "log"
"time"
"github.com/LoveLosita/smartflow/backend/conv" "github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao" "github.com/LoveLosita/smartflow/backend/dao"
@@ -32,7 +33,27 @@ func NewTaskClassService(taskClassRepo *dao.TaskClassDAO, cacheRepo *dao.CacheDA
// AddOrUpdateTaskClass 为指定用户添加任务类 // AddOrUpdateTaskClass 为指定用户添加任务类
func (sv *TaskClassService) AddOrUpdateTaskClass(ctx context.Context, req *model.UserAddTaskClassRequest, userID int, method int, targetTaskClassID int) error { 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 { if err := sv.taskClassRepo.Transaction(func(txDAO *dao.TaskClassDAO) error {
taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID) taskClass, items, err := conv.ProcessUserAddTaskClassRequest(req, userID)
if err != nil { if err != nil {