Version: 0.9.59.dev.260430

后端:
1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程
2. 同步更新主动调度实施文档的阶段状态与验收记录

前端:
3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
This commit is contained in:
LoveLosita
2026-04-30 12:05:15 +08:00
parent 1555042e80
commit e945578fbf
38 changed files with 10267 additions and 580 deletions

View File

@@ -0,0 +1,314 @@
package adapters
import (
"context"
"errors"
"fmt"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
// GormReaders 是主动调度 dry-run 的只读适配器。
//
// 职责边界:
// 1. 只在 adapter 层直接读取现有表,把外部领域模型转换为主动调度 facts
// 2. 不生成候选、不写 preview、不写正式日程
// 3. 后续拆微服务时可替换为 RPC/read model adapteractive_scheduler 主链路无需改动。
type GormReaders struct {
db *gorm.DB
}
func NewGormReaders(db *gorm.DB) *GormReaders {
return &GormReaders{db: db}
}
func ReadersFromGorm(readers *GormReaders) ports.Readers {
return ports.Readers{
TaskReader: readers,
ScheduleReader: readers,
FeedbackReader: readers,
}
}
func (r *GormReaders) ensureDB() error {
if r == nil || r.db == nil {
return errors.New("主动调度 GormReaders 未初始化")
}
return nil
}
func (r *GormReaders) GetTaskForActiveSchedule(ctx context.Context, req ports.TaskRequest) (ports.TaskFact, bool, error) {
if err := r.ensureDB(); err != nil {
return ports.TaskFact{}, false, err
}
if req.UserID <= 0 || req.TaskID <= 0 {
return ports.TaskFact{}, false, nil
}
var task model.Task
err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", req.TaskID, req.UserID).
First(&task).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ports.TaskFact{}, false, nil
}
return ports.TaskFact{}, false, err
}
estimatedSections := task.EstimatedSections
if estimatedSections <= 0 {
estimatedSections = 1
}
if estimatedSections > 4 {
estimatedSections = 4
}
return ports.TaskFact{
ID: task.ID,
UserID: task.UserID,
Title: task.Title,
Priority: task.Priority,
IsCompleted: task.IsCompleted,
DeadlineAt: task.DeadlineAt,
UrgencyThresholdAt: task.UrgencyThresholdAt,
EstimatedSections: estimatedSections,
}, true, nil
}
func (r *GormReaders) GetScheduleFactsByWindow(ctx context.Context, req ports.ScheduleWindowRequest) (ports.ScheduleWindowFacts, error) {
if err := r.ensureDB(); err != nil {
return ports.ScheduleWindowFacts{}, err
}
if req.UserID <= 0 || req.WindowStart.IsZero() || !req.WindowEnd.After(req.WindowStart) {
return ports.ScheduleWindowFacts{}, nil
}
windowSlots, err := buildWindowSlots(req.WindowStart, req.WindowEnd)
if err != nil {
return ports.ScheduleWindowFacts{}, err
}
weeks := uniqueWeeks(windowSlots)
var schedules []model.Schedule
if len(weeks) > 0 {
err = r.db.WithContext(ctx).
Preload("Event").
Where("user_id = ? AND week IN ?", req.UserID, weeks).
Find(&schedules).Error
if err != nil {
return ports.ScheduleWindowFacts{}, err
}
}
occupiedByKey := make(map[string]model.Schedule, len(schedules))
eventFacts := make(map[int]*ports.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([]ports.Slot, 0, len(occupiedByKey))
freeSlots := make([]ports.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([]ports.ScheduleEventFact, 0, len(eventFacts))
for _, fact := range eventFacts {
events = append(events, *fact)
}
return ports.ScheduleWindowFacts{
Events: events,
OccupiedSlots: occupiedSlots,
FreeSlots: freeSlots,
NextDynamicTask: firstDynamicTask(events, req.Now),
TargetAlreadyScheduled: targetAlreadyScheduled,
}, nil
}
func (r *GormReaders) GetFeedbackSignal(ctx context.Context, req ports.FeedbackRequest) (ports.FeedbackFact, bool, error) {
if err := r.ensureDB(); err != nil {
return ports.FeedbackFact{}, false, err
}
// 1. 第一版没有独立 feedback 表,显式传入 schedule_event target 时,把该事件视为已定位反馈目标。
// 2. 若无法定位目标,返回 found=true + TargetKnown=false让 observe 阶段稳定降级 ask_user。
if req.TargetType != string(trigger.TargetTypeScheduleEvent) || req.TargetID <= 0 {
return ports.FeedbackFact{
FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey),
TargetKnown: false,
SubmittedAt: time.Now(),
}, true, nil
}
var event model.ScheduleEvent
err := r.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 ports.FeedbackFact{
FeedbackID: firstNonEmpty(req.FeedbackID, req.IdempotencyKey),
TargetKnown: false,
SubmittedAt: time.Now(),
}, true, nil
}
return ports.FeedbackFact{}, false, err
}
taskItemID := 0
if event.RelID != nil {
taskItemID = *event.RelID
}
return ports.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) ([]ports.Slot, error) {
slots := make([]ports.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, ports.Slot{
Week: week,
DayOfWeek: dayOfWeek,
Section: section,
StartAt: sectionStart,
EndAt: sectionEnd,
})
}
}
return slots, nil
}
func slotFromSchedule(schedule model.Schedule) (ports.Slot, bool) {
startAt, endAt, err := conv.RelativeTimeToRealTime(schedule.Week, schedule.DayOfWeek, schedule.Section, schedule.Section)
if err != nil {
return ports.Slot{}, false
}
return ports.Slot{
Week: schedule.Week,
DayOfWeek: schedule.DayOfWeek,
Section: schedule.Section,
StartAt: startAt,
EndAt: endAt,
}, true
}
func scheduleToEventFact(schedule model.Schedule) *ports.ScheduleEventFact {
event := schedule.Event
relID := 0
if event.RelID != nil {
relID = *event.RelID
}
sourceType := event.TaskSourceType
if sourceType == "" && event.Type == "task" {
sourceType = string(trigger.TargetTypeTaskItem)
}
return &ports.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 = string(trigger.TargetTypeTaskItem)
}
return sourceType == targetType && *event.RelID == targetID
}
func firstDynamicTask(events []ports.ScheduleEventFact, now time.Time) *ports.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 []ports.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 ports.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 firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,261 @@
package apply
import (
"fmt"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
const (
rawChangeTypeAdd ChangeType = "add"
rawChangeTypeNone ChangeType = "none"
)
type candidateSnapshot struct {
CandidateID string
CandidateType ChangeType
Changes []ApplyChange
}
// ConvertConfirmToApplyRequest 把 preview 中的候选和 confirm 请求转换为正式 apply 请求。
//
// 职责边界:
// 1. 只读取调用方传入的 preview 快照,不直接访问数据库;
// 2. 负责候选定位、edited_changes 覆盖、范围校验、幂等摘要和 ApplyCommand 生成;
// 3. 不写 schedules也不执行 task/schedule 当前真值重校验,后者由 ScheduleApplyPort 完成。
func ConvertConfirmToApplyRequest(preview model.ActiveSchedulePreview, req ConfirmRequest, now time.Time) (*ApplyActiveScheduleRequest, error) {
if req.PreviewID == "" {
req.PreviewID = preview.ID
}
if req.Action == "" {
req.Action = ConfirmActionConfirm
}
if req.RequestedAt.IsZero() {
req.RequestedAt = now
}
if req.UserID <= 0 {
return nil, newApplyError(ErrorCodeInvalidRequest, "user_id 必须由接入层填入", nil)
}
if strings.TrimSpace(req.CandidateID) == "" {
return nil, newApplyError(ErrorCodeInvalidRequest, "candidate_id 不能为空", nil)
}
if req.Action != ConfirmActionConfirm {
return nil, newApplyError(ErrorCodeInvalidRequest, "当前只支持 confirm 动作", nil)
}
if err := ValidatePreviewConfirmable(preview, req.UserID, now); err != nil {
return nil, err
}
requestHash, err := BuildConfirmRequestHash(preview.ID, req)
if err != nil {
return nil, err
}
if DetectIdempotencyConflict(preview, requestHash.ApplyHash, req.IdempotencyKey) {
return nil, newApplyError(ErrorCodeIdempotencyConflict, "同一个幂等键已绑定不同确认内容", nil)
}
candidate, err := FindCandidateInPreview(preview, req.CandidateID)
if err != nil {
return nil, err
}
originalChanges, err := NormalizeChanges(candidate.Changes, candidate.CandidateType)
if err != nil {
return nil, err
}
changes := originalChanges
if len(req.EditedChanges) > 0 {
editedChanges, normalizeErr := NormalizeChanges(req.EditedChanges, candidate.CandidateType)
if normalizeErr != nil {
return nil, normalizeErr
}
if validateErr := ValidateChangeScope(originalChanges, editedChanges); validateErr != nil {
return nil, validateErr
}
changes = editedChanges
}
normalizedHash, err := BuildNormalizedChangesHash(changes)
if err != nil {
return nil, err
}
commands, skipped, err := ConvertChangesToCommands(changes)
if err != nil {
return nil, err
}
return &ApplyActiveScheduleRequest{
PreviewID: preview.ID,
ApplyID: requestHash.ApplyID,
IdempotencyKey: strings.TrimSpace(req.IdempotencyKey),
RequestHash: requestHash.ApplyHash,
RequestBodyHash: requestHash.BodyHash,
UserID: req.UserID,
CandidateID: req.CandidateID,
BaseVersion: preview.BaseVersion,
Changes: changes,
Commands: commands,
SkippedChanges: skipped,
NormalizedChangesHash: normalizedHash,
RequestedAt: req.RequestedAt,
TraceID: req.TraceID,
}, nil
}
// FindCandidateInPreview 从 selected_candidate_json 或 candidates_json 中定位 confirm 指定候选。
//
// 职责边界:
// 1. 优先使用 selected_candidate_json只有候选 ID 不匹配时才回退 candidates_json
// 2. 兼容 Go 结构体默认 JSON 字段名和前端常用 snake_case 字段;
// 3. 只返回候选快照,不判断候选是否仍可落库。
func FindCandidateInPreview(preview model.ActiveSchedulePreview, candidateID string) (candidateSnapshot, error) {
candidateID = strings.TrimSpace(candidateID)
if candidateID == "" {
return candidateSnapshot{}, newApplyError(ErrorCodeInvalidRequest, "candidate_id 不能为空", nil)
}
if preview.SelectedCandidateJSON != nil && strings.TrimSpace(*preview.SelectedCandidateJSON) != "" {
selected, err := parseCandidateSnapshot([]byte(*preview.SelectedCandidateJSON))
if err != nil {
return candidateSnapshot{}, err
}
if selected.CandidateID == candidateID {
return selected, nil
}
}
candidates, err := parseCandidateList(preview.CandidatesJSON)
if err != nil {
return candidateSnapshot{}, err
}
for _, item := range candidates {
if item.CandidateID == candidateID {
return item, nil
}
}
return candidateSnapshot{}, newApplyError(ErrorCodeTargetNotFound, "confirm 指定的 candidate_id 不属于当前 preview", nil)
}
// NormalizeChanges 对候选或用户编辑后的 changes 做最小规范化。
//
// 职责边界:
// 1. 填充 change_type、target、duration 和 slots 等缺省字段;
// 2. 合并同一目标的连续节次,降低后续 port 写入复杂度;
// 3. 不做数据库事实校验,不判断冲突。
func NormalizeChanges(changes []ApplyChange, candidateType ChangeType) ([]ApplyChange, error) {
if len(changes) == 0 && isNoopChangeType(candidateType) {
changes = []ApplyChange{{Type: candidateType}}
}
if len(changes) == 0 {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "候选没有可转换的 changes", nil)
}
normalized := make([]ApplyChange, 0, len(changes))
for _, change := range changes {
item, err := normalizeChange(change, candidateType)
if err != nil {
return nil, err
}
normalized = append(normalized, item)
}
sort.SliceStable(normalized, func(i, j int) bool {
return changeSortKey(normalized[i]) < changeSortKey(normalized[j])
})
merged := mergeContinuousChanges(normalized)
for i := range merged {
hash, err := hashJSON(changeForHash(merged[i]))
if err != nil {
return nil, err
}
merged[i].NormalizedHash = hash
}
return merged, nil
}
// ValidateChangeScope 校验 edited_changes 没有新增候选外目标。
//
// 职责边界:
// 1. 只比较候选原始 changes 与用户编辑后的 changes
// 2. 允许 EditedAllowed=true 的 change 改时间坐标,但不允许改 target/type
// 3. 更细的冲突、课程覆盖、base_version 重校验仍交给 apply port。
func ValidateChangeScope(original []ApplyChange, edited []ApplyChange) error {
allowed := make(map[string]ApplyChange, len(original))
for _, change := range original {
allowed[changeScopeKey(change)] = change
}
seen := make(map[string]struct{}, len(edited))
for _, change := range edited {
key := changeScopeKey(change)
base, ok := allowed[key]
if !ok {
return newApplyError(ErrorCodeInvalidEditedChanges, "edited_changes 包含候选外目标或变更类型", nil)
}
if _, exists := seen[key]; exists {
return newApplyError(ErrorCodeInvalidEditedChanges, "edited_changes 存在重复目标", nil)
}
seen[key] = struct{}{}
if !base.EditedAllowed && !sameChangeForScope(base, change) {
return newApplyError(ErrorCodeInvalidEditedChanges, "该 change 不允许用户编辑", nil)
}
}
return nil
}
// ConvertChangesToCommands 把规范化后的 changes 转成正式写入命令。
//
// 职责边界:
// 1. MVP 只生成 task_pool 新增和补做块新增两类命令;
// 2. ask_user/notify_only/close 只返回 skipped_changes不写正式日程
// 3. compress_with_next_dynamic_task 明确拒绝,避免 confirm 后出现不可应用候选。
func ConvertChangesToCommands(changes []ApplyChange) ([]ApplyCommand, []SkippedChange, error) {
commands := make([]ApplyCommand, 0, len(changes))
skipped := make([]SkippedChange, 0)
for _, change := range changes {
switch change.Type {
case ChangeTypeAddTaskPoolToSchedule:
if change.TargetID <= 0 {
return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "task_pool change 缺少 task_id/target_id", nil)
}
commands = append(commands, ApplyCommand{
CommandType: CommandTypeInsertTaskPoolEvent,
ChangeID: change.ChangeID,
ChangeType: change.Type,
TargetType: firstNonEmpty(change.TargetType, "task_pool"),
TargetID: change.TargetID,
Slots: slotsFromChange(change),
Metadata: cloneMetadata(change.Metadata),
})
case ChangeTypeCreateMakeup:
sourceEventID := firstPositive(change.SourceEventID, change.MakeupForEventID, change.EventID, change.TargetID)
if sourceEventID <= 0 {
return nil, nil, newApplyError(ErrorCodeInvalidEditedChanges, "create_makeup 缺少原 schedule_event id", nil)
}
commands = append(commands, ApplyCommand{
CommandType: CommandTypeInsertMakeupEvent,
ChangeID: change.ChangeID,
ChangeType: change.Type,
TargetType: firstNonEmpty(change.TargetType, "schedule_event"),
TargetID: firstPositive(change.TargetID, sourceEventID),
Slots: slotsFromChange(change),
SourceEventID: sourceEventID,
Metadata: cloneMetadata(change.Metadata),
})
case ChangeTypeAskUser, ChangeTypeNotifyOnly, ChangeTypeClose:
skipped = append(skipped, SkippedChange{
ChangeID: change.ChangeID,
ChangeType: change.Type,
Reason: "该候选只更新交互状态或通知结果,不写正式日程",
})
case ChangeTypeCompressWithNextDynamicTask:
return nil, nil, newApplyError(ErrorCodeUnsupportedChangeType, "MVP 明确关闭压缩融合 apply", nil)
default:
return nil, nil, newApplyError(ErrorCodeUnsupportedChangeType, fmt.Sprintf("不支持的 change_type: %s", change.Type), nil)
}
}
return commands, skipped, nil
}

View File

@@ -0,0 +1,455 @@
package apply
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
func normalizeChange(change ApplyChange, candidateType ChangeType) (ApplyChange, error) {
change.Type = normalizeChangeType(change.Type, candidateType)
if change.Type == ChangeTypeCompressWithNextDynamicTask {
return ApplyChange{}, newApplyError(ErrorCodeUnsupportedChangeType, "MVP 明确关闭压缩融合 apply", nil)
}
if isNoopChangeType(change.Type) {
return change, nil
}
fillTargetFields(&change)
fillSlotFields(&change)
if err := validateSlotFields(change); err != nil {
return ApplyChange{}, err
}
return change, nil
}
func normalizeChangeType(changeType ChangeType, candidateType ChangeType) ChangeType {
if changeType == "" || changeType == rawChangeTypeNone {
if isNoopChangeType(candidateType) {
return candidateType
}
return candidateType
}
if changeType == rawChangeTypeAdd && candidateType == ChangeTypeAddTaskPoolToSchedule {
return ChangeTypeAddTaskPoolToSchedule
}
if changeType == rawChangeTypeAdd && candidateType == ChangeTypeCreateMakeup {
return ChangeTypeCreateMakeup
}
return changeType
}
func fillTargetFields(change *ApplyChange) {
switch change.Type {
case ChangeTypeAddTaskPoolToSchedule:
change.TargetType = firstNonEmpty(change.TargetType, "task_pool")
change.TargetID = firstPositive(change.TargetID, change.TaskID)
change.TaskID = firstPositive(change.TaskID, change.TargetID)
case ChangeTypeCreateMakeup:
sourceEventID := firstPositive(change.SourceEventID, change.MakeupForEventID, change.EventID, change.TargetID)
change.TargetType = firstNonEmpty(change.TargetType, "schedule_event")
change.TargetID = firstPositive(change.TargetID, sourceEventID)
change.EventID = firstPositive(change.EventID, sourceEventID)
change.SourceEventID = sourceEventID
change.MakeupForEventID = firstPositive(change.MakeupForEventID, sourceEventID)
}
}
func fillSlotFields(change *ApplyChange) {
if len(change.Slots) > 0 {
sort.SliceStable(change.Slots, func(i, j int) bool {
return slotSortKey(change.Slots[i]) < slotSortKey(change.Slots[j])
})
first := change.Slots[0]
last := change.Slots[len(change.Slots)-1]
change.Week = firstPositive(change.Week, first.Week)
change.DayOfWeek = firstPositive(change.DayOfWeek, first.DayOfWeek)
change.SectionFrom = firstPositive(change.SectionFrom, first.Section)
change.SectionTo = firstPositive(change.SectionTo, last.Section)
}
if change.DurationSections <= 0 && change.SectionFrom > 0 && change.SectionTo >= change.SectionFrom {
change.DurationSections = change.SectionTo - change.SectionFrom + 1
}
if change.DurationSections <= 0 {
change.DurationSections = 1
}
if change.SectionTo <= 0 && change.SectionFrom > 0 {
change.SectionTo = change.SectionFrom + change.DurationSections - 1
}
if len(change.Slots) == 0 && change.Week > 0 && change.DayOfWeek > 0 && change.SectionFrom > 0 && change.SectionTo >= change.SectionFrom {
change.Slots = buildSlots(change.Week, change.DayOfWeek, change.SectionFrom, change.SectionTo)
}
}
func validateSlotFields(change ApplyChange) error {
if change.TargetID <= 0 {
return newApplyError(ErrorCodeInvalidEditedChanges, "change 缺少合法 target_id", nil)
}
if change.Week <= 0 || change.DayOfWeek <= 0 || change.SectionFrom <= 0 || change.SectionTo <= 0 {
return newApplyError(ErrorCodeInvalidEditedChanges, "change 缺少合法节次坐标", nil)
}
if change.DayOfWeek < 1 || change.DayOfWeek > 7 {
return newApplyError(ErrorCodeInvalidEditedChanges, "day_of_week 必须在 1 到 7 之间", nil)
}
if change.SectionFrom < 1 || change.SectionFrom > 12 || change.SectionTo < 1 || change.SectionTo > 12 || change.SectionTo < change.SectionFrom {
return newApplyError(ErrorCodeInvalidEditedChanges, "section_from/section_to 必须是合法连续节次", nil)
}
if change.DurationSections != change.SectionTo-change.SectionFrom+1 {
return newApplyError(ErrorCodeInvalidEditedChanges, "duration_sections 与节次数量不一致", nil)
}
return nil
}
func parseCandidateList(raw *string) ([]candidateSnapshot, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil, nil
}
var first any
if err := json.Unmarshal([]byte(*raw), &first); err != nil {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 不是合法 JSON", err)
}
switch typed := first.(type) {
case []any:
result := make([]candidateSnapshot, 0, len(typed))
var raws []json.RawMessage
if err := json.Unmarshal([]byte(*raw), &raws); err != nil {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 候选数组解析失败", err)
}
for _, item := range raws {
candidate, err := parseCandidateSnapshot(item)
if err != nil {
return nil, err
}
result = append(result, candidate)
}
return result, nil
case map[string]any:
obj := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(*raw), &obj); err != nil {
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 候选对象解析失败", err)
}
if nested := rawField(obj, "candidates", "Candidates"); len(nested) > 0 {
nestedText := string(nested)
return parseCandidateList(&nestedText)
}
candidate, err := parseCandidateSnapshot([]byte(*raw))
if err != nil {
return nil, err
}
return []candidateSnapshot{candidate}, nil
default:
return nil, newApplyError(ErrorCodeInvalidEditedChanges, "candidates_json 结构不受支持", nil)
}
}
func parseCandidateSnapshot(raw []byte) (candidateSnapshot, error) {
obj := make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &obj); err != nil {
return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate JSON 解析失败", err)
}
candidate := candidateSnapshot{
CandidateID: stringField(obj, "candidate_id", "CandidateID", "id", "ID"),
CandidateType: ChangeType(stringField(obj, "candidate_type", "CandidateType", "type", "change_type")),
}
if candidate.CandidateID == "" {
return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate 缺少 candidate_id", nil)
}
if candidate.CandidateType == "" {
return candidateSnapshot{}, newApplyError(ErrorCodeInvalidEditedChanges, "candidate 缺少 candidate_type", nil)
}
changeRaws := rawArrayField(obj, "changes", "Changes", "preview_changes", "PreviewChanges")
candidate.Changes = make([]ApplyChange, 0, len(changeRaws))
for _, rawChange := range changeRaws {
change, err := parseApplyChange(rawChange, candidate.CandidateType)
if err != nil {
return candidateSnapshot{}, err
}
candidate.Changes = append(candidate.Changes, change)
}
if len(candidate.Changes) == 0 && isNoopChangeType(candidate.CandidateType) {
candidate.Changes = []ApplyChange{{Type: candidate.CandidateType}}
}
return candidate, nil
}
func parseApplyChange(raw []byte, candidateType ChangeType) (ApplyChange, error) {
obj := make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &obj); err != nil {
return ApplyChange{}, newApplyError(ErrorCodeInvalidEditedChanges, "change JSON 解析失败", err)
}
change := ApplyChange{
ChangeID: stringField(obj, "change_id", "ChangeID", "id", "ID"),
Type: ChangeType(stringField(obj, "type", "change_type", "ChangeType")),
TargetType: stringField(obj, "target_type", "TargetType"),
TargetID: intField(obj, "target_id", "TargetID"),
TaskID: intField(obj, "task_id", "TaskID"),
EventID: intField(obj, "event_id", "EventID"),
Week: intField(obj, "week", "Week"),
DayOfWeek: intField(obj, "day_of_week", "DayOfWeek"),
SectionFrom: intField(obj, "section_from", "SectionFrom"),
SectionTo: intField(obj, "section_to", "SectionTo"),
DurationSections: intField(obj, "duration_sections", "DurationSections"),
MakeupForEventID: intField(obj, "makeup_for_event_id", "MakeupForEventID"),
SourceEventID: intField(obj, "source_event_id", "SourceEventID"),
EditedAllowed: boolField(obj, "edited_allowed", "EditedAllowed"),
Metadata: mapStringField(obj, "metadata", "Metadata"),
}
if change.Type == "" {
change.Type = candidateType
}
if len(change.Metadata) > 0 {
change.MakeupForEventID = firstPositive(change.MakeupForEventID, parsePositiveInt(change.Metadata["makeup_for_event_id"]))
change.SourceEventID = firstPositive(change.SourceEventID, parsePositiveInt(change.Metadata["source_event_id"]))
}
change.Slots = slotsFromRawChange(obj)
return change, nil
}
func slotsFromRawChange(obj map[string]json.RawMessage) []Slot {
if raw := rawField(obj, "slots", "Slots"); len(raw) > 0 {
var slots []Slot
if err := json.Unmarshal(raw, &slots); err == nil && len(slots) > 0 {
return slots
}
}
if raw := rawField(obj, "to_slot", "ToSlot"); len(raw) > 0 {
span, ok := parseSlotSpan(raw)
if ok {
return buildSlots(span.Start.Week, span.Start.DayOfWeek, span.Start.Section, span.End.Section)
}
}
return nil
}
func parseSlotSpan(raw []byte) (SlotSpan, bool) {
obj := make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &obj); err != nil {
return SlotSpan{}, false
}
start := parseSlot(rawField(obj, "start", "Start"))
end := parseSlot(rawField(obj, "end", "End"))
duration := intField(obj, "duration_sections", "DurationSections")
if end.IsZero() && !start.IsZero() {
end = start
if duration > 1 {
end.Section = start.Section + duration - 1
}
}
return SlotSpan{Start: start, End: end, DurationSections: firstPositive(duration, end.Section-start.Section+1)}, !start.IsZero() && !end.IsZero()
}
func parseSlot(raw []byte) Slot {
if len(raw) == 0 {
return Slot{}
}
obj := make(map[string]json.RawMessage)
if err := json.Unmarshal(raw, &obj); err != nil {
return Slot{}
}
return Slot{
Week: intField(obj, "week", "Week"),
DayOfWeek: intField(obj, "day_of_week", "DayOfWeek"),
Section: intField(obj, "section", "Section"),
}
}
func rawField(obj map[string]json.RawMessage, keys ...string) json.RawMessage {
for _, key := range keys {
if raw, ok := obj[key]; ok && len(raw) > 0 && string(raw) != "null" {
return raw
}
}
return nil
}
func rawArrayField(obj map[string]json.RawMessage, keys ...string) []json.RawMessage {
raw := rawField(obj, keys...)
if len(raw) == 0 {
return nil
}
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil
}
return items
}
func stringField(obj map[string]json.RawMessage, keys ...string) string {
raw := rawField(obj, keys...)
if len(raw) == 0 {
return ""
}
var value string
if err := json.Unmarshal(raw, &value); err == nil {
return strings.TrimSpace(value)
}
return strings.Trim(strings.TrimSpace(string(raw)), `"`)
}
func intField(obj map[string]json.RawMessage, keys ...string) int {
raw := rawField(obj, keys...)
if len(raw) == 0 {
return 0
}
var value int
if err := json.Unmarshal(raw, &value); err == nil {
return value
}
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return parsePositiveInt(asString)
}
return 0
}
func boolField(obj map[string]json.RawMessage, keys ...string) bool {
raw := rawField(obj, keys...)
if len(raw) == 0 {
return false
}
var value bool
if err := json.Unmarshal(raw, &value); err == nil {
return value
}
return false
}
func mapStringField(obj map[string]json.RawMessage, keys ...string) map[string]string {
raw := rawField(obj, keys...)
if len(raw) == 0 {
return nil
}
var result map[string]string
if err := json.Unmarshal(raw, &result); err == nil {
return result
}
var loose map[string]any
if err := json.Unmarshal(raw, &loose); err != nil {
return nil
}
result = make(map[string]string, len(loose))
for key, value := range loose {
result[key] = fmt.Sprint(value)
}
return result
}
func buildSlots(week int, dayOfWeek int, sectionFrom int, sectionTo int) []Slot {
if week <= 0 || dayOfWeek <= 0 || sectionFrom <= 0 || sectionTo < sectionFrom {
return nil
}
slots := make([]Slot, 0, sectionTo-sectionFrom+1)
for section := sectionFrom; section <= sectionTo; section++ {
slots = append(slots, Slot{Week: week, DayOfWeek: dayOfWeek, Section: section})
}
return slots
}
func slotsFromChange(change ApplyChange) []Slot {
if len(change.Slots) > 0 {
return append([]Slot(nil), change.Slots...)
}
return buildSlots(change.Week, change.DayOfWeek, change.SectionFrom, change.SectionTo)
}
func mergeContinuousChanges(changes []ApplyChange) []ApplyChange {
if len(changes) <= 1 {
return changes
}
merged := make([]ApplyChange, 0, len(changes))
for _, change := range changes {
if len(merged) == 0 {
merged = append(merged, change)
continue
}
last := &merged[len(merged)-1]
if canMergeChange(*last, change) {
last.SectionTo = change.SectionTo
last.DurationSections += change.DurationSections
last.Slots = append(last.Slots, change.Slots...)
continue
}
merged = append(merged, change)
}
return merged
}
func canMergeChange(left ApplyChange, right ApplyChange) bool {
return left.Type == right.Type &&
left.TargetType == right.TargetType &&
left.TargetID == right.TargetID &&
left.SourceEventID == right.SourceEventID &&
left.Week == right.Week &&
left.DayOfWeek == right.DayOfWeek &&
left.SectionTo+1 == right.SectionFrom &&
left.EditedAllowed == right.EditedAllowed
}
func isNoopChangeType(changeType ChangeType) bool {
return changeType == ChangeTypeAskUser || changeType == ChangeTypeNotifyOnly || changeType == ChangeTypeClose
}
func changeSortKey(change ApplyChange) string {
return fmt.Sprintf("%s:%s:%010d:%010d:%010d:%010d:%010d",
change.Type, change.TargetType, change.TargetID, change.SourceEventID, change.Week, change.DayOfWeek, change.SectionFrom)
}
func slotSortKey(slot Slot) string {
return fmt.Sprintf("%010d:%010d:%010d", slot.Week, slot.DayOfWeek, slot.Section)
}
func changeScopeKey(change ApplyChange) string {
return fmt.Sprintf("%s:%s:%d:%d", change.Type, change.TargetType, change.TargetID, change.SourceEventID)
}
func sameChangeForScope(left ApplyChange, right ApplyChange) bool {
return left.Type == right.Type &&
left.TargetType == right.TargetType &&
left.TargetID == right.TargetID &&
left.SourceEventID == right.SourceEventID &&
left.Week == right.Week &&
left.DayOfWeek == right.DayOfWeek &&
left.SectionFrom == right.SectionFrom &&
left.SectionTo == right.SectionTo &&
left.DurationSections == right.DurationSections
}
func changeForHash(change ApplyChange) ApplyChange {
change.NormalizedHash = ""
return change
}
func cloneMetadata(metadata map[string]string) map[string]string {
if len(metadata) == 0 {
return nil
}
cloned := make(map[string]string, len(metadata))
for key, value := range metadata {
cloned[key] = value
}
return cloned
}
func firstPositive(values ...int) int {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func parsePositiveInt(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
return 0
}
return parsed
}

