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,496 @@
package applyadapter
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// GormApplyAdapter 负责把主动调度确认后的变更写入正式 schedule 表。
//
// 职责边界:
// 1. 只写 schedule_events / schedules并在事务内完成目标重校验与冲突重校验
// 2. 不回写 active_schedule_previews不发布 outbox不调用 API/service/task
// 3. 不创建 task_item也不更新 task / task_items 状态task_pool 是否已安排由 schedule_events 反查判断。
//
// 迁移说明:
// 1. 本文件从 active-scheduler applyadapter 原样搬入 schedule 服务,先让正式日程写所有权回到 schedule
// 2. active-scheduler 下的旧 adapter 本轮暂留作回退与历史编译兼容,切流稳定后再删除旧实现;
// 3. 暂不抽 shared 公共层,因为该逻辑属于 schedule 写模型状态机,放入 shared 会污染跨进程契约层。
type GormApplyAdapter struct {
db *gorm.DB
}
func NewGormApplyAdapter(db *gorm.DB) *GormApplyAdapter {
return &GormApplyAdapter{db: db}
}
// ApplyActiveScheduleChanges 在单个数据库事务内应用主动调度变更。
//
// 事务语义:
// 1. 先规范化所有 change 的节次,并检查本次请求内部是否自相冲突;
// 2. 事务内锁定目标事实并重查 schedules 占用,任何冲突都直接返回 slot_conflict
// 3. 所有 event 和 schedules 都成功插入后才提交;任一错误都会回滚,避免半写。
//
// 输入输出:
// 1. req.UserID / req.PreviewID / req.Changes 必须有效;
// 2. 返回的 AppliedEventIDs 是新建 schedule_events.id
// 3. error 若为 *ApplyError上游可按 Code 分类处理。
func (a *GormApplyAdapter) ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error) {
if a == nil || a.db == nil {
return ApplyActiveScheduleResult{}, newApplyError(ErrorCodeInvalidRequest, "主动调度 apply adapter 未初始化", nil)
}
normalized, err := normalizeRequest(req)
if err != nil {
return ApplyActiveScheduleResult{}, err
}
result := ApplyActiveScheduleResult{ApplyID: req.ApplyID}
err = a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
appliedEventIDs := make([]int, 0, len(normalized))
appliedScheduleIDs := make([]int, 0)
for _, change := range normalized {
var eventIDs []int
var scheduleIDs []int
var applyErr error
switch {
case isAddTaskPoolChange(change):
eventIDs, scheduleIDs, applyErr = a.applyTaskPoolChange(ctx, tx, req, change)
case isCreateMakeupChange(change):
eventIDs, scheduleIDs, applyErr = a.applyMakeupChange(ctx, tx, req, change)
default:
applyErr = newApplyError(ErrorCodeUnsupportedChangeType, fmt.Sprintf("不支持的主动调度变更类型:%s", change.ChangeType), nil)
}
if applyErr != nil {
return applyErr
}
appliedEventIDs = append(appliedEventIDs, eventIDs...)
appliedScheduleIDs = append(appliedScheduleIDs, scheduleIDs...)
}
result.AppliedEventIDs = appliedEventIDs
result.AppliedScheduleIDs = appliedScheduleIDs
return nil
})
if err != nil {
return ApplyActiveScheduleResult{}, classifyDBError(err)
}
return result, nil
}
func (a *GormApplyAdapter) applyTaskPoolChange(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange) ([]int, []int, error) {
targetID := change.TargetID
if change.TargetType != "" && change.TargetType != TargetTypeTaskPool {
return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "add_task_pool_to_schedule 只能写入 task_pool 目标", nil)
}
// 调用目的:锁住同一个 task_pool 任务,串行化“是否已经进入日程”的判断,避免并发确认写出重复任务块。
task, err := lockTaskPool(ctx, tx, req.UserID, targetID)
if err != nil {
return nil, nil, err
}
if task.IsCompleted {
return nil, nil, newApplyError(ErrorCodeTargetCompleted, "task_pool 任务已完成,不能再加入日程", nil)
}
if err := ensureTaskPoolNotScheduled(ctx, tx, req.UserID, task.ID); err != nil {
return nil, nil, err
}
if err := ensureSlotsFree(ctx, tx, req.UserID, change); err != nil {
return nil, nil, err
}
eventName := strings.TrimSpace(task.Title)
if eventName == "" {
eventName = fmt.Sprintf("任务 %d", task.ID)
}
relID := task.ID
return insertTaskEventWithSchedules(ctx, tx, req, change, eventPayload{
Name: eventName,
TaskSourceType: TaskSourceTypeTaskPool,
RelID: relID,
Sections: change.Sections,
})
}
func (a *GormApplyAdapter) applyMakeupChange(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange) ([]int, []int, error) {
target, err := resolveMakeupTarget(ctx, tx, req.UserID, change)
if err != nil {
return nil, nil, err
}
if err := ensureSlotsFree(ctx, tx, req.UserID, change); err != nil {
return nil, nil, err
}
return insertTaskEventWithSchedules(ctx, tx, req, change, eventPayload{
Name: target.Name,
TaskSourceType: target.TaskSourceType,
RelID: target.RelID,
MakeupForEventID: &target.MakeupForEventID,
Sections: change.Sections,
})
}
type normalizedChange struct {
ApplyChange
Week int
DayOfWeek int
Sections []int
}
func normalizeRequest(req ApplyActiveScheduleRequest) ([]normalizedChange, error) {
if req.UserID <= 0 {
return nil, newApplyError(ErrorCodeInvalidRequest, "user_id 不能为空", nil)
}
if strings.TrimSpace(req.PreviewID) == "" {
return nil, newApplyError(ErrorCodeInvalidRequest, "preview_id 不能为空", nil)
}
if len(req.Changes) == 0 {
return nil, newApplyError(ErrorCodeInvalidRequest, "changes 不能为空", nil)
}
seenSlots := make(map[string]struct{})
normalized := make([]normalizedChange, 0, len(req.Changes))
for _, change := range req.Changes {
sections, err := normalizeSections(change)
if err != nil {
return nil, err
}
for _, section := range sections {
key := fmt.Sprintf("%d:%d:%d", change.ToSlot.Start.Week, change.ToSlot.Start.DayOfWeek, section)
if _, exists := seenSlots[key]; exists {
return nil, newApplyError(ErrorCodeSlotConflict, "本次确认请求内部存在重复节次", nil)
}
seenSlots[key] = struct{}{}
}
normalized = append(normalized, normalizedChange{
ApplyChange: change,
Week: change.ToSlot.Start.Week,
DayOfWeek: change.ToSlot.Start.DayOfWeek,
Sections: sections,
})
}
return normalized, nil
}
func normalizeSections(change ApplyChange) ([]int, error) {
if change.TargetID <= 0 {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "变更目标 ID 不能为空", nil)
}
if change.ToSlot == nil {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "变更缺少目标节次", nil)
}
start := change.ToSlot.Start
end := change.ToSlot.End
if start.Week <= 0 || start.DayOfWeek < 1 || start.DayOfWeek > 7 || start.Section < 1 || start.Section > 12 {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标起始节次不合法", nil)
}
duration := change.DurationSections
if duration <= 0 {
duration = change.ToSlot.DurationSections
}
if end.Section <= 0 && duration > 0 {
end = Slot{Week: start.Week, DayOfWeek: start.DayOfWeek, Section: start.Section + duration - 1}
}
if end.Week <= 0 && end.DayOfWeek <= 0 && end.Section <= 0 {
end = start
}
if end.Week != start.Week || end.DayOfWeek != start.DayOfWeek || end.Section < start.Section {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标节次必须是同一天内的连续区间", nil)
}
if end.Section > 12 {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标结束节次不合法", nil)
}
actualDuration := end.Section - start.Section + 1
if duration > 0 && duration != actualDuration {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "duration_sections 与目标节次跨度不一致", nil)
}
sections := make([]int, 0, actualDuration)
for section := start.Section; section <= end.Section; section++ {
sections = append(sections, section)
}
return sections, nil
}
func isAddTaskPoolChange(change normalizedChange) bool {
if change.ChangeType == ChangeTypeAddTaskPoolToSchedule {
return true
}
return change.ChangeType == changeTypeAdd && change.TargetType == TargetTypeTaskPool
}
func isCreateMakeupChange(change normalizedChange) bool {
return change.ChangeType == ChangeTypeCreateMakeup
}
func lockTaskPool(ctx context.Context, tx *gorm.DB, userID, taskID int) (model.Task, error) {
var task model.Task
err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND user_id = ?", taskID, userID).
First(&task).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Task{}, newApplyError(ErrorCodeTargetNotFound, "task_pool 任务不存在或不属于当前用户", nil)
}
return model.Task{}, newApplyError(ErrorCodeDBError, "读取 task_pool 任务失败", err)
}
return task, nil
}
func ensureTaskPoolNotScheduled(ctx context.Context, tx *gorm.DB, userID, taskID int) error {
var count int64
err := tx.WithContext(ctx).
Model(&model.ScheduleEvent{}).
Where("user_id = ? AND type = ? AND task_source_type = ? AND rel_id = ?", userID, scheduleEventTypeTask, TaskSourceTypeTaskPool, taskID).
Count(&count).Error
if err != nil {
return newApplyError(ErrorCodeDBError, "检查 task_pool 是否已进入日程失败", err)
}
if count > 0 {
return newApplyError(ErrorCodeTargetAlreadyScheduled, "task_pool 任务已进入日程", nil)
}
return nil
}
func ensureSlotsFree(ctx context.Context, tx *gorm.DB, userID int, change normalizedChange) error {
sections := change.Sections
if len(sections) == 0 {
return newApplyError(ErrorCodeInvalidEditedChanges, "目标节次不能为空", nil)
}
sort.Ints(sections)
startSection := sections[0]
endSection := sections[len(sections)-1]
// 1. 在事务内对目标节次加行锁,命中任何已有 schedules 都视为冲突。
// 2. 若并发事务在检查后抢先插入同一唯一键,后续 Create 会被唯一索引兜底拦截并整体回滚。
// 3. MVP 不处理课程嵌入,任何已有课程、固定日程或任务都不可覆盖。
var occupied []model.Schedule
err := tx.WithContext(ctx).
Model(&model.Schedule{}).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("user_id = ? AND week = ? AND day_of_week = ? AND section IN ?", userID, change.Week, change.DayOfWeek, sections).
Find(&occupied).Error
if err != nil {
return newApplyError(ErrorCodeDBError, "检查目标节次冲突失败", err)
}
if len(occupied) > 0 {
return newApplyError(ErrorCodeSlotConflict, fmt.Sprintf("第 %d-%d 节已被占用", startSection, endSection), nil)
}
return nil
}
type eventPayload struct {
Name string
TaskSourceType string
RelID int
MakeupForEventID *int
Sections []int
}
func insertTaskEventWithSchedules(ctx context.Context, tx *gorm.DB, req ApplyActiveScheduleRequest, change normalizedChange, payload eventPayload) ([]int, []int, error) {
sections := append([]int(nil), payload.Sections...)
sort.Ints(sections)
start := sections[0]
end := sections[len(sections)-1]
startTime, endTime, err := conv.RelativeTimeToRealTime(change.Week, change.DayOfWeek, start, end)
if err != nil {
return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "目标节次无法转换为绝对时间", err)
}
previewID := strings.TrimSpace(req.PreviewID)
event := model.ScheduleEvent{
UserID: req.UserID,
Name: payload.Name,
Type: scheduleEventTypeTask,
TaskSourceType: payload.TaskSourceType,
RelID: &payload.RelID,
MakeupForEventID: payload.MakeupForEventID,
ActivePreviewID: &previewID,
CanBeEmbedded: false,
StartTime: startTime,
EndTime: endTime,
}
if err := tx.WithContext(ctx).Create(&event).Error; err != nil {
return nil, nil, newApplyError(ErrorCodeDBError, "写入 schedule_events 失败", err)
}
schedules := make([]model.Schedule, 0, len(sections))
for _, section := range sections {
schedules = append(schedules, model.Schedule{
EventID: event.ID,
UserID: req.UserID,
Week: change.Week,
DayOfWeek: change.DayOfWeek,
Section: section,
Status: scheduleStatusNormal,
})
}
if err := tx.WithContext(ctx).Create(&schedules).Error; err != nil {
return nil, nil, newApplyError(ErrorCodeDBError, "写入 schedules 失败", err)
}
scheduleIDs := make([]int, 0, len(schedules))
for _, schedule := range schedules {
scheduleIDs = append(scheduleIDs, schedule.ID)
}
return []int{event.ID}, scheduleIDs, nil
}
type makeupTarget struct {
Name string
TaskSourceType string
RelID int
MakeupForEventID int
}
func resolveMakeupTarget(ctx context.Context, tx *gorm.DB, userID int, change normalizedChange) (makeupTarget, error) {
makeupForEventID := parsePositiveInt(change.Metadata["makeup_for_event_id"])
if change.TargetType == "" || change.TargetType == TargetTypeScheduleEvent {
if change.TargetID > 0 {
makeupForEventID = change.TargetID
}
return resolveMakeupFromEvent(ctx, tx, userID, makeupForEventID)
}
if makeupForEventID <= 0 {
return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 必须提供 makeup_for_event_id", nil)
}
if _, err := lockScheduleEvent(ctx, tx, userID, makeupForEventID); err != nil {
return makeupTarget{}, err
}
switch change.TargetType {
case TargetTypeTaskPool:
task, err := lockTaskPool(ctx, tx, userID, change.TargetID)
if err != nil {
return makeupTarget{}, err
}
if task.IsCompleted {
return makeupTarget{}, newApplyError(ErrorCodeTargetCompleted, "补做目标 task_pool 已完成", nil)
}
return makeupTarget{
Name: nonEmpty(task.Title, fmt.Sprintf("任务 %d", task.ID)),
TaskSourceType: TaskSourceTypeTaskPool,
RelID: task.ID,
MakeupForEventID: makeupForEventID,
}, nil
case TargetTypeTaskItem:
item, err := lockTaskItemForUser(ctx, tx, userID, change.TargetID)
if err != nil {
return makeupTarget{}, err
}
return makeupTarget{
Name: nonEmpty(stringPtrValue(item.Content), fmt.Sprintf("任务块 %d", item.ID)),
TaskSourceType: TaskSourceTypeTaskItem,
RelID: item.ID,
MakeupForEventID: makeupForEventID,
}, nil
default:
return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 目标类型不合法", nil)
}
}
func resolveMakeupFromEvent(ctx context.Context, tx *gorm.DB, userID, eventID int) (makeupTarget, error) {
event, err := lockScheduleEvent(ctx, tx, userID, eventID)
if err != nil {
return makeupTarget{}, err
}
if event.Type != scheduleEventTypeTask || event.RelID == nil || *event.RelID <= 0 {
return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "补做来源必须是已排任务日程", nil)
}
sourceType := event.TaskSourceType
if sourceType == "" {
sourceType = TaskSourceTypeTaskItem
}
if sourceType != TaskSourceTypeTaskItem && sourceType != TaskSourceTypeTaskPool {
return makeupTarget{}, newApplyError(ErrorCodeInvalidEditedChanges, "补做来源任务类型不合法", nil)
}
return makeupTarget{
Name: nonEmpty(event.Name, fmt.Sprintf("补做任务 %d", event.ID)),
TaskSourceType: sourceType,
RelID: *event.RelID,
MakeupForEventID: event.ID,
}, nil
}
func lockScheduleEvent(ctx context.Context, tx *gorm.DB, userID, eventID int) (model.ScheduleEvent, error) {
if eventID <= 0 {
return model.ScheduleEvent{}, newApplyError(ErrorCodeInvalidEditedChanges, "makeup_for_event_id 不能为空", nil)
}
var event model.ScheduleEvent
err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND user_id = ?", eventID, userID).
First(&event).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ScheduleEvent{}, newApplyError(ErrorCodeTargetNotFound, "补做来源日程不存在或不属于当前用户", nil)
}
return model.ScheduleEvent{}, newApplyError(ErrorCodeDBError, "读取补做来源日程失败", err)
}
return event, nil
}
func lockTaskItemForUser(ctx context.Context, tx *gorm.DB, userID, taskItemID int) (model.TaskClassItem, error) {
var item model.TaskClassItem
err := tx.WithContext(ctx).
Table("task_items").
Select("task_items.*").
Joins("JOIN task_classes ON task_classes.id = task_items.category_id").
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("task_items.id = ? AND task_classes.user_id = ?", taskItemID, userID).
First(&item).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.TaskClassItem{}, newApplyError(ErrorCodeTargetNotFound, "task_item 不存在或不属于当前用户", nil)
}
return model.TaskClassItem{}, newApplyError(ErrorCodeDBError, "读取 task_item 失败", err)
}
return item, nil
}
func parsePositiveInt(value string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return 0
}
return parsed
}
func nonEmpty(value, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
}
return strings.TrimSpace(value)
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func classifyDBError(err error) error {
if err == nil {
return nil
}
var applyErr *ApplyError
if errors.As(err, &applyErr) {
return applyErr
}
message := strings.ToLower(err.Error())
if strings.Contains(message, "duplicate entry") ||
strings.Contains(message, "unique constraint") ||
strings.Contains(message, "unique violation") ||
strings.Contains(message, "idx_user_slot_atomic") {
return newApplyError(ErrorCodeSlotConflict, "目标节次已被其他日程占用", err)
}
return newApplyError(ErrorCodeDBError, "主动调度正式写库失败", err)
}

