Version: 0.9.77.dev.260505

后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
This commit is contained in:
Losita
2026-05-05 23:25:07 +08:00
parent 2a96f4c6f9
commit 3b6fca44a6
226 changed files with 731 additions and 3497 deletions

View File

@@ -0,0 +1,823 @@
package planning
import (
"fmt"
"github.com/LoveLosita/smartflow/backend/services/runtime/conv"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
"github.com/LoveLosita/smartflow/backend/shared/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
}

View File

@@ -0,0 +1,154 @@
package planning
import (
"testing"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
"github.com/LoveLosita/smartflow/backend/shared/respond"
)
// newTestGrid 创建仅用于单测的最小 grid。
//
// 职责边界:
// 1. 只负责初始化时间窗口与 data 容器;
// 2. 不负责填充节次状态(由各测试用例自行设置)。
func newTestGrid(startWeek, startDay, endWeek, endDay int) *grid {
return &grid{
data: make(map[int]map[int][13]slotNode),
startWeek: startWeek,
startDay: startDay,
endWeek: endWeek,
endDay: endDay,
}
}
// setDayStatus 批量设置某一天 1~12 节的状态。
func setDayStatus(g *grid, week, day int, status slotStatus) {
for s := 1; s <= 12; s++ {
g.setNode(week, day, s, slotNode{Status: status})
}
}
// setSectionStatus 设置单个节次状态。
func setSectionStatus(g *grid, week, day, section int, status slotStatus) {
g.setNode(week, day, section, slotNode{Status: status})
}
// TestComputeAllocation_SkipIsolatedOneSlot 验证“孤立 1 节”不会被错误写成任务。
//
// 用例意图:
// 1. 第一天只放一个孤立可用节次10 节),后继 11 节被屏蔽;
// 2. 第二天提供一个合法的连续 2 节1-2 节);
// 3. 期望算法跳过第一天孤立节次,把任务落到第二天 1-2 节。
func TestComputeAllocation_SkipIsolatedOneSlot(t *testing.T) {
g := newTestGrid(1, 1, 1, 2)
// 1. 先全部置为 Blocked避免默认 Free 干扰本用例。
setDayStatus(g, 1, 1, Blocked)
setDayStatus(g, 1, 2, Blocked)
// 2. 构造“孤立 1 节 + 合法 2 节”场景。
setSectionStatus(g, 1, 1, 10, Free) // 第一天仅 10 节可用11/12 仍然 Blocked。
setSectionStatus(g, 1, 2, 1, Free)
setSectionStatus(g, 1, 2, 2, Free)
items := []model.TaskClassItem{{ID: 1}}
got, err := computeAllocation(g, items, "rapid")
if err != nil {
t.Fatalf("期望分配成功,实际报错: %v", err)
}
if len(got) != 1 || got[0].EmbeddedTime == nil {
t.Fatalf("期望回填 1 条 EmbeddedTime实际: %+v", got)
}
tt := got[0].EmbeddedTime
if tt.Week != 1 || tt.DayOfWeek != 2 || tt.SectionFrom != 1 || tt.SectionTo != 2 {
t.Fatalf("期望落位到 W1D2 1-2 节,实际: week=%d day=%d from=%d to=%d",
tt.Week, tt.DayOfWeek, tt.SectionFrom, tt.SectionTo)
}
}
// TestComputeAllocation_RejectAllIsolatedSlots 验证“全是孤立 1 节”时应返回时间不足。
//
// 用例意图:
// 1. 虽然总可用节次数量达到 2但它们分散成两个孤立 1 节;
// 2. 业务要求普通任务默认必须 2 连续节,因此应整体失败而不是偷偷降级为 1 节。
func TestComputeAllocation_RejectAllIsolatedSlots(t *testing.T) {
g := newTestGrid(1, 1, 1, 2)
// 1. 先全部置为 Blocked。
setDayStatus(g, 1, 1, Blocked)
setDayStatus(g, 1, 2, Blocked)
// 2. 仅放两个彼此分离的孤立可用节次。
setSectionStatus(g, 1, 1, 10, Free)
setSectionStatus(g, 1, 2, 10, Free)
items := []model.TaskClassItem{{ID: 1}}
_, err := computeAllocation(g, items, "rapid")
if err == nil {
t.Fatalf("期望返回时间不足错误,实际为 nil")
}
if err.Error() != respond.TimeNotEnoughForAutoScheduling.Error() {
t.Fatalf("期望错误=%s实际=%s", respond.TimeNotEnoughForAutoScheduling.Error(), err.Error())
}
}
// TestComputeAllocation_RejectEvenStartPair 验证偶数起点双节(如 8-9不允许作为粗排结果。
//
// 用例意图:
// 1. 构造一个看似连续的 8-9 空位;
// 2. 同时给出一个合法的 11-12 对齐空位;
// 3. 期望算法跳过 8-9选择 11-12。
func TestComputeAllocation_RejectEvenStartPair(t *testing.T) {
g := newTestGrid(1, 1, 1, 1)
// 1. 全部先置为 Blocked避免默认 Free 干扰判断。
setDayStatus(g, 1, 1, Blocked)
// 2. 构造“偶数起点双节 + 合法奇数起点双节”。
setSectionStatus(g, 1, 1, 8, Free)
setSectionStatus(g, 1, 1, 9, Free)
setSectionStatus(g, 1, 1, 11, Free)
setSectionStatus(g, 1, 1, 12, Free)
items := []model.TaskClassItem{{ID: 1}}
got, err := computeAllocation(g, items, "rapid")
if err != nil {
t.Fatalf("期望分配成功,实际报错: %v", err)
}
if got[0].EmbeddedTime == nil {
t.Fatalf("期望回填 EmbeddedTime实际为 nil")
}
tt := got[0].EmbeddedTime
if tt.SectionFrom != 11 || tt.SectionTo != 12 {
t.Fatalf("期望落位到 11-12实际落位到 %d-%d", tt.SectionFrom, tt.SectionTo)
}
}
// TestComputeAllocation_FillerNeedOddEvenPair 验证 Filler 课程块也必须满足奇数起点双节对齐。
//
// 用例意图:
// 1. 仅提供一个 Filler 课程块 8-9偶数起点
// 2. 即使总可用节数为 2也不能被当作合法落位
// 3. 期望返回时间不足错误。
func TestComputeAllocation_FillerNeedOddEvenPair(t *testing.T) {
g := newTestGrid(1, 1, 1, 1)
// 1. 全部先置为 Blocked。
setDayStatus(g, 1, 1, Blocked)
// 2. 课程块 8-9 标记为 Filler但其起点为偶数不满足对齐规则。
g.setNode(1, 1, 8, slotNode{Status: Filler, EventID: 1001})
g.setNode(1, 1, 9, slotNode{Status: Filler, EventID: 1001})
items := []model.TaskClassItem{{ID: 1}}
_, err := computeAllocation(g, items, "rapid")
if err == nil {
t.Fatalf("期望返回时间不足错误,实际为 nil")
}
if err.Error() != respond.TimeNotEnoughForAutoScheduling.Error() {
t.Fatalf("期望错误=%s实际=%s", respond.TimeNotEnoughForAutoScheduling.Error(), err.Error())
}
}