View File

@@ -0,0 +1,99 @@
package apply
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
)
type RequestHash struct {
BodyHash string
ApplyHash string
ApplyID string
}
// BuildConfirmRequestHash 计算 confirm 请求的幂等摘要。
//
// 职责边界:
// 1. body_hash 只覆盖一次确认动作中真正影响 apply 的 body 字段;
// 2. apply_hash 按 preview_id + idempotency_key + body_hash 计算,满足同 key 不同内容可识别;
// 3. 不查询历史记录,是否冲突由 DetectIdempotencyConflict 或接入层唯一约束判断。
func BuildConfirmRequestHash(previewID string, req ConfirmRequest) (RequestHash, error) {
previewID = strings.TrimSpace(firstNonEmpty(req.PreviewID, previewID))
idempotencyKey := strings.TrimSpace(req.IdempotencyKey)
if previewID == "" {
return RequestHash{}, newApplyError(ErrorCodeInvalidRequest, "preview_id 不能为空", nil)
}
if idempotencyKey == "" {
return RequestHash{}, newApplyError(ErrorCodeInvalidRequest, "idempotency_key 不能为空", nil)
}
bodyHash, err := hashJSON(confirmRequestBodyForHash{
CandidateID: strings.TrimSpace(req.CandidateID),
Action: normalizeConfirmAction(req.Action),
EditedChanges: req.EditedChanges,
IdempotencyKey: idempotencyKey,
})
if err != nil {
return RequestHash{}, err
}
applyHash := sha256Text(previewID + "\n" + idempotencyKey + "\n" + bodyHash)
applyID := "asap_" + applyHash[:24]
return RequestHash{
BodyHash: bodyHash,
ApplyHash: applyHash,
ApplyID: applyID,
}, nil
}
// BuildNormalizedChangesHash 计算转换后 changes 的稳定摘要。
//
// 职责边界:
// 1. 只用于审计和幂等辅助,不替代正式 DB 重校验;
// 2. 输入应是 NormalizeChanges 后的结果,避免相同语义因顺序不同得到不同摘要;
// 3. 序列化失败会返回 invalid_request调用方应拒绝本次 confirm。
func BuildNormalizedChangesHash(changes []ApplyChange) (string, error) {
return hashJSON(changes)
}
type confirmRequestBodyForHash struct {
CandidateID string `json:"candidate_id"`
Action ConfirmAction `json:"action"`
EditedChanges []ApplyChange `json:"edited_changes,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
}
func hashJSON(value any) (string, error) {
raw, err := json.Marshal(value)
if err != nil {
return "", newApplyError(ErrorCodeInvalidRequest, "请求体无法生成稳定摘要", err)
}
return sha256Bytes(raw), nil
}
func sha256Text(text string) string {
return sha256Bytes([]byte(text))
}
func sha256Bytes(raw []byte) string {
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:])
}
func normalizeConfirmAction(action ConfirmAction) ConfirmAction {
if action == "" {
return ConfirmActionConfirm
}
return action
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -0,0 +1,249 @@
package apply
import (
"context"
"errors"
"fmt"
"time"
)
type ConfirmAction string
const (
ConfirmActionConfirm ConfirmAction = "confirm"
)
type ChangeType string
const (
ChangeTypeAddTaskPoolToSchedule ChangeType = "add_task_pool_to_schedule"
ChangeTypeCreateMakeup ChangeType = "create_makeup"
ChangeTypeAskUser ChangeType = "ask_user"
ChangeTypeNotifyOnly ChangeType = "notify_only"
ChangeTypeClose ChangeType = "close"
ChangeTypeCompressWithNextDynamicTask ChangeType = "compress_with_next_dynamic_task"
)
type CommandType string
const (
CommandTypeInsertTaskPoolEvent CommandType = "insert_task_pool_event"
CommandTypeInsertMakeupEvent CommandType = "insert_makeup_event"
)
type ApplyStatus string
const (
ApplyStatusNone ApplyStatus = "none"
ApplyStatusApplying ApplyStatus = "applying"
ApplyStatusApplied ApplyStatus = "applied"
ApplyStatusFailed ApplyStatus = "failed"
ApplyStatusRejected ApplyStatus = "rejected"
ApplyStatusExpired ApplyStatus = "expired"
)
type ErrorCode string
const (
ErrorCodeExpired ErrorCode = "expired"
ErrorCodeIdempotencyConflict ErrorCode = "idempotency_conflict"
ErrorCodeBaseVersionChanged ErrorCode = "base_version_changed"
ErrorCodeTargetNotFound ErrorCode = "target_not_found"
ErrorCodeTargetCompleted ErrorCode = "target_completed"
ErrorCodeTargetAlreadySchedule ErrorCode = "target_already_scheduled"
ErrorCodeSlotConflict ErrorCode = "slot_conflict"
ErrorCodeInvalidEditedChanges ErrorCode = "invalid_edited_changes"
ErrorCodeUnsupportedChangeType ErrorCode = "unsupported_change_type"
ErrorCodeDBError ErrorCode = "db_error"
ErrorCodeInvalidRequest ErrorCode = "invalid_request"
ErrorCodeForbidden ErrorCode = "forbidden"
ErrorCodeAlreadyApplied ErrorCode = "already_applied"
)
// ApplyError 表示 confirm/apply 链路可被 API 直接映射的业务错误。
//
// 职责边界:
// 1. 只承载错误分类和可读信息,便于主线程写入 apply_error 或转成 HTTP 响应;
// 2. 不负责决定 preview 状态流转,状态更新仍由接入层或后续 preview repo 完成;
// 3. Err 保留底层错误Error() 返回中文消息,便于日志排障。
type ApplyError struct {
Code ErrorCode
Message string
Err error
}
func (e *ApplyError) Error() string {
if e == nil {
return ""
}
if e.Message != "" {
return fmt.Sprintf("%s: %s", e.Code, e.Message)
}
return string(e.Code)
}
func (e *ApplyError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func newApplyError(code ErrorCode, message string, err error) error {
return &ApplyError{Code: code, Message: message, Err: err}
}
func errorCodeOf(err error) ErrorCode {
if err == nil {
return ""
}
var applyErr *ApplyError
if errors.As(err, &applyErr) {
return applyErr.Code
}
return ErrorCodeDBError
}
// Slot 是 confirm 请求与 apply command 之间共享的最小节次坐标。
//
// 职责边界:
// 1. 只表达 week/day_of_week/section不绑定 schedules 表;
// 2. 不负责相对时间到绝对时间的转换,该转换由 apply port/adapter 完成;
// 3. IsZero 用于识别前端未传坐标或候选 JSON 缺字段的情况。
type Slot struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
Section int `json:"section"`
}
func (s Slot) IsZero() bool {
return s.Week == 0 && s.DayOfWeek == 0 && s.Section == 0
}
// SlotSpan 表示一段连续节次,供转换器展开为正式写入命令。
type SlotSpan struct {
Start Slot `json:"start"`
End Slot `json:"end"`
DurationSections int `json:"duration_sections"`
}
// ApplyChange 是 confirm 请求和候选转换后的统一 change DTO。
//
// 职责边界:
// 1. 表达用户最终确认的结构化变更,可来自 preview 原候选或 edited_changes
// 2. 不承载数据库模型,也不表示已经真实落库;
// 3. Type 决定是否可转换为正式写入命令ask_user/notify_only/close 会被保留为跳过项。
type ApplyChange struct {
ChangeID string `json:"change_id,omitempty"`
Type ChangeType `json:"type"`
TargetType string `json:"target_type,omitempty"`
TargetID int `json:"target_id,omitempty"`
TaskID int `json:"task_id,omitempty"`
EventID int `json:"event_id,omitempty"`
Week int `json:"week,omitempty"`
DayOfWeek int `json:"day_of_week,omitempty"`
SectionFrom int `json:"section_from,omitempty"`
SectionTo int `json:"section_to,omitempty"`
DurationSections int `json:"duration_sections,omitempty"`
MakeupForEventID int `json:"makeup_for_event_id,omitempty"`
SourceEventID int `json:"source_event_id,omitempty"`
Slots []Slot `json:"slots,omitempty"`
EditedAllowed bool `json:"edited_allowed,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
NormalizedHash string `json:"normalized_hash,omitempty"`
}
// ConfirmRequest 是主动调度详情页提交确认时的入口 DTO。
//
// 职责边界:
// 1. PreviewID 可由路由层补齐body 内没有 preview_id 时也能参与转换;
// 2. EditedChanges 为空时,转换器会回退使用 preview 中 candidate 的原始 changes
// 3. IdempotencyKey 只代表一次确认动作,不代表 candidate 身份。
type ConfirmRequest struct {
PreviewID string `json:"preview_id,omitempty"`
UserID int `json:"user_id,omitempty"`
CandidateID string `json:"candidate_id"`
Action ConfirmAction `json:"action"`
EditedChanges []ApplyChange `json:"edited_changes,omitempty"`
IdempotencyKey string `json:"idempotency_key"`
RequestedAt time.Time `json:"requested_at,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
type ApplyCommand struct {
CommandType CommandType `json:"command_type"`
ChangeID string `json:"change_id,omitempty"`
ChangeType ChangeType `json:"change_type"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
Slots []Slot `json:"slots"`
SourceEventID int `json:"source_event_id,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type SkippedChange struct {
ChangeID string `json:"change_id,omitempty"`
ChangeType ChangeType `json:"change_type"`
Reason string `json:"reason"`
}
// ApplyActiveScheduleRequest 是传给正式写入 port 的请求 DTO。
//
// 职责边界:
// 1. 只描述已完成 preview/candidate 转换和基础校验后的写入意图;
// 2. 不直接执行 schedules 写入,真正事务由 ScheduleApplyPort/adapter 负责;
// 3. RequestHash 用于 preview_id + idempotency_key + body_hash 的幂等识别。
type ApplyActiveScheduleRequest struct {
PreviewID string `json:"preview_id"`
ApplyID string `json:"apply_id"`
IdempotencyKey string `json:"idempotency_key"`
RequestHash string `json:"request_hash"`
RequestBodyHash string `json:"request_body_hash"`
UserID int `json:"user_id"`
CandidateID string `json:"candidate_id"`
BaseVersion string `json:"base_version"`
Changes []ApplyChange `json:"changes"`
Commands []ApplyCommand `json:"commands"`
SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"`
NormalizedChangesHash string `json:"normalized_changes_hash"`
RequestedAt time.Time `json:"requested_at"`
TraceID string `json:"trace_id,omitempty"`
}
type ApplyActiveScheduleResult struct {
ApplyID string `json:"apply_id"`
ApplyStatus ApplyStatus `json:"apply_status"`
AppliedEventIDs []int `json:"applied_event_ids,omitempty"`
AppliedScheduleIDs []int `json:"applied_schedule_ids,omitempty"`
AppliedChanges []ApplyChange `json:"applied_changes,omitempty"`
SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"`
WarningMessages []string `json:"warning_messages,omitempty"`
ErrorCode ErrorCode `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
RequestHash string `json:"request_hash,omitempty"`
NormalizedChangeHash string `json:"normalized_change_hash,omitempty"`
}
type ConfirmResult struct {
PreviewID string `json:"preview_id"`
ApplyID string `json:"apply_id"`
ApplyStatus ApplyStatus `json:"apply_status"`
CandidateID string `json:"candidate_id"`
RequestHash string `json:"request_hash,omitempty"`
RequestBodyHash string `json:"request_body_hash,omitempty"`
ApplyRequest *ApplyActiveScheduleRequest `json:"apply_request,omitempty"`
ApplyResult *ApplyActiveScheduleResult `json:"apply_result,omitempty"`
SkippedChanges []SkippedChange `json:"skipped_changes,omitempty"`
ErrorCode ErrorCode `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ScheduleApplyPort 是主动调度 apply 层唯一允许调用的正式写入端口。
//
// 职责边界:
// 1. 负责在事务内重读 task/schedule/task_item 真值并写入正式日程;
// 2. 负责返回真实 applied_changes/applied_event_ids而不是候选原始内容
// 3. apply 包本身不直接 import DAO 写 schedules避免绕过既有领域能力。
type ScheduleApplyPort interface {
ApplyActiveScheduleChanges(ctx context.Context, req ApplyActiveScheduleRequest) (ApplyActiveScheduleResult, error)
}

View File

@@ -0,0 +1,98 @@
package apply
import (
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/model"
)
// IsPreviewExpired 判断 preview 是否已经超过确认有效期。
//
// 职责边界:
// 1. 只比较 expires_at 与调用方传入的 now
// 2. 不读取数据库,也不更新 preview.status
// 3. now 为空时按“不能安全确认”处理,避免调用方误放过过期预览。
func IsPreviewExpired(preview model.ActiveSchedulePreview, now time.Time) bool {
if now.IsZero() || preview.ExpiresAt.IsZero() {
return true
}
return !now.Before(preview.ExpiresAt)
}
// IsPreviewOwnedByUser 判断 preview 是否归属当前用户。
//
// 职责边界:
// 1. 只做 user_id 等值判断;
// 2. userID 非法时直接返回 false
// 3. 不判断用户是否仍存在,该事实应由 API 鉴权或接入层保证。
func IsPreviewOwnedByUser(preview model.ActiveSchedulePreview, userID int) bool {
return userID > 0 && preview.UserID == userID
}
// IsPreviewAlreadyApplied 判断 preview 是否已经成功应用过。
//
// 职责边界:
// 1. 同时兼容 preview.status 与 apply_status 两个字段;
// 2. 只识别“已成功应用”,不把 failed/rejected 视为成功;
// 3. 返回 true 时主线程应避免再次写正式日程。
func IsPreviewAlreadyApplied(preview model.ActiveSchedulePreview) bool {
return preview.Status == model.ActiveSchedulePreviewStatusApplied ||
preview.ApplyStatus == model.ActiveScheduleApplyStatusApplied
}
// ValidatePreviewConfirmable 执行 confirm 入口的基础 preview 判断。
//
// 职责边界:
// 1. 只校验预览归属、过期、状态与已应用等轻量规则;
// 2. 不校验 task/schedule 当前真值,也不判断冲突,正式重校验由 apply port 完成;
// 3. 返回 nil 表示可以继续做候选转换,返回 ApplyError 表示本次 confirm 应被拒绝。
func ValidatePreviewConfirmable(preview model.ActiveSchedulePreview, userID int, now time.Time) error {
if preview.ID == "" {
return newApplyError(ErrorCodeTargetNotFound, "预览不存在或未加载", nil)
}
if !IsPreviewOwnedByUser(preview, userID) {
return newApplyError(ErrorCodeForbidden, "预览不属于当前用户", nil)
}
if IsPreviewExpired(preview, now) || preview.Status == model.ActiveSchedulePreviewStatusExpired || preview.ApplyStatus == model.ActiveScheduleApplyStatusExpired {
return newApplyError(ErrorCodeExpired, "预览已过期,请重新生成建议", nil)
}
if IsPreviewAlreadyApplied(preview) {
return newApplyError(ErrorCodeAlreadyApplied, "该预览已经应用过,不能重复写入日程", nil)
}
if preview.Status == model.ActiveSchedulePreviewStatusIgnored {
return newApplyError(ErrorCodeInvalidRequest, "该预览已被忽略,不能继续确认", nil)
}
if preview.Status == model.ActiveSchedulePreviewStatusFailed {
return newApplyError(ErrorCodeInvalidRequest, "该预览生成失败,不能继续确认", nil)
}
if preview.Status != "" && preview.Status != model.ActiveSchedulePreviewStatusReady && preview.Status != model.ActiveSchedulePreviewStatusPending {
return newApplyError(ErrorCodeInvalidRequest, "预览状态不允许确认", nil)
}
if preview.ApplyStatus != "" &&
preview.ApplyStatus != model.ActiveScheduleApplyStatusNone &&
preview.ApplyStatus != model.ActiveScheduleApplyStatusFailed &&
preview.ApplyStatus != model.ActiveScheduleApplyStatusRejected {
return newApplyError(ErrorCodeInvalidRequest, "当前 apply 状态不允许重新确认", nil)
}
return nil
}
// DetectIdempotencyConflict 判断同一个 preview_id + idempotency_key 是否被复用于不同请求体。
//
// 职责边界:
// 1. 只比较当前请求和 preview 已记录的 apply_idempotency_key / apply_request_hash
// 2. 不查询数据库唯一约束,主线程仍需要在事务或行锁内调用;
// 3. 返回 true 表示必须拒绝,避免同 key 不同内容污染审计链路。
func DetectIdempotencyConflict(preview model.ActiveSchedulePreview, requestHash string, idempotencyKey string) bool {
if strings.TrimSpace(idempotencyKey) == "" || strings.TrimSpace(preview.ApplyIdempotencyKey) == "" {
return false
}
if preview.ApplyIdempotencyKey != idempotencyKey {
return false
}
if preview.ApplyRequestHash == "" {
return false
}
return preview.ApplyRequestHash != requestHash
}

View File

@@ -0,0 +1,491 @@
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 反查判断。
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,337 @@
package candidate
import (
"fmt"
"sort"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
type Type string
const (
TypeAddTaskPoolToSchedule Type = "add_task_pool_to_schedule"
TypeCreateMakeup Type = "create_makeup"
TypeAskUser Type = "ask_user"
TypeNotifyOnly Type = "notify_only"
TypeClose Type = "close"
TypeCompressWithNextDynamicTask Type = "compress_with_next_dynamic_task" // 预留常量:第一版禁止生成该候选。
)
type ChangeType string
const (
ChangeTypeAdd ChangeType = "add"
ChangeTypeCreateMakeup ChangeType = "create_makeup"
ChangeTypeAskUser ChangeType = "ask_user"
ChangeTypeNone ChangeType = "none"
)
// Candidate 是主动调度后端确定性生成的候选。
//
// 职责边界:
// 1. 只描述可写 preview 的结构化变更或非变更建议;
// 2. 不包含 DAO model不直接修改正式日程
// 3. 第一版不会生成 compress_with_next_dynamic_task。
type Candidate struct {
CandidateID string
CandidateType Type
Title string
Summary string
Target Target
Changes []ChangeItem
BeforeSummary string
AfterSummary string
Risk string
Score int
Validation Validation
Source string
}
type Target struct {
TargetType string
TargetID int
Title string
}
type ChangeItem struct {
ChangeType ChangeType
TargetType string
TargetID int
FromSlot *ports.Slot
ToSlot *ports.SlotSpan
DurationSections int
AffectedEventIDs []int
EditedAllowed bool
Metadata map[string]string
}
type Validation struct {
Valid bool
Reason string
}
// Generator 负责枚举、校验、排序并截断候选。
//
// 职责边界:
// 1. 只消费 context 和 observe 结果;
// 2. 不调用 LLM不写 preview不发通知
// 3. 校验失败的候选直接丢弃,避免把合法性判断交给后续选择器。
type Generator struct{}
func NewGenerator() *Generator {
return &Generator{}
}
// GenerateCandidates 执行 dry-run 主链路第三步:生成候选。
func (g *Generator) GenerateCandidates(ctx *schedulercontext.ActiveScheduleContext, observation observe.Result) []Candidate {
var candidates []Candidate
for _, issue := range observation.Issues {
switch issue.Code {
case observe.IssueTargetCompleted, observe.IssueTargetAlreadyScheduled:
candidates = append(candidates, closeCandidate(ctx, issue))
case observe.IssueFeedbackTargetUnknown:
candidates = append(candidates, askUserCandidate(ctx, issue, "我还不能确定是哪一个日程块没有完成,需要用户确认目标。"))
case observe.IssueCanAddTaskPoolToSchedule:
if candidate, ok := g.addTaskPoolCandidate(ctx); ok {
candidates = append(candidates, candidate)
}
case observe.IssueNeedMakeupBlock:
if candidate, ok := g.createMakeupCandidate(ctx); ok {
candidates = append(candidates, candidate)
}
case observe.IssueNoFreeSlot, observe.IssueCapacityInsufficient:
candidates = append(candidates, notifyOnlyCandidate(ctx, issue, "当前 24 小时内没有足够空位,第一版不会生成压缩融合候选。"))
case observe.IssueNoValidTimeWindow:
candidates = append(candidates, askUserCandidate(ctx, issue, "缺少必要时间窗或目标事实,需要用户补充后再安排。"))
}
}
return trimCandidates(rankCandidates(validateCandidates(candidates)))
}
func (g *Generator) addTaskPoolCandidate(ctx *schedulercontext.ActiveScheduleContext) (Candidate, bool) {
needed := ctx.Target.EstimatedSections
if needed <= 0 {
needed = 1
}
span, ok := firstContiguousFreeSpan(ctx.ScheduleFacts.FreeSlots, needed)
if !ok {
return Candidate{}, false
}
id := fmt.Sprintf("%s:%d:%d:%d:%d", TypeAddTaskPoolToSchedule, ctx.Target.TaskID, span.Start.Week, span.Start.DayOfWeek, span.Start.Section)
return Candidate{
CandidateID: id,
CandidateType: TypeAddTaskPoolToSchedule,
Title: "加入日程",
Summary: "把重要且紧急任务放入滚动 24 小时内的空闲节次。",
Target: targetFromContext(ctx),
Changes: []ChangeItem{{
ChangeType: ChangeTypeAdd,
TargetType: string(trigger.TargetTypeTaskPool),
TargetID: ctx.Target.TaskID,
ToSlot: &span,
DurationSections: needed,
EditedAllowed: true,
Metadata: map[string]string{
"task_source_type": string(trigger.TargetTypeTaskPool),
},
}},
BeforeSummary: "任务尚未进入正式日程。",
AfterSummary: "任务将占用第一个可用连续节次块。",
Risk: "仅新增 task_pool 日程块,不移动已有日程。",
Score: 100 - span.Start.Section,
Validation: Validation{Valid: true},
Source: "backend_deterministic",
}, true
}
func (g *Generator) createMakeupCandidate(ctx *schedulercontext.ActiveScheduleContext) (Candidate, bool) {
span, ok := firstContiguousFreeSpan(ctx.ScheduleFacts.FreeSlots, 1)
if !ok {
return Candidate{}, false
}
targetID := ctx.FeedbackFacts.TargetEventID
if targetID <= 0 {
targetID = ctx.Trigger.TargetID
}
id := fmt.Sprintf("%s:%d:%d:%d:%d", TypeCreateMakeup, targetID, span.Start.Week, span.Start.DayOfWeek, span.Start.Section)
return Candidate{
CandidateID: id,
CandidateType: TypeCreateMakeup,
Title: "新增补做块",
Summary: "为未完成的日程块新增一个补做时间,不移动原任务。",
Target: targetFromContext(ctx),
Changes: []ChangeItem{{
ChangeType: ChangeTypeCreateMakeup,
TargetType: string(trigger.TargetTypeScheduleEvent),
TargetID: targetID,
ToSlot: &span,
DurationSections: 1,
AffectedEventIDs: []int{targetID},
EditedAllowed: true,
Metadata: map[string]string{
"makeup_for_event_id": fmt.Sprintf("%d", targetID),
},
}},
BeforeSummary: "用户反馈该日程块未完成。",
AfterSummary: "新增 1 节补做块,原日程不移动。",
Risk: "第一版不做局部重排;若补做块仍不合适,需要用户手动调整。",
Score: 90 - span.Start.Section,
Validation: Validation{Valid: true},
Source: "backend_deterministic",
}, true
}
func closeCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue) Candidate {
return Candidate{
CandidateID: fmt.Sprintf("%s:%s:%d", TypeClose, ctx.Trigger.TargetType, ctx.Trigger.TargetID),
CandidateType: TypeClose,
Title: "关闭主动调度",
Summary: issue.Reason,
Target: targetFromContext(ctx),
Changes: []ChangeItem{{
ChangeType: ChangeTypeNone,
TargetType: string(ctx.Trigger.TargetType),
TargetID: ctx.Trigger.TargetID,
}},
BeforeSummary: "当前事实已覆盖触发原因。",
AfterSummary: "无需生成预览或通知。",
Risk: "无正式日程变更。",
Score: 0,
Validation: Validation{Valid: true},
Source: "backend_deterministic",
}
}
func askUserCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue, summary string) Candidate {
return Candidate{
CandidateID: fmt.Sprintf("%s:%s:%d", TypeAskUser, issue.Code, ctx.Trigger.TargetID),
CandidateType: TypeAskUser,
Title: "需要用户确认",
Summary: summary,
Target: targetFromContext(ctx),
Changes: []ChangeItem{{
ChangeType: ChangeTypeAskUser,
TargetType: string(ctx.Trigger.TargetType),
TargetID: ctx.Trigger.TargetID,
}},
BeforeSummary: "缺少安全生成调整方案所需的事实。",
AfterSummary: "等待用户补充信息后再重新 dry-run。",
Risk: "不会修改正式日程。",
Score: 0,
Validation: Validation{Valid: true},
Source: "backend_deterministic",
}
}
func notifyOnlyCandidate(ctx *schedulercontext.ActiveScheduleContext, issue observe.Issue, summary string) Candidate {
return Candidate{
CandidateID: fmt.Sprintf("%s:%s:%d", TypeNotifyOnly, issue.Code, ctx.Trigger.TargetID),
CandidateType: TypeNotifyOnly,
Title: "仅提醒",
Summary: summary,
Target: targetFromContext(ctx),
Changes: []ChangeItem{{
ChangeType: ChangeTypeNone,
TargetType: string(ctx.Trigger.TargetType),
TargetID: ctx.Trigger.TargetID,
}},
BeforeSummary: "当前窗口没有可安全安排的连续空位。",
AfterSummary: "不生成压缩融合或正式变更。",
Risk: "任务可能继续保持未安排状态。",
Score: 0,
Validation: Validation{Valid: true},
Source: "backend_deterministic",
}
}
func targetFromContext(ctx *schedulercontext.ActiveScheduleContext) Target {
return Target{
TargetType: string(ctx.Trigger.TargetType),
TargetID: ctx.Trigger.TargetID,
Title: ctx.Target.Title,
}
}
func firstContiguousFreeSpan(slots []ports.Slot, needed int) (ports.SlotSpan, bool) {
if needed <= 0 {
return ports.SlotSpan{}, false
}
sorted := append([]ports.Slot(nil), slots...)
sort.Slice(sorted, func(i, j int) bool {
return slotLess(sorted[i], sorted[j])
})
for i := range sorted {
end := i + needed - 1
if end >= len(sorted) {
break
}
if isContiguous(sorted[i : end+1]) {
return ports.SlotSpan{Start: sorted[i], End: sorted[end], DurationSections: needed}, true
}
}
return ports.SlotSpan{}, false
}
func isContiguous(slots []ports.Slot) bool {
if len(slots) == 0 {
return false
}
for i := 1; i < len(slots); i++ {
prev := slots[i-1]
curr := slots[i]
if prev.Week != curr.Week || prev.DayOfWeek != curr.DayOfWeek || prev.Section+1 != curr.Section {
return false
}
}
return true
}
func slotLess(left, right ports.Slot) bool {
if !left.StartAt.IsZero() && !right.StartAt.IsZero() && !left.StartAt.Equal(right.StartAt) {
return left.StartAt.Before(right.StartAt)
}
if left.Week != right.Week {
return left.Week < right.Week
}
if left.DayOfWeek != right.DayOfWeek {
return left.DayOfWeek < right.DayOfWeek
}
return left.Section < right.Section
}
func validateCandidates(candidates []Candidate) []Candidate {
valid := make([]Candidate, 0, len(candidates))
for _, candidate := range candidates {
if candidate.CandidateType == TypeCompressWithNextDynamicTask {
// 1. 压缩融合只作为 schema 预留;
// 2. 第一版 dry-run 禁止生成,防止后续 preview/apply 误认为可以执行。
continue
}
if candidate.CandidateID == "" || candidate.CandidateType == "" {
continue
}
if candidate.CandidateType == TypeAddTaskPoolToSchedule && len(candidate.Changes) == 0 {
continue
}
valid = append(valid, candidate)
}
return valid
}
func rankCandidates(candidates []Candidate) []Candidate {
sort.SliceStable(candidates, func(i, j int) bool {
return candidates[i].Score > candidates[j].Score
})
return candidates
}
func trimCandidates(candidates []Candidate) []Candidate {
if len(candidates) <= 3 {
return candidates
}
return candidates[:3]
}

