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