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 } // getNode 和 setNode 是对 grid 数据结构的封装,确保我们在访问时能正确处理默认值(Free)和边界情况 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(currW, currD, currS int) int { count := 0 if currW == 0 && currD == 0 && currS == 0 { currW, currD, currS = g.startWeek, g.startDay, 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 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 dayData[s].Status == Free || dayData[s].Status == Filler { return w, d, s } } } } return -1, -1, -1 } // 辅助函数:向后跳过指定数量的可用坑位 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 currW == g.endWeek && currD > g.endDay { return g.endWeek, g.endDay, 12 } if g.isAvailable(currW, currD, currS) { found++ if found > skipCount { return currW, currD, currS } } currS++ } return currW, currD, currS } 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 } // SmartPlanningRawItems 执行粗排算法并直接返回已分配的任务项列表。 // // 与 SmartPlanningMainLogic 共享完全相同的构建网格和分配逻辑, // 但不做展示格式转换,直接返回 allocatedItems(每项的 EmbeddedTime 已回填)。 // 供 Agent 排程链路使用,避免从展示结构反向解析导致信息丢失。 func SmartPlanningRawItems(schedules []model.Schedule, taskClass *model.TaskClass) ([]model.TaskClassItem, error) { g := buildTimeGrid(schedules, taskClass) return computeAllocation(g, taskClass.Items, *taskClass.Strategy) } // SmartPlanningRawItemsMulti 执行“多任务类共享资源池”粗排。 // // 职责边界: // 1. 复用现有 SmartPlanningRawItems 的单任务类分配能力,不重写核心算法; // 2. 通过“增量占位”把前一个任务类的建议结果写入共享工作日程,供后续任务类避让; // 3. 返回聚合后的 allocatedItems(每项 EmbeddedTime 已回填); // 4. 不负责展示结构转换(由 service/conv 层处理)。 func SmartPlanningRawItemsMulti(schedules []model.Schedule, taskClasses []*model.TaskClass) ([]model.TaskClassItem, error) { if len(taskClasses) == 0 { return []model.TaskClassItem{}, nil } // 1. 构建“工作副本”: // 1.1 原始 schedules 不直接修改,避免污染调用方数据; // 1.2 后续每完成一个任务类分配,就把结果增量写入 workingSchedules。 workingSchedules := cloneSchedulesForPlanning(schedules) allAllocated := make([]model.TaskClassItem, 0) // 2. syntheticEventID 用于给“虚拟占位任务”分配唯一 EventID。 // 2.1 采用负数区间,避免和数据库自增正数 EventID 冲突; // 2.2 每个任务块占用一个 synthetic event,跨节次共享同一 eventID。 nextSyntheticEventID := -1 for _, taskClass := range taskClasses { if taskClass == nil { continue } if taskClass.Strategy == nil { return nil, fmt.Errorf("task_class_id=%d 缺少 strategy 配置", taskClass.ID) } // 3. 复用单任务类粗排。 allocatedItems, err := SmartPlanningRawItems(workingSchedules, taskClass) if err != nil { // 3.1 明确标注失败任务类,便于上层快速定位。 return nil, fmt.Errorf("task_class_id=%d 粗排失败: %w", taskClass.ID, err) } allAllocated = append(allAllocated, allocatedItems...) // 4. 把本任务类分配结果转成“虚拟 Schedule 占位”追加回工作副本。 // 4.1 目的:让后续任务类把这些已分配任务当成 Occupied,避免重叠; // 4.2 若某任务块没有 EmbeddedTime,直接跳过,不阻断后续。 virtualSchedules, nextID := buildVirtualSchedulesFromAllocated(allocatedItems, taskClass, nextSyntheticEventID) nextSyntheticEventID = nextID if len(virtualSchedules) > 0 { workingSchedules = append(workingSchedules, virtualSchedules...) } } return allAllocated, nil } // cloneSchedulesForPlanning 深拷贝 schedules,确保后续在算法中安全修改。 // // 说明: // 1. 主要拷贝 Schedule 结构体本身; // 2. Event 指针做浅字段复制,避免共享同一 Event 指针导致意外改写; // 3. EmbeddedTask 在粗排阶段不参与状态写入,保留原值即可。 func cloneSchedulesForPlanning(src []model.Schedule) []model.Schedule { if len(src) == 0 { return []model.Schedule{} } dst := make([]model.Schedule, len(src)) for i := range src { dst[i] = src[i] if src[i].Event != nil { eventCopy := *src[i].Event dst[i].Event = &eventCopy } } return dst } // buildVirtualSchedulesFromAllocated 将已分配任务块转成“虚拟占位 schedules”。 // // 设计目的: // 1. 让后续任务类在共享资源池里自动避让已分配任务; // 2. 不落库,仅用于内存中的粗排冲突控制; // 3. 通过 Type=task + CanBeEmbedded=false 强制标记为不可再嵌入。 func buildVirtualSchedulesFromAllocated(allocatedItems []model.TaskClassItem, taskClass *model.TaskClass, eventIDStart int) ([]model.Schedule, int) { if len(allocatedItems) == 0 { return []model.Schedule{}, eventIDStart } userID := 0 if taskClass != nil && taskClass.UserID != nil { userID = *taskClass.UserID } virtual := make([]model.Schedule, 0) nextEventID := eventIDStart for _, item := range allocatedItems { if item.EmbeddedTime == nil { continue } taskName := "未命名任务" if item.Content != nil && *item.Content != "" { taskName = *item.Content } location := "" event := &model.ScheduleEvent{ ID: nextEventID, UserID: userID, Name: taskName, Location: &location, Type: "task", CanBeEmbedded: false, } for section := item.EmbeddedTime.SectionFrom; section <= item.EmbeddedTime.SectionTo; section++ { virtual = append(virtual, model.Schedule{ EventID: nextEventID, UserID: userID, Week: item.EmbeddedTime.Week, DayOfWeek: item.EmbeddedTime.DayOfWeek, Section: section, Event: event, Status: "normal", }) } nextEventID-- } return virtual, nextEventID } // buildTimeGrid 构建一个时间格子,标记出哪些时间段被占用、哪些被屏蔽、哪些是水课 func buildTimeGrid(schedules []model.Schedule, taskClass *model.TaskClass) *grid { // 🚀 核心修正:获取精确的起始坐标 startW, startD, _ := conv.RealDateToRelativeDate(taskClass.StartDate.Format(conv.DateFormat)) endW, endD, _ := conv.RealDateToRelativeDate(taskClass.EndDate.Format(conv.DateFormat)) // 将信息初始化到 grid 结构中 g := &grid{ data: make(map[int]map[int][13]slotNode), startWeek: startW, startDay: startD, endWeek: endW, endDay: endD, } //标记屏蔽时段 (Blocked) 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++ { //🚀 注意:这里的屏蔽是针对每天的,所以直接循环 1-7 天 for s := sFrom; s <= sTo; s++ { g.setNode(w, d, s, slotNode{Status: Blocked}) } } } } // 标记整天屏蔽: // 1. excluded_days_of_week 表示“这些星期几整天都不允许粗排”; // 2. 与 excluded_slots 一样属于硬约束,因此直接写入 Blocked; // 3. 一旦工作日容量不足,粗排应直接失败,而不是偷偷排到被排除的星期里。 for _, blockedDay := range taskClass.ExcludedDaysOfWeek { for w := startW; w <= endW; w++ { for s := 1; s <= 12; s++ { g.setNode(w, blockedDay, 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 // 只有当课程允许嵌入且当前事件支持嵌入时,才标记为 Filler 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 := g.startDay // 建议从 conv 传入具体的 DayOfWeek startS := 1 // 1. 获取可用资源总量 totalAvailable := g.countAvailableSlots(0, 0, 0) // 假设每个任务块至少占用 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 nil, respond.TimeNotEnoughForAutoScheduling } return items, nil }*/ type slotCoord struct { w, d, s int } // planningSlotCandidate 表示一次“可落位任务块”的候选结果。 // // 职责边界: // 1. 负责把“游标位置”映射成真正可落地的周/天/节次区间; // 2. 不负责写入 grid,占位仍由 computeAllocation 统一执行; // 3. 通过 coordIndex 告诉上层“本次是从哪个逻辑切片位置开始命中的”,便于继续推进游标。 type planningSlotCandidate struct { coordIndex int week int dayOfWeek int sectionFrom int sectionTo int } // countDayAvailable 统计某一天当前还可用于粗排的节次数。 // // 职责边界: // 1. 只把 Free/Filler 视为“仍可消费”的资源; // 2. 不区分其来源是纯空位还是可嵌入课程,因为对粗排而言二者都代表后续还能放任务; // 3. 仅用于候选打分,不直接参与最终合法性判断。 func (g *grid) countDayAvailable(week, day int) int { if g == nil { return 0 } count := 0 for section := 1; section <= 12; section++ { node := g.getNode(week, day, section) if node.Status == Free || node.Status == Filler { count++ } } return count } // countDayOccupied 统计某一天当前已被 existing/virtual/task 占住的节次数。 func (g *grid) countDayOccupied(week, day int) int { if g == nil { return 0 } count := 0 for section := 1; section <= 12; section++ { if g.getNode(week, day, section).Status == Occupied { count++ } } return count } // collectPlanningCandidatesFromCursor 收集从给定游标开始仍然合法的候选落位。 // // 设计说明: // 1. 这里复用现有 findNextCandidateFromCursor 的合法性规则,避免复制一套“什么叫合法双节”的判断; // 2. 通过跳过已命中候选的跨度,减少同一课程块被重复返回; // 3. 保留快照上的 coordIndex,供 steady 策略计算“距离目标位置有多远”。 func (g *grid) collectPlanningCandidatesFromCursor(coords []slotCoord, startCursor int) []planningSlotCandidate { if g == nil || startCursor >= len(coords) { return nil } candidates := make([]planningSlotCandidate, 0, 16) seen := make(map[string]struct{}) for cursor := startCursor; cursor < len(coords); { candidate, found := g.findNextCandidateFromCursor(coords, cursor) if !found { break } key := fmt.Sprintf("%d-%d-%d-%d", candidate.week, candidate.dayOfWeek, candidate.sectionFrom, candidate.sectionTo) if _, exists := seen[key]; !exists { seen[key] = struct{}{} candidates = append(candidates, candidate) } nextCursor := candidate.coordIndex + (candidate.sectionTo - candidate.sectionFrom + 1) if nextCursor <= cursor { nextCursor = cursor + 1 } cursor = nextCursor } return candidates } func computeSteadyTargetCursor(totalAvailable, totalItems, itemIndex int) int { if totalAvailable <= 1 || totalItems <= 1 { return 0 } target := ((itemIndex + 1) * totalAvailable) / (totalItems + 1) if target < 0 { return 0 } if target >= totalAvailable { return totalAvailable - 1 } return target } func planningDayOrdinal(week, day int) int { return week*7 + day } func absInt(value int) int { if value < 0 { return -value } return value } // chooseSteadyCandidate 为 steady 策略挑选“更均衡、更分散、更留余地”的候选位。 // // 评分原则: // 1. 先尽量接近本任务在窗口中的目标分布位置; // 2. 再偏好当前已占用更少的天,避免单日继续堆高; // 3. 再惩罚与同任务类既有落位过近或同日重复,降低同科过度集中; // 4. 最后惩罚吃掉当天最后一小段缓冲,给后续调整保留容错空间。 func (g *grid) chooseSteadyCandidate( coords []slotCoord, targetCursor int, placedDayOrdinals []int, ) (planningSlotCandidate, bool) { candidates := g.collectPlanningCandidatesFromCursor(coords, 0) if len(candidates) == 0 { return planningSlotCandidate{}, false } best := candidates[0] bestScore := int(^uint(0) >> 1) for _, candidate := range candidates { slotSpan := candidate.sectionTo - candidate.sectionFrom + 1 distancePenalty := absInt(candidate.coordIndex-targetCursor) * 10 dayOccupiedPenalty := g.countDayOccupied(candidate.week, candidate.dayOfWeek) * 25 remainingAvailable := g.countDayAvailable(candidate.week, candidate.dayOfWeek) - slotSpan bufferPenalty := 0 if remainingAvailable < 2 { bufferPenalty = 80 } dayOrdinal := planningDayOrdinal(candidate.week, candidate.dayOfWeek) rhythmPenalty := 0 for _, placed := range placedDayOrdinals { diff := absInt(dayOrdinal - placed) switch { case diff == 0: rhythmPenalty += 180 case diff == 1: rhythmPenalty += 60 } } score := distancePenalty + dayOccupiedPenalty + bufferPenalty + rhythmPenalty + candidate.coordIndex if score < bestScore { bestScore = score best = candidate } } return best, true } // getAllAvailable 获取窗口内所有可用的原子节次坐标(逻辑一维化)。 // // 设计说明: // 1. 这里返回的是“快照坐标”,后续任务落位后,快照中的部分坐标可能失效; // 2. 因此 computeAllocation 在真正落位前会再次检查 grid 当前状态,避免覆盖占位。 func (g *grid) getAllAvailable() []slotCoord { var coords []slotCoord for w := g.startWeek; w <= g.endWeek; w++ { dayMap, hasData := g.data[w] for d := 1; d <= 7; d++ { // 1. 头尾边界裁剪:只遍历任务类有效日期窗口。 if w == g.startWeek && d < g.startDay { continue } if w == g.endWeek && d > g.endDay { break } var dayData [13]slotNode if hasData { dayData = dayMap[d] } // 2. 仅记录可用格子(Free/Filler)。 for s := 1; s <= 12; s++ { if dayData[s].Status == Free || dayData[s].Status == Filler { coords = append(coords, slotCoord{w: w, d: d, s: s}) } } } } return coords } // findNextCandidateFromCursor 从当前 cursor 起向后寻找“可真正落位”的候选块。 // // 职责边界: // 1. 负责“挑选起点”:从逻辑切片 coords 中向后扫描,直到命中可放置位置; // 2. 不负责“真正占位”:这里只做判断,不修改 grid 状态; // 3. 输入输出语义: // - startCursor:当前逻辑游标(已包含 steady 策略的间隔效果); // - found=false:表示从该游标到窗口末尾都无法再放置任务块。 // // 关键约束: // 1. 普通空位(Free)必须满足“连续 2 节都可用”才允许落位; // 2. 可嵌入课程(Filler)沿用“整块嵌入”语义:命中课程任意节次,都回溯到课程块起点并整块占用; // 3. 若某个坐标在前序迭代中已占用(coords 为快照可能过期),直接跳过继续扫描。 func (g *grid) findNextCandidateFromCursor(coords []slotCoord, startCursor int) (candidate planningSlotCandidate, found bool) { for idx := startCursor; idx < len(coords); idx++ { loc := coords[idx] node := g.getNode(loc.w, loc.d, loc.s) // 1. 快照过期校验: // 1.1 前序任务落位后,该坐标可能已变成 Occupied; // 1.2 若不二次校验,会出现覆盖已占位节次的风险。 if node.Status != Free && node.Status != Filler { continue } // 2. Filler 处理: // 2.1 先识别课程块边界; // 2.2 再在课程块内部寻找“奇数起点的双节对齐位”(1-2/3-4/...); // 2.3 找不到合法双节位则跳过该课程块,不允许退化成单节或偶数起点跨对齐块。 if node.Status == Filler { blockFrom := loc.s currID := node.EventID // 2.1 向左回溯到同一 EventID 的起点。 for checkS := loc.s - 1; checkS >= 1; checkS-- { prev := g.getNode(loc.w, loc.d, checkS) if prev.Status == Filler && prev.EventID == currID { blockFrom = checkS continue } break } // 2.2 向右扩展到同一 EventID 的终点。 blockTo := blockFrom for checkS := blockFrom + 1; checkS <= 12; checkS++ { next := g.getNode(loc.w, loc.d, checkS) if next.Status == Filler && next.EventID == currID { blockTo = checkS continue } break } // 2.3 在课程块中按“双节对齐位”查找合法起点(必须为奇数节)。 pairFrom := blockFrom if pairFrom%2 == 0 { pairFrom++ } for ; pairFrom+1 <= blockTo; pairFrom += 2 { // 虽然理论上 Filler 都可用,这里仍做显式校验,防止后续规则扩展导致误判。 if g.isAvailable(loc.w, loc.d, pairFrom) && g.isAvailable(loc.w, loc.d, pairFrom+1) { return planningSlotCandidate{ coordIndex: idx, week: loc.w, dayOfWeek: loc.d, sectionFrom: pairFrom, sectionTo: pairFrom + 1, }, true } } continue } // 3. Free 处理:必须严格满足“奇数起点双节对齐位”。 // 3.1 起点必须是奇数节(1/3/5/7/9/11); // 3.2 且后一节可用;不允许偶数起点(如 8-9)跨对齐块。 if loc.s%2 == 0 { continue } if loc.s >= 12 || !g.isAvailable(loc.w, loc.d, loc.s+1) { continue } return planningSlotCandidate{ coordIndex: idx, week: loc.w, dayOfWeek: loc.d, sectionFrom: loc.s, sectionTo: loc.s + 1, }, true } return planningSlotCandidate{}, false } // computeAllocation 根据当前时间格与策略,为每个任务块计算建议落位时间。 // // 职责边界: // 1. 负责“粗排落位”与“内存占位状态更新”; // 2. 不负责持久化写库(由 service/dao 层负责); // 3. 不负责最终展示结构转换(由 conv 层负责)。 // // 失败语义: // 1. 返回 TimeNotEnoughForAutoScheduling 表示“时间片总量或连续性不足”; // 2. 返回 nil error 表示所有 items 都已成功回填 EmbeddedTime。 func computeAllocation(g *grid, items []model.TaskClassItem, strategy string) ([]model.TaskClassItem, error) { if len(items) == 0 { return items, nil } // 1. 预处理可用坐标快照,并做容量下限校验(每个任务默认至少 2 节)。 coords := g.getAllAvailable() totalAvailable := len(coords) totalRequired := len(items) * 2 if totalAvailable < totalRequired { return nil, respond.TimeNotEnoughForAutoScheduling } // 2. 计算间隔策略: // 2.1 rapid:沿用“尽快塞满”的线性前进; // 2.2 steady:不再只靠 gap 跳格子,而是结合目标位置、单日负载、同科分散和缓冲保留做候选打分。 gap := 0 if strategy == "steady" { gap = (totalAvailable - totalRequired) / (len(items) + 1) } // 3. 线性分配主循环: // 3.1 cursor 是逻辑切片游标(不是物理节次指针); // 3.2 每次成功落位后,按“命中索引 + 占用长度 + gap”推进; // 3.3 若当前位置不满足约束(例如后继节被占),继续向后扫描,不降级为 1 节。 cursor := gap lastPlacedIndex := -1 placedDayOrdinals := make([]int, 0, len(items)) for i := range items { var ( candidate planningSlotCandidate found bool ) if strategy == "steady" { targetCursor := computeSteadyTargetCursor(totalAvailable, len(items), i) candidate, found = g.chooseSteadyCandidate(coords, targetCursor, placedDayOrdinals) } else { if cursor >= totalAvailable { break } candidate, found = g.findNextCandidateFromCursor(coords, cursor) } if !found { break } // 5. 回填任务块建议时间。 items[i].EmbeddedTime = &model.TargetTime{ SectionFrom: candidate.sectionFrom, SectionTo: candidate.sectionTo, Week: candidate.week, DayOfWeek: candidate.dayOfWeek, } // 6. 写入内存占位状态: // 6.1 这是后续候选判断的真实依据; // 6.2 失败兜底:纯内存操作无外部 IO,不存在部分提交问题。 for sec := candidate.sectionFrom; sec <= candidate.sectionTo; sec++ { g.setNode(candidate.week, candidate.dayOfWeek, sec, slotNode{Status: Occupied}) } // 7. 推进游标并记录成功位置。 slotLen := candidate.sectionTo - candidate.sectionFrom + 1 if strategy != "steady" { cursor = candidate.coordIndex + slotLen + gap } placedDayOrdinals = append(placedDayOrdinals, planningDayOrdinal(candidate.week, candidate.dayOfWeek)) lastPlacedIndex = i } // 8. 完整性校验: // 8.1 只要有任一任务未落位,就返回统一的“时间不足”错误; // 8.2 避免出现“部分任务有时间、部分任务为空”的半成品结果。 if lastPlacedIndex < len(items)-1 { return nil, respond.TimeNotEnoughForAutoScheduling } return items, nil }