View File

@@ -0,0 +1,220 @@
package schedulercontext
import (
"context"
"errors"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
// Builder 负责把统一 trigger 转成主动调度只读事实快照。
//
// 职责边界:
// 1. 只通过 ports 读取外部事实;
// 2. 不生成 candidates不调用 LLM不写 preview
// 3. 缺少业务事实时尽量写入 MissingInfo让 observe 阶段裁决 ask_user。
type Builder struct {
readers ports.Readers
clock func() time.Time
}
func NewBuilder(readers ports.Readers) (*Builder, error) {
if readers.ScheduleReader == nil {
return nil, errors.New("ScheduleReader 不能为空")
}
if readers.TaskReader == nil {
return nil, errors.New("TaskReader 不能为空")
}
if readers.FeedbackReader == nil {
return nil, errors.New("FeedbackReader 不能为空")
}
return &Builder{
readers: readers,
clock: time.Now,
}, nil
}
// SetClock 允许测试注入稳定时钟。
//
// 职责边界:
// 1. 仅影响 real_now
// 2. 不覆盖 trigger.MockNow 的业务语义;
// 3. nil 会被忽略,避免测试误把时钟置空。
func (b *Builder) SetClock(clock func() time.Time) {
if clock != nil {
b.clock = clock
}
}
// BuildContext 执行 dry-run 主链路第一步:构造主动调度上下文。
func (b *Builder) BuildContext(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*ActiveScheduleContext, error) {
if err := trig.Validate(); err != nil {
return nil, err
}
realNow := b.clock()
effectiveNow := trig.EffectiveNow(realNow)
windowStart := effectiveNow
windowEnd := effectiveNow.Add(24 * time.Hour)
result := &ActiveScheduleContext{
Trigger: trig,
User: UserFacts{
UserID: trig.UserID,
Timezone: effectiveNow.Location().String(),
},
Now: NowFacts{
RealNow: realNow,
EffectiveNow: effectiveNow,
},
Window: WindowFacts{
StartAt: windowStart,
EndAt: windowEnd,
WindowReason: WindowReasonRolling24H,
},
Target: TargetFacts{SourceType: trig.TargetType},
Trace: TraceFacts{
TraceID: trig.TraceID,
BuildSteps: []string{
"1. 校验 trigger 并确定 real_now / effective_now。",
"2. 构造滚动 24 小时时间窗,后续读取均基于同一窗口。",
},
},
}
switch trig.TriggerType {
case trigger.TriggerTypeImportantUrgentTask:
if err := b.fillTaskPoolFacts(ctx, result); err != nil {
return nil, err
}
case trigger.TriggerTypeUnfinishedFeedback:
if err := b.fillFeedbackFacts(ctx, result); err != nil {
return nil, err
}
}
if err := b.fillScheduleFacts(ctx, result); err != nil {
return nil, err
}
b.fillDerivedFacts(result)
return result, nil
}
func (b *Builder) fillTaskPoolFacts(ctx context.Context, result *ActiveScheduleContext) error {
task, found, err := b.readers.TaskReader.GetTaskForActiveSchedule(ctx, ports.TaskRequest{
UserID: result.Trigger.UserID,
TaskID: result.Trigger.TargetID,
Now: result.Now.EffectiveNow,
})
if err != nil {
return err
}
if !found {
result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "target_task")
result.Trace.Warnings = append(result.Trace.Warnings, "未读取到目标 task_pool 任务,后续应转为 ask_user。")
return nil
}
estimatedSections := task.EstimatedSections
if estimatedSections <= 0 {
// 1. 旧数据可能没有 estimated_sectionsMVP 兜底为 1 节,避免空值阻断 dry-run。
// 2. 正式 adapter 后续应尽量提供真实字段,减少兜底带来的预览偏差。
estimatedSections = 1
}
result.TaskPoolFacts.TargetTask = &task
result.Target.TaskID = task.ID
result.Target.Title = task.Title
result.Target.EstimatedSections = estimatedSections
result.Target.DeadlineAt = task.DeadlineAt
result.Target.UrgencyThresholdAt = task.UrgencyThresholdAt
result.Target.Priority = task.Priority
if task.IsCompleted {
result.Target.Status = "completed"
} else {
result.Target.Status = "pending"
}
result.Trace.BuildSteps = append(result.Trace.BuildSteps, "3. 通过 TaskReader 读取 task_pool 目标事实。")
return nil
}
func (b *Builder) fillFeedbackFacts(ctx context.Context, result *ActiveScheduleContext) error {
feedback, found, err := b.readers.FeedbackReader.GetFeedbackSignal(ctx, ports.FeedbackRequest{
UserID: result.Trigger.UserID,
FeedbackID: result.Trigger.FeedbackID,
IdempotencyKey: result.Trigger.IdempotencyKey,
TargetType: string(result.Trigger.TargetType),
TargetID: result.Trigger.TargetID,
})
if err != nil {
return err
}
if !found {
result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "feedback_signal")
result.Trace.Warnings = append(result.Trace.Warnings, "未读取到反馈信号,后续应转为 ask_user。")
return nil
}
result.FeedbackFacts = FeedbackFacts{
FeedbackID: feedback.FeedbackID,
FeedbackText: feedback.Text,
TargetKnown: feedback.TargetKnown,
TargetEventID: feedback.TargetEventID,
TargetTaskItemID: feedback.TargetTaskItemID,
FeedbackTarget: feedback.TargetTitle,
}
result.Target.ScheduleEventID = feedback.TargetEventID
result.Target.TaskItemID = feedback.TargetTaskItemID
result.Target.Title = feedback.TargetTitle
result.Target.EstimatedSections = 1
if !feedback.TargetKnown {
result.DerivedFacts.MissingInfo = append(result.DerivedFacts.MissingInfo, "feedback_target")
}
result.Trace.BuildSteps = append(result.Trace.BuildSteps, "3. 通过 FeedbackReader 读取 unfinished_feedback 信号。")
return nil
}
func (b *Builder) fillScheduleFacts(ctx context.Context, result *ActiveScheduleContext) error {
facts, err := b.readers.ScheduleReader.GetScheduleFactsByWindow(ctx, ports.ScheduleWindowRequest{
UserID: result.Trigger.UserID,
TargetType: string(result.Trigger.TargetType),
TargetID: result.Trigger.TargetID,
WindowStart: result.Window.StartAt,
WindowEnd: result.Window.EndAt,
Now: result.Now.EffectiveNow,
})
if err != nil {
return err
}
result.ScheduleFacts = ScheduleFacts{
Events: facts.Events,
OccupiedSlots: facts.OccupiedSlots,
FreeSlots: facts.FreeSlots,
NextDynamicTask: facts.NextDynamicTask,
}
result.Window.RelativeSlots = append(result.Window.RelativeSlots, facts.OccupiedSlots...)
result.Window.RelativeSlots = append(result.Window.RelativeSlots, facts.FreeSlots...)
result.DerivedFacts.TargetAlreadyScheduled = facts.TargetAlreadyScheduled
result.Trace.BuildSteps = append(result.Trace.BuildSteps, "4. 通过 ScheduleReader 读取滚动 24 小时日程事实。")
return nil
}
func (b *Builder) fillDerivedFacts(result *ActiveScheduleContext) {
result.DerivedFacts.AvailableCapacity = len(result.ScheduleFacts.FreeSlots)
if result.TaskPoolFacts.TargetTask != nil {
result.DerivedFacts.TargetCompleted = result.TaskPoolFacts.TargetTask.IsCompleted
}
if result.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && !result.FeedbackFacts.TargetKnown {
result.DerivedFacts.MissingInfo = appendMissing(result.DerivedFacts.MissingInfo, "feedback_target")
}
result.Trace.BuildSteps = append(result.Trace.BuildSteps, "5. 汇总完成状态、已排状态、可用容量与缺失事实。")
}
func appendMissing(values []string, next string) []string {
for _, value := range values {
if value == next {
return values
}
}
return append(values, next)
}

View File

