Version: 0.3.6.dev.260223
feat: 🚀 新增智能编排日程接口与算法模块 * 新增智能编排日程接口,实现自动生成周维度课程安排 * 抽离核心算法至 `Logic` 包,统一存放调度与排课相关算法逻辑,优化项目结构分层 * 大多数用例测试通过,当前存在少量边界用例下“排课时间是否充足”的误判问题 * 返回的周视图数据在极端场景下存在数量偏差,待进一步完善边界控制 fix: 🐛 修复批量导入课程接口 500 错误 * 修复批量导入课程接口中未在 `event` 结构体填写时间字段的问题 * 解决因时间字段为空导致的服务端 500 错误,保证数据完整性 refactor: ♻️ 新增入参校验逻辑保障调度稳定性 * 在添加任务类时新增入参校验逻辑 * 避免非法数据进入调度流程,确保自动编排日程接口执行稳定 docs: 📚 更新 README 智能编排算法说明 * 补充智能编排日程算法的设计思路与实现说明 undo: ⚠️ 追加导入课程后缓存未自动失效 * 追加导入课程后未自动删除对应周安排缓存,存在数据不一致风险 * 当前未能稳定复现,计划后续定位缓存失效时序与触发条件问题
This commit is contained in:
57
README.md
57
README.md
@@ -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范式实现细节
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" #学期结束日期,一定要设定为周日,确保最后一周完整
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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-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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
300
backend/logic/smart_planning.go
Normal file
300
backend/logic/smart_planning.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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引擎
|
||||||
|
|||||||
@@ -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 切片
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user