Files
Losita 7d324b77aa Version: 0.9.79.dev.260506
后端:
1. 本地后端启动体系收口到 `backend/scripts`,移除 `cmd/all` 聚合入口,并将仓库根兼容启动语义收敛为 `StartAPI` 别名;新增 dev-up / dev-down / services-up / services-down / dev-status / dev-logs / service-restart 脚本,统一托管多服务进程、日志、PID 与基础设施启动。
2. 课表服务超时口径统一放宽到 5 分钟,覆盖 gateway / client / rpc server / config example,避免课表导入与图片识别在长耗时场景下被内层提前截断。
3. `today` 课表查询修正为读取真实当前日期,不再使用硬编码测试日期;同时剔除旧缓存与返回结果里的 `empty` 占位事件,后端只返回真实日程,空档改由前端时间轴自行补齐。

前端:
4. 首页路由切回改为复用 `DashboardView` 实例,补 `keep-alive`、`onActivated` 与双帧缩放重算,修复从侧栏返回首页时首帧布局放大与重复加载闪动问题。
5. 首页加载态与今日时间线口径收口:移除额外 800ms `pageLoading` 人为延迟,task / schedule 改为分开驱动;时间线忽略 `empty` 事件,并统一空档文案为“无课”。
6. 收敛助手页与首页若干进场/弹性动画,降低结果卡片、微调弹窗、思考区与面板切换时的抖动感。

仓库:
7. README 补充后端本地快速启动说明,`.gitignore` 忽略 `backend/.dev` 脚本运行态产物。
2026-05-06 12:59:29 +08:00

