Files
smartmate/backend/logic/smart_planning.go
LoveLosita f934668838 Version: 0.3.6.dev.260223
feat: 🚀 新增智能编排日程接口与算法模块

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

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

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

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

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

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

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

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

* 追加导入课程后未自动删除对应周安排缓存,存在数据不一致风险
* 当前未能稳定复现,计划后续定位缓存失效时序与触发条件问题
2026-02-23 21:49:46 +08:00

301 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}