@@ -0,0 +1,94 @@
package schedulercontext
import (
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
const (
WindowReasonRolling24H = "rolling_24h"
)
// ActiveScheduleContext 是主动调度 dry-run 的只读事实快照。
//
// 职责边界:
// 1. 负责承载 BuildContext 阶段聚合出的事实;
// 2. 不包含 DAO、service 或 provider 实例;
// 3. 不负责生成候选,也不负责写 preview、通知或正式日程。
type ActiveScheduleContext struct {
Trigger trigger.ActiveScheduleTrigger
User UserFacts
Now NowFacts
Window WindowFacts
Target TargetFacts
TaskPoolFacts TaskPoolFacts
ScheduleFacts ScheduleFacts
FeedbackFacts FeedbackFacts
DerivedFacts DerivedFacts
Trace TraceFacts
}
type UserFacts struct {
UserID int
Timezone string
}
type NowFacts struct {
RealNow time.Time
EffectiveNow time.Time
}
type WindowFacts struct {
StartAt time.Time
EndAt time.Time
RelativeSlots []ports.Slot
WindowReason string
}
type TargetFacts struct {
SourceType trigger.TargetType
TaskID int
ScheduleEventID int
TaskItemID int
Title string
EstimatedSections int
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
Priority int
Status string
}
type TaskPoolFacts struct {
TargetTask *ports.TaskFact
}
type ScheduleFacts struct {
Events []ports.ScheduleEventFact
OccupiedSlots []ports.Slot
FreeSlots []ports.Slot
NextDynamicTask *ports.ScheduleEventFact
}
type FeedbackFacts struct {
FeedbackID string
FeedbackText string
FeedbackTarget string
TargetKnown bool
TargetEventID int
TargetTaskItemID int
}
type DerivedFacts struct {
TargetAlreadyScheduled bool
TargetCompleted bool
AvailableCapacity int
MissingInfo []string
}
type TraceFacts struct {
TraceID string
BuildSteps []string
Warnings []string
}

View File

@@ -0,0 +1,293 @@
package observe
import (
"time"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
type DecisionAction string
const (
DecisionActionClose DecisionAction = "close"
DecisionActionAskUser DecisionAction = "ask_user"
DecisionActionNotifyOnly DecisionAction = "notify_only"
DecisionActionSelectCandidate DecisionAction = "select_candidate"
)
type IssueCode string
const (
IssueTargetCompleted IssueCode = "target_completed"
IssueTargetAlreadyScheduled IssueCode = "target_already_scheduled"
IssueNoValidTimeWindow IssueCode = "no_valid_time_window"
IssueCapacityInsufficient IssueCode = "capacity_insufficient"
IssueNoFreeSlot IssueCode = "no_free_slot"
IssueFeedbackTargetUnknown IssueCode = "feedback_target_unknown"
IssueNeedMakeupBlock IssueCode = "need_makeup_block"
IssueCanAddTaskPoolToSchedule IssueCode = "can_add_task_pool_to_schedule"
IssueCanCompressWithNextDynamicTask IssueCode = "can_compress_with_next_dynamic_task"
)
// Metrics 是主动观测阶段输出的事实指标。
type Metrics struct {
Target TargetMetrics
Window WindowMetrics
Feedback FeedbackMetrics
Risk RiskMetrics
}
type TargetMetrics struct {
Completed bool
AlreadyScheduled bool
DeadlineAlreadyPassed bool
MinutesToDeadline int
EstimatedSections int
}
type WindowMetrics struct {
TotalSlots int
FreeSlots int
OccupiedSlots int
UsableSlotsBeforeDeadline int
CapacityGap int
}
type FeedbackMetrics struct {
HasFeedback bool
FeedbackTargetKnown bool
UnfinishedElapsedMinutes int
}
type RiskMetrics struct {
ConflictCount int
AffectedEventCount int
AffectedTaskCount int
RequiresReorder bool
}
type Issue struct {
IssueID string
Code IssueCode
Severity string
TargetType string
TargetID int
Reason string
Evidence map[string]string
CanGenerateCandidate bool
}
type Decision struct {
Action DecisionAction
ReasonCode string
PrimaryIssueCode IssueCode
ShouldNotify bool
ShouldWritePreview bool
LLMSelectionRequired bool
FallbackCandidateID string
}
type Result struct {
Metrics Metrics
Issues []Issue
Decision Decision
Trace []string
}
// Analyzer 负责把 ActiveScheduleContext 转成确定性观测结果。
//
// 职责边界:
// 1. 只生成 metrics / issues / 初步 decision
// 2. 不枚举候选,不调用 LLM
// 3. 候选生成后由 FinalizeDecision 根据候选数量收口最终 action。
type Analyzer struct{}
func NewAnalyzer() *Analyzer {
return &Analyzer{}
}
// Observe 执行主动观测。
func (a *Analyzer) Observe(ctx *schedulercontext.ActiveScheduleContext) Result {
result := Result{
Metrics: buildMetrics(ctx),
Trace: []string{
"1. 基于上下文构造 metrics保证后续裁决只依赖结构化事实。",
"2. 按触发类型检测 issue不在观测阶段修改正式日程。",
},
}
result.Issues = detectIssues(ctx, result.Metrics)
result.Decision = provisionalDecision(result.Issues)
return result
}
// FinalizeDecision 根据候选生成结果收口最终裁决。
//
// 职责边界:
// 1. 只根据后端已生成、已校验的候选收口 decision
// 2. 不改变候选内容;
// 3. 候选为空时不能写 preview必须降级为 ask_user / notify_only / close。
func (a *Analyzer) FinalizeDecision(result Result, candidateCount int, fallbackCandidateID string) Result {
if len(result.Issues) == 0 {
result.Decision = Decision{Action: DecisionActionClose, ReasonCode: "no_issue"}
return result
}
primary := result.Issues[0].Code
if hasIssue(result.Issues, IssueTargetCompleted) || hasIssue(result.Issues, IssueTargetAlreadyScheduled) {
result.Decision = Decision{Action: DecisionActionClose, ReasonCode: string(primary), PrimaryIssueCode: primary}
return result
}
if hasIssue(result.Issues, IssueFeedbackTargetUnknown) || hasIssue(result.Issues, IssueNoValidTimeWindow) {
result.Decision = Decision{Action: DecisionActionAskUser, ReasonCode: string(primary), PrimaryIssueCode: primary, ShouldNotify: true}
return result
}
if candidateCount > 0 {
result.Decision = Decision{
Action: DecisionActionSelectCandidate,
ReasonCode: "candidate_available",
PrimaryIssueCode: primary,
ShouldNotify: true,
ShouldWritePreview: true,
LLMSelectionRequired: true,
FallbackCandidateID: fallbackCandidateID,
}
return result
}
result.Decision = Decision{Action: DecisionActionNotifyOnly, ReasonCode: string(primary), PrimaryIssueCode: primary, ShouldNotify: true}
return result
}
func buildMetrics(ctx *schedulercontext.ActiveScheduleContext) Metrics {
estimated := ctx.Target.EstimatedSections
if estimated <= 0 {
estimated = 1
}
usable := countUsableSlots(ctx)
deadlinePassed := false
minutesToDeadline := 0
if ctx.Target.DeadlineAt != nil {
deadlinePassed = ctx.Target.DeadlineAt.Before(ctx.Now.EffectiveNow)
minutesToDeadline = int(ctx.Target.DeadlineAt.Sub(ctx.Now.EffectiveNow).Minutes())
}
return Metrics{
Target: TargetMetrics{
Completed: ctx.DerivedFacts.TargetCompleted,
AlreadyScheduled: ctx.DerivedFacts.TargetAlreadyScheduled,
DeadlineAlreadyPassed: deadlinePassed,
MinutesToDeadline: minutesToDeadline,
EstimatedSections: estimated,
},
Window: WindowMetrics{
TotalSlots: len(ctx.ScheduleFacts.FreeSlots) + len(ctx.ScheduleFacts.OccupiedSlots),
FreeSlots: len(ctx.ScheduleFacts.FreeSlots),
OccupiedSlots: len(ctx.ScheduleFacts.OccupiedSlots),
UsableSlotsBeforeDeadline: usable,
CapacityGap: estimated - usable,
},
Feedback: FeedbackMetrics{
HasFeedback: ctx.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && ctx.FeedbackFacts.FeedbackID != "",
FeedbackTargetKnown: ctx.FeedbackFacts.TargetKnown,
},
Risk: RiskMetrics{
AffectedEventCount: len(ctx.ScheduleFacts.Events),
RequiresReorder: ctx.Trigger.TriggerType == trigger.TriggerTypeUnfinishedFeedback && len(ctx.ScheduleFacts.FreeSlots) == 0,
},
}
}
func countUsableSlots(ctx *schedulercontext.ActiveScheduleContext) int {
if ctx.Target.DeadlineAt == nil {
return len(ctx.ScheduleFacts.FreeSlots)
}
count := 0
for _, slot := range ctx.ScheduleFacts.FreeSlots {
if slot.StartAt.IsZero() || !slot.StartAt.After(*ctx.Target.DeadlineAt) {
count++
}
}
return count
}
func detectIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue {
switch ctx.Trigger.TriggerType {
case trigger.TriggerTypeImportantUrgentTask:
return detectImportantUrgentIssues(ctx, metrics)
case trigger.TriggerTypeUnfinishedFeedback:
return detectUnfinishedFeedbackIssues(ctx, metrics)
default:
return []Issue{}
}
}
func detectImportantUrgentIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue {
if metrics.Target.Completed {
return []Issue{newIssue(IssueTargetCompleted, ctx, "目标任务已完成,主动调度无需继续处理。", false)}
}
if metrics.Target.AlreadyScheduled {
return []Issue{newIssue(IssueTargetAlreadyScheduled, ctx, "目标任务已经进入日程,不能重复加入 task_pool。", false)}
}
if len(ctx.DerivedFacts.MissingInfo) > 0 {
return []Issue{newIssue(IssueNoValidTimeWindow, ctx, "缺少目标任务或时间窗事实,需要用户补充信息。", false)}
}
if metrics.Window.FreeSlots == 0 {
return []Issue{newIssue(IssueNoFreeSlot, ctx, "滚动 24 小时内没有可用节次。", false)}
}
if metrics.Window.CapacityGap > 0 {
return []Issue{newIssue(IssueCapacityInsufficient, ctx, "可用节次不足以完整放入目标任务。", false)}
}
return []Issue{newIssue(IssueCanAddTaskPoolToSchedule, ctx, "目标任务可加入滚动 24 小时内的空闲节次。", true)}
}
func detectUnfinishedFeedbackIssues(ctx *schedulercontext.ActiveScheduleContext, metrics Metrics) []Issue {
if !metrics.Feedback.HasFeedback || !metrics.Feedback.FeedbackTargetKnown {
return []Issue{newIssue(IssueFeedbackTargetUnknown, ctx, "无法确定用户反馈的未完成日程块,需要进一步确认。", false)}
}
if metrics.Window.FreeSlots == 0 {
return []Issue{newIssue(IssueNoFreeSlot, ctx, "反馈目标已定位,但滚动 24 小时内没有补做空位。", false)}
}
return []Issue{newIssue(IssueNeedMakeupBlock, ctx, "反馈目标已定位,可生成新增补做块候选。", true)}
}
func newIssue(code IssueCode, ctx *schedulercontext.ActiveScheduleContext, reason string, canGenerate bool) Issue {
return Issue{
IssueID: string(code) + ":1",
Code: code,
Severity: issueSeverity(code),
TargetType: string(ctx.Trigger.TargetType),
TargetID: ctx.Trigger.TargetID,
Reason: reason,
Evidence: map[string]string{
"trigger_type": string(ctx.Trigger.TriggerType),
"window_start": ctx.Window.StartAt.Format(time.RFC3339),
"window_end": ctx.Window.EndAt.Format(time.RFC3339),
},
CanGenerateCandidate: canGenerate,
}
}
func issueSeverity(code IssueCode) string {
switch code {
case IssueTargetCompleted, IssueTargetAlreadyScheduled:
return "info"
case IssueFeedbackTargetUnknown, IssueNoValidTimeWindow:
return "warning"
default:
return "critical"
}
}
func provisionalDecision(issues []Issue) Decision {
if len(issues) == 0 {
return Decision{Action: DecisionActionClose, ReasonCode: "no_issue"}
}
return Decision{Action: DecisionActionNotifyOnly, ReasonCode: "pending_candidates", PrimaryIssueCode: issues[0].Code}
}
func hasIssue(issues []Issue, code IssueCode) bool {
for _, issue := range issues {
if issue.Code == code {
return true
}
}
return false
}

View File

@@ -0,0 +1,132 @@
package ports
import (
"context"
"time"
)
// Slot 是主动调度内部使用的原子节次坐标。
//
// 职责边界:
// 1. 只描述可比较、可落预览的时间格;
// 2. 不绑定 schedules 表模型;
// 3. StartAt / EndAt 可为空值,排序会退回到 week/day/section。
type Slot struct {
Week int
DayOfWeek int
Section int
StartAt time.Time
EndAt time.Time
}
// SlotSpan 表示一个连续节次块。
type SlotSpan struct {
Start Slot
End Slot
DurationSections int
}
// TaskFact 是 task_pool 任务在主动调度里的最小事实快照。
type TaskFact struct {
ID int
UserID int
Title string
Priority int
IsCompleted bool
DeadlineAt *time.Time
UrgencyThresholdAt *time.Time
EstimatedSections int
}
// ScheduleEventFact 是日程块在主动调度里的最小事实快照。
type ScheduleEventFact struct {
ID int
UserID int
Title string
SourceType string
RelID int
IsDynamicTask bool
IsCompleted bool
Slots []Slot
TaskClassID int
TaskItemID int
CanBeShortened bool
}
// ScheduleWindowFacts 是滚动窗口内日程事实快照。
type ScheduleWindowFacts struct {
Events []ScheduleEventFact
OccupiedSlots []Slot
FreeSlots []Slot
NextDynamicTask *ScheduleEventFact
TargetAlreadyScheduled bool
}
// FeedbackFact 是 unfinished_feedback 的最小事实快照。
type FeedbackFact struct {
FeedbackID string
Text string
TargetKnown bool
TargetEventID int
TargetTaskItemID int
TargetTitle string
SubmittedAt time.Time
}
// TaskRequest 是任务读取端口的入参。
type TaskRequest struct {
UserID int
TaskID int
Now time.Time
}
// ScheduleWindowRequest 是日程窗口读取端口的入参。
type ScheduleWindowRequest struct {
UserID int
TargetType string
TargetID int
WindowStart time.Time
WindowEnd time.Time
Now time.Time
}
// FeedbackRequest 是反馈读取端口的入参。
type FeedbackRequest struct {
UserID int
FeedbackID string
IdempotencyKey string
TargetType string
TargetID int
}
// TaskReader 负责读取主动调度所需的 task_pool 事实。
//
// 职责边界:
// 1. 可以由 adapter 调用既有 service / DAO 组装事实;
// 2. active_scheduler 主链路只依赖该端口,不直接 import 其它领域 DAO
// 3. found=false 表示目标不存在或当前用户无权访问,由观察链路转成 ask_user。
type TaskReader interface {
GetTaskForActiveSchedule(ctx context.Context, req TaskRequest) (task TaskFact, found bool, err error)
}
// ScheduleReader 负责读取滚动时间窗内的日程事实。
type ScheduleReader interface {
GetScheduleFactsByWindow(ctx context.Context, req ScheduleWindowRequest) (ScheduleWindowFacts, error)
}
// FeedbackReader 负责读取用户反馈信号。
type FeedbackReader interface {
GetFeedbackSignal(ctx context.Context, req FeedbackRequest) (feedback FeedbackFact, found bool, err error)
}
// Readers 聚合 dry-run 主链路依赖的外部读取端口。
//
// 职责边界:
// 1. 只聚合读取依赖,不包含正式写入 preview / schedule / notification 的能力;
// 2. 便于 API、worker 和测试使用同一套 dry-run service
// 3. 任一必需端口为空时,由 service 初始化阶段拒绝。
type Readers struct {
TaskReader TaskReader
ScheduleReader ScheduleReader
FeedbackReader FeedbackReader
}

View File

@@ -0,0 +1,358 @@
package preview
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/model"
)
func candidateDTO(item candidate.Candidate) CandidateDTO {
return CandidateDTO{
CandidateID: item.CandidateID,
CandidateType: string(item.CandidateType),
Title: item.Title,
Summary: item.Summary,
Target: CandidateTargetDTO{
TargetType: item.Target.TargetType,
TargetID: item.Target.TargetID,
Title: item.Target.Title,
},
Changes: changeDTOs(item.CandidateID, item.Changes),
BeforeSummary: item.BeforeSummary,
AfterSummary: item.AfterSummary,
Risk: item.Risk,
Score: item.Score,
Validation: item.Validation,
Source: item.Source,
}
}
func changeDTOs(candidateID string, changes []candidate.ChangeItem) []ActiveScheduleChangeItem {
result := make([]ActiveScheduleChangeItem, 0, len(changes))
for index, change := range changes {
var fromSlot *SlotDTO
if change.FromSlot != nil {
value := slotDTO(*change.FromSlot)
fromSlot = &value
}
var toSlot *SlotSpanDTO
if change.ToSlot != nil {
value := slotSpanDTO(*change.ToSlot)
toSlot = &value
}
result = append(result, ActiveScheduleChangeItem{
ChangeID: fmt.Sprintf("%s:chg_%d", candidateID, index+1),
ChangeType: string(change.ChangeType),
TargetType: change.TargetType,
TargetID: change.TargetID,
FromSlot: fromSlot,
ToSlot: toSlot,
DurationSections: change.DurationSections,
AffectedEventIDs: append([]int(nil), change.AffectedEventIDs...),
EditedAllowed: change.EditedAllowed,
Metadata: copyStringMap(change.Metadata),
})
}
return result
}
func contextSummaryDTO(activeContext *schedulercontext.ActiveScheduleContext) ContextSummaryDTO {
if activeContext == nil {
return ContextSummaryDTO{}
}
return ContextSummaryDTO{
UserID: activeContext.User.UserID,
Timezone: activeContext.User.Timezone,
TriggerSource: string(activeContext.Trigger.Source),
RequestedAt: activeContext.Trigger.RequestedAt,
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
WindowReason: activeContext.Window.WindowReason,
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
TargetTitle: activeContext.Target.Title,
MissingInfo: append([]string(nil), activeContext.DerivedFacts.MissingInfo...),
TraceSteps: append([]string(nil), activeContext.Trace.BuildSteps...),
Warnings: append([]string(nil), activeContext.Trace.Warnings...),
}
}
func buildBeforeSummary(activeContext *schedulercontext.ActiveScheduleContext, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
version := SchedulePreviewVersion{
Title: "调整前",
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
SummaryLines: compactLines(selected.BeforeSummary),
}
affected := affectedEventSet(changes)
for _, event := range activeContext.ScheduleFacts.Events {
entry := entryFromEvent(event)
if affected[event.ID] || (selected.Target.TargetType == "schedule_event" && selected.Target.TargetID == event.ID) {
entry.Status = "affected"
}
version.Entries = append(version.Entries, entry)
}
return version
}
func buildAfterSummary(before SchedulePreviewVersion, selected candidate.Candidate, changes []ActiveScheduleChangeItem) SchedulePreviewVersion {
after := SchedulePreviewVersion{
Title: "调整后",
WindowStart: before.WindowStart,
WindowEnd: before.WindowEnd,
Entries: append([]SchedulePreviewEntry(nil), before.Entries...),
SummaryLines: compactLines(selected.AfterSummary),
}
for _, change := range changes {
// 1. 只把会产生可视化新块的 change 追加到 afterask_user / none 不伪造正式日程。
// 2. 该 entry 仅用于展示和后续 confirm 校验输入,不代表已经写入 schedule_events / schedules。
if change.ToSlot == nil || (change.ChangeType != string(candidate.ChangeTypeAdd) && change.ChangeType != string(candidate.ChangeTypeCreateMakeup)) {
continue
}
after.Entries = append(after.Entries, SchedulePreviewEntry{
EntryID: "preview:" + change.ChangeID,
SourceType: change.TargetType,
SourceID: change.TargetID,
Title: selected.Target.Title,
StartAt: change.ToSlot.Start.StartAt,
EndAt: change.ToSlot.End.EndAt,
Week: change.ToSlot.Start.Week,
DayOfWeek: change.ToSlot.Start.DayOfWeek,
SectionFrom: change.ToSlot.Start.Section,
SectionTo: change.ToSlot.End.Section,
Status: "added",
Editable: change.EditedAllowed,
})
}
return after
}
func entryFromEvent(event ports.ScheduleEventFact) SchedulePreviewEntry {
slots := append([]ports.Slot(nil), event.Slots...)
sort.Slice(slots, func(i, j int) bool {
if !slots[i].StartAt.IsZero() && !slots[j].StartAt.IsZero() && !slots[i].StartAt.Equal(slots[j].StartAt) {
return slots[i].StartAt.Before(slots[j].StartAt)
}
if slots[i].Week != slots[j].Week {
return slots[i].Week < slots[j].Week
}
if slots[i].DayOfWeek != slots[j].DayOfWeek {
return slots[i].DayOfWeek < slots[j].DayOfWeek
}
return slots[i].Section < slots[j].Section
})
entry := SchedulePreviewEntry{
EntryID: fmt.Sprintf("%s:%d", event.SourceType, event.ID),
SourceType: event.SourceType,
SourceID: event.ID,
Title: event.Title,
Status: "unchanged",
Editable: event.IsDynamicTask,
}
if len(slots) == 0 {
return entry
}
first := slots[0]
last := slots[len(slots)-1]
entry.StartAt = first.StartAt
entry.EndAt = last.EndAt
entry.Week = first.Week
entry.DayOfWeek = first.DayOfWeek
entry.SectionFrom = first.Section
entry.SectionTo = last.Section
return entry
}
func riskDTO(selected candidate.Candidate, observation observe.Result, changes []ActiveScheduleChangeItem) RiskDTO {
affectedIDs := make([]int, 0)
seen := make(map[int]bool)
for _, change := range changes {
for _, id := range change.AffectedEventIDs {
if !seen[id] {
seen[id] = true
affectedIDs = append(affectedIDs, id)
}
}
}
level := "low"
if !selected.Validation.Valid {
level = "high"
} else if observation.Metrics.Risk.RequiresReorder || len(affectedIDs) > 0 {
level = "medium"
}
return RiskDTO{
Level: level,
Summary: selected.Risk,
Validation: selected.Validation,
RiskMetrics: observation.Metrics.Risk,
AffectedIDs: affectedIDs,
RequiresLLM: observation.Decision.LLMSelectionRequired,
FallbackUsed: observation.Decision.FallbackCandidateID == selected.CandidateID,
}
}
func buildBaseVersion(activeContext *schedulercontext.ActiveScheduleContext, changes []ActiveScheduleChangeItem) string {
type eventVersion struct {
ID int `json:"id"`
Slots []SlotDTO `json:"slots"`
}
events := make([]eventVersion, 0, len(activeContext.ScheduleFacts.Events))
for _, event := range activeContext.ScheduleFacts.Events {
slots := make([]SlotDTO, 0, len(event.Slots))
for _, slot := range event.Slots {
slots = append(slots, slotDTO(slot))
}
events = append(events, eventVersion{ID: event.ID, Slots: slots})
}
payload := struct {
UserID int `json:"user_id"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Events []eventVersion `json:"events"`
Changes []ActiveScheduleChangeItem `json:"changes"`
}{
UserID: activeContext.User.UserID,
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
WindowStart: activeContext.Window.StartAt,
WindowEnd: activeContext.Window.EndAt,
Events: events,
Changes: changes,
}
raw, _ := json.Marshal(payload)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
func detailFromModel(row *model.ActiveSchedulePreview, now time.Time) (ActiveSchedulePreviewDetail, error) {
selected, err := decodeJSONField(row.SelectedCandidateJSON, CandidateDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
candidates, err := decodeJSONField(row.CandidatesJSON, []CandidateDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
decision, err := decodeJSONField(row.DecisionJSON, observe.Decision{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
metrics, err := decodeJSONField(row.MetricsJSON, observe.Metrics{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
issues, err := decodeJSONField(row.IssuesJSON, []observe.Issue{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
contextSummary, err := decodeJSONField(row.ContextSummaryJSON, ContextSummaryDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
before, err := decodeJSONField(row.BeforeSummaryJSON, SchedulePreviewVersion{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
changes, err := decodeJSONField(row.PreviewChangesJSON, []ActiveScheduleChangeItem{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
after, err := decodeJSONField(row.AfterSummaryJSON, SchedulePreviewVersion{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
risk, err := decodeJSONField(row.RiskJSON, RiskDTO{})
if err != nil {
return ActiveSchedulePreviewDetail{}, err
}
expired := !row.ExpiresAt.After(now)
canConfirm := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
canIgnore := row.Status == model.ActiveSchedulePreviewStatusReady && row.ApplyStatus == model.ActiveScheduleApplyStatusNone && !expired
return ActiveSchedulePreviewDetail{
PreviewID: row.ID,
Status: row.Status,
ApplyStatus: row.ApplyStatus,
ExpiresAt: row.ExpiresAt,
GeneratedAt: row.GeneratedAt,
Expired: expired,
Trigger: PreviewTriggerDTO{
TriggerID: row.TriggerID,
TriggerType: row.TriggerType,
Source: contextSummary.TriggerSource,
TargetType: row.TargetType,
TargetID: row.TargetID,
RequestedAt: contextSummary.RequestedAt,
},
Explanation: row.ExplanationText,
Notification: row.NotificationSummary,
SelectedCandidate: selected,
Candidates: candidates,
Decision: decision,
Metrics: metrics,
Issues: issues,
ContextSummary: contextSummary,
Before: before,
After: after,
Changes: changes,
Risk: risk,
BaseVersion: row.BaseVersion,
CanConfirm: canConfirm,
CanIgnore: canIgnore,
TraceID: row.TraceID,
}, nil
}
func jsonString(value any) (string, error) {
raw, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(raw), nil
}
func compactLines(lines ...string) []string {
result := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func affectedEventSet(changes []ActiveScheduleChangeItem) map[int]bool {
result := make(map[int]bool)
for _, change := range changes {
for _, id := range change.AffectedEventIDs {
result[id] = true
}
}
return result
}
func copyStringMap(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,232 @@
package preview
import (
"encoding/json"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
)
// CreatePreviewRequest 是把 dry-run 结果固化成主动调度预览的请求 DTO。
//
// 职责边界:
// 1. 负责承载 preview 写库所需的 dry-run 结果与可选覆盖字段;
// 2. 不承载 confirm/apply 请求,也不允许调用方传入正式日程写入参数;
// 3. GeneratedAt 为空时由 Service 时钟生成ExpiresAt 固定由 generated_at + 1h 推导。
type CreatePreviewRequest struct {
ActiveContext *schedulercontext.ActiveScheduleContext `json:"-"`
Observation observe.Result `json:"-"`
Candidates []candidate.Candidate `json:"-"`
PreviewID string `json:"preview_id,omitempty"`
TriggerID string `json:"trigger_id,omitempty"`
BaseVersion string `json:"base_version,omitempty"`
GeneratedAt time.Time `json:"generated_at,omitempty"`
ExplanationText string `json:"explanation_text,omitempty"`
NotificationSummary string `json:"notification_summary,omitempty"`
}
// CreatePreviewResponse 是写入 preview 后可直接返回给 API 的响应 DTO。
type CreatePreviewResponse struct {
Detail ActiveSchedulePreviewDetail `json:"detail"`
}
// GetPreviewRequest 是查询 preview 详情的请求 DTO。
//
// 职责边界:
// 1. UserID 来自鉴权上下文,不能信任前端透传;
// 2. PreviewID 来自路由参数;
// 3. 查询只返回预览快照,不执行过期状态回写、不触发 apply。
type GetPreviewRequest struct {
UserID int `json:"user_id"`
PreviewID string `json:"preview_id"`
}
// ActiveSchedulePreviewDetail 是主动调度预览详情页响应 DTO。
//
// 职责边界:
// 1. 负责把 active_schedule_previews 中的 JSON 快照还原成前端可展示结构;
// 2. 不包含正式写日程能力,不代表 confirm 请求已通过校验;
// 3. CanConfirm 只表达当前快照状态可发起确认,最终是否能应用仍由 confirm/apply 链路重校验。
type ActiveSchedulePreviewDetail struct {
PreviewID string `json:"preview_id"`
Status string `json:"status"`
ApplyStatus string `json:"apply_status"`
ExpiresAt time.Time `json:"expires_at"`
GeneratedAt time.Time `json:"generated_at"`
Expired bool `json:"expired"`
Trigger PreviewTriggerDTO `json:"trigger"`
Explanation string `json:"explanation"`
Notification string `json:"notification_summary"`
SelectedCandidate CandidateDTO `json:"selected_candidate"`
Candidates []CandidateDTO `json:"candidates"`
Decision observe.Decision `json:"decision"`
Metrics observe.Metrics `json:"metrics"`
Issues []observe.Issue `json:"issues"`
ContextSummary ContextSummaryDTO `json:"context_summary"`
Before SchedulePreviewVersion `json:"before"`
After SchedulePreviewVersion `json:"after"`
Changes []ActiveScheduleChangeItem `json:"changes"`
Risk RiskDTO `json:"risk"`
BaseVersion string `json:"base_version"`
CanConfirm bool `json:"can_confirm"`
CanIgnore bool `json:"can_ignore"`
TraceID string `json:"trace_id"`
}
type PreviewTriggerDTO struct {
TriggerID string `json:"trigger_id"`
TriggerType string `json:"trigger_type"`
Source string `json:"source"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
RequestedAt time.Time `json:"requested_at"`
}
type CandidateDTO struct {
CandidateID string `json:"candidate_id"`
CandidateType string `json:"candidate_type"`
Title string `json:"title"`
Summary string `json:"summary"`
Target CandidateTargetDTO `json:"target"`
Changes []ActiveScheduleChangeItem `json:"changes"`
BeforeSummary string `json:"before_summary"`
AfterSummary string `json:"after_summary"`
Risk string `json:"risk"`
Score int `json:"score"`
Validation candidate.Validation `json:"validation"`
Source string `json:"source"`
}
type CandidateTargetDTO struct {
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
Title string `json:"title"`
}
type ContextSummaryDTO struct {
UserID int `json:"user_id"`
Timezone string `json:"timezone"`
TriggerSource string `json:"trigger_source"`
RequestedAt time.Time `json:"requested_at"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
WindowReason string `json:"window_reason"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
TargetTitle string `json:"target_title"`
MissingInfo []string `json:"missing_info"`
TraceSteps []string `json:"trace_steps"`
Warnings []string `json:"warnings"`
}
type SchedulePreviewVersion struct {
Title string `json:"title"`
WindowStart time.Time `json:"window_start"`
WindowEnd time.Time `json:"window_end"`
Entries []SchedulePreviewEntry `json:"entries"`
SummaryLines []string `json:"summary_lines"`
}
type SchedulePreviewEntry struct {
EntryID string `json:"entry_id"`
SourceType string `json:"source_type"`
SourceID int `json:"source_id"`
Title string `json:"title"`
StartAt time.Time `json:"start_at,omitempty"`
EndAt time.Time `json:"end_at,omitempty"`
Week int `json:"week,omitempty"`
DayOfWeek int `json:"day_of_week,omitempty"`
SectionFrom int `json:"section_from,omitempty"`
SectionTo int `json:"section_to,omitempty"`
Status string `json:"status"`
Editable bool `json:"editable"`
}
type ActiveScheduleChangeItem struct {
ChangeID string `json:"change_id"`
ChangeType string `json:"change_type"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
FromSlot *SlotDTO `json:"from_slot,omitempty"`
ToSlot *SlotSpanDTO `json:"to_slot,omitempty"`
DurationSections int `json:"duration_sections"`
AffectedEventIDs []int `json:"affected_event_ids"`
EditedAllowed bool `json:"edited_allowed"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type SlotDTO struct {
Week int `json:"week"`
DayOfWeek int `json:"day_of_week"`
Section int `json:"section"`
StartAt time.Time `json:"start_at,omitempty"`
EndAt time.Time `json:"end_at,omitempty"`
}
type SlotSpanDTO struct {
Start SlotDTO `json:"start"`
End SlotDTO `json:"end"`
DurationSections int `json:"duration_sections"`
}
type RiskDTO struct {
Level string `json:"level"`
Summary string `json:"summary"`
Validation candidate.Validation `json:"validation"`
RiskMetrics observe.RiskMetrics `json:"risk_metrics"`
AffectedIDs []int `json:"affected_event_ids"`
RequiresLLM bool `json:"requires_llm"`
FallbackUsed bool `json:"fallback_used"`
}
// rawPreviewSnapshot 聚合需要写入 active_schedule_previews JSON 字段的快照。
type rawPreviewSnapshot struct {
selectedCandidate CandidateDTO
candidates []CandidateDTO
decision observe.Decision
metrics observe.Metrics
issues []observe.Issue
contextSummary ContextSummaryDTO
before SchedulePreviewVersion
changes []ActiveScheduleChangeItem
after SchedulePreviewVersion
risk RiskDTO
}
func slotDTO(slot ports.Slot) SlotDTO {
return SlotDTO{
Week: slot.Week,
DayOfWeek: slot.DayOfWeek,
Section: slot.Section,
StartAt: slot.StartAt,
EndAt: slot.EndAt,
}
}
func slotSpanDTO(span ports.SlotSpan) SlotSpanDTO {
return SlotSpanDTO{
Start: slotDTO(span.Start),
End: slotDTO(span.End),
DurationSections: span.DurationSections,
}
}
// decodeJSONField 只负责 preview 包内部 DTO 解码。
//
// 说明:
// 1. 当前任务限制只允许修改 preview 目录,不能把 JSON helper 下沉到公共层;
// 2. 因此这里暂时保留包内小函数,后续若第二个 active_scheduler 子包也需要同类能力,再按 AGENTS 规则抽公共层;
// 3. 解码失败返回原始错误,避免把损坏快照静默展示给用户。
func decodeJSONField[T any](raw *string, fallback T) (T, error) {
if raw == nil || *raw == "" {
return fallback, nil
}
var value T
if err := json.Unmarshal([]byte(*raw), &value); err != nil {
return fallback, err
}
return value, nil
}

View File

@@ -0,0 +1,281 @@
package preview
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrInvalidPreviewRequest = errors.New("主动调度预览请求不合法")
ErrPreviewNotFound = errors.New("主动调度预览不存在")
)
// Repository 是 preview service 依赖的最小持久化端口。
//
// 职责边界:
// 1. 只覆盖本轮 preview 写入和详情查询需要的方法;
// 2. 不暴露正式日程写入、通知投递或 confirm apply 能力;
// 3. 现有 dao.ActiveScheduleDAO 已满足该接口,后续迁移独立 repo 时可并行替换实现。
type Repository interface {
CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error
GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error)
}
// Service 负责主动调度 preview 的写入和查询。
//
// 职责边界:
// 1. 将 dry-run 结果固化为 active_schedule_previews 中的 ready 快照;
// 2. 查询时校验 user_id并返回 API 可直接透传的详情 DTO
// 3. 不正式写日程、不发通知、不处理 confirm/apply也不修改 trigger 状态。
type Service struct {
repo Repository
clock func() time.Time
}
func NewService(repo Repository) (*Service, error) {
if repo == nil {
return nil, fmt.Errorf("%w: preview repository 不能为空", ErrInvalidPreviewRequest)
}
return &Service{repo: repo, clock: time.Now}, nil
}
// SetClock 注入测试时钟。
//
// 职责边界:
// 1. 只影响 generated_at / expires_at 和查询时的 expired 计算;
// 2. 不改写 dry-run 上下文中的业务当前时间;
// 3. clock 为空时保持原时钟,避免运行期误注入导致 panic。
func (s *Service) SetClock(clock func() time.Time) {
if s == nil || clock == nil {
return
}
s.clock = clock
}
// CreatePreview 把 dry-run 结果保存为 ready preview。
//
// 职责边界:
// 1. 只消费已经完成的 dry-run 结果,不重新读取任务/日程事实;
// 2. MVP 没有 LLM 选择器,固定使用后端排序后的 top1 candidate 作为 selected_candidate
// 3. 写库后只返回详情 DTO不发布通知、不正式应用候选、不回写 trigger。
func (s *Service) CreatePreview(ctx context.Context, req CreatePreviewRequest) (*CreatePreviewResponse, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if req.ActiveContext == nil {
return nil, fmt.Errorf("%w: dry-run 结果不能为空", ErrInvalidPreviewRequest)
}
if len(req.Candidates) == 0 {
return nil, fmt.Errorf("%w: dry-run 未生成可保存候选", ErrInvalidPreviewRequest)
}
activeContext := req.ActiveContext
triggerID := strings.TrimSpace(req.TriggerID)
if triggerID == "" {
triggerID = strings.TrimSpace(activeContext.Trigger.TriggerID)
}
if triggerID == "" {
return nil, fmt.Errorf("%w: trigger_id 不能为空", ErrInvalidPreviewRequest)
}
generatedAt := req.GeneratedAt
if generatedAt.IsZero() {
generatedAt = s.now()
}
previewID := strings.TrimSpace(req.PreviewID)
if previewID == "" {
previewID = "asp_" + uuid.NewString()
}
// 1. 先构造所有展示快照,再写库;任何 JSON 转换失败都提前返回,避免落入半结构化记录。
selected := req.Candidates[0]
snapshot := buildSnapshot(activeContext, req.Observation, req.Candidates, selected)
baseVersion := strings.TrimSpace(req.BaseVersion)
if baseVersion == "" {
baseVersion = buildBaseVersion(activeContext, snapshot.changes)
}
explanation := strings.TrimSpace(req.ExplanationText)
if explanation == "" {
explanation = selected.Summary
}
notificationSummary := strings.TrimSpace(req.NotificationSummary)
if notificationSummary == "" {
notificationSummary = selected.Summary
}
row, err := buildPreviewModel(previewID, triggerID, generatedAt, baseVersion, explanation, notificationSummary, activeContext, snapshot)
if err != nil {
return nil, err
}
// 2. 写入 active_schedule_previews。这里不包事务写其它表因为本服务不负责 trigger/notification/apply 状态推进。
if err := s.repo.CreatePreview(ctx, row); err != nil {
return nil, err
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &CreatePreviewResponse{Detail: detail}, nil
}
// GetPreview 查询 preview 详情,并强制校验归属用户。
//
// 职责边界:
// 1. preview_id 不存在或不属于 user_id 时统一返回 ErrPreviewNotFound避免泄漏其它用户数据
// 2. 查询不会把过期 preview 回写为 expired过期状态仅在 DTO 中计算;
// 3. 不读取正式日程实时状态,因此不会触发 confirm 的 base_version 重校验。
func (s *Service) GetPreview(ctx context.Context, userID int, previewID string) (*ActiveSchedulePreviewDetail, error) {
if s == nil || s.repo == nil {
return nil, fmt.Errorf("%w: preview service 未初始化", ErrInvalidPreviewRequest)
}
if userID <= 0 || strings.TrimSpace(previewID) == "" {
return nil, ErrPreviewNotFound
}
row, err := s.repo.GetPreviewByID(ctx, strings.TrimSpace(previewID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPreviewNotFound
}
return nil, err
}
if row == nil || row.UserID != userID {
return nil, ErrPreviewNotFound
}
detail, err := detailFromModel(row, s.now())
if err != nil {
return nil, err
}
return &detail, nil
}
func (s *Service) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
func buildPreviewModel(
previewID string,
triggerID string,
generatedAt time.Time,
baseVersion string,
explanation string,
notificationSummary string,
activeContext *schedulercontext.ActiveScheduleContext,
snapshot rawPreviewSnapshot,
) (*model.ActiveSchedulePreview, error) {
selectedJSON, err := jsonString(snapshot.selectedCandidate)
if err != nil {
return nil, err
}
candidatesJSON, err := jsonString(snapshot.candidates)
if err != nil {
return nil, err
}
decisionJSON, err := jsonString(snapshot.decision)
if err != nil {
return nil, err
}
metricsJSON, err := jsonString(snapshot.metrics)
if err != nil {
return nil, err
}
issuesJSON, err := jsonString(snapshot.issues)
if err != nil {
return nil, err
}
contextJSON, err := jsonString(snapshot.contextSummary)
if err != nil {
return nil, err
}
beforeJSON, err := jsonString(snapshot.before)
if err != nil {
return nil, err
}
changesJSON, err := jsonString(snapshot.changes)
if err != nil {
return nil, err
}
afterJSON, err := jsonString(snapshot.after)
if err != nil {
return nil, err
}
riskJSON, err := jsonString(snapshot.risk)
if err != nil {
return nil, err
}
return &model.ActiveSchedulePreview{
ID: previewID,
UserID: activeContext.User.UserID,
TriggerID: triggerID,
TriggerType: string(activeContext.Trigger.TriggerType),
TargetType: string(activeContext.Trigger.TargetType),
TargetID: activeContext.Trigger.TargetID,
Status: model.ActiveSchedulePreviewStatusReady,
SelectedCandidateID: snapshot.selectedCandidate.CandidateID,
CandidateCount: len(snapshot.candidates),
SelectedCandidateJSON: &selectedJSON,
CandidatesJSON: &candidatesJSON,
DecisionJSON: &decisionJSON,
MetricsJSON: &metricsJSON,
IssuesJSON: &issuesJSON,
ContextSummaryJSON: &contextJSON,
BeforeSummaryJSON: &beforeJSON,
PreviewChangesJSON: &changesJSON,
AfterSummaryJSON: &afterJSON,
RiskJSON: &riskJSON,
ExplanationText: explanation,
NotificationSummary: notificationSummary,
BaseVersion: baseVersion,
ExpiresAt: generatedAt.Add(time.Hour),
GeneratedAt: generatedAt,
ApplyStatus: model.ActiveScheduleApplyStatusNone,
TraceID: activeContext.Trace.TraceID,
}, nil
}
func buildSnapshot(
activeContext *schedulercontext.ActiveScheduleContext,
observation observe.Result,
candidates []candidate.Candidate,
selected candidate.Candidate,
) rawPreviewSnapshot {
selectedDTO := candidateDTO(selected)
candidateDTOs := make([]CandidateDTO, 0, len(candidates))
for _, item := range candidates {
candidateDTOs = append(candidateDTOs, candidateDTO(item))
}
changes := changeDTOs(selected.CandidateID, selected.Changes)
before := buildBeforeSummary(activeContext, selected, changes)
after := buildAfterSummary(before, selected, changes)
return rawPreviewSnapshot{
selectedCandidate: selectedDTO,
candidates: candidateDTOs,
decision: observation.Decision,
metrics: observation.Metrics,
issues: observation.Issues,
contextSummary: contextSummaryDTO(activeContext),
before: before,
changes: changes,
after: after,
risk: riskDTO(selected, observation, changes),
}
}

View File

@@ -0,0 +1,92 @@
package service
import (
"context"
"errors"
"time"
"github.com/LoveLosita/smartflow/backend/active_scheduler/candidate"
schedulercontext "github.com/LoveLosita/smartflow/backend/active_scheduler/context"
"github.com/LoveLosita/smartflow/backend/active_scheduler/observe"
"github.com/LoveLosita/smartflow/backend/active_scheduler/ports"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
)
// DryRunResult 是 API dry-run / worker 测试入口可直接消费的同步结果。
type DryRunResult struct {
Context *schedulercontext.ActiveScheduleContext
Observation observe.Result
Candidates []candidate.Candidate
}
// DryRunService 编排主动调度 dry-run 主链路。
//
// 职责边界:
// 1. 固定执行 BuildContext -> Observe -> GenerateCandidates
// 2. 不调用 LLM、不写 preview、不发 notification、不正式写日程
// 3. 后续 API / worker 应复用该入口,避免出现第二套 dry-run 诊断逻辑。
type DryRunService struct {
builder *schedulercontext.Builder
analyzer *observe.Analyzer
generator *candidate.Generator
}
// NewDryRunService 创建主动调度 dry-run 服务。
func NewDryRunService(readers ports.Readers) (*DryRunService, error) {
builder, err := schedulercontext.NewBuilder(readers)
if err != nil {
return nil, err
}
return &DryRunService{
builder: builder,
analyzer: observe.NewAnalyzer(),
generator: candidate.NewGenerator(),
}, nil
}
// SetClock 注入测试时钟。
func (s *DryRunService) SetClock(clock func() time.Time) {
if s != nil && s.builder != nil {
s.builder.SetClock(clock)
}
}
// DryRun 执行主动调度同步诊断。
func (s *DryRunService) DryRun(ctx context.Context, trig trigger.ActiveScheduleTrigger) (*DryRunResult, error) {
if s == nil || s.builder == nil || s.analyzer == nil || s.generator == nil {
return nil, errors.New("DryRunService 尚未正确初始化")
}
// 1. 构造上下文:读取 task / schedule / feedback 的只读事实快照。
activeContext, err := s.builder.BuildContext(ctx, trig)
if err != nil {
return nil, err
}
// 2. 主动观测:生成 metrics、issues 和初步裁决,不生成正式变更。
observation := s.analyzer.Observe(activeContext)
// 3. 候选生成:只枚举第一版允许的确定性候选,压缩融合保持关闭。
candidates := s.generator.GenerateCandidates(activeContext, observation)
fallbackCandidateID := ""
if len(candidates) > 0 {
fallbackCandidateID = candidates[0].CandidateID
}
observation = s.analyzer.FinalizeDecision(observation, len(applicableCandidates(candidates)), fallbackCandidateID)
return &DryRunResult{
Context: activeContext,
Observation: observation,
Candidates: candidates,
}, nil
}
func applicableCandidates(candidates []candidate.Candidate) []candidate.Candidate {
result := make([]candidate.Candidate, 0, len(candidates))
for _, item := range candidates {
if item.CandidateType == candidate.TypeAddTaskPoolToSchedule || item.CandidateType == candidate.TypeCreateMakeup {
result = append(result, item)
}
}
return result
}

View File

@@ -0,0 +1,282 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply"
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
"github.com/LoveLosita/smartflow/backend/dao"
"github.com/LoveLosita/smartflow/backend/model"
)
// PreviewConfirmService 编排第三阶段的预览生成、查询和确认应用。
//
// 职责边界:
// 1. 复用 dry-run 结果写 preview不重新实现候选生成
// 2. confirm 时只负责 preview 状态、幂等和 apply port 调用编排;
// 3. 正式 schedule 写入仍由 applyadapter 在事务中完成。
type PreviewConfirmService struct {
dryRun *DryRunService
preview *activepreview.Service
activeDAO *dao.ActiveScheduleDAO
applyAdapter *applyadapter.GormApplyAdapter
clock func() time.Time
}
func NewPreviewConfirmService(dryRun *DryRunService, previewService *activepreview.Service, activeDAO *dao.ActiveScheduleDAO, applyAdapter *applyadapter.GormApplyAdapter) (*PreviewConfirmService, error) {
if dryRun == nil {
return nil, errors.New("dry-run service 不能为空")
}
if previewService == nil {
return nil, errors.New("preview service 不能为空")
}
if activeDAO == nil {
return nil, errors.New("active schedule dao 不能为空")
}
if applyAdapter == nil {
return nil, errors.New("apply adapter 不能为空")
}
return &PreviewConfirmService{
dryRun: dryRun,
preview: previewService,
activeDAO: activeDAO,
applyAdapter: applyAdapter,
clock: time.Now,
}, nil
}
func (s *PreviewConfirmService) SetClock(clock func() time.Time) {
if s != nil && clock != nil {
s.clock = clock
}
}
func (s *PreviewConfirmService) CreatePreviewFromDryRun(ctx context.Context, req activepreview.CreatePreviewRequest) (*activepreview.CreatePreviewResponse, error) {
if s == nil || s.preview == nil {
return nil, errors.New("preview confirm service 未初始化")
}
return s.preview.CreatePreview(ctx, req)
}
func (s *PreviewConfirmService) GetPreview(ctx context.Context, userID int, previewID string) (*activepreview.ActiveSchedulePreviewDetail, error) {
if s == nil || s.preview == nil {
return nil, errors.New("preview confirm service 未初始化")
}
return s.preview.GetPreview(ctx, userID, previewID)
}
// ConfirmPreview 同步确认并应用主动调度预览。
//
// 步骤化说明:
// 1. 先读取 preview 并做同用户校验,避免跨用户确认;
// 2. 对已应用且命中同一幂等键的请求直接返回历史结果,避免重复写日程;
// 3. 转换 candidate/edited_changes 为 apply 请求;
// 4. 先把 preview 标记 applying再调用正式 apply adapter
// 5. 成功或失败都回写 preview保证接口返回后可排障。
func (s *PreviewConfirmService) ConfirmPreview(ctx context.Context, req activeapply.ConfirmRequest) (*activeapply.ConfirmResult, error) {
if s == nil || s.activeDAO == nil || s.applyAdapter == nil {
return nil, errors.New("preview confirm service 未初始化")
}
now := s.now()
if req.RequestedAt.IsZero() {
req.RequestedAt = now
}
previewRow, err := s.activeDAO.GetPreviewByID(ctx, req.PreviewID)
if err != nil {
return nil, err
}
if previewRow.UserID != req.UserID {
return nil, fmt.Errorf("preview 不属于当前用户")
}
if previewRow.ApplyStatus == model.ActiveScheduleApplyStatusApplied {
if previewRow.ApplyIdempotencyKey == req.IdempotencyKey {
return alreadyAppliedResult(*previewRow), nil
}
return nil, fmt.Errorf("preview 已应用,不能使用新的幂等键重复确认")
}
applyReq, err := activeapply.ConvertConfirmToApplyRequest(*previewRow, req, now)
if err != nil {
_ = s.markApplyFailed(ctx, previewRow.ID, "", err)
return nil, err
}
if len(applyReq.Commands) == 0 {
return nil, fmt.Errorf("当前候选没有可正式应用的日程变更")
}
if err = s.markApplying(ctx, *applyReq); err != nil {
return nil, err
}
adapterReq := toAdapterRequest(*applyReq)
adapterResult, err := s.applyAdapter.ApplyActiveScheduleChanges(ctx, adapterReq)
if err != nil {
_ = s.markApplyFailed(ctx, previewRow.ID, applyReq.ApplyID, err)
return nil, err
}
result := activeapply.ApplyActiveScheduleResult{
ApplyID: applyReq.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedEventIDs: adapterResult.AppliedEventIDs,
AppliedScheduleIDs: adapterResult.AppliedScheduleIDs,
AppliedChanges: applyReq.Changes,
SkippedChanges: applyReq.SkippedChanges,
RequestHash: applyReq.RequestHash,
NormalizedChangeHash: applyReq.NormalizedChangesHash,
}
if err = s.markApplied(ctx, *applyReq, result); err != nil {
return nil, err
}
return &activeapply.ConfirmResult{
PreviewID: applyReq.PreviewID,
ApplyID: applyReq.ApplyID,
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: applyReq.CandidateID,
RequestHash: applyReq.RequestHash,
RequestBodyHash: applyReq.RequestBodyHash,
ApplyRequest: applyReq,
ApplyResult: &result,
SkippedChanges: applyReq.SkippedChanges,
}, nil
}
func (s *PreviewConfirmService) markApplying(ctx context.Context, req activeapply.ApplyActiveScheduleRequest) error {
return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{
"apply_id": req.ApplyID,
"apply_status": model.ActiveScheduleApplyStatusApplying,
"apply_candidate_id": req.CandidateID,
"apply_idempotency_key": req.IdempotencyKey,
"apply_request_hash": req.RequestHash,
})
}
func (s *PreviewConfirmService) markApplied(ctx context.Context, req activeapply.ApplyActiveScheduleRequest, result activeapply.ApplyActiveScheduleResult) error {
now := s.now()
appliedChangesJSON := mustJSON(result.AppliedChanges)
appliedEventIDsJSON := mustJSON(result.AppliedEventIDs)
return s.activeDAO.UpdatePreviewFields(ctx, req.PreviewID, map[string]any{
"status": model.ActiveSchedulePreviewStatusApplied,
"apply_status": model.ActiveScheduleApplyStatusApplied,
"applied_changes_json": &appliedChangesJSON,
"applied_event_ids_json": &appliedEventIDsJSON,
"apply_error": nil,
"applied_at": &now,
})
}
func (s *PreviewConfirmService) markApplyFailed(ctx context.Context, previewID string, applyID string, err error) error {
if previewID == "" {
return nil
}
message := ""
if err != nil {
message = err.Error()
}
updates := map[string]any{
"apply_status": model.ActiveScheduleApplyStatusFailed,
"apply_error": &message,
}
if applyID != "" {
updates["apply_id"] = applyID
}
return s.activeDAO.UpdatePreviewFields(ctx, previewID, updates)
}
func (s *PreviewConfirmService) now() time.Time {
if s == nil || s.clock == nil {
return time.Now()
}
return s.clock()
}
func toAdapterRequest(req activeapply.ApplyActiveScheduleRequest) applyadapter.ApplyActiveScheduleRequest {
changes := make([]applyadapter.ApplyChange, 0, len(req.Changes))
for _, change := range req.Changes {
changes = append(changes, toAdapterChange(change))
}
return applyadapter.ApplyActiveScheduleRequest{
PreviewID: req.PreviewID,
ApplyID: req.ApplyID,
UserID: req.UserID,
CandidateID: req.CandidateID,
Changes: changes,
RequestedAt: req.RequestedAt,
TraceID: req.TraceID,
}
}
func toAdapterChange(change activeapply.ApplyChange) applyadapter.ApplyChange {
return applyadapter.ApplyChange{
ChangeID: change.ChangeID,
ChangeType: string(change.Type),
TargetType: change.TargetType,
TargetID: change.TargetID,
ToSlot: toAdapterSlotSpan(change),
DurationSections: change.DurationSections,
Metadata: cloneStringMap(change.Metadata),
}
}
func toAdapterSlotSpan(change activeapply.ApplyChange) *applyadapter.SlotSpan {
if len(change.Slots) == 0 {
return nil
}
start := change.Slots[0]
end := change.Slots[len(change.Slots)-1]
return &applyadapter.SlotSpan{
Start: applyadapter.Slot{Week: start.Week, DayOfWeek: start.DayOfWeek, Section: start.Section},
End: applyadapter.Slot{Week: end.Week, DayOfWeek: end.DayOfWeek, Section: end.Section},
DurationSections: len(change.Slots),
}
}
func alreadyAppliedResult(preview model.ActiveSchedulePreview) *activeapply.ConfirmResult {
appliedEventIDs := []int{}
if preview.AppliedEventIDsJSON != nil && *preview.AppliedEventIDsJSON != "" {
_ = json.Unmarshal([]byte(*preview.AppliedEventIDsJSON), &appliedEventIDs)
}
return &activeapply.ConfirmResult{
PreviewID: preview.ID,
ApplyID: stringValue(preview.ApplyID),
ApplyStatus: activeapply.ApplyStatusApplied,
CandidateID: preview.ApplyCandidateID,
RequestHash: preview.ApplyRequestHash,
ApplyResult: &activeapply.ApplyActiveScheduleResult{
ApplyID: stringValue(preview.ApplyID),
ApplyStatus: activeapply.ApplyStatusApplied,
AppliedEventIDs: appliedEventIDs,
RequestHash: preview.ApplyRequestHash,
},
}
}
func mustJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return "null"
}
return string(raw)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
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,103 @@
package trigger
import (
"errors"
"time"
)
// TriggerType 是主动调度第一版允许进入 dry-run 主链路的触发类型。
//
// 职责边界:
// 1. 只表达触发信号分类;
// 2. 不负责判断任务是否真的需要调度;
// 3. 不承载 preview、notification 或 apply 状态。
type TriggerType string
const (
TriggerTypeImportantUrgentTask TriggerType = "important_urgent_task"
TriggerTypeUnfinishedFeedback TriggerType = "unfinished_feedback"
)
// Source 表示触发信号来源dry-run 第一版只消费该字段用于审计和 mock_now 校验。
type Source string
const (
SourceWorkerDueJob Source = "worker_due_job"
SourceAPITrigger Source = "api_trigger"
SourceAPIDryRun Source = "api_dry_run"
SourceUserFeedback Source = "user_feedback"
)
// TargetType 表示触发信号指向的业务对象类型。
type TargetType string
const (
TargetTypeTaskPool TargetType = "task_pool"
TargetTypeScheduleEvent TargetType = "schedule_event"
TargetTypeTaskItem TargetType = "task_item"
)
// ActiveScheduleTrigger 是主动调度主链路的统一输入。
//
// 职责边界:
// 1. 负责承载 API dry-run、正式 trigger、worker 与用户反馈归一后的输入;
// 2. 不负责读取任务、日程或反馈事实;
// 3. TargetID 在 unfinished_feedback 且反馈目标未知时允许为 0由观察链路转成 ask_user。
type ActiveScheduleTrigger struct {
TriggerID string
UserID int
TriggerType TriggerType
Source Source
TargetType TargetType
TargetID int
FeedbackID string
IdempotencyKey string
MockNow *time.Time
IsMockTime bool
RequestedAt time.Time
TraceID string
}
// Validate 校验触发信号是否能进入主动调度 dry-run 主链路。
//
// 职责边界:
// 1. 只做枚举、归属与 mock_now 入口级校验;
// 2. 不判断目标是否存在,也不判断是否应生成候选;
// 3. 返回 nil 表示可以继续构造上下文error 表示调用方应直接拒绝请求。
func (t ActiveScheduleTrigger) Validate() error {
if t.UserID <= 0 {
return errors.New("user_id 必须大于 0")
}
if t.TriggerType != TriggerTypeImportantUrgentTask && t.TriggerType != TriggerTypeUnfinishedFeedback {
return errors.New("trigger_type 不受支持")
}
if t.Source != SourceWorkerDueJob && t.Source != SourceAPITrigger && t.Source != SourceAPIDryRun && t.Source != SourceUserFeedback {
return errors.New("source 不受支持")
}
if t.TargetType != TargetTypeTaskPool && t.TargetType != TargetTypeScheduleEvent && t.TargetType != TargetTypeTaskItem {
return errors.New("target_type 不受支持")
}
if t.TargetID <= 0 && t.TriggerType != TriggerTypeUnfinishedFeedback {
return errors.New("target_id 必须大于 0")
}
if t.MockNow != nil && t.Source != SourceAPIDryRun && t.Source != SourceAPITrigger {
return errors.New("mock_now 只允许 API dry-run 或 API trigger 使用")
}
if t.MockNow != nil && !t.IsMockTime {
return errors.New("传入 mock_now 时必须显式标记 is_mock_time")
}
return nil
}
// EffectiveNow 返回主动调度本次运行应使用的业务当前时间。
//
// 职责边界:
// 1. dry-run / 测试 trigger 可使用 MockNow
// 2. 后台 worker 使用调用方传入的真实 now
// 3. 不负责时区转换,调用方应保证 now 与用户时区语义一致。
func (t ActiveScheduleTrigger) EffectiveNow(realNow time.Time) time.Time {
if t.MockNow != nil {
return *t.MockNow
}
return realNow
}