923 lines
31 KiB
Go
Raw Permalink 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 sv
import (
"context"
"errors"
"log"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/runtime/conv"
rootdao "github.com/LoveLosita/smartflow/backend/services/runtime/dao"
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
"github.com/LoveLosita/smartflow/backend/services/schedule/core/applyadapter"
"github.com/LoveLosita/smartflow/backend/services/schedule/core/planning"
scheduledao "github.com/LoveLosita/smartflow/backend/services/schedule/dao"
"github.com/LoveLosita/smartflow/backend/shared/respond"
"github.com/go-redis/redis/v8"
)
type ScheduleService struct {
scheduleDAO *scheduledao.ScheduleDAO
taskClassDAO *rootdao.TaskClassDAO
repoManager *rootdao.RepoManager // 统一管理多个 DAO 的事务
cacheDAO *rootdao.CacheDAO // 负责 today/week/ongoing 等课表缓存读写
applyAdapter *applyadapter.GormApplyAdapter
}
// scheduleNow 允许测试注入当前时钟,避免 today 接口再次退回到硬编码日期。
var scheduleNow = time.Now
func NewScheduleService(scheduleDAO *scheduledao.ScheduleDAO, taskClassDAO *rootdao.TaskClassDAO, repoManager *rootdao.RepoManager, cacheDAO *rootdao.CacheDAO) *ScheduleService {
return &ScheduleService{
scheduleDAO: scheduleDAO,
taskClassDAO: taskClassDAO,
repoManager: repoManager,
cacheDAO: cacheDAO,
}
}
// SetApplyAdapter 注入正式日程 apply 端口。
//
// 职责边界:
// 1. 只用于 schedule 独立服务接管 active-scheduler confirm/apply 写入;
// 2. 不改变既有 HTTP schedule 读写接口;
// 3. 未注入时 apply RPC 会明确返回初始化错误,避免静默写回旧路径。
func (ss *ScheduleService) SetApplyAdapter(applyAdapter *applyadapter.GormApplyAdapter) {
if ss != nil {
ss.applyAdapter = applyAdapter
}
}
func (ss *ScheduleService) GetUserTodaySchedule(ctx context.Context, userID int) ([]model.UserTodaySchedule, error) {
//1.先尝试从缓存获取数据
cachedResp, err := ss.cacheDAO.GetUserTodayScheduleFromCache(ctx, userID)
if err == nil {
normalized, changed := filterPlaceholderTodayEvents(cachedResp)
if changed {
_ = ss.cacheDAO.SetUserTodayScheduleToCache(ctx, userID, normalized)
}
cachedResp = normalized
}
if err == nil {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//2.获取当前日期
week, dayOfWeek, err := currentRelativeWeekAndDay()
if err != nil {
return nil, err
}
//3.查询用户当天的日程安排
schedules, err := ss.scheduleDAO.GetUserTodaySchedule(ctx, userID, week, dayOfWeek)
if err != nil {
return nil, err
}
//4.转换为前端需要的格式
todaySchedules := conv.SchedulesToExistingUserTodaySchedule(schedules)
todaySchedules, _ = filterPlaceholderTodayEvents(todaySchedules)
//5.将查询结果存入缓存,设置过期时间为当天结束
err = ss.cacheDAO.SetUserTodayScheduleToCache(ctx, userID, todaySchedules)
return todaySchedules, nil
}
func (ss *ScheduleService) GetUserWeeklySchedule(ctx context.Context, userID, week int) (*model.UserWeekSchedule, error) {
//1.先检查 week 参数是否合法
if week < 0 || week > 25 {
return nil, respond.WeekOutOfRange
}
//2.先看看缓存里有没有数据(如果有的话直接返回,没有的话继续查库)
cachedResp, err := ss.cacheDAO.GetUserWeeklyScheduleFromCache(ctx, userID, week)
if err == nil {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//3.查询用户每周的日程安排
//如果没有传入 week 参数,则默认查询当前周的日程安排
if week == 0 {
curTime := time.Now().Format("2006-01-02")
var err error
week, _, err = conv.RealDateToRelativeDate(curTime)
if err != nil {
return nil, err
}
}
schedules, err := ss.scheduleDAO.GetUserWeeklySchedule(ctx, userID, week)
if err != nil {
return nil, err
}
//3.转换为前端需要的格式
weeklySchedule := conv.SchedulesToUserWeeklySchedule(schedules)
weeklySchedule.Week = week
//4.将查询结果存入缓存,设置过期时间为一周(或者根据实际情况调整)
err = ss.cacheDAO.SetUserWeeklyScheduleToCache(ctx, userID, weeklySchedule)
return weeklySchedule, nil
}
// currentRelativeWeekAndDay 只负责把“当前时间”换算成课表内部使用的周次与星期。
func currentRelativeWeekAndDay() (int, int, error) {
currentDate := scheduleNow().Format(conv.DateFormat)
return conv.RealDateToRelativeDate(currentDate)
}
// filterPlaceholderTodayEvents 负责剔除旧缓存里遗留的 empty 占位事件,保证 today 接口只返回真实日程。
func filterPlaceholderTodayEvents(schedules []model.UserTodaySchedule) ([]model.UserTodaySchedule, bool) {
if len(schedules) == 0 {
return []model.UserTodaySchedule{}, false
}
result := make([]model.UserTodaySchedule, 0, len(schedules))
changed := false
for _, day := range schedules {
filteredEvents := make([]model.EventBrief, 0, len(day.Events))
for _, event := range day.Events {
if strings.EqualFold(strings.TrimSpace(event.Type), "empty") {
changed = true
continue
}
filteredEvents = append(filteredEvents, event)
}
if len(filteredEvents) != len(day.Events) {
changed = true
}
day.Events = filteredEvents
result = append(result, day)
}
return result, changed
}
func (ss *ScheduleService) DeleteScheduleEvent(ctx context.Context, requests []model.UserDeleteScheduleEvent, userID int) error {
err := ss.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
for _, req := range requests {
//1.如果要删课程和嵌入的事件
if req.DeleteEmbeddedTask && req.DeleteCourse {
//通过schedule表的embedded_task_id字段找到对应的task_id
taskID, err := txM.Schedule.GetScheduleEmbeddedTaskID(ctx, req.ID)
if err != nil {
return err
}
//再将task_items表中对应的embedded_time字段设置为null
if taskID != 0 {
err = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if err != nil {
return err
}
}
//再删除课程事件和嵌入的事件(通过级联删除实现)
err = txM.Schedule.DeleteScheduleEventAndSchedule(ctx, req.ID, userID)
if err != nil {
return err
}
continue
}
//2.只删课程/事件
if req.DeleteCourse {
//2.1.检查课程是否有嵌入的任务事件
exists, err := txM.Schedule.IfScheduleEventIDExists(ctx, req.ID)
if err != nil {
return err
}
if !exists {
return respond.WrongScheduleEventID
}
embeddedTaskID, err := txM.Schedule.GetScheduleEmbeddedTaskID(ctx, req.ID)
if err != nil {
return err
}
//2.2.如果有则需另外为其创建新的scheduleEventtype=task
//课程事件先删除后再创建任务事件
if embeddedTaskID != 0 {
//2.2.1.先通过id取出taskClassItem详情
taskClassItem, err := txM.TaskClass.GetTaskClassItemByID(ctx, embeddedTaskID)
if err != nil {
return err
}
//下方开启事务,删除课程事件并创建新的任务事件
//2.2.2.删除课程事件
txErr := txM.Schedule.DeleteScheduleEventAndSchedule(ctx, req.ID, userID)
if txErr != nil {
return txErr
}
//2.2.3.再复用代码创建新的scheduleEvent下方代码改编自AddTaskClassItemIntoSchedule函数
//直接构造Schedule模型
sections := make([]int, 0, taskClassItem.EmbeddedTime.SectionTo-taskClassItem.EmbeddedTime.SectionFrom+1)
// 这里的 req 主要是为了传递 Week 和 DayOfWeek其他字段不需要了
schedules, scheduleEvent, err := conv.UserInsertTaskItemRequestToModel(
&model.UserInsertTaskClassItemToScheduleRequest{
Week: taskClassItem.EmbeddedTime.Week,
DayOfWeek: taskClassItem.EmbeddedTime.DayOfWeek},
taskClassItem, nil, userID, taskClassItem.EmbeddedTime.SectionFrom, taskClassItem.EmbeddedTime.SectionTo)
if err != nil {
return err
}
//将节次区间转换为节次切片,方便后续检查冲突
for section := taskClassItem.EmbeddedTime.SectionFrom; section <= taskClassItem.EmbeddedTime.SectionTo; section++ {
sections = append(sections, section)
}
//单用户不存在删除时这个格子被占用的情况,所以不检查冲突了
/*//4.1 统一检查冲突(避免逐条查库)
conflict, err := ss.scheduleDAO.HasUserScheduleConflict(ctx, userID, req.Week, req.DayOfWeek, sections)
if err != nil {
return err
}
if conflict {
return respond.ScheduleConflict
}*/
// 5. 写入数据库(通过 RepoManager 统一管理事务)
// 这里的 sv.daoManager 是你在初始化 Service 时注入的全局 RepoManager 实例
// 5.1 使用事务中的 ScheduleRepo 插入 Event
eventID, txErr := txM.Schedule.AddScheduleEvent(scheduleEvent)
if txErr != nil {
return txErr // 触发回滚
}
// 5.2 关联 ID纯内存操作无需 tx
for i := range schedules {
schedules[i].EventID = eventID
}
// 5.3 使用事务中的 ScheduleRepo 批量插入原子槽位
if _, txErr = txM.Schedule.AddSchedules(schedules); txErr != nil {
return txErr // 触发回滚
}
// 5.4 使用事务中的 TaskRepo 更新任务状态
if txErr = txM.TaskClass.UpdateTaskClassItemEmbeddedTime(ctx, embeddedTaskID, taskClassItem.EmbeddedTime); txErr != nil {
return txErr // 触发回滚
}
continue
}
//2.3.如果没有嵌入的事件,就直接删除课程事件
err = txM.Schedule.DeleteScheduleEventAndSchedule(ctx, req.ID, userID)
if err != nil {
return err
}
//先通过rel_id找到对应的task_id
taskID, txErr := txM.Schedule.GetRelIDByScheduleEventID(ctx, req.ID)
if txErr != nil {
return err
}
//2.4.如果是任务块转而去清除task_items表中的嵌入时间
if taskID != 0 {
//再将task_items表中对应的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
}
continue
}
//3.只删嵌入的事件
if req.DeleteEmbeddedTask {
//下面先设置schedule表的embedded_task_id字段为null再设置task_items表的embedded_time字段为null实现删除嵌入事件的效果
//3.1.先将schedule表的embedded_task_id字段设置为null
taskID, txErr := txM.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, req.ID)
if txErr != nil {
return txErr
}
//3.2.再将task_items表的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
continue
}
}
return nil
})
if err != nil {
return err
}
return nil
}
func (ss *ScheduleService) GetUserRecentCompletedSchedules(ctx context.Context, userID, index, limit int) (*model.UserRecentCompletedScheduleResponse, error) {
//1.先查缓存
cachedResp, err := ss.cacheDAO.GetUserRecentCompletedSchedulesFromCache(ctx, userID, index, limit)
if err == nil {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//2.查询用户最近完成的日程安排
//获取现在的时间
/*nowTime := time.Now()*/
nowTime := time.Date(2026, 6, 30, 12, 0, 0, 0, time.Local) //测试数据
schedules, err := ss.scheduleDAO.GetUserRecentCompletedSchedules(ctx, nowTime, userID, index, limit)
if err != nil {
return nil, err
}
//3.转换为前端需要的格式
result := conv.SchedulesToRecentCompletedSchedules(schedules)
//4.将查询结果存入缓存设置过期时间为30分钟根据实际情况调整
err = ss.cacheDAO.SetUserRecentCompletedSchedulesToCache(ctx, userID, index, limit, result)
if err != nil {
return nil, err
}
return result, nil
}
func (ss *ScheduleService) GetUserOngoingSchedule(ctx context.Context, userID int) (*model.OngoingSchedule, error) {
//1.先查缓存
cachedResp, err := ss.cacheDAO.GetUserOngoingScheduleFromCache(ctx, userID)
if err == nil && cachedResp == nil {
// 之前缓存过没有正在进行的日程,直接返回 nil
return nil, respond.NoOngoingOrUpcomingSchedule
}
if err == nil {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//2.查询用户正在进行的日程安排
/*nowTime := time.Now()*/
nowTime := time.Date(2026, 6, 30, 18, 50, 0, 0, time.Local) //测试数据
schedules, err := ss.scheduleDAO.GetUserOngoingSchedule(ctx, userID, nowTime)
if err != nil {
return nil, err
}
//3.转换为前端需要的格式
result := conv.SchedulesToUserOngoingSchedule(schedules)
if result != nil {
if result.StartTime.After(nowTime) {
result.TimeStatus = "upcoming"
} else {
result.TimeStatus = "ongoing"
}
}
//4.将查询结果存入缓存,设置过期时间直到此任务结束(根据实际情况调整)
err = ss.cacheDAO.SetUserOngoingScheduleToCache(ctx, userID, result)
if err != nil {
return nil, err
}
if result == nil {
// 没有正在进行或即将开始的日程,返回特定错误
return nil, respond.NoOngoingOrUpcomingSchedule
}
return result, nil
}
func (ss *ScheduleService) RevocateUserTaskClassItem(ctx context.Context, userID, eventID int) error {
//1.先查库看看这个event是任务事件还是课程事件以及判断它是否属于用户
eventType, err := ss.scheduleDAO.GetScheduleTypeByEventID(ctx, eventID, userID)
if err != nil {
return err
}
//2.根据查询结果进行不同的撤销操作
if eventType == "course" {
//下面开启事务,撤销嵌入事件
err := ss.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
//下面先设置schedule表的embedded_task_id字段为null再设置task_items表的embedded_time字段为null实现删除嵌入事件的效果
//3.1.先将schedule表的embedded_task_id字段设置为null
taskID, txErr := txM.Schedule.SetScheduleEmbeddedTaskIDToNull(ctx, eventID)
if txErr != nil {
return txErr
}
//3.2.再将task_items表的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
//3.3.最后设置task_items表的status字段为已撤销
txErr = txM.Schedule.RevocateSchedulesByEventID(ctx, eventID)
if txErr != nil {
return txErr
}
return nil
})
if err != nil {
return err
}
} else if eventType == "task" {
//下面开启事务,撤销任务事件
err := ss.repoManager.Transaction(ctx, func(txM *rootdao.RepoManager) error {
//先通过rel_id找到对应的task_id
taskID, txErr := txM.Schedule.GetRelIDByScheduleEventID(ctx, eventID)
if txErr != nil {
return err
}
//再将task_items表中对应的embedded_time字段设置为null
txErr = txM.TaskClass.DeleteTaskClassItemEmbeddedTime(ctx, taskID)
if txErr != nil {
return txErr
}
//最后将其从日程表中删除(通过级联删除实现)
err = txM.Schedule.DeleteScheduleEventAndSchedule(ctx, eventID, userID)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
} else {
log.Println("ScheduleService.RevocateUserTaskClassItem: eventType is neither embedded_task nor task, something must be wrong")
}
return nil
}
func (ss *ScheduleService) SmartPlanning(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, error) {
//1.通过任务类id获取任务类详情
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, err
}
//2.校验任务类的参数是否合法
if taskClass == nil {
return nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, respond.TaskClassModeNotAuto
}
//3.获取任务类安排的时间范围内的全部周数信息(左右边界不足一周的情况也要算作一周)
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate))
if err != nil {
return nil, err
}
//4.将多个周的信息传入智能排课算法,获取推荐的时间安排(周+周内的天+节次)
result, err := planning.SmartPlanningMainLogic(schedules, taskClass)
if err != nil {
return nil, err
}
//5.将推荐的时间安排转换为前端需要的格式返回
return result, nil
}
// SmartPlanningRaw 执行粗排算法并同时返回展示结构和已分配的任务项。
//
// 职责边界:
// 1. 与 SmartPlanning 共享完全相同的前置校验和粗排逻辑;
// 2. 额外返回 allocatedItems每项的 EmbeddedTime 已由算法回填),
// 供 Agent 排程链路直接转换为 BatchApplyPlans 请求,无需再让模型"二次分配"。
func (ss *ScheduleService) SmartPlanningRaw(ctx context.Context, userID, taskClassID int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) {
// 1. 获取任务类详情。
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
// 2. 获取时间范围内的全部日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(ctx, userID, conv.CalculateFirstDayOfWeek(*taskClass.StartDate), conv.CalculateLastDayOfWeek(*taskClass.EndDate))
if err != nil {
return nil, nil, err
}
// 3. 执行粗排算法,拿到已分配的 itemsEmbeddedTime 已回填)。
allocatedItems, err := planning.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 同时生成展示结构,供 SSE 阶段推送给前端预览。
displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems)
return displayResult, allocatedItems, nil
}
// SmartPlanningMulti 执行“多任务类智能粗排”,仅返回前端展示结构。
//
// 职责边界:
// 1. 负责把多任务类请求收口到统一粗排流程;
// 2. 负责返回展示结构;
// 3. 不返回底层分配细节(由 SmartPlanningMultiRaw 提供)。
func (ss *ScheduleService) SmartPlanningMulti(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, error) {
displayResult, _, err := ss.SmartPlanningMultiRaw(ctx, userID, taskClassIDs)
if err != nil {
return nil, err
}
return displayResult, nil
}
// SmartPlanningMultiRaw 执行“多任务类智能粗排”,同时返回展示结构和已分配任务项。
//
// 职责边界:
// 1. 负责多任务类请求的完整前置处理(归一化/校验/排序/时间窗收敛);
// 2. 负责调用多任务类粗排主逻辑(共享资源池);
// 3. 只计算建议,不负责落库。
func (ss *ScheduleService) SmartPlanningMultiRaw(ctx context.Context, userID int, taskClassIDs []int) ([]model.UserWeekSchedule, []model.TaskClassItem, error) {
// 1. 输入归一化。
normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs)
if len(normalizedIDs) == 0 {
return nil, nil, respond.WrongTaskClassID
}
// 2. 批量读取完整任务类(含 Items
taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs)
if err != nil {
return nil, nil, err
}
// 3. 校验任务类并计算全局时间窗。
orderedTaskClasses, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs)
if err != nil {
return nil, nil, err
}
// 4. 拉取全局时间窗内的既有日程底板。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(
ctx,
userID,
conv.CalculateFirstDayOfWeek(globalStartDate),
conv.CalculateLastDayOfWeek(globalEndDate),
)
if err != nil {
return nil, nil, err
}
// 5. 执行多任务类粗排(共享资源池 + 增量占位)。
allocatedItems, err := planning.SmartPlanningRawItemsMulti(schedules, orderedTaskClasses)
if err != nil {
return nil, nil, err
}
// 6. 转换前端展示结构。
displayResult := conv.PlanningResultToUserWeekSchedules(schedules, allocatedItems)
return displayResult, allocatedItems, nil
}
// ResolvePlanningWindowByTaskClasses 解析“多任务类排程窗口”的相对周/天边界。
//
// 职责边界:
// 1. 只负责根据 task_class_ids 计算全局起止日期并转换成相对周/天;
// 2. 不执行粗排、不查询课表、不生成 HybridEntries
// 3. 供 Agent 周级 Move 工具做硬边界校验,防止越界移动。
//
// 返回语义:
// 1. startWeek/startDay允许排程的起点
// 2. endWeek/endDay允许排程的终点
// 3. error任何校验或日期转换失败都返回错误。
func (ss *ScheduleService) ResolvePlanningWindowByTaskClasses(ctx context.Context, userID int, taskClassIDs []int) (int, int, int, int, error) {
// 1. 输入归一化:过滤非法值并去重。
normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs)
if len(normalizedIDs) == 0 {
return 0, 0, 0, 0, respond.WrongTaskClassID
}
// 2. 批量查询任务类并复用统一校验逻辑,拿到全局起止日期。
taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs)
if err != nil {
return 0, 0, 0, 0, err
}
_, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs)
if err != nil {
return 0, 0, 0, 0, err
}
// 3. 把绝对日期转换为“相对周/天”。
// 3.1 这里统一复用 conv.RealDateToRelativeDate确保和现有排程口径一致
// 3.2 若日期超出学期配置范围,直接返回错误,避免错误边界进入工具层。
startWeek, startDay, err := conv.RealDateToRelativeDate(globalStartDate.Format(conv.DateFormat))
if err != nil {
return 0, 0, 0, 0, err
}
endWeek, endDay, err := conv.RealDateToRelativeDate(globalEndDate.Format(conv.DateFormat))
if err != nil {
return 0, 0, 0, 0, err
}
if endWeek < startWeek || (endWeek == startWeek && endDay < startDay) {
return 0, 0, 0, 0, respond.InvalidDateRange
}
return startWeek, startDay, endWeek, endDay, nil
}
// normalizeTaskClassIDsForMultiPlanning 归一化 task_class_ids过滤非法值、去重并保序
func normalizeTaskClassIDsForMultiPlanning(ids []int) []int {
if len(ids) == 0 {
return []int{}
}
normalized := make([]int, 0, len(ids))
seen := make(map[int]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
normalized = append(normalized, id)
}
return normalized
}
// prepareTaskClassesForMultiPlanning 把 DAO 结果转成可直接粗排的数据集。
//
// 职责边界:
// 1. 校验每个任务类可参与自动排程;
// 2. 计算全局时间窗(最早开始 ~ 最晚结束);
// 3. 执行多任务类排序策略。
func prepareTaskClassesForMultiPlanning(taskClasses []model.TaskClass, orderedIDs []int) ([]*model.TaskClass, time.Time, time.Time, error) {
if len(orderedIDs) == 0 {
return nil, time.Time{}, time.Time{}, respond.WrongTaskClassID
}
classByID := make(map[int]*model.TaskClass, len(taskClasses))
for i := range taskClasses {
tc := &taskClasses[i]
classByID[tc.ID] = tc
}
ordered := make([]*model.TaskClass, 0, len(orderedIDs))
var globalStart time.Time
var globalEnd time.Time
for idx, id := range orderedIDs {
taskClass, exists := classByID[id]
if !exists || taskClass == nil {
return nil, time.Time{}, time.Time{}, respond.WrongTaskClassID
}
if taskClass.Mode == nil || *taskClass.Mode != "auto" {
return nil, time.Time{}, time.Time{}, respond.TaskClassModeNotAuto
}
if taskClass.StartDate == nil || taskClass.EndDate == nil {
return nil, time.Time{}, time.Time{}, respond.InvalidDateRange
}
start := *taskClass.StartDate
end := *taskClass.EndDate
if end.Before(start) {
return nil, time.Time{}, time.Time{}, respond.InvalidDateRange
}
if idx == 0 || start.Before(globalStart) {
globalStart = start
}
if idx == 0 || end.After(globalEnd) {
globalEnd = end
}
ordered = append(ordered, taskClass)
}
sortTaskClassesForMultiPlanning(ordered, orderedIDs)
return ordered, globalStart, globalEnd, nil
}
// sortTaskClassesForMultiPlanning 执行稳定排序:
// 1. end_date 早优先;
// 2. rapid 优先于 steady
// 3. 输入顺序兜底。
func sortTaskClassesForMultiPlanning(taskClasses []*model.TaskClass, inputOrder []int) {
if len(taskClasses) <= 1 {
return
}
orderIndex := make(map[int]int, len(inputOrder))
for idx, id := range inputOrder {
orderIndex[id] = idx
}
sort.SliceStable(taskClasses, func(i, j int) bool {
left := taskClasses[i]
right := taskClasses[j]
if left == nil || right == nil {
return left != nil
}
if left.EndDate != nil && right.EndDate != nil && !left.EndDate.Equal(*right.EndDate) {
return left.EndDate.Before(*right.EndDate)
}
leftRapid := left.Strategy != nil && *left.Strategy == "rapid"
rightRapid := right.Strategy != nil && *right.Strategy == "rapid"
if leftRapid != rightRapid {
return leftRapid
}
leftOrder, leftOK := orderIndex[left.ID]
rightOrder, rightOK := orderIndex[right.ID]
if leftOK && rightOK && leftOrder != rightOrder {
return leftOrder < rightOrder
}
return left.ID < right.ID
})
}
// HybridScheduleWithPlan 构建“单任务类”混合日程existing + suggested
func (ss *ScheduleService) HybridScheduleWithPlan(
ctx context.Context, userID, taskClassID int,
) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) {
// 1. 校验并读取任务类。
taskClass, err := ss.taskClassDAO.GetCompleteTaskClassByID(ctx, taskClassID, userID)
if err != nil {
return nil, nil, err
}
if taskClass == nil {
return nil, nil, respond.WrongTaskClassID
}
if taskClass.Mode == nil || *taskClass.Mode != "auto" {
return nil, nil, respond.TaskClassModeNotAuto
}
if taskClass.StartDate == nil || taskClass.EndDate == nil {
return nil, nil, respond.InvalidDateRange
}
// 2. 拉取时间窗内既有日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(
ctx, userID,
conv.CalculateFirstDayOfWeek(*taskClass.StartDate),
conv.CalculateLastDayOfWeek(*taskClass.EndDate),
)
if err != nil {
return nil, nil, err
}
// 3. 执行粗排。
allocatedItems, err := planning.SmartPlanningRawItems(schedules, taskClass)
if err != nil {
return nil, nil, err
}
// 4. 统一合并。
entries := buildHybridEntriesFromSchedulesAndAllocated(schedules, allocatedItems)
return entries, allocatedItems, nil
}
// HybridScheduleWithPlanMulti 构建“多任务类”混合日程existing + suggested
func (ss *ScheduleService) HybridScheduleWithPlanMulti(
ctx context.Context,
userID int,
taskClassIDs []int,
) ([]model.HybridScheduleEntry, []model.TaskClassItem, error) {
// 1. 归一化任务类 ID。
normalizedIDs := normalizeTaskClassIDsForMultiPlanning(taskClassIDs)
if len(normalizedIDs) == 0 {
return nil, nil, respond.WrongTaskClassID
}
// 2. 拉取任务类并做校验/排序。
taskClasses, err := ss.taskClassDAO.GetCompleteTaskClassesByIDs(ctx, userID, normalizedIDs)
if err != nil {
return nil, nil, err
}
orderedTaskClasses, globalStartDate, globalEndDate, err := prepareTaskClassesForMultiPlanning(taskClasses, normalizedIDs)
if err != nil {
return nil, nil, err
}
// 3. 拉取全局时间窗内既有日程。
schedules, err := ss.scheduleDAO.GetUserSchedulesByTimeRange(
ctx,
userID,
conv.CalculateFirstDayOfWeek(globalStartDate),
conv.CalculateLastDayOfWeek(globalEndDate),
)
if err != nil {
return nil, nil, err
}
// 4. 多任务类粗排。
allocatedItems, err := planning.SmartPlanningRawItemsMulti(schedules, orderedTaskClasses)
if err != nil {
return nil, nil, err
}
// 5. 统一合并。
entries := buildHybridEntriesFromSchedulesAndAllocated(schedules, allocatedItems)
return entries, allocatedItems, nil
}
// buildHybridEntriesFromSchedulesAndAllocated 合并 existing/suggested 条目。
//
// 说明:
// 1. existing 按“事件 + 天 + 可嵌入语义 + 阻塞语义”分组,再按连续节次切块;
// 2. suggested 直接根据 allocatedItems 生成;
// 3. 仅做内存组装,不做数据库操作。
func buildHybridEntriesFromSchedulesAndAllocated(
schedules []model.Schedule,
allocatedItems []model.TaskClassItem,
) []model.HybridScheduleEntry {
entries := make([]model.HybridScheduleEntry, 0, len(schedules)/2+len(allocatedItems))
type eventGroupKey struct {
EventID int
Week int
DayOfWeek int
CanBeEmbedded bool
BlockForSuggested bool
}
type eventGroup struct {
Key eventGroupKey
Name string
Type string
Sections []int
}
groupMap := make(map[eventGroupKey]*eventGroup)
// 1. 先处理 existing。
for _, s := range schedules {
name := "未知"
typ := "course"
canBeEmbedded := false
if s.Event != nil {
name = s.Event.Name
typ = s.Event.Type
canBeEmbedded = s.Event.CanBeEmbedded
}
// 1.1 阻塞语义:
// 1.1.1 task 默认阻塞;
// 1.1.2 course 且不可嵌入时阻塞;
// 1.1.3 course 且可嵌入时,若当前原子格未被 embedded_task 占用,则不阻塞。
blockForSuggested := true
if typ == "course" && canBeEmbedded && s.EmbeddedTaskID == nil {
blockForSuggested = false
}
key := eventGroupKey{
EventID: s.EventID,
Week: s.Week,
DayOfWeek: s.DayOfWeek,
CanBeEmbedded: canBeEmbedded,
BlockForSuggested: blockForSuggested,
}
group, ok := groupMap[key]
if !ok {
group = &eventGroup{
Key: key,
Name: name,
Type: typ,
}
groupMap[key] = group
}
group.Sections = append(group.Sections, s.Section)
}
for _, group := range groupMap {
if len(group.Sections) == 0 {
continue
}
sort.Ints(group.Sections)
runStart := group.Sections[0]
prev := group.Sections[0]
flushRun := func(from, to int) {
entries = append(entries, model.HybridScheduleEntry{
Week: group.Key.Week,
DayOfWeek: group.Key.DayOfWeek,
SectionFrom: from,
SectionTo: to,
Name: group.Name,
Type: group.Type,
Status: "existing",
EventID: group.Key.EventID,
CanBeEmbedded: group.Key.CanBeEmbedded,
BlockForSuggested: group.Key.BlockForSuggested,
})
}
for i := 1; i < len(group.Sections); i++ {
cur := group.Sections[i]
if cur == prev+1 {
prev = cur
continue
}
flushRun(runStart, prev)
runStart = cur
prev = cur
}
flushRun(runStart, prev)
}
// 2. 再处理 suggested。
for _, item := range allocatedItems {
if item.EmbeddedTime == nil {
continue
}
name := "未命名任务"
if item.Content != nil && strings.TrimSpace(*item.Content) != "" {
name = strings.TrimSpace(*item.Content)
}
entries = append(entries, model.HybridScheduleEntry{
Week: item.EmbeddedTime.Week,
DayOfWeek: item.EmbeddedTime.DayOfWeek,
SectionFrom: item.EmbeddedTime.SectionFrom,
SectionTo: item.EmbeddedTime.SectionTo,
Name: name,
Type: "task",
Status: "suggested",
TaskItemID: item.ID,
TaskClassID: derefInt(item.CategoryID),
BlockForSuggested: true,
})
}
return entries
}
func derefInt(p *int) int {
if p == nil {
return 0
}
return *p
}