后端:
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 个
632 lines
18 KiB
Go
632 lines
18 KiB
Go
package agentconv
|
||
|
||
import (
|
||
"sort"
|
||
|
||
schedule "github.com/LoveLosita/smartflow/backend/services/agent/tools/schedule"
|
||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||
)
|
||
|
||
// WindowDay 表示排课窗口中的一天(相对周 + 周几)。
|
||
type WindowDay struct {
|
||
Week int
|
||
DayOfWeek int
|
||
}
|
||
|
||
// LoadScheduleState 将数据库层的 schedules + taskClasses 聚合为 agent 工具层可直接操作的 ScheduleState。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只负责数据映射与状态归一,不做数据库读写;
|
||
// 2. 同时兼容三种“任务已落位”信号:event.rel_id、schedules.embedded_task_id、task_item.embedded_time;
|
||
// 3. 对嵌入课程任务优先判定为 existing,避免误挂回 pending。
|
||
func LoadScheduleState(
|
||
schedules []model.Schedule,
|
||
taskClasses []model.TaskClass,
|
||
extraItemCategories map[int]string,
|
||
windowDays []WindowDay,
|
||
) *schedule.ScheduleState {
|
||
state := &schedule.ScheduleState{
|
||
Window: schedule.ScheduleWindow{
|
||
TotalDays: len(windowDays),
|
||
DayMapping: make([]schedule.DayMapping, len(windowDays)),
|
||
},
|
||
Tasks: make([]schedule.ScheduleTask, 0),
|
||
}
|
||
|
||
// 1. 构建 day_index 与 (week, day_of_week) 的双向转换基础索引。
|
||
dayLookup := make(map[[2]int]int, len(windowDays))
|
||
for i, wd := range windowDays {
|
||
dayIndex := i + 1
|
||
state.Window.DayMapping[i] = schedule.DayMapping{
|
||
DayIndex: dayIndex,
|
||
Week: wd.Week,
|
||
DayOfWeek: wd.DayOfWeek,
|
||
}
|
||
dayLookup[[2]int{wd.Week, wd.DayOfWeek}] = dayIndex
|
||
}
|
||
|
||
// 2. 构建 task_item -> 分类名映射。
|
||
// 2.1 先放 extraItemCategories(低优先级,兜底);
|
||
// 2.2 再用 taskClasses 覆盖(高优先级,确保本轮排课分类准确)。
|
||
itemCategoryLookup := make(map[int]string)
|
||
itemOrderLookup := buildTaskItemOrderLookup(taskClasses)
|
||
for id, name := range extraItemCategories {
|
||
itemCategoryLookup[id] = name
|
||
}
|
||
for _, tc := range taskClasses {
|
||
catName := "任务"
|
||
if tc.Name != nil && *tc.Name != "" {
|
||
catName = *tc.Name
|
||
}
|
||
for _, item := range tc.Items {
|
||
itemCategoryLookup[item.ID] = catName
|
||
}
|
||
}
|
||
|
||
// 3. 先把 schedules 聚合成 event 任务(existing)。
|
||
type slotGroup struct {
|
||
week int
|
||
dayOfWeek int
|
||
sections []int
|
||
}
|
||
eventSlotMap := make(map[int][]slotGroup) // eventID -> 多天多段槽位
|
||
eventInfo := make(map[int]*model.ScheduleEvent)
|
||
|
||
for i := range schedules {
|
||
s := &schedules[i]
|
||
if s.Event == nil {
|
||
continue
|
||
}
|
||
if _, exists := eventInfo[s.EventID]; !exists {
|
||
eventInfo[s.EventID] = s.Event
|
||
}
|
||
|
||
groups := eventSlotMap[s.EventID]
|
||
found := false
|
||
for gi := range groups {
|
||
if groups[gi].week == s.Week && groups[gi].dayOfWeek == s.DayOfWeek {
|
||
groups[gi].sections = append(groups[gi].sections, s.Section)
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
if !found {
|
||
groups = append(groups, slotGroup{
|
||
week: s.Week,
|
||
dayOfWeek: s.DayOfWeek,
|
||
sections: []int{s.Section},
|
||
})
|
||
}
|
||
eventSlotMap[s.EventID] = groups
|
||
}
|
||
|
||
nextStateID := 1
|
||
eventStateIDs := make(map[int]int) // eventID -> stateID
|
||
for eventID, groups := range eventSlotMap {
|
||
event := eventInfo[eventID]
|
||
if event == nil {
|
||
continue
|
||
}
|
||
|
||
category := "课程"
|
||
if event.Type == "task" {
|
||
category = "任务"
|
||
if event.RelID != nil {
|
||
if cat, ok := itemCategoryLookup[*event.RelID]; ok && cat != "" {
|
||
category = cat
|
||
}
|
||
}
|
||
}
|
||
|
||
locked := event.Type == "course" && !event.CanBeEmbedded
|
||
var slots []schedule.TaskSlot
|
||
for _, g := range groups {
|
||
if len(g.sections) == 0 {
|
||
continue
|
||
}
|
||
sort.Ints(g.sections)
|
||
start, end := g.sections[0], g.sections[0]
|
||
for _, sec := range g.sections[1:] {
|
||
if sec == end+1 {
|
||
end = sec
|
||
continue
|
||
}
|
||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||
}
|
||
start, end = sec, sec
|
||
}
|
||
if day, ok := dayLookup[[2]int{g.week, g.dayOfWeek}]; ok {
|
||
slots = append(slots, schedule.TaskSlot{Day: day, SlotStart: start, SlotEnd: end})
|
||
}
|
||
}
|
||
sort.Slice(slots, func(i, j int) bool {
|
||
if slots[i].Day != slots[j].Day {
|
||
return slots[i].Day < slots[j].Day
|
||
}
|
||
return slots[i].SlotStart < slots[j].SlotStart
|
||
})
|
||
|
||
stateID := nextStateID
|
||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||
StateID: stateID,
|
||
Source: "event",
|
||
SourceID: eventID,
|
||
Name: event.Name,
|
||
Category: category,
|
||
Status: "existing",
|
||
Locked: locked,
|
||
Slots: slots,
|
||
CanEmbed: event.CanBeEmbedded,
|
||
EventType: event.Type,
|
||
})
|
||
eventStateIDs[eventID] = stateID
|
||
nextStateID++
|
||
}
|
||
|
||
// 4. 构建 task_item 占位索引(后续 pending 判定优先用这两个索引短路)。
|
||
// 4.1 event.rel_id 占位:该 item 已有 task event;
|
||
// 4.2 schedules.embedded_task_id 占位:该 item 已嵌入到课程槽位。
|
||
itemIDToTaskEventStateID := make(map[int]int)
|
||
for eventID, stateID := range eventStateIDs {
|
||
event := eventInfo[eventID]
|
||
if event == nil || event.Type != "task" || event.RelID == nil {
|
||
continue
|
||
}
|
||
itemIDToTaskEventStateID[*event.RelID] = stateID
|
||
}
|
||
|
||
itemIDToEmbedHostStateID := make(map[int]int)
|
||
for i := range schedules {
|
||
s := &schedules[i]
|
||
if s.EmbeddedTaskID == nil {
|
||
continue
|
||
}
|
||
hostStateID, ok := eventStateIDs[s.EventID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
itemIDToEmbedHostStateID[*s.EmbeddedTaskID] = hostStateID
|
||
}
|
||
|
||
// 5. 处理 task_items:
|
||
// 5.1 先消化 existing(task event / 课程嵌入 / embedded_time);
|
||
// 5.2 剩余条目再按 status 判 pending。
|
||
itemStateIDs := make(map[int]int) // task_item_id -> stateID
|
||
for _, tc := range taskClasses {
|
||
catName := "任务"
|
||
if tc.Name != nil && *tc.Name != "" {
|
||
catName = *tc.Name
|
||
}
|
||
defaultDuration := estimateTaskItemDuration(tc)
|
||
pendingCount := 0
|
||
|
||
for _, item := range tc.Items {
|
||
if stateID, ok := itemIDToTaskEventStateID[item.ID]; ok {
|
||
itemStateIDs[item.ID] = stateID
|
||
continue
|
||
}
|
||
|
||
if hostStateID, ok := itemIDToEmbedHostStateID[item.ID]; ok {
|
||
hostSlots := []schedule.TaskSlot(nil)
|
||
if hostTask := state.TaskByStateID(hostStateID); hostTask != nil {
|
||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||
}
|
||
stateID := nextStateID
|
||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||
StateID: stateID,
|
||
Source: "task_item",
|
||
SourceID: item.ID,
|
||
Name: taskItemName(item),
|
||
Category: catName,
|
||
Status: "existing",
|
||
Slots: hostSlots,
|
||
CategoryID: tc.ID,
|
||
TaskClassID: tc.ID,
|
||
TaskOrder: itemOrderLookup[item.ID],
|
||
})
|
||
itemStateIDs[item.ID] = stateID
|
||
nextStateID++
|
||
continue
|
||
}
|
||
|
||
if slots, ok := slotsFromTargetTime(item.EmbeddedTime, dayLookup); ok {
|
||
stateID := nextStateID
|
||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||
StateID: stateID,
|
||
Source: "task_item",
|
||
SourceID: item.ID,
|
||
Name: taskItemName(item),
|
||
Category: catName,
|
||
Status: "existing",
|
||
Slots: slots,
|
||
CategoryID: tc.ID,
|
||
TaskClassID: tc.ID,
|
||
TaskOrder: itemOrderLookup[item.ID],
|
||
})
|
||
itemStateIDs[item.ID] = stateID
|
||
nextStateID++
|
||
continue
|
||
}
|
||
|
||
if !isTaskItemPending(item) {
|
||
continue
|
||
}
|
||
|
||
stateID := nextStateID
|
||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||
StateID: stateID,
|
||
Source: "task_item",
|
||
SourceID: item.ID,
|
||
Name: taskItemName(item),
|
||
Category: catName,
|
||
Status: "pending",
|
||
Duration: defaultDuration,
|
||
CategoryID: tc.ID,
|
||
TaskClassID: tc.ID,
|
||
TaskOrder: itemOrderLookup[item.ID],
|
||
})
|
||
itemStateIDs[item.ID] = stateID
|
||
nextStateID++
|
||
pendingCount++
|
||
}
|
||
|
||
// 仅当该任务类仍有 pending item 时,才把约束暴露给 LLM。
|
||
if pendingCount > 0 {
|
||
meta := schedule.TaskClassMeta{
|
||
ID: tc.ID,
|
||
Name: catName,
|
||
}
|
||
if tc.Strategy != nil {
|
||
meta.Strategy = *tc.Strategy
|
||
}
|
||
if tc.TotalSlots != nil {
|
||
meta.TotalSlots = *tc.TotalSlots
|
||
}
|
||
if tc.AllowFillerCourse != nil {
|
||
meta.AllowFillerCourse = *tc.AllowFillerCourse
|
||
}
|
||
if tc.ExcludedSlots != nil {
|
||
meta.ExcludedSlots = []int(tc.ExcludedSlots)
|
||
}
|
||
if tc.ExcludedDaysOfWeek != nil {
|
||
meta.ExcludedDaysOfWeek = []int(tc.ExcludedDaysOfWeek)
|
||
}
|
||
if tc.StartDate != nil {
|
||
meta.StartDate = tc.StartDate.Format("2006-01-02")
|
||
}
|
||
if tc.EndDate != nil {
|
||
meta.EndDate = tc.EndDate.Format("2006-01-02")
|
||
}
|
||
if tc.SubjectType != nil {
|
||
meta.SubjectType = *tc.SubjectType
|
||
}
|
||
if tc.DifficultyLevel != nil {
|
||
meta.DifficultyLevel = *tc.DifficultyLevel
|
||
}
|
||
if tc.CognitiveIntensity != nil {
|
||
meta.CognitiveIntensity = *tc.CognitiveIntensity
|
||
}
|
||
state.TaskClasses = append(state.TaskClasses, meta)
|
||
}
|
||
}
|
||
|
||
// 6. 统一回填嵌入关系:
|
||
// 6.1 host 记录 EmbeddedBy;
|
||
// 6.2 guest 记录 EmbedHost;
|
||
// 6.3 guest 强制 existing + host slots,防止“嵌入任务残留 pending”。
|
||
for i := range schedules {
|
||
s := &schedules[i]
|
||
if s.EmbeddedTaskID == nil {
|
||
continue
|
||
}
|
||
hostStateID, ok := eventStateIDs[s.EventID]
|
||
if !ok {
|
||
continue
|
||
}
|
||
hostTask := state.TaskByStateID(hostStateID)
|
||
itemID := *s.EmbeddedTaskID
|
||
|
||
guestStateID, ok := itemStateIDs[itemID]
|
||
if !ok {
|
||
// 兜底:只在 schedules 层看到嵌入关系,taskClasses 不含该 item 时补建 guest。
|
||
name := ""
|
||
categoryID := 0
|
||
taskClassID := 0
|
||
if s.EmbeddedTask != nil {
|
||
name = taskItemName(*s.EmbeddedTask)
|
||
if s.EmbeddedTask.CategoryID != nil {
|
||
categoryID = *s.EmbeddedTask.CategoryID
|
||
taskClassID = *s.EmbeddedTask.CategoryID
|
||
}
|
||
}
|
||
category := "任务"
|
||
if cat, exists := itemCategoryLookup[itemID]; exists && cat != "" {
|
||
category = cat
|
||
}
|
||
hostSlots := []schedule.TaskSlot(nil)
|
||
if hostTask != nil {
|
||
hostSlots = cloneTaskSlots(hostTask.Slots)
|
||
}
|
||
guestStateID = nextStateID
|
||
state.Tasks = append(state.Tasks, schedule.ScheduleTask{
|
||
StateID: guestStateID,
|
||
Source: "task_item",
|
||
SourceID: itemID,
|
||
Name: name,
|
||
Category: category,
|
||
Status: "existing",
|
||
Slots: hostSlots,
|
||
CategoryID: categoryID,
|
||
TaskClassID: taskClassID,
|
||
TaskOrder: itemOrderLookup[itemID],
|
||
})
|
||
itemStateIDs[itemID] = guestStateID
|
||
nextStateID++
|
||
}
|
||
|
||
if hostTask != nil && hostTask.EmbeddedBy == nil {
|
||
v := guestStateID
|
||
hostTask.EmbeddedBy = &v
|
||
}
|
||
|
||
guestTask := state.TaskByStateID(guestStateID)
|
||
if guestTask == nil {
|
||
continue
|
||
}
|
||
if guestTask.EmbedHost == nil {
|
||
v := hostStateID
|
||
guestTask.EmbedHost = &v
|
||
}
|
||
guestTask.Status = "existing"
|
||
if hostTask != nil && len(guestTask.Slots) == 0 {
|
||
guestTask.Slots = cloneTaskSlots(hostTask.Slots)
|
||
}
|
||
// existing 的 task_item 不应再携带 Duration,避免预览层误判成 suggested。
|
||
guestTask.Duration = 0
|
||
}
|
||
|
||
return state
|
||
}
|
||
|
||
// isTaskItemPending 仅根据 status 判断是否应进入 pending 池。
|
||
//
|
||
// 说明:
|
||
// 1. status=nil 兼容历史数据,按“未安排”处理;
|
||
// 2. 仅 status=TaskItemStatusUnscheduled 进入 pending;
|
||
// 3. 其它“已安排”信号由 LoadScheduleState 主流程统一判定,避免多处口径不一致。
|
||
func isTaskItemPending(item model.TaskClassItem) bool {
|
||
if item.Status == nil {
|
||
return true
|
||
}
|
||
return *item.Status == model.TaskItemStatusUnscheduled
|
||
}
|
||
|
||
// buildTaskItemOrderLookup 为每个 task_item 构建稳定顺序号。
|
||
//
|
||
// 职责边界:
|
||
// 1. 优先使用数据库里的 item.Order,保持用户或上游生成的显式顺序;
|
||
// 2. 若历史数据缺少 order,则退回 TaskClass.Items 当前顺序,保证写工具层仍有稳定边界;
|
||
// 3. 只负责构建运行态映射,不回写数据库。
|
||
func buildTaskItemOrderLookup(taskClasses []model.TaskClass) map[int]int {
|
||
lookup := make(map[int]int)
|
||
for _, tc := range taskClasses {
|
||
for idx, item := range tc.Items {
|
||
order := idx + 1
|
||
if item.Order != nil && *item.Order > 0 {
|
||
order = *item.Order
|
||
}
|
||
lookup[item.ID] = order
|
||
}
|
||
}
|
||
return lookup
|
||
}
|
||
|
||
// estimateTaskItemDuration 估算 pending 任务默认时长。
|
||
//
|
||
// 规则:若任务类声明了 total_slots,则按 total_slots / item_count 取整(最少 1);
|
||
// 否则回退到 2 节。
|
||
func estimateTaskItemDuration(tc model.TaskClass) int {
|
||
duration := 2
|
||
if tc.TotalSlots != nil && *tc.TotalSlots > 0 && len(tc.Items) > 0 {
|
||
if d := *tc.TotalSlots / len(tc.Items); d > 0 {
|
||
duration = d
|
||
}
|
||
}
|
||
return duration
|
||
}
|
||
|
||
// taskItemName 读取任务项展示名。
|
||
func taskItemName(item model.TaskClassItem) string {
|
||
if item.Content == nil {
|
||
return ""
|
||
}
|
||
return *item.Content
|
||
}
|
||
|
||
// slotsFromTargetTime 将 task_items.embedded_time 转换为 state 的槽位结构。
|
||
// 若 target 为空、节次非法、或不在窗口内,返回 false。
|
||
func slotsFromTargetTime(
|
||
target *model.TargetTime,
|
||
dayLookup map[[2]int]int,
|
||
) ([]schedule.TaskSlot, bool) {
|
||
if target == nil {
|
||
return nil, false
|
||
}
|
||
if target.SectionFrom < 1 || target.SectionTo < target.SectionFrom {
|
||
return nil, false
|
||
}
|
||
day, ok := dayLookup[[2]int{target.Week, target.DayOfWeek}]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
return []schedule.TaskSlot{
|
||
{
|
||
Day: day,
|
||
SlotStart: target.SectionFrom,
|
||
SlotEnd: target.SectionTo,
|
||
},
|
||
}, true
|
||
}
|
||
|
||
// ScheduleChangeType 表示两份 ScheduleState 对比后的变更类型。
|
||
type ScheduleChangeType string
|
||
|
||
const (
|
||
ChangePlace ScheduleChangeType = "place" // 从 pending 变为已放置
|
||
ChangeMove ScheduleChangeType = "move" // 已有槽位发生移动
|
||
ChangeUnplace ScheduleChangeType = "unplace" // 从已放置变回 pending
|
||
)
|
||
|
||
// SlotCoord 表示数据库坐标系中的单节槽位(week/day_of_week/section)。
|
||
type SlotCoord struct {
|
||
Week int
|
||
DayOfWeek int
|
||
Section int
|
||
}
|
||
|
||
// ScheduleChange 描述单个任务在前后状态间的变化。
|
||
type ScheduleChange struct {
|
||
Type ScheduleChangeType
|
||
StateID int
|
||
Source string // "event" | "task_item"
|
||
SourceID int // ScheduleEvent.ID 或 TaskClassItem.ID
|
||
EventType string // 仅 source=event 时有意义(course/task)
|
||
CategoryID int // 仅 source=task_item 时有意义
|
||
Name string
|
||
|
||
// place/move 的新位置(展开到逐节坐标)。
|
||
NewCoords []SlotCoord
|
||
// move/unplace 的旧位置(展开到逐节坐标)。
|
||
OldCoords []SlotCoord
|
||
|
||
// HostEventID:变更后位置对应的宿主 event(非嵌入为 0)。
|
||
HostEventID int
|
||
// OldHostEventID:move 时旧位置对应的宿主 event(非嵌入为 0)。
|
||
OldHostEventID int
|
||
}
|
||
|
||
// DiffScheduleState 比较 original 与 modified,返回需要持久化的变更集合。
|
||
func DiffScheduleState(
|
||
original *schedule.ScheduleState,
|
||
modified *schedule.ScheduleState,
|
||
) []ScheduleChange {
|
||
if original == nil || modified == nil {
|
||
return nil
|
||
}
|
||
|
||
origTasks := indexByStateID(original)
|
||
var changes []ScheduleChange
|
||
|
||
for i := range modified.Tasks {
|
||
mod := &modified.Tasks[i]
|
||
orig := origTasks[mod.StateID]
|
||
|
||
wasPending := orig == nil || orig.Status == "pending"
|
||
hasSlots := len(mod.Slots) > 0
|
||
hadSlots := orig != nil && len(orig.Slots) > 0
|
||
|
||
switch {
|
||
case wasPending && hasSlots:
|
||
changes = append(changes, ScheduleChange{
|
||
Type: ChangePlace,
|
||
StateID: mod.StateID,
|
||
Source: mod.Source,
|
||
SourceID: mod.SourceID,
|
||
EventType: mod.EventType,
|
||
CategoryID: mod.CategoryID,
|
||
Name: mod.Name,
|
||
NewCoords: expandToCoords(mod.Slots, modified),
|
||
HostEventID: resolveHostEventID(mod, modified),
|
||
})
|
||
case hadSlots && hasSlots && !slotsEqual(orig.Slots, mod.Slots):
|
||
changes = append(changes, ScheduleChange{
|
||
Type: ChangeMove,
|
||
StateID: mod.StateID,
|
||
Source: mod.Source,
|
||
SourceID: mod.SourceID,
|
||
EventType: mod.EventType,
|
||
CategoryID: mod.CategoryID,
|
||
Name: mod.Name,
|
||
OldCoords: expandToCoords(orig.Slots, original),
|
||
NewCoords: expandToCoords(mod.Slots, modified),
|
||
HostEventID: resolveHostEventID(mod, modified),
|
||
OldHostEventID: resolveHostEventID(orig, original),
|
||
})
|
||
case hadSlots && !hasSlots:
|
||
changes = append(changes, ScheduleChange{
|
||
Type: ChangeUnplace,
|
||
StateID: mod.StateID,
|
||
Source: orig.Source,
|
||
SourceID: orig.SourceID,
|
||
EventType: orig.EventType,
|
||
Name: orig.Name,
|
||
OldCoords: expandToCoords(orig.Slots, original),
|
||
HostEventID: resolveHostEventID(orig, original),
|
||
})
|
||
}
|
||
}
|
||
|
||
return changes
|
||
}
|
||
|
||
// indexByStateID 将任务列表按 state_id 建立索引。
|
||
func indexByStateID(state *schedule.ScheduleState) map[int]*schedule.ScheduleTask {
|
||
m := make(map[int]*schedule.ScheduleTask, len(state.Tasks))
|
||
for i := range state.Tasks {
|
||
m[state.Tasks[i].StateID] = &state.Tasks[i]
|
||
}
|
||
return m
|
||
}
|
||
|
||
// slotsEqual 判断两个压缩槽位切片是否完全一致。
|
||
func slotsEqual(a, b []schedule.TaskSlot) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i] != b[i] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// cloneTaskSlots 深拷贝槽位切片。
|
||
func cloneTaskSlots(src []schedule.TaskSlot) []schedule.TaskSlot {
|
||
if len(src) == 0 {
|
||
return nil
|
||
}
|
||
dst := make([]schedule.TaskSlot, len(src))
|
||
copy(dst, src)
|
||
return dst
|
||
}
|
||
|
||
// resolveHostEventID 通过任务的 EmbedHost 反查宿主 event_id。
|
||
// 非嵌入任务或宿主不存在时返回 0。
|
||
func resolveHostEventID(task *schedule.ScheduleTask, state *schedule.ScheduleState) int {
|
||
if task == nil || task.EmbedHost == nil {
|
||
return 0
|
||
}
|
||
host := state.TaskByStateID(*task.EmbedHost)
|
||
if host == nil {
|
||
return 0
|
||
}
|
||
return host.SourceID
|
||
}
|
||
|
||
// expandToCoords 将压缩槽位展开成逐节坐标,便于后续持久化层处理。
|
||
func expandToCoords(slots []schedule.TaskSlot, state *schedule.ScheduleState) []SlotCoord {
|
||
var coords []SlotCoord
|
||
for _, slot := range slots {
|
||
week, dow, ok := state.DayToWeekDay(slot.Day)
|
||
if !ok {
|
||
continue
|
||
}
|
||
for sec := slot.SlotStart; sec <= slot.SlotEnd; sec++ {
|
||
coords = append(coords, SlotCoord{Week: week, DayOfWeek: dow, Section: sec})
|
||
}
|
||
}
|
||
return coords
|
||
}
|