View File

@@ -0,0 +1,181 @@
package api
import (
"context"
"fmt"
"net/http"
"time"
activeapply "github.com/LoveLosita/smartflow/backend/active_scheduler/apply"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service"
"github.com/LoveLosita/smartflow/backend/active_scheduler/trigger"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/gin-gonic/gin"
)
// ActiveScheduleAPI 承载主动调度开发期和验收期 API。
//
// 职责边界:
// 1. 只负责鉴权用户、绑定请求和调用主动调度 service
// 2. 不直接读取 DAO、不生成候选、不写 preview
// 3. 阶段 1-2 只开放 dry-run正式 trigger/preview/confirm 后续阶段再接入。
type ActiveScheduleAPI struct {
dryRunService *activesvc.DryRunService
previewConfirmService *activesvc.PreviewConfirmService
}
func NewActiveScheduleAPI(dryRunService *activesvc.DryRunService, previewConfirmService *activesvc.PreviewConfirmService) *ActiveScheduleAPI {
return &ActiveScheduleAPI{
dryRunService: dryRunService,
previewConfirmService: previewConfirmService,
}
}
type ActiveScheduleDryRunRequest struct {
TriggerType string `json:"trigger_type" binding:"required"`
TargetType string `json:"target_type" binding:"required"`
TargetID int `json:"target_id"`
FeedbackID string `json:"feedback_id"`
IdempotencyKey string `json:"idempotency_key"`
MockNow *time.Time `json:"mock_now"`
}
// DryRun 同步执行主动调度诊断,不写 preview、不发通知、不修改正式日程。
func (api *ActiveScheduleAPI) DryRun(c *gin.Context) {
if api == nil || api.dryRunService == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 dry-run service 未初始化")))
return
}
var req ActiveScheduleDryRunRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userID := c.GetInt("user_id")
now := time.Now()
isMockTime := req.MockNow != nil
trig := trigger.ActiveScheduleTrigger{
UserID: userID,
TriggerType: trigger.TriggerType(req.TriggerType),
Source: trigger.SourceAPIDryRun,
TargetType: trigger.TargetType(req.TargetType),
TargetID: req.TargetID,
FeedbackID: req.FeedbackID,
IdempotencyKey: req.IdempotencyKey,
MockNow: req.MockNow,
IsMockTime: isMockTime,
RequestedAt: now,
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
result, err := api.dryRunService.DryRun(ctx, trig)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
// CreatePreview 先同步 dry-run再把 top1 候选固化为待确认预览。
func (api *ActiveScheduleAPI) CreatePreview(c *gin.Context) {
if api == nil || api.dryRunService == nil || api.previewConfirmService == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 preview service 未初始化")))
return
}
var req ActiveScheduleDryRunRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
userID := c.GetInt("user_id")
now := time.Now()
trig := trigger.ActiveScheduleTrigger{
TriggerID: fmt.Sprintf("ast_api_%d_%d", userID, now.UnixNano()),
UserID: userID,
TriggerType: trigger.TriggerType(req.TriggerType),
Source: trigger.SourceAPIDryRun,
TargetType: trigger.TargetType(req.TargetType),
TargetID: req.TargetID,
FeedbackID: req.FeedbackID,
IdempotencyKey: req.IdempotencyKey,
MockNow: req.MockNow,
IsMockTime: req.MockNow != nil,
RequestedAt: now,
TraceID: fmt.Sprintf("trace_api_preview_%d_%d", userID, now.UnixNano()),
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
dryRunResult, err := api.dryRunService.DryRun(ctx, trig)
if err != nil {
respond.DealWithError(c, err)
return
}
previewResp, err := api.previewConfirmService.CreatePreviewFromDryRun(ctx, activepreview.CreatePreviewRequest{
ActiveContext: dryRunResult.Context,
Observation: dryRunResult.Observation,
Candidates: dryRunResult.Candidates,
TriggerID: trig.TriggerID,
GeneratedAt: now,
})
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, previewResp.Detail))
}
// GetPreview 查询主动调度预览详情。
func (api *ActiveScheduleAPI) GetPreview(c *gin.Context) {
if api == nil || api.previewConfirmService == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 preview service 未初始化")))
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
detail, err := api.previewConfirmService.GetPreview(ctx, c.GetInt("user_id"), c.Param("preview_id"))
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, detail))
}
// ConfirmPreview 同步确认并正式应用主动调度预览。
func (api *ActiveScheduleAPI) ConfirmPreview(c *gin.Context) {
if api == nil || api.previewConfirmService == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(nilServiceError("主动调度 confirm service 未初始化")))
return
}
var req activeapply.ConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
req.PreviewID = c.Param("preview_id")
req.UserID = c.GetInt("user_id")
if req.RequestedAt.IsZero() {
req.RequestedAt = time.Now()
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
defer cancel()
result, err := api.previewConfirmService.ConfirmPreview(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, result))
}
type nilServiceError string
func (e nilServiceError) Error() string {
return string(e)
}