View File

@@ -0,0 +1,127 @@
package applyadapter
import "time"
const (
ChangeTypeAddTaskPoolToSchedule = "add_task_pool_to_schedule"
ChangeTypeCreateMakeup = "create_makeup"
changeTypeAdd = "add"
TargetTypeTaskPool = "task_pool"
TargetTypeTaskItem = "task_item"
TargetTypeScheduleEvent = "schedule_event"
scheduleEventTypeTask = "task"
scheduleStatusNormal = "normal"
TaskSourceTypeTaskPool = "task_pool"
TaskSourceTypeTaskItem = "task_item"
)
const (
ErrorCodeInvalidRequest = "invalid_request"
ErrorCodeUnsupportedChangeType = "unsupported_change_type"
ErrorCodeTargetNotFound = "target_not_found"
ErrorCodeTargetCompleted = "target_completed"
ErrorCodeTargetAlreadyScheduled = "target_already_scheduled"
ErrorCodeSlotConflict = "slot_conflict"
ErrorCodeInvalidEditedChanges = "invalid_edited_changes"
ErrorCodeDBError = "db_error"
)
// ApplyActiveScheduleRequest 是主动调度确认后交给 schedule 域的正式写库请求。
//
// 职责边界:
// 1. 只承载已经由上游 preview/confirm 校验过的用户、候选和变更事实;
// 2. 不负责表达 preview 状态回写adapter 成功后仅返回正式落库 ID
// 3. Changes 可以来自原始 preview_changes也可以来自用户编辑后的 edited_changes。
type ApplyActiveScheduleRequest struct {
PreviewID string
ApplyID string
UserID int
CandidateID string
Changes []ApplyChange
RequestedAt time.Time
TraceID string
}
// ApplyChange 是 apply adapter 可执行的最小变更单元。
//
// 字段语义:
// 1. ChangeType 支持 add_task_pool_to_schedule / create_makeup
// 2. TargetType + TargetID 描述要落库的任务来源或原日程块;
// 3. ToSlot 是最终确认后的落位节次adapter 不信任调用方的冲突判断,会在事务内重查。
type ApplyChange struct {
ChangeID string
ChangeType string
TargetType string
TargetID int
ToSlot *SlotSpan
DurationSections int
Metadata map[string]string
}
// Slot 描述 schedules 表的一格原子节次坐标。
type Slot struct {
Week int
DayOfWeek int
Section int
}
// SlotSpan 描述一个连续节次块。
//
// 说明:
// 1. Start 必填;
// 2. End 可由 DurationSections 推导,但调用方传入时必须与 Start 同周同日且连续;
// 3. DurationSections 小于等于 0 时adapter 会按 Start/End 计算。
type SlotSpan struct {
Start Slot
End Slot
DurationSections int
}
// ApplyActiveScheduleResult 是正式日程写库结果。
//
// 职责边界:
// 1. AppliedEventIDs 返回本次新建的 schedule_events.id
// 2. AppliedScheduleIDs 返回本次新建的 schedules.id
// 3. 不包含 preview apply_status避免 adapter 越权回写 active_schedule_previews。
type ApplyActiveScheduleResult struct {
ApplyID string
AppliedEventIDs []int
AppliedScheduleIDs []int
}
// ApplyError 是 adapter 返回给上游的可分类业务错误。
//
// 说明:
// 1. Code 用于上游决定 preview apply_error / 交互文案;
// 2. Cause 保留底层错误,便于日志排障;
// 3. Error() 面向调用方,保持中文可读。
type ApplyError struct {
Code string
Message string
Cause error
}
func (e *ApplyError) Error() string {
if e == nil {
return ""
}
if e.Cause == nil {
return e.Message
}
return e.Message + ": " + e.Cause.Error()
}
func (e *ApplyError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
func newApplyError(code, message string, cause error) error {
return &ApplyError{Code: code, Message: message, Cause: cause}
}

View File

@@ -0,0 +1,76 @@
package dao
import (
"context"
"fmt"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/go-redis/redis/v8"
"github.com/spf13/viper"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// OpenDBFromConfig 创建 schedule 服务自己的数据库句柄。
//
// 职责边界:
// 1. 只迁移 schedule_events / schedules 这两个正式日程写模型;
// 2. 不迁移 task、task-class、course 或 active-scheduler 的表,避免 schedule 服务越权管理其它领域;
// 3. 迁移期仍检查 task/task-class 依赖表是否存在,方便启动阶段暴露部署顺序问题。
func OpenDBFromConfig() (*gorm.DB, error) {
host := viper.GetString("database.host")
port := viper.GetString("database.port")
user := viper.GetString("database.user")
password := viper.GetString("database.password")
dbname := viper.GetString("database.dbname")
dsn := fmt.Sprintf(
"%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, password, host, port, dbname,
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
if err = db.AutoMigrate(&model.ScheduleEvent{}, &model.Schedule{}); err != nil {
return nil, fmt.Errorf("auto migrate schedule tables failed: %w", err)
}
if err = ensureRuntimeDependencyTables(db); err != nil {
return nil, err
}
return db, nil
}
// OpenRedisFromConfig 创建 schedule 服务自己的 Redis 句柄。
//
// 职责边界:
// 1. 只负责初始化 schedule 读缓存所需 Redis client
// 2. 不创建、不清理任何业务 key
// 3. Ping 失败直接返回错误,避免读缓存链路静默降级成难排查的启动问题。
func OpenRedisFromConfig() (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: viper.GetString("redis.host") + ":" + viper.GetString("redis.port"),
Password: viper.GetString("redis.password"),
DB: 0,
})
if _, err := client.Ping(context.Background()).Result(); err != nil {
return nil, err
}
return client, nil
}
// ensureRuntimeDependencyTables 显式检查迁移期仍需读取或锁定的外部表。
//
// 说明:
// 1. task_classes / task_items 支撑智能粗排、任务块撤销和补做目标校验;
// 2. tasks 支撑 active-scheduler confirm 的 task_pool 锁定;
// 3. 下一轮拆 task / task-class 后,应把这些依赖改为 RPC 或 read model再从这里移除。
func ensureRuntimeDependencyTables(db *gorm.DB) error {
for _, table := range []string{"task_classes", "task_items", "tasks"} {
if !db.Migrator().HasTable(table) {
return fmt.Errorf("schedule runtime dependency table missing: %s", table)
}
}
return nil
}

View File

@@ -0,0 +1,256 @@
package dao
import (
"context"
"errors"
"fmt"
"time"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
"gorm.io/gorm"
)
// GetScheduleFactsByWindow 读取主动调度所需的滚动窗口日程事实。
//
// 职责边界:
// 1. 只读取 schedule_events / schedules 并转换为跨进程 facts
// 2. 不生成候选、不写 preview、不写正式日程
// 3. active-scheduler 通过 RPC 调用该能力后,不再直连 schedule 表读取窗口事实。
func (d *ScheduleDAO) GetScheduleFactsByWindow(ctx context.Context, req schedulecontracts.ScheduleWindowRequest) (schedulecontracts.ScheduleWindowFacts, error) {
if d == nil || d.db == nil {
return schedulecontracts.ScheduleWindowFacts{}, errors.New("schedule dao 未初始化")
}
if req.UserID <= 0 || req.WindowStart.IsZero() || !req.WindowEnd.After(req.WindowStart) {
return schedulecontracts.ScheduleWindowFacts{}, nil
}
windowSlots, err := buildWindowSlots(req.WindowStart, req.WindowEnd)
if err != nil {
return schedulecontracts.ScheduleWindowFacts{}, err
}
weeks := uniqueWeeks(windowSlots)
var schedules []model.Schedule
if len(weeks) > 0 {
err = d.db.WithContext(ctx).
Preload("Event").
Where("user_id = ? AND week IN ?", req.UserID, weeks).
Find(&schedules).Error
if err != nil {
return schedulecontracts.ScheduleWindowFacts{}, err
}
}
occupiedByKey := make(map[string]model.Schedule, len(schedules))
eventFacts := make(map[int]*schedulecontracts.ScheduleEventFact)
targetAlreadyScheduled := false
for _, schedule := range schedules {
if schedule.Event == nil {
continue
}
slot, ok := slotFromSchedule(schedule)
if !ok || slot.StartAt.Before(req.WindowStart) || !slot.StartAt.Before(req.WindowEnd) {
continue
}
occupiedByKey[slotKey(slot)] = schedule
eventFact := eventFacts[schedule.EventID]
if eventFact == nil {
eventFact = scheduleToEventFact(schedule)
eventFacts[schedule.EventID] = eventFact
}
eventFact.Slots = append(eventFact.Slots, slot)
if isSameTarget(schedule.Event, req.TargetType, req.TargetID) {
targetAlreadyScheduled = true
}
}
occupiedSlots := make([]schedulecontracts.Slot, 0, len(occupiedByKey))
freeSlots := make([]schedulecontracts.Slot, 0, len(windowSlots))
for _, slot := range windowSlots {
if schedule, exists := occupiedByKey[slotKey(slot)]; exists {
occupied, ok := slotFromSchedule(schedule)
if ok {
occupiedSlots = append(occupiedSlots, occupied)
}
continue
}
freeSlots = append(freeSlots, slot)
}
events := make([]schedulecontracts.ScheduleEventFact, 0, len(eventFacts))
for _, fact := range eventFacts {
events = append(events, *fact)
}
return schedulecontracts.ScheduleWindowFacts{
Events: events,
OccupiedSlots: occupiedSlots,
FreeSlots: freeSlots,
NextDynamicTask: firstDynamicTask(events, req.Now),
TargetAlreadyScheduled: targetAlreadyScheduled,
}, nil
}
// GetFeedbackSignal 读取主动调度 unfinished_feedback 的日程目标事实。
//
// 职责边界:
// 1. 第一版没有独立 feedback 表,因此只在 target_type=schedule_event 时定位日程事件;
// 2. 目标缺失时返回 found=true + TargetKnown=false让 active-scheduler 稳定追问用户;
// 3. 不修改 schedule也不写 active-scheduler 会话状态。
func (d *ScheduleDAO) GetFeedbackSignal(ctx context.Context, req schedulecontracts.FeedbackRequest) (schedulecontracts.FeedbackFact, bool, error) {
if d == nil || d.db == nil {
return schedulecontracts.FeedbackFact{}, false, errors.New("schedule dao 未初始化")
}
if req.TargetType != schedulecontracts.TargetTypeScheduleEvent || req.TargetID <= 0 {
return unknownFeedbackTarget(req), true, nil
}
var event model.ScheduleEvent
err := d.db.WithContext(ctx).
Where("id = ? AND user_id = ?", req.TargetID, req.UserID).
First(&event).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return unknownFeedbackTarget(req), true, nil
}
return schedulecontracts.FeedbackFact{}, false, err
}
taskItemID := 0
if event.RelID != nil {
taskItemID = *event.RelID
}
return schedulecontracts.FeedbackFact{
FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey),
TargetKnown: true,
TargetEventID: event.ID,
TargetTaskItemID: taskItemID,
TargetTitle: event.Name,
SubmittedAt: time.Now(),
}, true, nil
}
func buildWindowSlots(startAt, endAt time.Time) ([]schedulecontracts.Slot, error) {
slots := make([]schedulecontracts.Slot, 0, 24)
for day := truncateToDate(startAt); day.Before(endAt); day = day.AddDate(0, 0, 1) {
week, dayOfWeek, err := conv.RealDateToRelativeDate(day.Format(conv.DateFormat))
if err != nil {
return nil, err
}
for section := 1; section <= 12; section++ {
sectionStart, sectionEnd, err := conv.RelativeTimeToRealTime(week, dayOfWeek, section, section)
if err != nil {
return nil, err
}
if sectionStart.Before(startAt) || !sectionStart.Before(endAt) {
continue
}
slots = append(slots, schedulecontracts.Slot{
Week: week,
DayOfWeek: dayOfWeek,
Section: section,
StartAt: sectionStart,
EndAt: sectionEnd,
})
}
}
return slots, nil
}
func slotFromSchedule(schedule model.Schedule) (schedulecontracts.Slot, bool) {
startAt, endAt, err := conv.RelativeTimeToRealTime(schedule.Week, schedule.DayOfWeek, schedule.Section, schedule.Section)
if err != nil {
return schedulecontracts.Slot{}, false
}
return schedulecontracts.Slot{
Week: schedule.Week,
DayOfWeek: schedule.DayOfWeek,
Section: schedule.Section,
StartAt: startAt,
EndAt: endAt,
}, true
}
func scheduleToEventFact(schedule model.Schedule) *schedulecontracts.ScheduleEventFact {
event := schedule.Event
relID := 0
if event.RelID != nil {
relID = *event.RelID
}
sourceType := event.TaskSourceType
if sourceType == "" && event.Type == "task" {
sourceType = schedulecontracts.TargetTypeTaskItem
}
return &schedulecontracts.ScheduleEventFact{
ID: event.ID,
UserID: event.UserID,
Title: event.Name,
SourceType: sourceType,
RelID: relID,
IsDynamicTask: event.Type == "task",
TaskItemID: relID,
}
}
func isSameTarget(event *model.ScheduleEvent, targetType string, targetID int) bool {
if event == nil || targetID <= 0 || event.RelID == nil || event.Type != "task" {
return false
}
sourceType := event.TaskSourceType
if sourceType == "" {
sourceType = schedulecontracts.TargetTypeTaskItem
}
return sourceType == targetType && *event.RelID == targetID
}
func firstDynamicTask(events []schedulecontracts.ScheduleEventFact, now time.Time) *schedulecontracts.ScheduleEventFact {
for i := range events {
if !events[i].IsDynamicTask {
continue
}
for _, slot := range events[i].Slots {
if slot.StartAt.IsZero() || !slot.StartAt.Before(now) {
return &events[i]
}
}
}
return nil
}
func uniqueWeeks(slots []schedulecontracts.Slot) []int {
seen := make(map[int]struct{})
weeks := make([]int, 0)
for _, slot := range slots {
if _, exists := seen[slot.Week]; exists {
continue
}
seen[slot.Week] = struct{}{}
weeks = append(weeks, slot.Week)
}
return weeks
}
func slotKey(slot schedulecontracts.Slot) string {
return fmt.Sprintf("%d:%d:%d", slot.Week, slot.DayOfWeek, slot.Section)
}
func truncateToDate(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
}
func unknownFeedbackTarget(req schedulecontracts.FeedbackRequest) schedulecontracts.FeedbackFact {
return schedulecontracts.FeedbackFact{
FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey),
TargetKnown: false,
SubmittedAt: time.Now(),
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,627 @@
package dao
import (
"context"
"errors"
"fmt"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/LoveLosita/smartflow/backend/respond"
"gorm.io/gorm"
)
type ScheduleDAO struct {
db *gorm.DB
}
// NewScheduleDAO 创建TaskClassDAO实例
func NewScheduleDAO(db *gorm.DB) *ScheduleDAO {
return &ScheduleDAO{
db: db,
}
}
func (d *ScheduleDAO) WithTx(tx *gorm.DB) *ScheduleDAO {
return &ScheduleDAO{db: tx}
}
func (d *ScheduleDAO) AddSchedules(schedules []model.Schedule) ([]int, error) {
if err := d.db.Create(&schedules).Error; err != nil {
return nil, err
}
ids := make([]int, len(schedules))
for i, s := range schedules {
ids[i] = s.ID
}
return ids, nil
}
func (d *ScheduleDAO) EmbedTaskIntoSchedule(startSection, endSection, dayOfWeek, week, userID, taskID int) error {
// 仅更新指定:用户/周/星期/节次区间 的记录,将 embedded_task_id 精准写入 taskID
res := d.db.
Table("schedules").
Where("user_id = ? AND week = ? AND day_of_week = ? AND section BETWEEN ? AND ?", userID, week, dayOfWeek, startSection, endSection).
Update("embedded_task_id", taskID)
return res.Error
}
func (d *ScheduleDAO) GetCourseUserIDByID(ctx context.Context, courseScheduleEventID int) (int, error) {
type row struct {
UserID *int `gorm:"column:user_id"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedule_events").
Select("user_id").
Where("id = ?", courseScheduleEventID).
First(&r).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, respond.WrongCourseID
}
return 0, err
}
if r.UserID == nil {
return 0, respond.WrongCourseID
}
return *r.UserID, nil
}
// IsCourseEmbeddedByOtherTaskBlock 判断课程在给定节次区间内是否已被其他任务块嵌入(用于业务限制)
func (d *ScheduleDAO) IsCourseEmbeddedByOtherTaskBlock(ctx context.Context, courseID, startSection, endSection int) (bool, error) {
// 若区间非法,视为不冲突
if startSection <= 0 || endSection <= 0 || startSection > endSection {
return false, nil
}
var cnt int64
err := d.db.WithContext(ctx).
Table("schedules").
Where("id = ?", courseID).
Where("section BETWEEN ? AND ?", startSection, endSection).
Where("embedded_task_id IS NOT NULL AND embedded_task_id <> 0").
Count(&cnt).Error
if err != nil {
return false, err
}
return cnt > 0, nil
}
func (d *ScheduleDAO) HasUserScheduleConflict(ctx context.Context, userID, week, dayOfWeek int, sections []int) (bool, error) {
// 无节次则视为无冲突
if len(sections) == 0 {
return false, nil
}
// 统计同一用户、同一周、同一天、且节次有交集的排程数量
// 约定表字段user_id, week, day_of_week, section
var cnt int64
err := d.db.WithContext(ctx).
Table("schedules").
Where("user_id = ? AND week = ? AND day_of_week = ?", userID, week, dayOfWeek).
Where("section IN ?", sections).
Count(&cnt).Error
if err != nil {
return false, err
}
return cnt > 0, nil
}
func (d *ScheduleDAO) IsCourseTimeMatch(ctx context.Context, courseScheduleEventID, week, dayOfWeek, startSection, endSection int) (bool, error) {
// 区间非法直接不匹配
if startSection <= 0 || endSection <= 0 || startSection > endSection {
return false, nil
}
// 核对该课程事件在指定 周\+星期 下,是否存在覆盖整个节次区间的排程记录
// 说明此处按你当前表结构的用法schedule\_events 存事件schedules 存节次明细)来写:
// schedules 里通过 schedule\_event\_id 关联到 schedule\_events.id
var cnt int64
err := d.db.WithContext(ctx).
Table("schedules").
Where("event_id = ?", courseScheduleEventID).
Where("week = ? AND day_of_week = ?", week, dayOfWeek).
Where("section BETWEEN ? AND ?", startSection, endSection).
Count(&cnt).Error
if err != nil {
return false, err
}
// 需要区间内的每一节都存在记录才算匹配
return cnt == int64(endSection-startSection+1), nil
}
func (d *ScheduleDAO) AddScheduleEvent(scheduleEvent *model.ScheduleEvent) (int, error) {
if err := d.db.Create(&scheduleEvent).Error; err != nil {
return 0, err
}
return scheduleEvent.ID, nil
}
// CheckScheduleConflict 检查给定的 Schedule 切片中是否存在课程的冲突(即同一用户、同一周、同一天、且节次有交集的记录,并且只管课程,不管其它任务类型)
func (d *ScheduleDAO) CheckScheduleConflict(ctx context.Context, schedules []model.Schedule) (bool, error) {
if len(schedules) == 0 {
return false, nil
}
// 聚合:同一 user/week/day 的节次去重后一次性查库
type key struct {
UserID int
Week int
DayOfWeek int
}
groups := make(map[key]map[int]struct{})
for _, s := range schedules {
// 基础字段不合法直接跳过(按不冲突处理)
if s.UserID <= 0 || s.Week <= 0 || s.DayOfWeek <= 0 || s.Section <= 0 {
continue
}
k := key{UserID: s.UserID, Week: s.Week, DayOfWeek: s.DayOfWeek}
if _, ok := groups[k]; !ok {
groups[k] = make(map[int]struct{})
}
groups[k][s.Section] = struct{}{}
}
for k, set := range groups {
if len(set) == 0 {
continue
}
sections := make([]int, 0, len(set))
for sec := range set {
sections = append(sections, sec)
}
// 仅判断“课程type=course”是否冲突
// schedules.event_id -> schedule_events.id再用 schedule_events.type 过滤
var cnt int64
err := d.db.WithContext(ctx).
Table("schedules s").
Joins("JOIN schedule_events e ON e.id = s.event_id").
Where("s.user_id = ? AND s.week = ? AND s.day_of_week = ?", k.UserID, k.Week, k.DayOfWeek).
Where("s.section IN ?", sections).
Where("e.type = ?", "course").
Count(&cnt).Error
if err != nil {
return false, err
}
if cnt > 0 {
return true, nil
}
}
return false, nil
}
func (d *ScheduleDAO) GetNonCourseScheduleConflicts(ctx context.Context, newSchedules []model.Schedule) ([]model.Schedule, error) {
if len(newSchedules) == 0 {
return nil, nil
}
// 1. 构建指纹图:用于快速比对坐标
userID := newSchedules[0].UserID
weeksMap := make(map[int]bool)
newSlotsFingerprints := make(map[string]bool)
for _, s := range newSchedules {
weeksMap[s.Week] = true
key := fmt.Sprintf("%d-%d-%d", s.Week, s.DayOfWeek, s.Section)
newSlotsFingerprints[key] = true
}
weeks := make([]int, 0, len(weeksMap))
for w := range weeksMap {
weeks = append(weeks, w)
}
// 2. 第一步:定义一个临时小结构体,精准捞取坐标和 EventID
type simpleSlot struct {
EventID int
Week int
DayOfWeek int
Section int
}
var candidates []simpleSlot
// 💡 这里的逻辑:只查索引覆盖到的字段,速度极快
err := d.db.WithContext(ctx).
Table("schedules").
Select("schedules.event_id, schedules.week, schedules.day_of_week, schedules.section").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
Where("schedules.user_id = ? AND schedules.week IN ? AND schedule_events.type != ?", userID, weeks, "course").
Scan(&candidates).Error
if err != nil {
return nil, err
}
// 3. 筛选出真正碰撞的 EventID
eventIDMap := make(map[int]bool)
for _, s := range candidates {
key := fmt.Sprintf("%d-%d-%d", s.Week, s.DayOfWeek, s.Section)
if newSlotsFingerprints[key] {
eventIDMap[s.EventID] = true
}
}
if len(eventIDMap) == 0 {
return nil, nil
}
// 4. 第二步:“抄全家”——根据碰撞到的 ID 捞出这些任务的所有原子槽位
var ids []int
for id := range eventIDMap {
ids = append(ids, id)
}
var fullConflicts []model.Schedule
// 💡 关键:这里必须 Preload("Event"),这样 DTO 才有名称显示
err = d.db.WithContext(ctx).
Preload("Event").
Where("event_id IN ?", ids).
Find(&fullConflicts).Error
return fullConflicts, err
}
func (d *ScheduleDAO) GetUserTodaySchedule(ctx context.Context, userID, week, dayOfWeek int) ([]model.Schedule, error) {
var schedules []model.Schedule
// 1. Preload("Event"): 拿到课程/任务的基础信息(名、地、型)
// 2. Preload("EmbeddedTask"): 拿到“水课”里嵌入的具体任务详情
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Where("user_id = ? AND week = ? AND day_of_week = ?", userID, week, dayOfWeek).
Order("section ASC").
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) GetUserWeeklySchedule(ctx context.Context, userID, week int) ([]model.Schedule, error) {
var schedules []model.Schedule
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Where("user_id = ? AND week = ?", userID, week).
Order("day_of_week ASC, section ASC").
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) DeleteScheduleEventAndSchedule(ctx context.Context, eventID int, userID int) error {
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 先查出要删除的 schedules让 GORM 在 Delete 时能带上模型字段(供钩子读取 UserID/Week
var schedules []model.Schedule
if err := tx.
Where("event_id = ? AND user_id = ?", eventID, userID).
Find(&schedules).Error; err != nil {
return err
}
// 显式删子表 schedules触发 schedules 的 GORM Delete 回调/插件)
if len(schedules) > 0 {
if err := tx.Delete(&schedules).Error; err != nil {
return err
}
}
// 再删父表 schedule_events同样触发回调/插件)
res := tx.Where("id = ? AND user_id = ?", eventID, userID).
Delete(&model.ScheduleEvent{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return respond.WrongScheduleEventID
}
return nil
})
}
func (d *ScheduleDAO) GetScheduleTypeByEventID(ctx context.Context, eventID, userID int) (string, error) {
type row struct {
Type *string `gorm:"column:type"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedule_events").
Select("type").
Where("id = ? AND user_id=?", eventID, userID).
First(&r).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", respond.WrongScheduleEventID // 事件不存在或不属于该用户,统一返回错误
}
return "", err
}
if r.Type == nil {
return "", respond.WrongScheduleEventID
}
return *r.Type, nil
}
func (d *ScheduleDAO) GetScheduleEmbeddedTaskID(ctx context.Context, eventID int) (int, error) {
// embedded_task_id 存在于 schedules 表中(按 event_id 聚合取一个非空值)
// 若该事件没有任何嵌入任务,则返回 0, nil
type row struct {
EmbeddedTaskID *int `gorm:"column:embedded_task_id"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedules").
Select("embedded_task_id").
Where("event_id = ?", eventID).
Where("embedded_task_id IS NOT NULL AND embedded_task_id <> 0").
Order("id ASC").
Limit(1).
Scan(&r).Error
if err != nil {
return 0, err
}
if r.EmbeddedTaskID == nil { // 没有任何嵌入任务
return 0, nil
}
return *r.EmbeddedTaskID, nil
}
func (d *ScheduleDAO) IfScheduleEventIDExists(ctx context.Context, eventID int) (bool, error) {
var count int64
err := d.db.WithContext(ctx).
Table("schedule_events").
Where("id = ?", eventID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func (d *ScheduleDAO) SetScheduleEmbeddedTaskIDToNull(ctx context.Context, eventID int) (int, error) {
// 先取出该事件当前嵌入的任务 id若没有嵌入则返回对应业务错误
embeddedTaskID, err := d.GetScheduleEmbeddedTaskID(ctx, eventID)
if err != nil {
return 0, err
}
if embeddedTaskID == 0 {
return 0, respond.TargetScheduleNotHaveEmbeddedTask
}
// 将 schedules 表中指定 event_id 的 embedded_task_id 字段置空(用于解除嵌入关系)
res := d.db.WithContext(ctx).
Table("schedules").
Where("event_id = ?", eventID).
Where("embedded_task_id IS NOT NULL AND embedded_task_id <> 0").
Update("embedded_task_id", nil)
if res.Error != nil {
return 0, res.Error
}
if res.RowsAffected == 0 {
return 0, respond.TargetScheduleNotHaveEmbeddedTask
}
return embeddedTaskID, nil
}
func (d *ScheduleDAO) FindEmbeddedTaskIDAndDeleteIt(ctx context.Context, taskID int) (int, error) {
// 1. 先找到 schedules 表中 embedded_task_id = taskID 的记录,获取对应的 event_id
type row struct {
EventID *int `gorm:"column:event_id"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedules").
Select("event_id").
Where("embedded_task_id = ?", taskID).
Order("id ASC").
Limit(1).
Scan(&r).Error
if err != nil {
return 0, err
}
if r.EventID == nil {
return 0, respond.TargetTaskNotEmbeddedInAnySchedule
}
eventID := *r.EventID
// 2. 删除该 event_id 对应的课程事件(通过级联删除实现)
res := d.db.WithContext(ctx).
Table("schedule_events").
Where("id = ?", eventID).
Delete(&model.ScheduleEvent{})
if res.Error != nil {
return 0, res.Error
}
if res.RowsAffected == 0 {
return 0, respond.TargetTaskNotEmbeddedInAnySchedule
}
return eventID, nil
}
func (d *ScheduleDAO) DeleteScheduleEventByTaskItemID(ctx context.Context, taskItemID int) error {
//直接找schedule_events表中type=task且rel_id=taskItemID的记录删除它级联删schedules
res := d.db.WithContext(ctx).
Table("schedule_events").
Where("type = ? AND rel_id = ?", "task", taskItemID).
Delete(&model.ScheduleEvent{})
if res.Error != nil {
return res.Error
}
return nil
}
func (d *ScheduleDAO) GetUserRecentCompletedSchedules(ctx context.Context, nowTime time.Time, userID int, index, limit int) ([]model.Schedule, error) {
var schedules []model.Schedule
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
// 修改后的核心逻辑:
// 1. 用户匹配 & 已结束
// 2. 满足 (事件本身是任务) OR (虽然是课程但嵌入了任务)
Where("schedules.user_id = ? AND schedule_events.end_time < ? AND (schedule_events.type = ? OR schedules.embedded_task_id IS NOT NULL)",
userID, nowTime, "task").
Order("schedule_events.end_time DESC"). // 命中索引
Offset(index).
Limit(limit).
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) GetScheduleEventWeekByID(ctx context.Context, eventID int) (int, error) {
type row struct {
Week *int `gorm:"column:week"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedules").
Select("week").
Where("event_id = ?", eventID).
Order("id ASC").
Limit(1).
Scan(&r).Error
if err != nil {
return 0, err
}
if r.Week == nil {
return 0, respond.WrongScheduleEventID
}
return *r.Week, nil
}
func (d *ScheduleDAO) GetUserOngoingSchedule(ctx context.Context, userID int, nowTime time.Time) ([]model.Schedule, error) {
var schedules []model.Schedule
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
Where("schedules.user_id = ? AND schedule_events.start_time <= ? AND schedule_events.end_time >= ?",
userID, nowTime, nowTime).
Or("schedules.user_id = ? AND schedule_events.start_time > ?",
userID, nowTime).
Order("schedule_events.start_time ASC"). // 命中索引
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) RevocateSchedulesByEventID(ctx context.Context, eventID int) error {
// 将 schedules 表中指定 event_id 的 embedded_task_id 字段置空(用于撤销嵌入关系)
res := d.db.WithContext(ctx).
Table("schedules").
Where("event_id = ?", eventID).
Update("status", "interrupted")
if res.RowsAffected == 0 {
return respond.WrongScheduleEventID
}
return res.Error
}
func (d *ScheduleDAO) GetRelIDByScheduleEventID(ctx context.Context, eventID int) (int, error) {
type row struct {
RelID *int `gorm:"column:rel_id"`
}
var r row
err := d.db.WithContext(ctx).
Table("schedule_events").
Select("rel_id").
Where("id = ?", eventID).
First(&r).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, respond.WrongScheduleEventID
}
return 0, err
}
if r.RelID == nil {
return 0, nil
}
return *r.RelID, nil
}
func (d *ScheduleDAO) GetUserSchedulesByTimeRange(ctx context.Context, userID int, startTime, endTime time.Time) ([]model.Schedule, error) {
var schedules []model.Schedule
err := d.db.WithContext(ctx).
Preload("Event").
Preload("EmbeddedTask").
Joins("JOIN schedule_events ON schedule_events.id = schedules.event_id").
Where("schedules.user_id = ? AND schedule_events.start_time >= ? AND schedule_events.end_time <= ?",
userID, startTime, endTime).
Order("schedule_events.start_time ASC"). // 命中索引
Find(&schedules).Error
if err != nil {
return nil, err
}
return schedules, nil
}
func (d *ScheduleDAO) BatchEmbedTaskIntoSchedule(ctx context.Context, eventIDs, taskItemIDs []int) error {
if len(eventIDs) == 0 {
return nil
}
if len(eventIDs) != len(taskItemIDs) {
return fmt.Errorf("eventIDs length != taskItemIDs length")
}
db := d.db.WithContext(ctx)
for i, eventID := range eventIDs {
taskItemID := taskItemIDs[i]
// 1) 校验该 event 是否为 course
var typ string
if err := db.
Table("schedule_events").
Select("type").
Where("id = ?", eventID).
Scan(&typ).Error; err != nil {
return err
}
if typ != "course" {
continue
}
// 2) 一 event 对多 schedules批量写入 embedded_task_id
if err := db.
Table("schedules").
Where("event_id = ?", eventID).
Update("embedded_task_id", taskItemID).Error; err != nil {
return err
}
}
return nil
}
func (d *ScheduleDAO) InsertScheduleEvents(ctx context.Context, events []model.ScheduleEvent) ([]int, error) {
if len(events) == 0 {
return nil, nil
}
if err := d.db.WithContext(ctx).Create(&events).Error; err != nil {
return nil, err
}
ids := make([]int, len(events))
for i, e := range events {
ids[i] = e.ID
}
return ids, nil
}

View File

@@ -0,0 +1,113 @@
package rpc
import (
"errors"
"log"
"strings"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/services/schedule/core/applyadapter"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
scheduleErrorDomain = "smartflow.schedule"
scheduleApplyErrorDomain = "smartflow.schedule.apply"
)
// grpcErrorFromServiceError 负责把 schedule 内部错误转换为 gRPC status。
//
// 职责边界:
// 1. apply 业务错误保留错误码,供 active-scheduler 反解后继续按原 confirm 语义处理;
// 2. respond.Response 继续传输项目内部 status/info
// 3. 未分类错误只暴露通用内部错误,详细信息留在服务日志。
func grpcErrorFromServiceError(err error) error {
if err == nil {
return nil
}
var applyErr *applyadapter.ApplyError
if errors.As(err, &applyErr) {
return grpcErrorFromApplyError(applyErr)
}
var resp respond.Response
if errors.As(err, &resp) {
return grpcErrorFromResponse(resp)
}
log.Printf("schedule rpc internal error: %v", err)
return status.Error(codes.Internal, "schedule service internal error")
}
func grpcErrorFromApplyError(applyErr *applyadapter.ApplyError) error {
if applyErr == nil {
return status.Error(codes.Internal, "schedule apply error")
}
message := strings.TrimSpace(applyErr.Message)
if message == "" {
message = strings.TrimSpace(applyErr.Code)
}
st := status.New(grpcCodeFromApplyErrorCode(applyErr.Code), message)
detail := &errdetails.ErrorInfo{
Domain: scheduleApplyErrorDomain,
Reason: applyErr.Code,
Metadata: map[string]string{
"info": message,
},
}
withDetails, err := st.WithDetails(detail)
if err != nil {
return st.Err()
}
return withDetails.Err()
}
func grpcErrorFromResponse(resp respond.Response) error {
code := grpcCodeFromRespondStatus(resp.Status)
message := strings.TrimSpace(resp.Info)
if message == "" {
message = strings.TrimSpace(resp.Status)
}
st := status.New(code, message)
detail := &errdetails.ErrorInfo{
Domain: scheduleErrorDomain,
Reason: resp.Status,
Metadata: map[string]string{
"info": resp.Info,
},
}
withDetails, err := st.WithDetails(detail)
if err != nil {
return st.Err()
}
return withDetails.Err()
}
func grpcCodeFromApplyErrorCode(code string) codes.Code {
switch strings.TrimSpace(code) {
case applyadapter.ErrorCodeTargetNotFound:
return codes.NotFound
case applyadapter.ErrorCodeDBError:
return codes.Internal
case applyadapter.ErrorCodeTargetCompleted,
applyadapter.ErrorCodeTargetAlreadyScheduled,
applyadapter.ErrorCodeSlotConflict:
return codes.FailedPrecondition
default:
return codes.InvalidArgument
}
}
func grpcCodeFromRespondStatus(statusValue string) codes.Code {
switch strings.TrimSpace(statusValue) {
case respond.MissingToken.Status, respond.InvalidToken.Status, respond.InvalidClaims.Status,
respond.ErrUnauthorized.Status, respond.WrongTokenType.Status, respond.UserLoggedOut.Status:
return codes.Unauthenticated
case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status:
return codes.InvalidArgument
}
if strings.HasPrefix(strings.TrimSpace(statusValue), "5") {
return codes.Internal
}
return codes.InvalidArgument
}

View File

@@ -0,0 +1,166 @@
package rpc
import (
"context"
"encoding/json"
"errors"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/services/schedule/rpc/pb"
schedulesv "github.com/LoveLosita/smartflow/backend/services/schedule/sv"
schedulecontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/schedule"
)
type Handler struct {
pb.UnimplementedScheduleServer
svc *schedulesv.ScheduleService
}
func NewHandler(svc *schedulesv.ScheduleService) *Handler {
return &Handler{svc: svc}
}
// Ping 供调用方在启动期确认 schedule zrpc 已可用。
func (h *Handler) Ping(ctx context.Context, req *pb.StatusResponse) (*pb.StatusResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
return &pb.StatusResponse{}, nil
}
func (h *Handler) GetToday(ctx context.Context, req *pb.UserRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
data, err := h.svc.GetUserTodaySchedule(ctx, int(req.UserId))
return jsonResponse(data, err)
}
func (h *Handler) GetWeek(ctx context.Context, req *pb.WeekRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
data, err := h.svc.GetUserWeeklySchedule(ctx, int(req.UserId), int(req.Week))
return jsonResponse(data, err)
}
func (h *Handler) DeleteEvents(ctx context.Context, req *pb.DeleteEventsRequest) (*pb.StatusResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
var events []schedulecontracts.UserDeleteScheduleEvent
if err := json.Unmarshal(req.EventsJson, &events); err != nil {
return nil, grpcErrorFromServiceError(respond.WrongParamType)
}
err := h.svc.DeleteScheduleEventByContract(ctx, schedulecontracts.DeleteScheduleEventsRequest{
UserID: int(req.UserId),
Events: events,
})
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.StatusResponse{}, nil
}
func (h *Handler) GetRecentCompleted(ctx context.Context, req *pb.RecentCompletedRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
data, err := h.svc.GetUserRecentCompletedSchedules(ctx, int(req.UserId), int(req.Index), int(req.Limit))
return jsonResponse(data, err)
}
func (h *Handler) GetCurrent(ctx context.Context, req *pb.UserRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
data, err := h.svc.GetUserOngoingSchedule(ctx, int(req.UserId))
return jsonResponse(data, err)
}
func (h *Handler) RevokeTaskItem(ctx context.Context, req *pb.RevokeTaskItemRequest) (*pb.StatusResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
if err := h.svc.RevocateUserTaskClassItem(ctx, int(req.UserId), int(req.EventId)); err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.StatusResponse{}, nil
}
func (h *Handler) SmartPlanning(ctx context.Context, req *pb.SmartPlanningRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
data, err := h.svc.SmartPlanning(ctx, int(req.UserId), int(req.TaskClassId))
return jsonResponse(data, err)
}
func (h *Handler) SmartPlanningMulti(ctx context.Context, req *pb.SmartPlanningMultiRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
taskClassIDs := make([]int, 0, len(req.TaskClassIds))
for _, id := range req.TaskClassIds {
taskClassIDs = append(taskClassIDs, int(id))
}
data, err := h.svc.SmartPlanningMulti(ctx, int(req.UserId), taskClassIDs)
return jsonResponse(data, err)
}
func (h *Handler) GetScheduleFactsByWindow(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
var contractReq schedulecontracts.ScheduleWindowRequest
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
return nil, grpcErrorFromServiceError(respond.WrongParamType)
}
data, err := h.svc.GetScheduleFactsByWindow(ctx, contractReq)
return jsonResponse(data, err)
}
func (h *Handler) GetFeedbackSignal(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
var contractReq schedulecontracts.FeedbackRequest
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
return nil, grpcErrorFromServiceError(respond.WrongParamType)
}
feedback, found, err := h.svc.GetFeedbackSignal(ctx, contractReq)
return jsonResponse(schedulecontracts.FeedbackResponse{Feedback: feedback, Found: found}, err)
}
func (h *Handler) ApplyActiveScheduleChanges(ctx context.Context, req *pb.JSONRequest) (*pb.JSONResponse, error) {
if err := h.ensureReady(req); err != nil {
return nil, err
}
var contractReq schedulecontracts.ApplyActiveScheduleRequest
if err := json.Unmarshal(req.PayloadJson, &contractReq); err != nil {
return nil, grpcErrorFromServiceError(respond.WrongParamType)
}
data, err := h.svc.ApplyActiveScheduleChanges(ctx, contractReq)
return jsonResponse(data, err)
}
func (h *Handler) ensureReady(req any) error {
if h == nil || h.svc == nil {
return grpcErrorFromServiceError(errors.New("schedule service dependency not initialized"))
}
if req == nil {
return grpcErrorFromServiceError(respond.MissingParam)
}
return nil
}
func jsonResponse(value any, err error) (*pb.JSONResponse, error) {
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
raw, err := json.Marshal(value)
if err != nil {
return nil, grpcErrorFromServiceError(err)
}
return &pb.JSONResponse{DataJson: raw}, nil
}

View File

@@ -0,0 +1,123 @@
package pb
import proto "github.com/golang/protobuf/proto"
var _ = proto.Marshal
const _ = proto.ProtoPackageIsVersion3
type UserRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *UserRequest) Reset() { *m = UserRequest{} }
func (m *UserRequest) String() string { return proto.CompactTextString(m) }
func (*UserRequest) ProtoMessage() {}
type WeekRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Week int64 `protobuf:"varint,2,opt,name=week,proto3" json:"week,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *WeekRequest) Reset() { *m = WeekRequest{} }
func (m *WeekRequest) String() string { return proto.CompactTextString(m) }
func (*WeekRequest) ProtoMessage() {}
type DeleteEventsRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
EventsJson []byte `protobuf:"bytes,2,opt,name=events_json,json=eventsJson,proto3" json:"events_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *DeleteEventsRequest) Reset() { *m = DeleteEventsRequest{} }
func (m *DeleteEventsRequest) String() string { return proto.CompactTextString(m) }
func (*DeleteEventsRequest) ProtoMessage() {}
type RecentCompletedRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Index int64 `protobuf:"varint,2,opt,name=index,proto3" json:"index,omitempty"`
Limit int64 `protobuf:"varint,3,opt,name=limit,proto3" json:"limit,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *RecentCompletedRequest) Reset() { *m = RecentCompletedRequest{} }
func (m *RecentCompletedRequest) String() string { return proto.CompactTextString(m) }
func (*RecentCompletedRequest) ProtoMessage() {}
type RevokeTaskItemRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
EventId int64 `protobuf:"varint,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *RevokeTaskItemRequest) Reset() { *m = RevokeTaskItemRequest{} }
func (m *RevokeTaskItemRequest) String() string { return proto.CompactTextString(m) }
func (*RevokeTaskItemRequest) ProtoMessage() {}
type SmartPlanningRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
TaskClassId int64 `protobuf:"varint,2,opt,name=task_class_id,json=taskClassId,proto3" json:"task_class_id,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *SmartPlanningRequest) Reset() { *m = SmartPlanningRequest{} }
func (m *SmartPlanningRequest) String() string { return proto.CompactTextString(m) }
func (*SmartPlanningRequest) ProtoMessage() {}
type SmartPlanningMultiRequest struct {
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
TaskClassIds []int64 `protobuf:"varint,2,rep,packed,name=task_class_ids,json=taskClassIds,proto3" json:"task_class_ids,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *SmartPlanningMultiRequest) Reset() { *m = SmartPlanningMultiRequest{} }
func (m *SmartPlanningMultiRequest) String() string { return proto.CompactTextString(m) }
func (*SmartPlanningMultiRequest) ProtoMessage() {}
type JSONRequest struct {
PayloadJson []byte `protobuf:"bytes,1,opt,name=payload_json,json=payloadJson,proto3" json:"payload_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *JSONRequest) Reset() { *m = JSONRequest{} }
func (m *JSONRequest) String() string { return proto.CompactTextString(m) }
func (*JSONRequest) ProtoMessage() {}
type JSONResponse struct {
DataJson []byte `protobuf:"bytes,1,opt,name=data_json,json=dataJson,proto3" json:"data_json,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *JSONResponse) Reset() { *m = JSONResponse{} }
func (m *JSONResponse) String() string { return proto.CompactTextString(m) }
func (*JSONResponse) ProtoMessage() {}
type StatusResponse struct {
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *StatusResponse) Reset() { *m = StatusResponse{} }
func (m *StatusResponse) String() string { return proto.CompactTextString(m) }
func (*StatusResponse) ProtoMessage() {}

View File

@@ -0,0 +1,379 @@
package pb
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
const (
Schedule_Ping_FullMethodName = "/smartflow.schedule.Schedule/Ping"
Schedule_GetToday_FullMethodName = "/smartflow.schedule.Schedule/GetToday"
Schedule_GetWeek_FullMethodName = "/smartflow.schedule.Schedule/GetWeek"
Schedule_DeleteEvents_FullMethodName = "/smartflow.schedule.Schedule/DeleteEvents"
Schedule_GetRecentCompleted_FullMethodName = "/smartflow.schedule.Schedule/GetRecentCompleted"
Schedule_GetCurrent_FullMethodName = "/smartflow.schedule.Schedule/GetCurrent"
Schedule_RevokeTaskItem_FullMethodName = "/smartflow.schedule.Schedule/RevokeTaskItem"
Schedule_SmartPlanning_FullMethodName = "/smartflow.schedule.Schedule/SmartPlanning"
Schedule_SmartPlanningMulti_FullMethodName = "/smartflow.schedule.Schedule/SmartPlanningMulti"
Schedule_GetScheduleFactsByWindow_FullMethodName = "/smartflow.schedule.Schedule/GetScheduleFactsByWindow"
Schedule_GetFeedbackSignal_FullMethodName = "/smartflow.schedule.Schedule/GetFeedbackSignal"
Schedule_ApplyActiveScheduleChanges_FullMethodName = "/smartflow.schedule.Schedule/ApplyActiveScheduleChanges"
)
type ScheduleClient interface {
Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error)
GetToday(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetWeek(ctx context.Context, in *WeekRequest, opts ...grpc.CallOption) (*JSONResponse, error)
DeleteEvents(ctx context.Context, in *DeleteEventsRequest, opts ...grpc.CallOption) (*StatusResponse, error)
GetRecentCompleted(ctx context.Context, in *RecentCompletedRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetCurrent(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*JSONResponse, error)
RevokeTaskItem(ctx context.Context, in *RevokeTaskItemRequest, opts ...grpc.CallOption) (*StatusResponse, error)
SmartPlanning(ctx context.Context, in *SmartPlanningRequest, opts ...grpc.CallOption) (*JSONResponse, error)
SmartPlanningMulti(ctx context.Context, in *SmartPlanningMultiRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetScheduleFactsByWindow(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
GetFeedbackSignal(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
ApplyActiveScheduleChanges(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error)
}
type scheduleClient struct {
cc grpc.ClientConnInterface
}
func NewScheduleClient(cc grpc.ClientConnInterface) ScheduleClient {
return &scheduleClient{cc}
}
func (c *scheduleClient) Ping(ctx context.Context, in *StatusResponse, opts ...grpc.CallOption) (*StatusResponse, error) {
out := new(StatusResponse)
err := c.cc.Invoke(ctx, Schedule_Ping_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetToday(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetToday_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetWeek(ctx context.Context, in *WeekRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetWeek_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) DeleteEvents(ctx context.Context, in *DeleteEventsRequest, opts ...grpc.CallOption) (*StatusResponse, error) {
out := new(StatusResponse)
err := c.cc.Invoke(ctx, Schedule_DeleteEvents_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetRecentCompleted(ctx context.Context, in *RecentCompletedRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetRecentCompleted_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetCurrent(ctx context.Context, in *UserRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetCurrent_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) RevokeTaskItem(ctx context.Context, in *RevokeTaskItemRequest, opts ...grpc.CallOption) (*StatusResponse, error) {
out := new(StatusResponse)
err := c.cc.Invoke(ctx, Schedule_RevokeTaskItem_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) SmartPlanning(ctx context.Context, in *SmartPlanningRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_SmartPlanning_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) SmartPlanningMulti(ctx context.Context, in *SmartPlanningMultiRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_SmartPlanningMulti_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetScheduleFactsByWindow(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetScheduleFactsByWindow_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) GetFeedbackSignal(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_GetFeedbackSignal_FullMethodName, in, out, opts...)
return out, err
}
func (c *scheduleClient) ApplyActiveScheduleChanges(ctx context.Context, in *JSONRequest, opts ...grpc.CallOption) (*JSONResponse, error) {
out := new(JSONResponse)
err := c.cc.Invoke(ctx, Schedule_ApplyActiveScheduleChanges_FullMethodName, in, out, opts...)
return out, err
}
type ScheduleServer interface {
Ping(context.Context, *StatusResponse) (*StatusResponse, error)
GetToday(context.Context, *UserRequest) (*JSONResponse, error)
GetWeek(context.Context, *WeekRequest) (*JSONResponse, error)
DeleteEvents(context.Context, *DeleteEventsRequest) (*StatusResponse, error)
GetRecentCompleted(context.Context, *RecentCompletedRequest) (*JSONResponse, error)
GetCurrent(context.Context, *UserRequest) (*JSONResponse, error)
RevokeTaskItem(context.Context, *RevokeTaskItemRequest) (*StatusResponse, error)
SmartPlanning(context.Context, *SmartPlanningRequest) (*JSONResponse, error)
SmartPlanningMulti(context.Context, *SmartPlanningMultiRequest) (*JSONResponse, error)
GetScheduleFactsByWindow(context.Context, *JSONRequest) (*JSONResponse, error)
GetFeedbackSignal(context.Context, *JSONRequest) (*JSONResponse, error)
ApplyActiveScheduleChanges(context.Context, *JSONRequest) (*JSONResponse, error)
}
type UnimplementedScheduleServer struct{}
func (UnimplementedScheduleServer) Ping(context.Context, *StatusResponse) (*StatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented")
}
func (UnimplementedScheduleServer) GetToday(context.Context, *UserRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetToday not implemented")
}
func (UnimplementedScheduleServer) GetWeek(context.Context, *WeekRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetWeek not implemented")
}
func (UnimplementedScheduleServer) DeleteEvents(context.Context, *DeleteEventsRequest) (*StatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method DeleteEvents not implemented")
}
func (UnimplementedScheduleServer) GetRecentCompleted(context.Context, *RecentCompletedRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRecentCompleted not implemented")
}
func (UnimplementedScheduleServer) GetCurrent(context.Context, *UserRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetCurrent not implemented")
}
func (UnimplementedScheduleServer) RevokeTaskItem(context.Context, *RevokeTaskItemRequest) (*StatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RevokeTaskItem not implemented")
}
func (UnimplementedScheduleServer) SmartPlanning(context.Context, *SmartPlanningRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SmartPlanning not implemented")
}
func (UnimplementedScheduleServer) SmartPlanningMulti(context.Context, *SmartPlanningMultiRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SmartPlanningMulti not implemented")
}
func (UnimplementedScheduleServer) GetScheduleFactsByWindow(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetScheduleFactsByWindow not implemented")
}
func (UnimplementedScheduleServer) GetFeedbackSignal(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetFeedbackSignal not implemented")
}
func (UnimplementedScheduleServer) ApplyActiveScheduleChanges(context.Context, *JSONRequest) (*JSONResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ApplyActiveScheduleChanges not implemented")
}
func RegisterScheduleServer(s grpc.ServiceRegistrar, srv ScheduleServer) {
s.RegisterService(&Schedule_ServiceDesc, srv)
}
func _Schedule_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(StatusResponse)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).Ping(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_Ping_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).Ping(ctx, req.(*StatusResponse))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetToday_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetToday(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetToday_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetToday(ctx, req.(*UserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetWeek_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(WeekRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetWeek(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetWeek_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetWeek(ctx, req.(*WeekRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_DeleteEvents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteEventsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).DeleteEvents(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_DeleteEvents_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).DeleteEvents(ctx, req.(*DeleteEventsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetRecentCompleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecentCompletedRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetRecentCompleted(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetRecentCompleted_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetRecentCompleted(ctx, req.(*RecentCompletedRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetCurrent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UserRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetCurrent(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetCurrent_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetCurrent(ctx, req.(*UserRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_RevokeTaskItem_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RevokeTaskItemRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).RevokeTaskItem(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_RevokeTaskItem_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).RevokeTaskItem(ctx, req.(*RevokeTaskItemRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_SmartPlanning_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SmartPlanningRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).SmartPlanning(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_SmartPlanning_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).SmartPlanning(ctx, req.(*SmartPlanningRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_SmartPlanningMulti_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SmartPlanningMultiRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).SmartPlanningMulti(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_SmartPlanningMulti_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).SmartPlanningMulti(ctx, req.(*SmartPlanningMultiRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetScheduleFactsByWindow_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetScheduleFactsByWindow(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetScheduleFactsByWindow_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetScheduleFactsByWindow(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_GetFeedbackSignal_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).GetFeedbackSignal(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_GetFeedbackSignal_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).GetFeedbackSignal(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
func _Schedule_ApplyActiveScheduleChanges_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(JSONRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ScheduleServer).ApplyActiveScheduleChanges(ctx, in)
}
info := &grpc.UnaryServerInfo{Server: srv, FullMethod: Schedule_ApplyActiveScheduleChanges_FullMethodName}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ScheduleServer).ApplyActiveScheduleChanges(ctx, req.(*JSONRequest))
}
return interceptor(ctx, in, info, handler)
}
var Schedule_ServiceDesc = grpc.ServiceDesc{
ServiceName: "smartflow.schedule.Schedule",
HandlerType: (*ScheduleServer)(nil),
Methods: []grpc.MethodDesc{
{MethodName: "Ping", Handler: _Schedule_Ping_Handler},
{MethodName: "GetToday", Handler: _Schedule_GetToday_Handler},
{MethodName: "GetWeek", Handler: _Schedule_GetWeek_Handler},
{MethodName: "DeleteEvents", Handler: _Schedule_DeleteEvents_Handler},
{MethodName: "GetRecentCompleted", Handler: _Schedule_GetRecentCompleted_Handler},
{MethodName: "GetCurrent", Handler: _Schedule_GetCurrent_Handler},
{MethodName: "RevokeTaskItem", Handler: _Schedule_RevokeTaskItem_Handler},
{MethodName: "SmartPlanning", Handler: _Schedule_SmartPlanning_Handler},
{MethodName: "SmartPlanningMulti", Handler: _Schedule_SmartPlanningMulti_Handler},
{MethodName: "GetScheduleFactsByWindow", Handler: _Schedule_GetScheduleFactsByWindow_Handler},
{MethodName: "GetFeedbackSignal", Handler: _Schedule_GetFeedbackSignal_Handler},
{MethodName: "ApplyActiveScheduleChanges", Handler: _Schedule_ApplyActiveScheduleChanges_Handler},
},
Streams: []grpc.StreamDesc{},
Metadata: "schedule.proto",
}

View File

@@ -0,0 +1,66 @@
syntax = "proto3";
package smartflow.schedule;
option go_package = "github.com/LoveLosita/smartflow/backend/services/schedule/rpc/pb";
service Schedule {
rpc Ping(StatusResponse) returns (StatusResponse);
rpc GetToday(UserRequest) returns (JSONResponse);
rpc GetWeek(WeekRequest) returns (JSONResponse);
rpc DeleteEvents(DeleteEventsRequest) returns (StatusResponse);
rpc GetRecentCompleted(RecentCompletedRequest) returns (JSONResponse);
rpc GetCurrent(UserRequest) returns (JSONResponse);
rpc RevokeTaskItem(RevokeTaskItemRequest) returns (StatusResponse);
rpc SmartPlanning(SmartPlanningRequest) returns (JSONResponse);
rpc SmartPlanningMulti(SmartPlanningMultiRequest) returns (JSONResponse);
rpc GetScheduleFactsByWindow(JSONRequest) returns (JSONResponse);
rpc GetFeedbackSignal(JSONRequest) returns (JSONResponse);
rpc ApplyActiveScheduleChanges(JSONRequest) returns (JSONResponse);
}
message UserRequest {
int64 user_id = 1;
}
message WeekRequest {
int64 user_id = 1;
int64 week = 2;
}
message DeleteEventsRequest {
int64 user_id = 1;
bytes events_json = 2;
}
message RecentCompletedRequest {
int64 user_id = 1;
int64 index = 2;
int64 limit = 3;
}
message RevokeTaskItemRequest {
int64 user_id = 1;
int64 event_id = 2;
}
message SmartPlanningRequest {
int64 user_id = 1;
int64 task_class_id = 2;
}
message SmartPlanningMultiRequest {
int64 user_id = 1;
repeated int64 task_class_ids = 2;
}
message JSONRequest {
bytes payload_json = 1;
}
message JSONResponse {
bytes data_json = 1;
}
message StatusResponse {
}

View File

@@ -0,0 +1,60 @@
package rpc
import (
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/schedule/rpc/pb"
schedulesv "github.com/LoveLosita/smartflow/backend/services/schedule/sv"
"github.com/zeromicro/go-zero/core/service"
"github.com/zeromicro/go-zero/zrpc"
"google.golang.org/grpc"
)
const (
defaultListenOn = "0.0.0.0:9084"
defaultTimeout = 6 * time.Second
)
type ServerOptions struct {
ListenOn string
Timeout time.Duration
Service *schedulesv.ScheduleService
}
// NewServer 创建 schedule zrpc 服务端。
//
// 职责边界:
// 1. 只负责 zrpc server 配置与 gRPC handler 注册;
// 2. 不创建数据库、Redis 或业务服务,它们由 cmd/schedule 管理;
// 3. 返回 listenOn 供进程入口打印启动日志。
func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) {
if opts.Service == nil {
return nil, "", errors.New("schedule service dependency not initialized")
}
listenOn := strings.TrimSpace(opts.ListenOn)
if listenOn == "" {
listenOn = defaultListenOn
}
timeout := opts.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
server, err := zrpc.NewServer(zrpc.RpcServerConf{
ServiceConf: service.ServiceConf{
Name: "schedule.rpc",
Mode: service.DevMode,
},
ListenOn: listenOn,
Timeout: int64(timeout / time.Millisecond),
}, func(grpcServer *grpc.Server) {
pb.RegisterScheduleServer(grpcServer, NewHandler(opts.Service))
})
if err != nil {
return nil, "", err
}
return server, listenOn, nil
}

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
}