Version: 0.9.70.dev.260504

后端:
1. 阶段 5 schedule 首刀服务化落地,新增 `cmd/schedule`、`services/schedule/{dao,rpc,sv,core}`、`gateway/client/schedule`、`shared/contracts/schedule` 和 schedule port
2. gateway `/api/v1/schedule/*` 切到 schedule zrpc client,HTTP 门面只保留鉴权、参数绑定、超时和轻量转发
3. active-scheduler 的 schedule facts、feedback 和 confirm apply 改为调用 schedule RPC adapter,减少对 `schedule_events`、`schedules`、`task_classes`、`task_items` 的跨域 DB 依赖
4. 单体聊天主动调度 rerun 的 schedule 读写链路切到 schedule RPC,迁移期仅保留 task facts 直读 Gorm
5. 为 schedule zrpc 补充 `Ping` 启动健康检查,并在 gateway client 与 active-scheduler adapter 初始化时校验服务可用
6. `cmd/schedule` 独立初始化 DB / Redis,只 AutoMigrate schedule 自有表,并显式检查迁移期 task / task-class 依赖表
7. 更新 active-scheduler 依赖表检查和 preview confirm apply 抽象,保留旧 Gorm 实现作为迁移期回退路径
8. 补充 `schedule.rpc` 示例配置和 schedule HTTP RPC 超时配置

文档:
1. 更新微服务迁移计划,将阶段 5 schedule 首刀进展、当前切流点、旧实现保留范围和 active-scheduler DB 依赖收缩情况写入基线
This commit is contained in:
Losita
2026-05-04 22:33:38 +08:00
parent 4d9a5c4d30
commit 29b8cf0ada
27 changed files with 4456 additions and 51 deletions

View File

@@ -0,0 +1,114 @@
package sv
import (
"context"
"errors"
rootmodel "github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/services/schedule/core/applyadapter"
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
)
// DeleteScheduleEventByContract 把跨进程删除契约转换为既有 schedule 核心逻辑入参。
func (ss *ScheduleService) DeleteScheduleEventByContract(ctx context.Context, req schedulecontracts.DeleteScheduleEventsRequest) error {
events := make([]rootmodel.UserDeleteScheduleEvent, 0, len(req.Events))
for _, event := range req.Events {
events = append(events, rootmodel.UserDeleteScheduleEvent{
ID: event.ID,
DeleteCourse: event.DeleteCourse,
DeleteEmbeddedTask: event.DeleteEmbeddedTask,
})
}
return ss.DeleteScheduleEvent(ctx, events, req.UserID)
}
// GetScheduleFactsByWindow 暴露主动调度需要的滚动窗口日程事实。
func (ss *ScheduleService) GetScheduleFactsByWindow(ctx context.Context, req schedulecontracts.ScheduleWindowRequest) (schedulecontracts.ScheduleWindowFacts, error) {
if ss == nil || ss.scheduleDAO == nil {
return schedulecontracts.ScheduleWindowFacts{}, errors.New("schedule facts service 未初始化")
}
return ss.scheduleDAO.GetScheduleFactsByWindow(ctx, req)
}
// GetFeedbackSignal 暴露主动调度 unfinished_feedback 的日程目标定位事实。
func (ss *ScheduleService) GetFeedbackSignal(ctx context.Context, req schedulecontracts.FeedbackRequest) (schedulecontracts.FeedbackFact, bool, error) {
if ss == nil || ss.scheduleDAO == nil {
return schedulecontracts.FeedbackFact{}, false, errors.New("schedule feedback service 未初始化")
}
return ss.scheduleDAO.GetFeedbackSignal(ctx, req)
}
// ApplyActiveScheduleChanges 在 schedule 服务内执行主动调度正式写入。
//
// 职责边界:
// 1. 只把 shared 契约转换为 schedule 私有 applyadapter 入参;
// 2. 具体事务、锁定、冲突检查和写库仍由搬运后的 applyadapter 负责;
// 3. 返回结果只包含正式落库 ID不回写 active-scheduler preview 状态。
func (ss *ScheduleService) ApplyActiveScheduleChanges(ctx context.Context, req schedulecontracts.ApplyActiveScheduleRequest) (schedulecontracts.ApplyActiveScheduleResult, error) {
if ss == nil || ss.applyAdapter == nil {
return schedulecontracts.ApplyActiveScheduleResult{}, errors.New("schedule apply adapter 未初始化")
}
result, err := ss.applyAdapter.ApplyActiveScheduleChanges(ctx, toAdapterApplyRequest(req))
if err != nil {
return schedulecontracts.ApplyActiveScheduleResult{}, err
}
return schedulecontracts.ApplyActiveScheduleResult{
ApplyID: result.ApplyID,
AppliedEventIDs: result.AppliedEventIDs,
AppliedScheduleIDs: result.AppliedScheduleIDs,
}, nil
}
func toAdapterApplyRequest(req schedulecontracts.ApplyActiveScheduleRequest) applyadapter.ApplyActiveScheduleRequest {
changes := make([]applyadapter.ApplyChange, 0, len(req.Changes))
for _, change := range req.Changes {
changes = append(changes, applyadapter.ApplyChange{
ChangeID: change.ChangeID,
ChangeType: change.ChangeType,
TargetType: change.TargetType,
TargetID: change.TargetID,
ToSlot: toAdapterSlotSpan(change.ToSlot),
DurationSections: change.DurationSections,
Metadata: cloneStringMap(change.Metadata),
})
}
return applyadapter.ApplyActiveScheduleRequest{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
UserID: req.UserID,
CandidateID: req.CandidateID,
Changes: changes,
RequestedAt: req.RequestedAt,
TraceID: req.TraceID,
}
}
func toAdapterSlotSpan(span *schedulecontracts.SlotSpan) *applyadapter.SlotSpan {
if span == nil {
return nil
}
return &applyadapter.SlotSpan{
Start: applyadapter.Slot{
Week: span.Start.Week,
DayOfWeek: span.Start.DayOfWeek,
Section: span.Start.Section,
},
End: applyadapter.Slot{
Week: span.End.Week,
DayOfWeek: span.End.DayOfWeek,
Section: span.End.Section,
},
DurationSections: span.DurationSections,
}
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return nil
}
output := make(map[string]string, len(input))
for key, value := range input {
output[key] = value
}
return output
}

View File

@@ -0,0 +1,881 @@
package sv
import (
"context"
"errors"
"log"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
rootdao "github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/logic"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/services/schedule/core/applyadapter"
scheduledao "github.com/LoveLosita/smartflow/backend/services/schedule/dao"
"github.com/go-redis/redis/v8"
)
type ScheduleService struct {
scheduleDAO *scheduledao.ScheduleDAO
taskClassDAO *rootdao.TaskClassDAO
repoManager *rootdao.RepoManager // 统一管理多个 DAO 的事务
cacheDAO *rootdao.CacheDAO // 需要在 ScheduleService 中使用缓存
applyAdapter *applyadapter.GormApplyAdapter
}
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 {
// 缓存命中,直接返回
return cachedResp, nil
}
// 如果是 redis.Nil 错误,说明缓存未命中,我们继续查库
if !errors.Is(err, redis.Nil) {
return nil, err
}
//2.获取当前日期
/*curTime := time.Now().Format("2006-01-02")*/
curTime := "2026-03-02" //测试数据
week, dayOfWeek, err := conv.RealDateToRelativeDate(curTime)
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.SchedulesToUserTodaySchedule(schedules)
//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
}
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 := logic.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 := logic.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 := logic.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 := logic.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 := logic.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
}