View File

@@ -8,4 +8,5 @@ type ApiHandlers struct {
ScheduleHandler *ScheduleAPI
AgentHandler *AgentHandler
MemoryHandler *MemoryHandler
ActiveSchedule *ActiveScheduleAPI
}

View File

@@ -10,6 +10,10 @@ import (
"syscall"
"time"
activeadapters "github.com/LoveLosita/smartflow/backend/active_scheduler/adapters"
"github.com/LoveLosita/smartflow/backend/active_scheduler/applyadapter"
activepreview "github.com/LoveLosita/smartflow/backend/active_scheduler/preview"
activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service"
"github.com/LoveLosita/smartflow/backend/api"
"github.com/LoveLosita/smartflow/backend/dao"
kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka"
@@ -198,6 +202,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
// Service 层初始化。
userService := service.NewUserService(userRepo, cacheRepo)
taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus)
taskSv.SetActiveScheduleDAO(manager.ActiveSchedule)
courseService := buildCourseService(courseRepo, scheduleRepo)
taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager)
scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo)
@@ -215,7 +220,15 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) {
memoryCfg,
)
handlers := buildAPIHandlers(userService, taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule)
activeScheduleDryRun, err := buildActiveScheduleDryRunService(db)
if err != nil {
return nil, err
}
activeSchedulePreviewConfirm, err := buildActiveSchedulePreviewConfirmService(db, manager.ActiveSchedule, activeScheduleDryRun)
if err != nil {
return nil, err
}
handlers := buildAPIHandlers(userService, taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule, activeScheduleDryRun, activeSchedulePreviewConfirm)
return &appRuntime{
db: db,
@@ -289,6 +302,19 @@ func buildCourseService(courseRepo *dao.CourseDAO, scheduleRepo *dao.ScheduleDAO
)
}
func buildActiveScheduleDryRunService(db *gorm.DB) (*activesvc.DryRunService, error) {
readers := activeadapters.NewGormReaders(db)
return activesvc.NewDryRunService(activeadapters.ReadersFromGorm(readers))
}
func buildActiveSchedulePreviewConfirmService(db *gorm.DB, activeDAO *dao.ActiveScheduleDAO, dryRun *activesvc.DryRunService) (*activesvc.PreviewConfirmService, error) {
previewService, err := activepreview.NewService(activeDAO)
if err != nil {
return nil, err
}
return activesvc.NewPreviewConfirmService(dryRun, previewService, activeDAO, applyadapter.NewGormApplyAdapter(db))
}
func configureAgentService(
agentService *service.AgentService,
ragRuntime infrarag.Runtime,
@@ -477,6 +503,8 @@ func buildAPIHandlers(
scheduleService *service.ScheduleService,
agentService *service.AgentService,
memoryModule *memory.Module,
activeScheduleDryRun *activesvc.DryRunService,
activeSchedulePreviewConfirm *activesvc.PreviewConfirmService,
) *api.ApiHandlers {
return &api.ApiHandlers{
UserHandler: api.NewUserHandler(userService),
@@ -486,6 +514,7 @@ func buildAPIHandlers(
ScheduleHandler: api.NewScheduleAPI(scheduleService),
AgentHandler: api.NewAgentHandler(agentService),
MemoryHandler: api.NewMemoryHandler(memoryModule),
ActiveSchedule: api.NewActiveScheduleAPI(activeScheduleDryRun, activeSchedulePreviewConfirm),
}
}

View File

@@ -0,0 +1,395 @@
package dao
import (
"context"
"errors"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ActiveScheduleDAO 管理主动调度阶段 1 的自有表。
//
// 职责边界:
// 1. 只负责 active_schedule_jobs / triggers / previews / notification_records 的基础读写;
// 2. 不负责构造候选、调用 LLM、投递 provider 或写正式日程;
// 3. 幂等查询只按持久化键读取事实,是否复用结果由上层状态机判断。
type ActiveScheduleDAO struct {
db *gorm.DB
}
func NewActiveScheduleDAO(db *gorm.DB) *ActiveScheduleDAO {
return &ActiveScheduleDAO{db: db}
}
func (d *ActiveScheduleDAO) WithTx(tx *gorm.DB) *ActiveScheduleDAO {
return &ActiveScheduleDAO{db: tx}
}
func (d *ActiveScheduleDAO) ensureDB() error {
if d == nil || d.db == nil {
return errors.New("active schedule dao 未初始化")
}
return nil
}
// CreateOrUpdateJob 按 job.id 幂等创建或覆盖主动调度 job。
//
// 职责边界:
// 1. 只按主键 upsert 当前传入的 job 快照;
// 2. 不判断 task 是否仍满足主动调度条件,该判断由 job scanner 读取 task 真值后完成;
// 3. 调用方需要保证 ID 稳定,例如按 task_id 当前有效 job 或生成 asj_*。
func (d *ActiveScheduleDAO) CreateOrUpdateJob(ctx context.Context, job *model.ActiveScheduleJob) error {
if err := d.ensureDB(); err != nil {
return err
}
if job == nil || job.ID == "" {
return errors.New("active schedule job 不能为空且必须包含 id")
}
return d.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
UpdateAll: true,
}).
Create(job).Error
}
// UpdateJobFields 按 job_id 更新指定字段。
//
// 职责边界:
// 1. 只执行局部字段更新,不隐式改变其它状态;
// 2. updates 为空时直接返回 nil方便上层按条件拼装更新
// 3. 不做状态机合法性校验,状态流转由 active_scheduler/job 负责。
func (d *ActiveScheduleDAO) UpdateJobFields(ctx context.Context, jobID string, updates map[string]any) error {
if err := d.ensureDB(); err != nil {
return err
}
if jobID == "" {
return errors.New("active schedule job id 不能为空")
}
if len(updates) == 0 {
return nil
}
return d.db.WithContext(ctx).
Model(&model.ActiveScheduleJob{}).
Where("id = ?", jobID).
Updates(updates).Error
}
func (d *ActiveScheduleDAO) GetJobByID(ctx context.Context, jobID string) (*model.ActiveScheduleJob, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if jobID == "" {
return nil, gorm.ErrRecordNotFound
}
var job model.ActiveScheduleJob
err := d.db.WithContext(ctx).Where("id = ?", jobID).First(&job).Error
if err != nil {
return nil, err
}
return &job, nil
}
// FindPendingJobByTask 查询某个 task 当前待触发 job。
//
// 说明:
// 1. 用于 task 创建/更新时决定复用还是覆盖当前有效 job
// 2. 只查 pending已 triggered/canceled/skipped 的历史 job 保留审计,不再被覆盖。
func (d *ActiveScheduleDAO) FindPendingJobByTask(ctx context.Context, userID int, taskID int) (*model.ActiveScheduleJob, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if userID <= 0 || taskID <= 0 {
return nil, gorm.ErrRecordNotFound
}
var job model.ActiveScheduleJob
err := d.db.WithContext(ctx).
Where("user_id = ? AND task_id = ? AND status = ?", userID, taskID, model.ActiveScheduleJobStatusPending).
Order("trigger_at ASC, created_at ASC").
First(&job).Error
if err != nil {
return nil, err
}
return &job, nil
}
// ListDueJobs 读取到期且仍待触发的 job。
//
// 失败处理:
// 1. 参数非法时返回空列表,避免 worker 因配置抖动误扫全表;
// 2. 数据库错误直接返回,让上层按扫描器策略记录并重试。
func (d *ActiveScheduleDAO) ListDueJobs(ctx context.Context, now time.Time, limit int) ([]model.ActiveScheduleJob, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if limit <= 0 || now.IsZero() {
return []model.ActiveScheduleJob{}, nil
}
var jobs []model.ActiveScheduleJob
err := d.db.WithContext(ctx).
Where("status = ? AND trigger_at <= ?", model.ActiveScheduleJobStatusPending, now).
Order("trigger_at ASC, id ASC").
Limit(limit).
Find(&jobs).Error
if err != nil {
return nil, err
}
return jobs, nil
}
func (d *ActiveScheduleDAO) CreateTrigger(ctx context.Context, trigger *model.ActiveScheduleTrigger) error {
if err := d.ensureDB(); err != nil {
return err
}
if trigger == nil || trigger.ID == "" {
return errors.New("active schedule trigger 不能为空且必须包含 id")
}
return d.db.WithContext(ctx).Create(trigger).Error
}
// UpdateTriggerFields 按 trigger_id 局部更新触发状态。
//
// 职责边界:
// 1. 只提供字段更新能力,不判断 pending -> processing -> preview_generated 是否合规;
// 2. 上层若需要 CAS 状态流转,应在 updates 外自行加 where 条件或后续扩展专用方法;
// 3. updates 为空时直接返回 nil。
func (d *ActiveScheduleDAO) UpdateTriggerFields(ctx context.Context, triggerID string, updates map[string]any) error {
if err := d.ensureDB(); err != nil {
return err
}
if triggerID == "" {
return errors.New("active schedule trigger id 不能为空")
}
if len(updates) == 0 {
return nil
}
return d.db.WithContext(ctx).
Model(&model.ActiveScheduleTrigger{}).
Where("id = ?", triggerID).
Updates(updates).Error
}
func (d *ActiveScheduleDAO) GetTriggerByID(ctx context.Context, triggerID string) (*model.ActiveScheduleTrigger, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if triggerID == "" {
return nil, gorm.ErrRecordNotFound
}
var trigger model.ActiveScheduleTrigger
err := d.db.WithContext(ctx).Where("id = ?", triggerID).First(&trigger).Error
if err != nil {
return nil, err
}
return &trigger, nil
}
// FindTriggerByDedupeKey 查询触发去重键对应的最近 trigger。
//
// 说明:
// 1. important_urgent_task 使用 user_id + trigger_type + target + 30 分钟窗口构造 dedupe_key
// 2. unfinished_feedback 可把反馈幂等键放入 dedupe_key
// 3. statuses 为空时读取所有状态,方便调用方按场景选择是否复用 failed 记录。
func (d *ActiveScheduleDAO) FindTriggerByDedupeKey(ctx context.Context, dedupeKey string, statuses []string) (*model.ActiveScheduleTrigger, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if dedupeKey == "" {
return nil, gorm.ErrRecordNotFound
}
query := d.db.WithContext(ctx).
Where("dedupe_key = ?", dedupeKey)
if len(statuses) > 0 {
query = query.Where("status IN ?", statuses)
}
var trigger model.ActiveScheduleTrigger
err := query.Order("created_at DESC, id DESC").First(&trigger).Error
if err != nil {
return nil, err
}
return &trigger, nil
}
// FindTriggerByIdempotencyKey 查询 API/用户反馈幂等键对应的 trigger。
func (d *ActiveScheduleDAO) FindTriggerByIdempotencyKey(ctx context.Context, userID int, triggerType string, idempotencyKey string) (*model.ActiveScheduleTrigger, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if userID <= 0 || triggerType == "" || idempotencyKey == "" {
return nil, gorm.ErrRecordNotFound
}
var trigger model.ActiveScheduleTrigger
err := d.db.WithContext(ctx).
Where("user_id = ? AND trigger_type = ? AND idempotency_key = ?", userID, triggerType, idempotencyKey).
Order("created_at DESC, id DESC").
First(&trigger).Error
if err != nil {
return nil, err
}
return &trigger, nil
}
func (d *ActiveScheduleDAO) CreatePreview(ctx context.Context, preview *model.ActiveSchedulePreview) error {
if err := d.ensureDB(); err != nil {
return err
}
if preview == nil || preview.ID == "" {
return errors.New("active schedule preview 不能为空且必须包含 preview_id")
}
return d.db.WithContext(ctx).Create(preview).Error
}
func (d *ActiveScheduleDAO) UpdatePreviewFields(ctx context.Context, previewID string, updates map[string]any) error {
if err := d.ensureDB(); err != nil {
return err
}
if previewID == "" {
return errors.New("active schedule preview id 不能为空")
}
if len(updates) == 0 {
return nil
}
return d.db.WithContext(ctx).
Model(&model.ActiveSchedulePreview{}).
Where("preview_id = ?", previewID).
Updates(updates).Error
}
func (d *ActiveScheduleDAO) GetPreviewByID(ctx context.Context, previewID string) (*model.ActiveSchedulePreview, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if previewID == "" {
return nil, gorm.ErrRecordNotFound
}
var preview model.ActiveSchedulePreview
err := d.db.WithContext(ctx).Where("preview_id = ?", previewID).First(&preview).Error
if err != nil {
return nil, err
}
return &preview, nil
}
func (d *ActiveScheduleDAO) GetPreviewByTriggerID(ctx context.Context, triggerID string) (*model.ActiveSchedulePreview, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if triggerID == "" {
return nil, gorm.ErrRecordNotFound
}
var preview model.ActiveSchedulePreview
err := d.db.WithContext(ctx).
Where("trigger_id = ?", triggerID).
Order("created_at DESC").
First(&preview).Error
if err != nil {
return nil, err
}
return &preview, nil
}
// FindPreviewByApplyIdempotencyKey 查询 confirm 重试时的预览应用状态。
func (d *ActiveScheduleDAO) FindPreviewByApplyIdempotencyKey(ctx context.Context, previewID string, idempotencyKey string) (*model.ActiveSchedulePreview, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if previewID == "" || idempotencyKey == "" {
return nil, gorm.ErrRecordNotFound
}
var preview model.ActiveSchedulePreview
err := d.db.WithContext(ctx).
Where("preview_id = ? AND apply_idempotency_key = ?", previewID, idempotencyKey).
First(&preview).Error
if err != nil {
return nil, err
}
return &preview, nil
}
func (d *ActiveScheduleDAO) CreateNotificationRecord(ctx context.Context, record *model.NotificationRecord) error {
if err := d.ensureDB(); err != nil {
return err
}
if record == nil {
return errors.New("notification record 不能为空")
}
return d.db.WithContext(ctx).Create(record).Error
}
func (d *ActiveScheduleDAO) UpdateNotificationRecordFields(ctx context.Context, notificationID int64, updates map[string]any) error {
if err := d.ensureDB(); err != nil {
return err
}
if notificationID <= 0 {
return errors.New("notification record id 不能为空")
}
if len(updates) == 0 {
return nil
}
return d.db.WithContext(ctx).
Model(&model.NotificationRecord{}).
Where("id = ?", notificationID).
Updates(updates).Error
}
func (d *ActiveScheduleDAO) GetNotificationRecordByID(ctx context.Context, notificationID int64) (*model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if notificationID <= 0 {
return nil, gorm.ErrRecordNotFound
}
var record model.NotificationRecord
err := d.db.WithContext(ctx).Where("id = ?", notificationID).First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
// FindNotificationRecordByDedupeKey 查询通知去重记录。
//
// 说明:
// 1. notification 第一版按 channel + dedupe_key 聚合去重;
// 2. 若返回 pending/sending/sent上层应避免重复投递
// 3. 若返回 failed上层可以复用同一条记录进入 provider retry。
func (d *ActiveScheduleDAO) FindNotificationRecordByDedupeKey(ctx context.Context, channel string, dedupeKey string) (*model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if channel == "" || dedupeKey == "" {
return nil, gorm.ErrRecordNotFound
}
var record model.NotificationRecord
err := d.db.WithContext(ctx).
Where("channel = ? AND dedupe_key = ?", channel, dedupeKey).
Order("created_at DESC, id DESC").
First(&record).Error
if err != nil {
return nil, err
}
return &record, nil
}
// ListRetryableNotificationRecords 查询到达重试时间的通知记录。
func (d *ActiveScheduleDAO) ListRetryableNotificationRecords(ctx context.Context, now time.Time, limit int) ([]model.NotificationRecord, error) {
if err := d.ensureDB(); err != nil {
return nil, err
}
if limit <= 0 || now.IsZero() {
return []model.NotificationRecord{}, nil
}
var records []model.NotificationRecord
err := d.db.WithContext(ctx).
Where("status = ? AND next_retry_at IS NOT NULL AND next_retry_at <= ?", model.NotificationRecordStatusFailed, now).
Order("next_retry_at ASC, id ASC").
Limit(limit).
Find(&records).Error
if err != nil {
return nil, err
}
return records, nil
}

View File

@@ -8,24 +8,26 @@ import (
// RepoManager 聚合所有 DAO供服务层做跨仓储事务编排。
type RepoManager struct {
db *gorm.DB
Schedule *ScheduleDAO
Task *TaskDAO
Course *CourseDAO
TaskClass *TaskClassDAO
User *UserDAO
Agent *AgentDAO
db *gorm.DB
Schedule *ScheduleDAO
Task *TaskDAO
Course *CourseDAO
TaskClass *TaskClassDAO
User *UserDAO
Agent *AgentDAO
ActiveSchedule *ActiveScheduleDAO
}
func NewManager(db *gorm.DB) *RepoManager {
return &RepoManager{
db: db,
Schedule: NewScheduleDAO(db),
Task: NewTaskDAO(db),
Course: NewCourseDAO(db),
TaskClass: NewTaskClassDAO(db),
User: NewUserDAO(db),
Agent: NewAgentDAO(db),
db: db,
Schedule: NewScheduleDAO(db),
Task: NewTaskDAO(db),
Course: NewCourseDAO(db),
TaskClass: NewTaskClassDAO(db),
User: NewUserDAO(db),
Agent: NewAgentDAO(db),
ActiveSchedule: NewActiveScheduleDAO(db),
}
}
@@ -37,13 +39,14 @@ func NewManager(db *gorm.DB) *RepoManager {
// 3. 适用于 outbox 消费处理器这类“基础设施事务 + 业务事务合并”的场景。
func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager {
return &RepoManager{
db: tx,
Schedule: m.Schedule.WithTx(tx),
Task: m.Task.WithTx(tx),
TaskClass: m.TaskClass.WithTx(tx),
Course: m.Course.WithTx(tx),
User: m.User.WithTx(tx),
Agent: m.Agent.WithTx(tx),
db: tx,
Schedule: m.Schedule.WithTx(tx),
Task: m.Task.WithTx(tx),
TaskClass: m.TaskClass.WithTx(tx),
Course: m.Course.WithTx(tx),
User: m.User.WithTx(tx),
Agent: m.Agent.WithTx(tx),
ActiveSchedule: m.ActiveSchedule.WithTx(tx),
}
}

View File

@@ -21,6 +21,10 @@ func autoMigrateModels(db *gorm.DB) error {
&model.TaskClassItem{},
&model.ScheduleEvent{},
&model.Schedule{},
&model.ActiveScheduleJob{},
&model.ActiveScheduleTrigger{},
&model.ActiveSchedulePreview{},
&model.NotificationRecord{},
&model.AgentOutboxMessage{},
&model.AgentScheduleState{},
&model.AgentStateSnapshotRecord{},
@@ -35,6 +39,30 @@ func autoMigrateModels(db *gorm.DB) error {
return fmt.Errorf("auto migrate failed for %T: %w", m, err)
}
}
if err := backfillAutoMigrateData(db); err != nil {
return err
}
return nil
}
// backfillAutoMigrateData 补齐 AutoMigrate 无法表达的条件回填。
//
// 职责边界:
// 1. 只处理新增列上线后的兼容数据修复,不替代业务迁移系统;
// 2. 当前仅回填历史动态任务日程来源,确保旧的 type=task 记录按 task_item 解释;
// 3. 失败时直接返回错误,避免服务在 schema 半迁移状态下继续启动。
func backfillAutoMigrateData(db *gorm.DB) error {
// 1. AutoMigrate 只能新增列和默认值,不能表达"仅 type=task 时回填"。
// 2. 这里把历史任务日程显式标记为 task_item避免后续主动调度读取 rel_id 时误判来源。
// 3. 新增 task_pool 正式落库仍必须由 apply 链路显式写 task_source_type=task_pool。
result := db.Exec(
"UPDATE schedule_events SET task_source_type = ? WHERE type = ? AND (task_source_type IS NULL OR task_source_type = '')",
"task_item",
"task",
)
if result.Error != nil {
return fmt.Errorf("backfill schedule_events.task_source_type failed: %w", result.Error)
}
return nil
}

View File

@@ -0,0 +1,264 @@
package model
import (
"time"
"gorm.io/gorm"
)
const (
// ActiveScheduleJobStatusPending 表示 job 已创建,等待到达 trigger_at 后扫描。
ActiveScheduleJobStatusPending = "pending"
// ActiveScheduleJobStatusTriggered 表示 job 已生成正式 trigger后续由 trigger 串联状态。
ActiveScheduleJobStatusTriggered = "triggered"
// ActiveScheduleJobStatusCanceled 表示任务已完成或被取消job 不再触发。
ActiveScheduleJobStatusCanceled = "canceled"
// ActiveScheduleJobStatusSkipped 表示扫描时发现已无需主动调度。
ActiveScheduleJobStatusSkipped = "skipped"
// ActiveScheduleJobStatusFailed 表示扫描或触发写入失败,保留错误供重试/排障。
ActiveScheduleJobStatusFailed = "failed"
)
const (
// ActiveScheduleTriggerStatusPending 表示触发信号已持久化,等待 worker 消费。
ActiveScheduleTriggerStatusPending = "pending"
// ActiveScheduleTriggerStatusProcessing 表示 worker 正在处理该触发信号。
ActiveScheduleTriggerStatusProcessing = "processing"
// ActiveScheduleTriggerStatusPreviewGenerated 表示已生成可查询的预览。
ActiveScheduleTriggerStatusPreviewGenerated = "preview_generated"
// ActiveScheduleTriggerStatusSkipped 表示本次触发被判定无需继续处理。
ActiveScheduleTriggerStatusSkipped = "skipped"
// ActiveScheduleTriggerStatusClosed 表示主动观测结论为关闭,不生成预览。
ActiveScheduleTriggerStatusClosed = "closed"
// ActiveScheduleTriggerStatusFailed 表示链路处理失败,可根据错误分类决定是否重试。
ActiveScheduleTriggerStatusFailed = "failed"
// ActiveScheduleTriggerStatusRejected 表示参数或归属校验失败,不进入 pipeline。
ActiveScheduleTriggerStatusRejected = "rejected"
)
const (
// ActiveSchedulePreviewStatusPending 表示预览正在组装,不应展示为可确认。
ActiveSchedulePreviewStatusPending = "pending"
// ActiveSchedulePreviewStatusReady 表示预览可查看、可确认。
ActiveSchedulePreviewStatusReady = "ready"
// ActiveSchedulePreviewStatusApplied 表示用户已确认并成功应用。
ActiveSchedulePreviewStatusApplied = "applied"
// ActiveSchedulePreviewStatusIgnored 表示用户明确忽略本次建议。
ActiveSchedulePreviewStatusIgnored = "ignored"
// ActiveSchedulePreviewStatusExpired 表示预览已过期,不再允许确认。
ActiveSchedulePreviewStatusExpired = "expired"
// ActiveSchedulePreviewStatusFailed 表示预览生成或回写失败。
ActiveSchedulePreviewStatusFailed = "failed"
)
const (
// ActiveScheduleApplyStatusNone 表示尚未发起确认应用。
ActiveScheduleApplyStatusNone = "none"
// ActiveScheduleApplyStatusApplying 表示确认请求正在事务应用中。
ActiveScheduleApplyStatusApplying = "applying"
// ActiveScheduleApplyStatusApplied 表示确认应用成功。
ActiveScheduleApplyStatusApplied = "applied"
// ActiveScheduleApplyStatusFailed 表示应用失败,正式日程不应产生半写状态。
ActiveScheduleApplyStatusFailed = "failed"
// ActiveScheduleApplyStatusRejected 表示请求因过期、幂等冲突等业务规则被拒绝。
ActiveScheduleApplyStatusRejected = "rejected"
// ActiveScheduleApplyStatusExpired 表示预览过期导致不可应用。
ActiveScheduleApplyStatusExpired = "expired"
)
const (
// NotificationRecordStatusPending 表示通知记录已落库,等待投递。
NotificationRecordStatusPending = "pending"
// NotificationRecordStatusSending 表示当前 worker 正在调用 provider。
NotificationRecordStatusSending = "sending"
// NotificationRecordStatusSent 表示 provider 明确返回成功。
NotificationRecordStatusSent = "sent"
// NotificationRecordStatusFailed 表示本次投递失败,但仍可重试。
NotificationRecordStatusFailed = "failed"
// NotificationRecordStatusDead 表示达到重试上限或不可恢复错误。
NotificationRecordStatusDead = "dead"
// NotificationRecordStatusSkipped 表示命中去重或配置关闭,本次不投递。
NotificationRecordStatusSkipped = "skipped"
)
const (
// ActiveScheduleTriggerTypeImportantUrgentTask 是重要且紧急任务到线触发。
ActiveScheduleTriggerTypeImportantUrgentTask = "important_urgent_task"
// ActiveScheduleTriggerTypeUnfinishedFeedback 是用户明确反馈已排任务未完成触发。
ActiveScheduleTriggerTypeUnfinishedFeedback = "unfinished_feedback"
// ActiveScheduleSourceWorkerDueJob 表示后台到期 job 扫描触发。
ActiveScheduleSourceWorkerDueJob = "worker_due_job"
// ActiveScheduleSourceAPITrigger 表示测试/开发 API 正式触发。
ActiveScheduleSourceAPITrigger = "api_trigger"
// ActiveScheduleSourceAPIDryRun 表示测试/开发 API dry-run不应发布正式事件。
ActiveScheduleSourceAPIDryRun = "api_dry_run"
// ActiveScheduleSourceUserFeedback 表示用户反馈入口触发。
ActiveScheduleSourceUserFeedback = "user_feedback"
// ActiveScheduleTargetTypeTaskPool 表示 target_id 指向 tasks.id。
ActiveScheduleTargetTypeTaskPool = "task_pool"
// ActiveScheduleTargetTypeScheduleEvent 表示 target_id 指向 schedule_events.id。
ActiveScheduleTargetTypeScheduleEvent = "schedule_event"
// ActiveScheduleTargetTypeTaskItem 表示 target_id 指向 task_items.id。
ActiveScheduleTargetTypeTaskItem = "task_item"
)
// ActiveScheduleJob 是主动调度 due job 表模型。
//
// 职责边界:
// 1. 负责记录 task 到达 urgency_threshold_at 后是否需要生成主动调度触发;
// 2. 不负责判断 task 当前是否仍重要且紧急,该判断由 worker 扫描时重新读取真实任务状态;
// 3. 不负责发布 outbox 事件,只保存扫描和排障所需状态。
type ActiveScheduleJob struct {
ID string `gorm:"column:id;type:varchar(64);primaryKey"`
UserID int `gorm:"column:user_id;not null;index:idx_active_jobs_user_status_trigger,priority:1;index:idx_active_jobs_task_status,priority:1"`
TaskID int `gorm:"column:task_id;not null;index:idx_active_jobs_task_status,priority:2;comment:对应 tasks.id"`
TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null;default:'important_urgent_task';comment:触发类型"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_active_jobs_user_status_trigger,priority:2;index:idx_active_jobs_task_status,priority:3;comment:pending/triggered/canceled/skipped/failed"`
TriggerAt time.Time `gorm:"column:trigger_at;not null;index:idx_active_jobs_user_status_trigger,priority:3;comment:到期触发时间"`
DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);index:idx_active_jobs_dedupe;comment:触发去重窗口键"`
LastTriggerID *string `gorm:"column:last_trigger_id;type:varchar(64);index:idx_active_jobs_last_trigger;comment:最近一次生成的 trigger_id"`
LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64);comment:最近一次扫描错误码"`
LastError *string `gorm:"column:last_error;type:text;comment:最近一次扫描错误详情"`
LastScannedAt *time.Time `gorm:"column:last_scanned_at;comment:最近一次被 worker 扫描时间"`
TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_jobs_trace_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
}
func (ActiveScheduleJob) TableName() string { return "active_schedule_jobs" }
// ActiveScheduleTrigger 是主动调度统一触发信号表模型。
//
// 职责边界:
// 1. 负责持久化 worker/API/用户反馈归一后的触发事实;
// 2. 负责串联 trigger -> preview -> notification -> apply 的审计主线;
// 3. 不承载候选生成、LLM 选择或通知投递的业务实现。
type ActiveScheduleTrigger struct {
ID string `gorm:"column:id;type:varchar(64);primaryKey"`
UserID int `gorm:"column:user_id;not null;index:idx_active_triggers_user_created,priority:1"`
TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null;index:idx_active_triggers_dedupe,priority:2"`
Source string `gorm:"column:source;type:varchar(64);not null;comment:worker_due_job/api_trigger/api_dry_run/user_feedback"`
TargetType string `gorm:"column:target_type;type:varchar(64);not null;index:idx_active_triggers_target,priority:1"`
TargetID int `gorm:"column:target_id;not null;index:idx_active_triggers_target,priority:2"`
FeedbackID string `gorm:"column:feedback_id;type:varchar(128);index:idx_active_triggers_feedback;comment:用户反馈来源ID可为空"`
JobID *string `gorm:"column:job_id;type:varchar(64);index:idx_active_triggers_job_id"`
IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(191);index:idx_active_triggers_idempotency;comment:API/用户反馈幂等键"`
DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);index:idx_active_triggers_dedupe,priority:1;comment:触发去重窗口键"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_active_triggers_status_updated,priority:1"`
MockNow *time.Time `gorm:"column:mock_now;comment:测试触发模拟时间"`
IsMockTime bool `gorm:"column:is_mock_time;not null;default:false;comment:是否使用模拟时间"`
RequestedAt time.Time `gorm:"column:requested_at;not null;comment:触发请求时间"`
PayloadJSON *string `gorm:"column:payload_json;type:json;comment:触发来源补充信息"`
PreviewID *string `gorm:"column:preview_id;type:varchar(64);index:idx_active_triggers_preview_id"`
LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64);comment:链路错误码"`
LastError *string `gorm:"column:last_error;type:text;comment:链路错误详情"`
ProcessedAt *time.Time `gorm:"column:processed_at;comment:worker 开始处理时间"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:本触发进入终态时间"`
TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_triggers_trace_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_active_triggers_user_created,priority:2"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;index:idx_active_triggers_status_updated,priority:2"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
}
func (ActiveScheduleTrigger) TableName() string { return "active_schedule_triggers" }
// ActiveSchedulePreview 是主动调度可确认预览表模型。
//
// 职责边界:
// 1. 负责保存主动调度生成的候选、解释、before/after 摘要与过期时间;
// 2. 负责保存一次确认应用的轻量状态,不新增 apply request 表;
// 3. 不负责正式日程写入,正式写入仍由后续 apply/service port 完成。
type ActiveSchedulePreview struct {
ID string `gorm:"column:preview_id;type:varchar(64);primaryKey;uniqueIndex:uk_active_previews_apply_idempotency,priority:1"`
UserID int `gorm:"column:user_id;not null;index:idx_active_previews_user_created_at,priority:1"`
TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_active_previews_trigger_id"`
TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null"`
TargetType string `gorm:"column:target_type;type:varchar(64);not null"`
TargetID int `gorm:"column:target_id;not null"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';comment:pending/ready/applied/ignored/expired/failed"`
SelectedCandidateID string `gorm:"column:selected_candidate_id;type:varchar(64);comment:LLM 或后端 fallback 选中的候选ID"`
CandidateCount int `gorm:"column:candidate_count;not null;default:0"`
SelectedCandidateJSON *string `gorm:"column:selected_candidate_json;type:json"`
CandidatesJSON *string `gorm:"column:candidates_json;type:json"`
DecisionJSON *string `gorm:"column:decision_json;type:json"`
MetricsJSON *string `gorm:"column:metrics_json;type:json"`
IssuesJSON *string `gorm:"column:issues_json;type:json"`
ContextSummaryJSON *string `gorm:"column:context_summary_json;type:json"`
BeforeSummaryJSON *string `gorm:"column:before_summary_json;type:json"`
PreviewChangesJSON *string `gorm:"column:preview_changes_json;type:json"`
AfterSummaryJSON *string `gorm:"column:after_summary_json;type:json"`
RiskJSON *string `gorm:"column:risk_json;type:json"`
ExplanationText string `gorm:"column:explanation_text;type:text"`
NotificationSummary string `gorm:"column:notification_summary;type:text"`
BaseVersion string `gorm:"column:base_version;type:varchar(128);not null;comment:确认前重校验基准版本"`
ExpiresAt time.Time `gorm:"column:expires_at;not null;index:idx_active_previews_expires_at"`
GeneratedAt time.Time `gorm:"column:generated_at;not null"`
ApplyID *string `gorm:"column:apply_id;type:varchar(64);index:idx_active_previews_apply_id"`
ApplyStatus string `gorm:"column:apply_status;type:varchar(32);not null;default:'none';comment:none/applying/applied/failed/rejected/expired"`
ApplyCandidateID string `gorm:"column:apply_candidate_id;type:varchar(64)"`
ApplyIdempotencyKey string `gorm:"column:apply_idempotency_key;type:varchar(191);uniqueIndex:uk_active_previews_apply_idempotency,priority:2"`
ApplyRequestHash string `gorm:"column:apply_request_hash;type:varchar(128);comment:确认请求体摘要"`
AppliedChangesJSON *string `gorm:"column:applied_changes_json;type:json"`
AppliedEventIDsJSON *string `gorm:"column:applied_event_ids_json;type:json"`
ApplyError *string `gorm:"column:apply_error;type:text"`
AppliedAt *time.Time `gorm:"column:applied_at"`
TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_active_previews_trace_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_active_previews_user_created_at,priority:2"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
}
func (ActiveSchedulePreview) TableName() string { return "active_schedule_previews" }
// NotificationRecord 是通知投递记录表模型。
//
// 职责边界:
// 1. 负责记录飞书等通知渠道的幂等、状态流转和 provider 返回;
// 2. 不负责决定是否生成调度预览,也不负责 apply 状态;
// 3. 重试时复用同一条记录,避免短时间重复打扰用户。
type NotificationRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Channel string `gorm:"column:channel;type:varchar(32);not null;uniqueIndex:uk_notification_dedupe,priority:1;comment:通知渠道"`
UserID int `gorm:"column:user_id;not null;index:idx_notification_user_created,priority:1"`
TriggerID string `gorm:"column:trigger_id;type:varchar(64);not null;index:idx_notification_trigger"`
PreviewID string `gorm:"column:preview_id;type:varchar(64);not null;index:idx_notification_preview"`
TriggerType string `gorm:"column:trigger_type;type:varchar(64);not null"`
TargetType string `gorm:"column:target_type;type:varchar(64);not null"`
TargetID int `gorm:"column:target_id;not null"`
DedupeKey string `gorm:"column:dedupe_key;type:varchar(191);not null;uniqueIndex:uk_notification_dedupe,priority:2"`
TargetURL string `gorm:"column:target_url;type:varchar(255);not null;comment:站内预览链接"`
SummaryText string `gorm:"column:summary_text;type:text"`
FallbackText string `gorm:"column:fallback_text;type:text"`
FallbackUsed bool `gorm:"column:fallback_used;not null;default:false"`
Status string `gorm:"column:status;type:varchar(32);not null;default:'pending';index:idx_notification_status_retry,priority:1;comment:pending/sending/sent/failed/dead/skipped"`
AttemptCount int `gorm:"column:attempt_count;not null;default:0"`
MaxAttempts int `gorm:"column:max_attempts;not null;default:5"`
NextRetryAt *time.Time `gorm:"column:next_retry_at;index:idx_notification_status_retry,priority:2"`
LastErrorCode *string `gorm:"column:last_error_code;type:varchar(64)"`
LastError *string `gorm:"column:last_error;type:text"`
ProviderMessageID *string `gorm:"column:provider_message_id;type:varchar(128)"`
ProviderRequestJSON *string `gorm:"column:provider_request_json;type:json"`
ProviderResponseJSON *string `gorm:"column:provider_response_json;type:json"`
SentAt *time.Time `gorm:"column:sent_at"`
TraceID string `gorm:"column:trace_id;type:varchar(64);index:idx_notification_trace_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_notification_user_created,priority:2"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index"`
}
func (NotificationRecord) TableName() string { return "notification_records" }

View File

@@ -3,15 +3,35 @@ package model
import "time"
type ScheduleEvent struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int `gorm:"column:user_id;index:idx_user_events;not null" json:"user_id"`
Name string `gorm:"column:name;type:varchar(255);not null;comment:课程或任务名称" json:"name"`
Location *string `gorm:"column:location;type:varchar(255);default:'';comment:地点 (教学楼/会议室)" json:"location"`
Type string `gorm:"column:type;type:enum('course','task');not null;comment:日程类型" json:"type"`
RelID *int `gorm:"column:rel_id;comment:关联原始数据ID (如教务系统的课程ID)" json:"rel_id"`
CanBeEmbedded bool `gorm:"column:can_be_embedded;not null;default:0;comment:是否允许在此时段嵌入其他任务" json:"can_be_embedded"`
StartTime time.Time `gorm:"column:start_time;type:time;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"column:end_time;type:time;comment:结束时间" json:"end_time"`
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int `gorm:"column:user_id;index:idx_user_events;not null" json:"user_id"`
Name string `gorm:"column:name;type:varchar(255);not null;comment:课程或任务名称" json:"name"`
Location *string `gorm:"column:location;type:varchar(255);default:'';comment:地点 (教学楼/会议室)" json:"location"`
Type string `gorm:"column:type;type:enum('course','task');not null;comment:日程类型" json:"type"`
RelID *int `gorm:"column:rel_id;comment:关联原始数据ID (如教务系统的课程ID)" json:"rel_id"`
// TaskSourceType 标记 type=task 时 rel_id 指向哪个任务来源。
//
// 职责边界:
// 1. 只表达任务日程块的数据来源,不改变 Type 的 course/task 展示语义;
// 2. task_item 表示 rel_id 指向 task_items.idtask_pool 表示 rel_id 指向 tasks.id
// 3. 课程事件保持空值,由迁移回填历史 task 事件,避免影响现有课程逻辑。
TaskSourceType string `gorm:"column:task_source_type;type:varchar(32);not null;default:'';index:idx_schedule_event_task_source;comment:任务来源 task_item/task_pool" json:"task_source_type,omitempty"`
// MakeupForEventID 记录补做块来源事件,用于用户反馈未完成后的审计串联。
//
// 说明:
// 1. 只有主动调度生成补做块时写入;
// 2. 不负责校验目标事件是否仍存在,正式 apply 链路需要在事务内重校验;
// 3. 为空表示普通日程块或非补做块。
MakeupForEventID *int `gorm:"column:makeup_for_event_id;index:idx_schedule_event_makeup_for;comment:补做块对应的原 schedule_event.id" json:"makeup_for_event_id,omitempty"`
// ActivePreviewID 记录主动调度预览来源,方便从正式日程反查触发链路。
//
// 说明:
// 1. 该字段只做审计与排障,不作为正式日程主键;
// 2. preview 详情仍归 active_schedule_previews 表所有。
ActivePreviewID *string `gorm:"column:active_preview_id;type:varchar(64);index:idx_schedule_event_active_preview;comment:主动调度预览ID" json:"active_preview_id,omitempty"`
CanBeEmbedded bool `gorm:"column:can_be_embedded;not null;default:0;comment:是否允许在此时段嵌入其他任务" json:"can_be_embedded"`
StartTime time.Time `gorm:"column:start_time;type:time;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"column:end_time;type:time;comment:结束时间" json:"end_time"`
}
type Schedule struct {

View File

@@ -39,6 +39,13 @@ type Task struct {
// 7.3 为空表示该任务不参与自动平移;
// 7.4 该字段参与"懒触发平移"复合索引。
UrgencyThresholdAt *time.Time `gorm:"column:urgency_threshold_at;index:idx_user_done_threshold_priority,priority:3"`
// 8. 任务预计占用节数。
//
// 说明:
// 8.1 主动调度只消费该字段,不在调度阶段重新推断任务复杂度;
// 8.2 MVP 约定有效范围为 1~4模型层仅提供默认值具体截断由主动调度上下文构造负责
// 8.3 默认 1 节,兼容历史任务与未显式填写的任务。
EstimatedSections int `gorm:"column:estimated_sections;not null;default:1"`
}
type UserAddTaskResponse struct {

View File

@@ -112,6 +112,14 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d
memoryGroup.DELETE("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.DeleteItem)
memoryGroup.POST("/items/:id/restore", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.RestoreItem)
}
activeScheduleGroup := apiGroup.Group("/active-schedule")
{
activeScheduleGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1))
activeScheduleGroup.POST("/dry-run", handlers.ActiveSchedule.DryRun)
activeScheduleGroup.POST("/preview", handlers.ActiveSchedule.CreatePreview)
activeScheduleGroup.GET("/preview/:preview_id", handlers.ActiveSchedule.GetPreview)
activeScheduleGroup.POST("/preview/:preview_id/confirm", handlers.ActiveSchedule.ConfirmPreview)
}
}
// 初始化Gin引擎
log.Println("Routes setup completed")

View File

@@ -38,6 +38,8 @@ type TaskService struct {
cache *dao.CacheDAO
// eventPublisher 负责发布 outbox 事件(可能为空:例如未启用 Kafka/总线时)。
eventPublisher outboxinfra.EventPublisher
// activeScheduleDAO 负责维护主动调度 due job为空时保持旧任务链路兼容。
activeScheduleDAO *dao.ActiveScheduleDAO
}
// NewTaskService 创建 TaskService 实例。
@@ -53,6 +55,17 @@ func NewTaskService(taskDAO *dao.TaskDAO, cacheDAO *dao.CacheDAO, eventPublisher
}
}
// SetActiveScheduleDAO 注入主动调度自有表仓储。
//
// 职责边界:
// 1. 只负责迁移期依赖接线,避免扩大 TaskService 构造函数调用面;
// 2. 不改变任务主流程语义,未注入时主动调度 job 同步自动降级为 no-op。
func (ts *TaskService) SetActiveScheduleDAO(activeScheduleDAO *dao.ActiveScheduleDAO) {
if ts != nil {
ts.activeScheduleDAO = activeScheduleDAO
}
}
// AddTask 新增任务。
//
// 职责边界:
@@ -70,6 +83,7 @@ func (ts *TaskService) AddTask(ctx context.Context, req *model.UserAddTaskReques
if err != nil {
return nil, err
}
ts.syncActiveScheduleJobBestEffort(ctx, createdTask)
// 4. 返回对外响应 DTO。
response := conv.ModelToUserAddTaskResponse(createdTask)
return response, nil
@@ -112,6 +126,7 @@ func (ts *TaskService) CompleteTask(ctx context.Context, req *model.UserComplete
AlreadyCompleted: alreadyCompleted,
Status: "completed",
}
ts.cancelActiveScheduleJobBestEffort(ctx, updatedTask.UserID, updatedTask.ID, "task_completed")
return resp, nil
}
@@ -488,6 +503,7 @@ func (ts *TaskService) UpdateTask(ctx context.Context, req *model.UserUpdateTask
}
return model.GetUserTaskResp{}, err
}
ts.syncActiveScheduleJobBestEffort(ctx, updatedTask)
// 5. 转换为响应 DTO。
return conv.ModelToGetUserTaskResp(updatedTask), nil
@@ -515,6 +531,7 @@ func (ts *TaskService) DeleteTask(ctx context.Context, req *model.UserCompleteTa
}
return 0, err
}
ts.cancelActiveScheduleJobBestEffort(ctx, deletedTask.UserID, deletedTask.ID, "task_deleted")
return deletedTask.ID, nil
}

View File

@@ -0,0 +1,91 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/LoveLosita/smartflow/backend/model"
"gorm.io/gorm"
)
// syncActiveScheduleJobBestEffort 在任务变更后同步主动调度 due job。
//
// 职责边界:
// 1. 只维护 important_urgent_task 的 job不直接触发主动调度主链路
// 2. 任务未完成且存在 urgency_threshold_at 时 upsert pending job
// 3. 任务已完成或阈值为空时取消当前 pending job
// 4. 当前任务接口尚未整体事务化job 同步失败只记日志,避免任务主写入出现“已落库但接口失败”的更差体验。
func (ts *TaskService) syncActiveScheduleJobBestEffort(ctx context.Context, task *model.Task) {
if ts == nil || ts.activeScheduleDAO == nil || task == nil {
return
}
if task.IsCompleted || task.UrgencyThresholdAt == nil {
ts.cancelActiveScheduleJobBestEffort(ctx, task.UserID, task.ID, "task_not_schedulable")
return
}
job := &model.ActiveScheduleJob{
ID: activeScheduleJobID(task.UserID, task.ID),
UserID: task.UserID,
TaskID: task.ID,
TriggerType: model.ActiveScheduleTriggerTypeImportantUrgentTask,
Status: model.ActiveScheduleJobStatusPending,
TriggerAt: *task.UrgencyThresholdAt,
DedupeKey: activeScheduleTriggerDedupeKey(task.UserID, task.ID, *task.UrgencyThresholdAt),
TraceID: activeScheduleTraceID(task.UserID, task.ID),
}
if err := ts.activeScheduleDAO.CreateOrUpdateJob(ctx, job); err != nil {
log.Printf("主动调度 job upsert 失败: user_id=%d task_id=%d err=%v", task.UserID, task.ID, err)
}
}
// cancelActiveScheduleJobBestEffort 取消任务当前待触发 job。
//
// 职责边界:
// 1. 只取消 pending job历史 triggered/skipped/failed 记录保留审计;
// 2. 找不到 pending job 属于正常幂等场景;
// 3. reason 只进入 last_error_code方便后续排障知道取消来源。
func (ts *TaskService) cancelActiveScheduleJobBestEffort(ctx context.Context, userID int, taskID int, reason string) {
if ts == nil || ts.activeScheduleDAO == nil || userID <= 0 || taskID <= 0 {
return
}
job, err := ts.activeScheduleDAO.FindPendingJobByTask(ctx, userID, taskID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return
}
log.Printf("主动调度 pending job 查询失败: user_id=%d task_id=%d err=%v", userID, taskID, err)
return
}
now := time.Now()
updates := map[string]any{
"status": model.ActiveScheduleJobStatusCanceled,
"last_error_code": reason,
"last_scanned_at": &now,
}
if err = ts.activeScheduleDAO.UpdateJobFields(ctx, job.ID, updates); err != nil {
log.Printf("主动调度 pending job 取消失败: user_id=%d task_id=%d job_id=%s err=%v", userID, taskID, job.ID, err)
}
}
func activeScheduleJobID(userID int, taskID int) string {
return fmt.Sprintf("asj_task_%d_%d", userID, taskID)
}
func activeScheduleTraceID(userID int, taskID int) string {
return fmt.Sprintf("trace_active_task_%d_%d", userID, taskID)
}
func activeScheduleTriggerDedupeKey(userID int, taskID int, triggerAt time.Time) string {
windowStart := triggerAt.Truncate(30 * time.Minute)
return fmt.Sprintf("%d:%s:%s:%d:%s",
userID,
model.ActiveScheduleTriggerTypeImportantUrgentTask,
model.ActiveScheduleTargetTypeTaskPool,
taskID,
windowStart.Format(time.RFC3339),
)
}

View File

@@ -0,0 +1,136 @@
package events
import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"
)
const (
ActiveScheduleTriggeredEventType = "active_schedule.triggered"
ActiveScheduleTriggeredEventVersion = "1"
)
const (
ActiveScheduleTriggerTypeImportantUrgentTask = "important_urgent_task"
ActiveScheduleTriggerTypeUnfinishedFeedback = "unfinished_feedback"
ActiveScheduleSourceWorkerDueJob = "worker_due_job"
ActiveScheduleSourceAPITrigger = "api_trigger"
ActiveScheduleSourceAPIDryRun = "api_dry_run"
ActiveScheduleSourceUserFeedback = "user_feedback"
ActiveScheduleTargetTypeTaskPool = "task_pool"
ActiveScheduleTargetTypeScheduleEvent = "schedule_event"
ActiveScheduleTargetTypeTaskItem = "task_item"
)
// ActiveScheduleTriggeredPayload 是 active_schedule.triggered 的事件载荷。
//
// 职责边界:
// 1. 只描述“主动调度链路需要处理一个触发信号”这一事件事实;
// 2. 不复用 GORM model也不承载候选生成、预览写入或通知投递逻辑
// 3. Payload 只保留触发源补充 JSON消费方需要按自身 DTO 再解析。
type ActiveScheduleTriggeredPayload struct {
TriggerID string `json:"trigger_id"`
UserID int `json:"user_id"`
TriggerType string `json:"trigger_type"`
Source string `json:"source"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
FeedbackID string `json:"feedback_id,omitempty"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
DedupeKey string `json:"dedupe_key,omitempty"`
MockNow *time.Time `json:"mock_now,omitempty"`
IsMockTime bool `json:"is_mock_time"`
RequestedAt time.Time `json:"requested_at"`
Payload json.RawMessage `json:"payload,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
// Validate 校验事件契约必填字段与第一版枚举范围。
//
// 职责边界:
// 1. 只做协议级基础校验,避免无效事件进入 worker
// 2. 不检查 target 是否存在、是否归属用户,这些属于业务读模型责任;
// 3. dry-run 不应发布该事件,因此 source=api_dry_run 会被拒绝。
func (p ActiveScheduleTriggeredPayload) Validate() error {
if strings.TrimSpace(p.TriggerID) == "" {
return errors.New("trigger_id 不能为空")
}
if p.UserID <= 0 {
return errors.New("user_id 必须大于 0")
}
if !isAllowedActiveScheduleTriggerType(p.TriggerType) {
return errors.New("trigger_type 不在主动调度第一版允许范围内")
}
if !isAllowedActiveScheduleSource(p.Source) {
return errors.New("source 不在主动调度第一版允许范围内")
}
if p.Source == ActiveScheduleSourceAPIDryRun {
return errors.New("api_dry_run 不允许发布 active_schedule.triggered")
}
if !isAllowedActiveScheduleTargetType(p.TargetType) {
return errors.New("target_type 不在主动调度第一版允许范围内")
}
if p.TargetID <= 0 {
return errors.New("target_id 必须大于 0")
}
if p.RequestedAt.IsZero() {
return errors.New("requested_at 不能为空")
}
if p.MockNow != nil && !p.IsMockTime {
return errors.New("mock_now 非空时必须标记 is_mock_time=true")
}
return nil
}
// MessageKey 返回 outbox/Kafka 消息键。
//
// 说明:
// 1. 按文档约定使用 user_id便于同一用户事件在消费侧保持局部有序
// 2. 只做字符串构造,不访问数据库。
func (p ActiveScheduleTriggeredPayload) MessageKey() string {
if p.UserID <= 0 {
return ""
}
return strconv.Itoa(p.UserID)
}
// AggregateID 返回事件聚合 ID。
//
// 说明:
// 1. active_schedule.triggered 的聚合主键是 trigger_id
// 2. 若 trigger_id 为空,返回空字符串,由发布方在 Validate 前发现问题。
func (p ActiveScheduleTriggeredPayload) AggregateID() string {
return strings.TrimSpace(p.TriggerID)
}
func isAllowedActiveScheduleTriggerType(value string) bool {
switch strings.TrimSpace(value) {
case ActiveScheduleTriggerTypeImportantUrgentTask, ActiveScheduleTriggerTypeUnfinishedFeedback:
return true
default:
return false
}
}
func isAllowedActiveScheduleSource(value string) bool {
switch strings.TrimSpace(value) {
case ActiveScheduleSourceWorkerDueJob, ActiveScheduleSourceAPITrigger, ActiveScheduleSourceAPIDryRun, ActiveScheduleSourceUserFeedback:
return true
default:
return false
}
}
func isAllowedActiveScheduleTargetType(value string) bool {
switch strings.TrimSpace(value) {
case ActiveScheduleTargetTypeTaskPool, ActiveScheduleTargetTypeScheduleEvent, ActiveScheduleTargetTypeTaskItem:
return true
default:
return false
}
}

View File

@@ -0,0 +1,82 @@
package events
import (
"errors"
"strconv"
"strings"
"time"
)
const (
NotificationFeishuRequestedEventType = "notification.feishu.requested"
NotificationFeishuRequestedEventVersion = "1"
)
// FeishuNotificationRequestedPayload 是飞书通知请求事件载荷。
//
// 职责边界:
// 1. 只描述“需要尝试发送一条飞书提醒”的跨模块协议;
// 2. 不包含 provider SDK 参数,也不复用 notification_records 的 GORM model
// 3. 不决定是否真正投递,去重、配置关闭和重试由 notification 模块处理。
type FeishuNotificationRequestedPayload struct {
NotificationID int64 `json:"notification_id,omitempty"`
UserID int `json:"user_id"`
TriggerID string `json:"trigger_id"`
PreviewID string `json:"preview_id"`
TriggerType string `json:"trigger_type"`
TargetType string `json:"target_type"`
TargetID int `json:"target_id"`
DedupeKey string `json:"dedupe_key"`
TargetURL string `json:"target_url"`
SummaryText string `json:"summary_text,omitempty"`
FallbackText string `json:"fallback_text,omitempty"`
TraceID string `json:"trace_id,omitempty"`
RequestedAt time.Time `json:"requested_at"`
}
// Validate 校验飞书通知事件的协议级必填字段。
//
// 职责边界:
// 1. 只保证通知 handler 能定位用户、预览和去重键;
// 2. 不校验用户是否绑定飞书,也不调用 provider
// 3. target_url 必须是站内相对路径,避免事件载荷携带任意外部跳转。
func (p FeishuNotificationRequestedPayload) Validate() error {
if p.UserID <= 0 {
return errors.New("user_id 必须大于 0")
}
if strings.TrimSpace(p.PreviewID) == "" {
return errors.New("preview_id 不能为空")
}
if strings.TrimSpace(p.DedupeKey) == "" {
return errors.New("dedupe_key 不能为空")
}
targetURL := strings.TrimSpace(p.TargetURL)
if targetURL == "" {
return errors.New("target_url 不能为空")
}
if !strings.HasPrefix(targetURL, "/schedule-adjust/") {
return errors.New("target_url 必须是 /schedule-adjust/{preview_id} 站内相对路径")
}
if strings.Contains(targetURL, "://") || strings.HasPrefix(targetURL, "//") {
return errors.New("target_url 不允许携带外部链接")
}
if p.RequestedAt.IsZero() {
return errors.New("requested_at 不能为空")
}
return nil
}
// MessageKey 返回 outbox/Kafka 消息键。
func (p FeishuNotificationRequestedPayload) MessageKey() string {
if p.UserID <= 0 {
return ""
}
return strconv.Itoa(p.UserID)
}
// AggregateID 返回事件聚合 ID。
//
// 说明notification.feishu.requested 使用 preview_id 串联通知与预览。
func (p FeishuNotificationRequestedPayload) AggregateID() string {
return strings.TrimSpace(p.PreviewID)
}

View File

@@ -0,0 +1,76 @@
package events
import (
"errors"
"strconv"
"strings"
)
const (
ScheduleApplySucceededEventType = "schedule.apply.succeeded"
ScheduleApplyFailedEventType = "schedule.apply.failed"
ScheduleApplyResultEventVersion = "1"
)
// ScheduleApplyResultPayload 是主动调度确认应用的结果事件载荷。
//
// 职责边界:
// 1. 只描述 apply 成功或失败的结果事实,不表达 apply 请求;
// 2. 不复用 preview DB model也不携带完整变更明细
// 3. MVP 第一版 confirm 同步执行,是否发布该事件由后续接入点决定。
type ScheduleApplyResultPayload struct {
PreviewID string `json:"preview_id"`
ApplyID string `json:"apply_id"`
UserID int `json:"user_id"`
TriggerID string `json:"trigger_id,omitempty"`
CandidateID string `json:"candidate_id,omitempty"`
AppliedEventIDs []int `json:"applied_event_ids,omitempty"`
ApplyStatus string `json:"apply_status"`
ErrorCode string `json:"error_code,omitempty"`
TraceID string `json:"trace_id,omitempty"`
}
// ValidateForEvent 按具体事件类型校验 apply 结果载荷。
//
// 职责边界:
// 1. succeeded 要求至少包含一个正式日程 event id
// 2. failed 要求 error_code方便排障和前端提示映射
// 3. 不校验 event id 是否存在,正式落库事务负责保证。
func (p ScheduleApplyResultPayload) ValidateForEvent(eventType string) error {
if strings.TrimSpace(p.PreviewID) == "" {
return errors.New("preview_id 不能为空")
}
if strings.TrimSpace(p.ApplyID) == "" {
return errors.New("apply_id 不能为空")
}
if p.UserID <= 0 {
return errors.New("user_id 必须大于 0")
}
switch strings.TrimSpace(eventType) {
case ScheduleApplySucceededEventType:
if len(p.AppliedEventIDs) == 0 {
return errors.New("schedule.apply.succeeded 必须包含 applied_event_ids")
}
case ScheduleApplyFailedEventType:
if strings.TrimSpace(p.ErrorCode) == "" {
return errors.New("schedule.apply.failed 必须包含 error_code")
}
default:
return errors.New("未知的 schedule apply 结果事件类型")
}
return nil
}
func (p ScheduleApplyResultPayload) MessageKey() string {
if p.UserID <= 0 {
return ""
}
return strconv.Itoa(p.UserID)
}
func (p ScheduleApplyResultPayload) AggregateID() string {
if strings.TrimSpace(p.ApplyID) != "" {
return strings.TrimSpace(p.ApplyID)
}
return strings.TrimSpace(p.PreviewID)
}