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:
314
backend/active_scheduler/adapters/gorm_readers.go
Normal file
314
backend/active_scheduler/adapters/gorm_readers.go
Normal 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 adapter,active_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 ""
|
||||
}
|
||||
261
backend/active_scheduler/apply/convert.go
Normal file
261
backend/active_scheduler/apply/convert.go
Normal 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
|
||||
}
|
||||
455
backend/active_scheduler/apply/convert_helpers.go
Normal file
455
backend/active_scheduler/apply/convert_helpers.go
Normal 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
|
||||
}
|
||||
99
backend/active_scheduler/apply/hash.go
Normal file
99
backend/active_scheduler/apply/hash.go
Normal 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 ""
|
||||
}
|
||||
249
backend/active_scheduler/apply/types.go
Normal file
249
backend/active_scheduler/apply/types.go
Normal 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)
|
||||
}
|
||||
98
backend/active_scheduler/apply/validate.go
Normal file
98
backend/active_scheduler/apply/validate.go
Normal 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
|
||||
}
|
||||
491
backend/active_scheduler/applyadapter/adapter.go
Normal file
491
backend/active_scheduler/applyadapter/adapter.go
Normal 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)
|
||||
}
|
||||
127
backend/active_scheduler/applyadapter/types.go
Normal file
127
backend/active_scheduler/applyadapter/types.go
Normal 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}
|
||||
}
|
||||
337
backend/active_scheduler/candidate/candidate.go
Normal file
337
backend/active_scheduler/candidate/candidate.go
Normal 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]
|
||||
}
|
||||
220
backend/active_scheduler/context/builder.go
Normal file
220
backend/active_scheduler/context/builder.go
Normal 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_sections;MVP 兜底为 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)
|
||||
}
|
||||
94
backend/active_scheduler/context/context.go
Normal file
94
backend/active_scheduler/context/context.go
Normal 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
|
||||
}
|
||||
293
backend/active_scheduler/observe/observe.go
Normal file
293
backend/active_scheduler/observe/observe.go
Normal 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
|
||||
}
|
||||
132
backend/active_scheduler/ports/facts.go
Normal file
132
backend/active_scheduler/ports/facts.go
Normal 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
|
||||
}
|
||||
358
backend/active_scheduler/preview/converter.go
Normal file
358
backend/active_scheduler/preview/converter.go
Normal 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 追加到 after;ask_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
|
||||
}
|
||||
232
backend/active_scheduler/preview/dto.go
Normal file
232
backend/active_scheduler/preview/dto.go
Normal 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
|
||||
}
|
||||
281
backend/active_scheduler/preview/service.go
Normal file
281
backend/active_scheduler/preview/service.go
Normal 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),
|
||||
}
|
||||
}
|
||||
92
backend/active_scheduler/service/dry_run.go
Normal file
92
backend/active_scheduler/service/dry_run.go
Normal 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
|
||||
}
|
||||
282
backend/active_scheduler/service/preview_confirm.go
Normal file
282
backend/active_scheduler/service/preview_confirm.go
Normal 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
|
||||
}
|
||||
103
backend/active_scheduler/trigger/types.go
Normal file
103
backend/active_scheduler/trigger/types.go
Normal 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
|
||||
}
|
||||
181
backend/api/active_schedule.go
Normal file
181
backend/api/active_schedule.go
Normal 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)
|
||||
}
|
||||
@@ -8,4 +8,5 @@ type ApiHandlers struct {
|
||||
ScheduleHandler *ScheduleAPI
|
||||
AgentHandler *AgentHandler
|
||||
MemoryHandler *MemoryHandler
|
||||
ActiveSchedule *ActiveScheduleAPI
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
395
backend/dao/active_schedule.go
Normal file
395
backend/dao/active_schedule.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
264
backend/model/active_schedule.go
Normal file
264
backend/model/active_schedule.go
Normal 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" }
|
||||
@@ -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.id,task_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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
91
backend/service/task_active_schedule.go
Normal file
91
backend/service/task_active_schedule.go
Normal 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),
|
||||
)
|
||||
}
|
||||
136
backend/shared/events/active_schedule.go
Normal file
136
backend/shared/events/active_schedule.go
Normal 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
|
||||
}
|
||||
}
|
||||
82
backend/shared/events/notification.go
Normal file
82
backend/shared/events/notification.go
Normal 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)
|
||||
}
|
||||
76
backend/shared/events/schedule_apply.go
Normal file
76
backend/shared/events/schedule_apply.go
Normal 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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,12 +36,36 @@ import type {
|
||||
ConversationMeta,
|
||||
ThinkingModeType,
|
||||
SchedulePreviewData,
|
||||
ThinkingSummaryPayload,
|
||||
} from '@/types/dashboard'
|
||||
import ScheduleResultCard from '@/components/assistant/ScheduleResultCard.vue'
|
||||
import ScheduleFineTuneModal from '@/components/assistant/ScheduleFineTuneModal.vue'
|
||||
import { formatConversationTime, formatMessageTime } from '@/utils/date'
|
||||
import { renderMarkdown } from '@/utils/markdown'
|
||||
import {
|
||||
buildAssistantChatRequestExtra,
|
||||
buildConversationPreviewItem,
|
||||
buildThinkingSummarySignature,
|
||||
buildStatusSummary,
|
||||
buildTaskUpdatePayload,
|
||||
buildToolDetail,
|
||||
createDraftConversationId,
|
||||
createMessageId,
|
||||
formatTaskDeadlineForStatus,
|
||||
getThinkingBackendKey,
|
||||
isAssistantTimelineKind,
|
||||
isDraftConversationId,
|
||||
isLocalEphemeralMessageId,
|
||||
isManualThinkingEnabled,
|
||||
isLegacyToolStatusCode,
|
||||
mapLegacyToolStatusToState,
|
||||
mapToolEventState,
|
||||
mergeConversationListItems,
|
||||
migrateConversationListIds,
|
||||
normalizeStatusCode,
|
||||
normalizeToolSummary,
|
||||
resolveConversationGroupLabel,
|
||||
shouldSkipStatusEvent,
|
||||
} from '@/utils/assistantPanelTrace'
|
||||
import BusinessCardRenderer from '@/components/assistant/cards/BusinessCardRenderer.vue'
|
||||
import ToolCardRenderer from '@/components/dashboard/ToolCardRenderer.vue'
|
||||
import type {
|
||||
@@ -49,150 +73,24 @@ import type {
|
||||
TaskQueryCardData,
|
||||
TaskRecordCardData
|
||||
} from '@/api/schedule_agent'
|
||||
|
||||
interface StreamDeltaPayload {
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface StreamChoicePayload {
|
||||
delta?: StreamDeltaPayload
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
interface StreamErrorPayload {
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface StreamConfirmPayload {
|
||||
interaction_id?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
interface StreamStatusExtraPayload {
|
||||
code?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
interface StreamToolExtraPayload {
|
||||
name?: string
|
||||
status?: string
|
||||
summary?: string
|
||||
arguments_preview?: string
|
||||
argument_view?: ToolView
|
||||
result_view?: ToolView
|
||||
}
|
||||
|
||||
interface StreamExtraPayload {
|
||||
kind?: string
|
||||
block_id?: string
|
||||
stage?: string
|
||||
status?: StreamStatusExtraPayload
|
||||
tool?: StreamToolExtraPayload
|
||||
confirm?: StreamConfirmPayload
|
||||
business_card?: TimelineBusinessCardPayload
|
||||
thinking_summary?: ThinkingSummaryPayload
|
||||
}
|
||||
|
||||
interface StreamEventPayload {
|
||||
choices?: StreamChoicePayload[]
|
||||
delta?: StreamDeltaPayload
|
||||
content?: string
|
||||
finish_reason?: string | null
|
||||
error?: StreamErrorPayload
|
||||
extra?: StreamExtraPayload
|
||||
}
|
||||
|
||||
type ToolTraceState = 'called' | 'completed' | 'create' | 'blocked'
|
||||
|
||||
interface ToolTraceEvent {
|
||||
id: string
|
||||
seq: number
|
||||
state: ToolTraceState
|
||||
summary: string
|
||||
detail?: string
|
||||
toolName?: string
|
||||
argumentView?: ToolView
|
||||
resultView?: ToolView
|
||||
}
|
||||
|
||||
interface StatusTraceEvent {
|
||||
id: string
|
||||
seq: number
|
||||
code: string
|
||||
stage: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
|
||||
interface ConversationGroup {
|
||||
key: string
|
||||
label: string
|
||||
items: ConversationListItem[]
|
||||
}
|
||||
|
||||
interface ConfirmOverlayState {
|
||||
visible: boolean
|
||||
manuallyClosed: boolean
|
||||
interactionId: string
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
interface ConversationListItemRevealOptions {
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
interface EnsureConversationMetaOptions {
|
||||
forceReload?: boolean
|
||||
syncListItem?: boolean
|
||||
listItemReveal?: ConversationListItemRevealOptions
|
||||
}
|
||||
|
||||
// 展示用消息:合并连续 assistant 消息后的视图模型
|
||||
interface DisplayMessage {
|
||||
/** 第一条源消息的 id,用作 Vue key */
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
/** 合并后的正文内容 */
|
||||
content: string
|
||||
/** 最后一条源消息的时间 */
|
||||
createdAt: string
|
||||
/** 合并后的推理内容 */
|
||||
reasoning?: string
|
||||
/** 原始消息引用列表 */
|
||||
sources: AssistantMessage[]
|
||||
/** 是否为多条合并 */
|
||||
merged: boolean
|
||||
}
|
||||
|
||||
interface DisplayAssistantBlock {
|
||||
id: string
|
||||
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card'
|
||||
seq: number
|
||||
text?: string
|
||||
event?: ToolTraceEvent
|
||||
statusEvent?: StatusTraceEvent
|
||||
schedulePreview?: SchedulePreviewData
|
||||
businessCard?: TimelineBusinessCardPayload
|
||||
/** 所属的源消息 ID,用于状态查询 */
|
||||
sourceId?: string
|
||||
/** 所属的源消息引用,用于渲染辅助信息 */
|
||||
source?: AssistantMessage
|
||||
}
|
||||
|
||||
interface AssistantContentBlock {
|
||||
id: string
|
||||
seq: number
|
||||
text: string
|
||||
}
|
||||
|
||||
interface ThinkingSummaryBlockState {
|
||||
blockId: string
|
||||
lastSummarySeq: number
|
||||
lastSignature: string
|
||||
finished: boolean
|
||||
}
|
||||
import type {
|
||||
ApplyThinkingSummaryOptions,
|
||||
AssistantContentBlock,
|
||||
ConfirmOverlayState,
|
||||
ConversationGroup,
|
||||
ConversationListItemRevealOptions,
|
||||
DisplayAssistantBlock,
|
||||
DisplayMessage,
|
||||
EnsureConversationMetaOptions,
|
||||
StatusTraceEvent,
|
||||
StreamConfirmPayload,
|
||||
StreamEventPayload,
|
||||
StreamExtraPayload,
|
||||
StreamToolExtraPayload,
|
||||
ThinkingSummaryBlockState,
|
||||
ToolTraceEvent,
|
||||
ToolTraceState,
|
||||
} from '@/types/assistant-panel'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -589,34 +487,6 @@ const displayMessages = computed<DisplayMessage[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
function resolveConversationGroupLabel(timeText?: string | null) {
|
||||
if (!timeText) {
|
||||
return '更早'
|
||||
}
|
||||
|
||||
const messageDate = new Date(timeText)
|
||||
if (Number.isNaN(messageDate.getTime())) {
|
||||
return '更早'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const targetDay = new Date(messageDate.getFullYear(), messageDate.getMonth(), messageDate.getDate())
|
||||
const diffDays = Math.floor((today.getTime() - targetDay.getTime()) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return '今天'
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return '7 天内'
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return '30 天内'
|
||||
}
|
||||
|
||||
return `${messageDate.getFullYear()}-${String(messageDate.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const groupedConversationList = computed<ConversationGroup[]>(() => {
|
||||
const orderedGroups: ConversationGroup[] = []
|
||||
const groupMap = new Map<string, ConversationGroup>()
|
||||
@@ -1080,31 +950,6 @@ function enqueueThinkingDetail(messageId: string, blockId: string, detail: strin
|
||||
startThinkingStreamTicker(messageId, blockId)
|
||||
}
|
||||
|
||||
interface ApplyThinkingSummaryOptions {
|
||||
backendBlockId?: string
|
||||
stage?: string
|
||||
summary: ThinkingSummaryPayload
|
||||
/** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配 */
|
||||
eventSeq?: number
|
||||
/** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入 */
|
||||
fromHistory?: boolean
|
||||
}
|
||||
|
||||
function getThinkingBackendKey(opts: Pick<ApplyThinkingSummaryOptions, 'backendBlockId' | 'stage'>) {
|
||||
const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim()
|
||||
return backendKeyRaw || 'thinking'
|
||||
}
|
||||
|
||||
function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) {
|
||||
return [
|
||||
typeof summary.summary_seq === 'number' ? summary.summary_seq : '',
|
||||
(summary.short_summary || '').trim(),
|
||||
(summary.detail_summary || '').trim(),
|
||||
typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '',
|
||||
summary.final === true ? '1' : '0',
|
||||
].join('\u001f')
|
||||
}
|
||||
|
||||
function ensureThinkingSummaryBlockBucket(messageId: string) {
|
||||
if (!thinkingSummaryBlockStateMap[messageId]) {
|
||||
thinkingSummaryBlockStateMap[messageId] = {}
|
||||
@@ -1261,150 +1106,6 @@ function appendBusinessCardEvent(messageId: string, payload: TimelineBusinessCar
|
||||
assistantTimelineLastKindMap[messageId] = 'business_card'
|
||||
}
|
||||
|
||||
function mapToolEventState(rawStatus?: string): ToolTraceState {
|
||||
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
|
||||
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
|
||||
return 'called'
|
||||
}
|
||||
if (normalized === 'create' || normalized === 'created') {
|
||||
return 'create'
|
||||
}
|
||||
if (normalized === 'blocked') {
|
||||
return 'blocked'
|
||||
}
|
||||
if (normalized === 'failed' || normalized === 'error') {
|
||||
return 'blocked'
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function normalizeToolSummary(extra: StreamToolExtraPayload): string {
|
||||
const summary = `${extra.summary || ''}`.trim()
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
const toolName = `${extra.name || ''}`.trim()
|
||||
if (!toolName) {
|
||||
return '工具事件'
|
||||
}
|
||||
return `已调用工具:${toolName}`
|
||||
}
|
||||
|
||||
function buildToolDetail(extra: StreamToolExtraPayload): string {
|
||||
const argsPreview = `${extra.arguments_preview || ''}`.trim()
|
||||
if (!argsPreview || argsPreview === '{}') {
|
||||
return ''
|
||||
}
|
||||
return argsPreview
|
||||
}
|
||||
|
||||
function normalizeStatusCode(rawCode?: string) {
|
||||
const code = `${rawCode || ''}`.trim().toLowerCase()
|
||||
if (!code) {
|
||||
return 'status'
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
function mapStatusCodeLabel(code: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
accepted: '请求已接收',
|
||||
planning: '正在规划',
|
||||
resumed: '继续处理中',
|
||||
confirmed: '确认后继续执行',
|
||||
rejected: '已取消并重新规划',
|
||||
executing: '正在执行',
|
||||
plan_confirm: '等待计划确认',
|
||||
tool_confirm: '等待操作确认',
|
||||
ask_user: '等待补充信息',
|
||||
confirm: '等待用户确认',
|
||||
interrupted: '会话已中断',
|
||||
summarizing: '正在生成总结',
|
||||
done: '流程已结束',
|
||||
rough_building: '正在生成初始排课方案',
|
||||
rough_build_failed: '初始排课失败',
|
||||
rough_build_done: '初始排课已完成',
|
||||
rough_build_done_no_refine: '初始排课已完成',
|
||||
order_guard_initialized: '已记录顺序基线',
|
||||
order_guard_passed: '顺序校验通过',
|
||||
order_guard_restored: '顺序已自动恢复',
|
||||
order_guard_restore_skipped: '顺序恢复已跳过',
|
||||
context_compact_start: '正在压缩上下文',
|
||||
context_compact_done: '上下文压缩完成',
|
||||
plan_auto_confirmed: '计划已自动确认',
|
||||
}
|
||||
return labelMap[code] || '状态已更新'
|
||||
}
|
||||
|
||||
function buildStatusSummary(extra: StreamExtraPayload): string {
|
||||
const summary = `${extra.status?.summary || ''}`.trim()
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
return mapStatusCodeLabel(normalizeStatusCode(extra.status?.code))
|
||||
}
|
||||
|
||||
function isLegacyToolStatusCode(code: string) {
|
||||
return code === 'tool_call' || code === 'tool_result' || code === 'tool_blocked'
|
||||
}
|
||||
|
||||
function mapLegacyToolStatusToState(code: string): ToolTraceState {
|
||||
if (code === 'tool_call') {
|
||||
return 'called'
|
||||
}
|
||||
if (code === 'tool_blocked') {
|
||||
return 'blocked'
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
function shouldSkipStatusEvent(code: string, stage = '') {
|
||||
// confirm_request 已有专属卡片,避免重复显示同语义状态行。
|
||||
if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hiddenStatusCodes = new Set([
|
||||
'accepted',
|
||||
'ask_user',
|
||||
'planning',
|
||||
'resumed',
|
||||
'confirmed',
|
||||
'rejected',
|
||||
'executing',
|
||||
'summarizing',
|
||||
'done',
|
||||
'rough_building',
|
||||
'order_guard_initialized',
|
||||
'order_guard_passed',
|
||||
'order_guard_restored',
|
||||
'order_guard_restore_skipped',
|
||||
'context_compact_start',
|
||||
'context_compact_done',
|
||||
'plan_auto_confirmed',
|
||||
])
|
||||
|
||||
if (hiddenStatusCodes.has(code)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssistantTimelineKind(kind: string) {
|
||||
const assistantKinds = new Set([
|
||||
'assistant_text',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'confirm_request',
|
||||
'schedule_completed',
|
||||
'interrupt',
|
||||
'status',
|
||||
'business_card',
|
||||
'thinking_summary',
|
||||
])
|
||||
return assistantKinds.has(kind)
|
||||
}
|
||||
|
||||
function isToolTraceExpanded(eventId: string) {
|
||||
return toolTraceExpandedMap[eventId] === true
|
||||
}
|
||||
@@ -1450,18 +1151,6 @@ function clearConfirmStreamFlags(messageId: string) {
|
||||
delete confirmVisiblePrefixMap[messageId]
|
||||
}
|
||||
|
||||
function createDraftConversationId() {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function createMessageId(role: AssistantMessage['role']) {
|
||||
return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function isDraftConversationId(conversationId: string) {
|
||||
return conversationId.startsWith('draft-')
|
||||
}
|
||||
|
||||
function upsertConversationMeta(meta: ConversationMeta) {
|
||||
conversationMetaMap[meta.conversation_id] = meta
|
||||
}
|
||||
@@ -1495,26 +1184,11 @@ function migrateConversationState(fromConversationId: string, toConversationId:
|
||||
delete conversationMetaMap[fromConversationId]
|
||||
}
|
||||
|
||||
const latestMap = new Map<string, ConversationListItem>()
|
||||
const deduplicated: ConversationListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of conversationList.value) {
|
||||
const nextItem =
|
||||
item.conversation_id === fromConversationId ? { ...item, conversation_id: toConversationId } : item
|
||||
latestMap.set(nextItem.conversation_id, nextItem)
|
||||
}
|
||||
|
||||
for (const item of conversationList.value) {
|
||||
const nextId = item.conversation_id === fromConversationId ? toConversationId : item.conversation_id
|
||||
if (seen.has(nextId)) {
|
||||
continue
|
||||
}
|
||||
seen.add(nextId)
|
||||
deduplicated.push(latestMap.get(nextId)!)
|
||||
}
|
||||
|
||||
conversationList.value = deduplicated
|
||||
conversationList.value = migrateConversationListIds(
|
||||
conversationList.value,
|
||||
fromConversationId,
|
||||
toConversationId,
|
||||
)
|
||||
|
||||
if (selectedConversationId.value === fromConversationId) {
|
||||
selectedConversationId.value = toConversationId
|
||||
@@ -1530,37 +1204,18 @@ function migrateConversationState(fromConversationId: string, toConversationId:
|
||||
// 2. 不负责决定选中哪个会话,选中逻辑交给 ensureSelectedConversationAfterListLoad。
|
||||
// 3. 本地尚未完成 round-trip 的 draft 会话会原样保留,避免用户发出首条消息后列表瞬间丢失。
|
||||
function mergeConversationList(items: ConversationListItem[]) {
|
||||
const merged = [...conversationList.value, ...items]
|
||||
const latestMap = new Map<string, ConversationListItem>()
|
||||
const deduplicated: ConversationListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of merged) {
|
||||
latestMap.set(item.conversation_id, item)
|
||||
}
|
||||
|
||||
for (const item of merged) {
|
||||
if (seen.has(item.conversation_id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(item.conversation_id)
|
||||
deduplicated.push(latestMap.get(item.conversation_id) ?? item)
|
||||
}
|
||||
|
||||
conversationList.value = deduplicated
|
||||
conversationList.value = mergeConversationListItems(conversationList.value, items)
|
||||
}
|
||||
|
||||
function prependConversationPreview(conversationId: string, previewText: string, createdAt: string) {
|
||||
const current = conversationList.value.find((item) => item.conversation_id === conversationId)
|
||||
const nextItem: ConversationListItem = {
|
||||
conversation_id: conversationId,
|
||||
title: current?.title || previewText.slice(0, 24),
|
||||
has_title: current?.has_title ?? false,
|
||||
message_count: Math.max(current?.message_count ?? 0, conversationMessagesMap[conversationId]?.length ?? 0),
|
||||
last_message_at: createdAt,
|
||||
status: current?.status || 'active',
|
||||
created_at: current?.created_at || createdAt,
|
||||
}
|
||||
const nextItem = buildConversationPreviewItem(
|
||||
conversationId,
|
||||
previewText,
|
||||
createdAt,
|
||||
current,
|
||||
conversationMessagesMap[conversationId]?.length ?? 0,
|
||||
)
|
||||
|
||||
conversationList.value = [
|
||||
nextItem,
|
||||
@@ -1666,10 +1321,6 @@ function isLatestAssistantMessage(messageId: string) {
|
||||
return lastAssistant?.id === messageId
|
||||
}
|
||||
|
||||
function isLocalEphemeralMessageId(id: string) {
|
||||
return /^(user|assistant|system)-\d{13}-[a-z0-9]+$/i.test(id)
|
||||
}
|
||||
|
||||
function resolvePromptBeforeAssistantMessage(messageId: string) {
|
||||
const index = findMessageIndex(messageId)
|
||||
if (index <= 0) {
|
||||
@@ -2766,10 +2417,6 @@ function startNewConversation() {
|
||||
suppressEmptyStateTransition.value = false
|
||||
}
|
||||
|
||||
function isManualThinkingEnabled(mode: ThinkingModeType) {
|
||||
return mode === 'true'
|
||||
}
|
||||
|
||||
async function openFineTuneModal(data: SchedulePreviewData) {
|
||||
// 1. 如果点击的是占位卡片(尚未加载详情),则触发实时拉取。
|
||||
if ((data as any).is_placeholder) {
|
||||
@@ -2901,13 +2548,7 @@ async function handleSaveTask() {
|
||||
try {
|
||||
if (isEditMode.value && editingTaskId.value) {
|
||||
const taskId = editingTaskId.value
|
||||
const updateData = {
|
||||
task_id: taskId,
|
||||
title: taskForm.title.trim(),
|
||||
priority_group: taskForm.priority_group,
|
||||
deadline_at: taskForm.deadline_at ? (typeof taskForm.deadline_at === 'string' ? taskForm.deadline_at : taskForm.deadline_at.toISOString()) : null,
|
||||
urgency_threshold_at: taskForm.urgency_threshold_at ? (typeof taskForm.urgency_threshold_at === 'string' ? taskForm.urgency_threshold_at : taskForm.urgency_threshold_at.toISOString()) : null,
|
||||
}
|
||||
const updateData = buildTaskUpdatePayload(taskId, taskForm)
|
||||
await updateTask(updateData)
|
||||
|
||||
// 同步更新本地状态映射,让所有历史卡片实时联动
|
||||
@@ -2915,11 +2556,7 @@ async function handleSaveTask() {
|
||||
taskStatusMap[taskId].title = updateData.title
|
||||
taskStatusMap[taskId].priority_group = updateData.priority_group
|
||||
// 格式化截止时间用于展示
|
||||
taskStatusMap[taskId].deadline_at = taskForm.deadline_at
|
||||
? (taskForm.deadline_at instanceof Date
|
||||
? taskForm.deadline_at.toLocaleDateString('zh-CN').replace(/\//g, '-')
|
||||
: String(taskForm.deadline_at).split('T')[0])
|
||||
: null
|
||||
taskStatusMap[taskId].deadline_at = formatTaskDeadlineForStatus(taskForm.deadline_at)
|
||||
}
|
||||
|
||||
ElMessage.success('任务详情已更新')
|
||||
@@ -2961,19 +2598,7 @@ function applyConfirmOverlay(confirmPayload?: StreamConfirmPayload) {
|
||||
function buildChatRequestExtra(
|
||||
planningTaskClassIds: number[] = [],
|
||||
): ChatRequestExtra | undefined {
|
||||
const extra: ChatRequestExtra = {}
|
||||
|
||||
// 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。
|
||||
if (planningTaskClassIds.length > 0) {
|
||||
extra.task_class_ids = [...planningTaskClassIds]
|
||||
}
|
||||
|
||||
// 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。
|
||||
if (selectedExecutionMode.value === 'always') {
|
||||
extra.always_execute = true
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
return buildAssistantChatRequestExtra(planningTaskClassIds, selectedExecutionMode.value)
|
||||
}
|
||||
|
||||
function handlePlanningSelectionApplied(taskClassIds: number[]) {
|
||||
|
||||
160
frontend/src/types/assistant-panel.ts
Normal file
160
frontend/src/types/assistant-panel.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { TimelineBusinessCardPayload, ToolView } from '@/api/schedule_agent'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ConversationListItem,
|
||||
SchedulePreviewData,
|
||||
ThinkingSummaryPayload,
|
||||
} from '@/types/dashboard'
|
||||
|
||||
export interface StreamDeltaPayload {
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface StreamChoicePayload {
|
||||
delta?: StreamDeltaPayload
|
||||
finish_reason?: string | null
|
||||
}
|
||||
|
||||
export interface StreamErrorPayload {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface StreamConfirmPayload {
|
||||
interaction_id?: string
|
||||
title?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface StreamStatusExtraPayload {
|
||||
code?: string
|
||||
summary?: string
|
||||
}
|
||||
|
||||
export interface StreamToolExtraPayload {
|
||||
name?: string
|
||||
status?: string
|
||||
summary?: string
|
||||
arguments_preview?: string
|
||||
argument_view?: ToolView
|
||||
result_view?: ToolView
|
||||
}
|
||||
|
||||
export interface StreamExtraPayload {
|
||||
kind?: string
|
||||
block_id?: string
|
||||
stage?: string
|
||||
status?: StreamStatusExtraPayload
|
||||
tool?: StreamToolExtraPayload
|
||||
confirm?: StreamConfirmPayload
|
||||
business_card?: TimelineBusinessCardPayload
|
||||
thinking_summary?: ThinkingSummaryPayload
|
||||
}
|
||||
|
||||
export interface StreamEventPayload {
|
||||
choices?: StreamChoicePayload[]
|
||||
delta?: StreamDeltaPayload
|
||||
content?: string
|
||||
finish_reason?: string | null
|
||||
error?: StreamErrorPayload
|
||||
extra?: StreamExtraPayload
|
||||
}
|
||||
|
||||
export type ToolTraceState = 'called' | 'completed' | 'create' | 'blocked'
|
||||
|
||||
export interface ToolTraceEvent {
|
||||
id: string
|
||||
seq: number
|
||||
state: ToolTraceState
|
||||
summary: string
|
||||
detail?: string
|
||||
toolName?: string
|
||||
argumentView?: ToolView
|
||||
resultView?: ToolView
|
||||
}
|
||||
|
||||
export interface StatusTraceEvent {
|
||||
id: string
|
||||
seq: number
|
||||
code: string
|
||||
stage: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ConversationGroup {
|
||||
key: string
|
||||
label: string
|
||||
items: ConversationListItem[]
|
||||
}
|
||||
|
||||
export interface ConfirmOverlayState {
|
||||
visible: boolean
|
||||
manuallyClosed: boolean
|
||||
interactionId: string
|
||||
title: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface ConversationListItemRevealOptions {
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
export interface EnsureConversationMetaOptions {
|
||||
forceReload?: boolean
|
||||
syncListItem?: boolean
|
||||
listItemReveal?: ConversationListItemRevealOptions
|
||||
}
|
||||
|
||||
// 展示用消息:合并连续 assistant 消息后的视图模型。
|
||||
export interface DisplayMessage {
|
||||
/** 第一条源消息的 id,用作 Vue key。 */
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'system'
|
||||
/** 合并后的正文内容。 */
|
||||
content: string
|
||||
/** 最后一条源消息的时间。 */
|
||||
createdAt: string
|
||||
/** 合并后的推理内容。 */
|
||||
reasoning?: string
|
||||
/** 原始消息引用列表。 */
|
||||
sources: AssistantMessage[]
|
||||
/** 是否为多条合并。 */
|
||||
merged: boolean
|
||||
}
|
||||
|
||||
export interface DisplayAssistantBlock {
|
||||
id: string
|
||||
type: 'tool' | 'status' | 'reasoning' | 'content' | 'content_indicator' | 'schedule_card' | 'business_card'
|
||||
seq: number
|
||||
text?: string
|
||||
event?: ToolTraceEvent
|
||||
statusEvent?: StatusTraceEvent
|
||||
schedulePreview?: SchedulePreviewData
|
||||
businessCard?: TimelineBusinessCardPayload
|
||||
/** 所属的源消息 ID,用于状态查询。 */
|
||||
sourceId?: string
|
||||
/** 所属的源消息引用,用于渲染辅助信息。 */
|
||||
source?: AssistantMessage
|
||||
}
|
||||
|
||||
export interface AssistantContentBlock {
|
||||
id: string
|
||||
seq: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ThinkingSummaryBlockState {
|
||||
blockId: string
|
||||
lastSummarySeq: number
|
||||
lastSignature: string
|
||||
finished: boolean
|
||||
}
|
||||
|
||||
export interface ApplyThinkingSummaryOptions {
|
||||
backendBlockId?: string
|
||||
stage?: string
|
||||
summary: ThinkingSummaryPayload
|
||||
/** 历史回放时使用 timeline 事件自身顺序;实时流未传时按真实到达顺序分配。 */
|
||||
eventSeq?: number
|
||||
/** true 表示来自 timeline 历史恢复:不写短摘要、不更新思考态、长摘要一次性写入。 */
|
||||
fromHistory?: boolean
|
||||
}
|
||||
336
frontend/src/utils/assistantPanelTrace.ts
Normal file
336
frontend/src/utils/assistantPanelTrace.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { ApplyThinkingSummaryOptions, StreamExtraPayload, StreamToolExtraPayload, ToolTraceState } from '@/types/assistant-panel'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
ChatRequestExtra,
|
||||
ConversationListItem,
|
||||
ThinkingModeType,
|
||||
ThinkingSummaryPayload,
|
||||
} from '@/types/dashboard'
|
||||
|
||||
interface TaskFormLike {
|
||||
title: string
|
||||
priority_group: number
|
||||
deadline_at: Date | string | null
|
||||
urgency_threshold_at: Date | string | null
|
||||
}
|
||||
|
||||
export function createDraftConversationId() {
|
||||
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export function createMessageId(role: AssistantMessage['role']) {
|
||||
return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
export function isDraftConversationId(conversationId: string) {
|
||||
return conversationId.startsWith('draft-')
|
||||
}
|
||||
|
||||
export function isLocalEphemeralMessageId(id: string) {
|
||||
return /^(user|assistant|system)-\d{13}-[a-z0-9]+$/i.test(id)
|
||||
}
|
||||
|
||||
export function resolveConversationGroupLabel(timeText?: string | null) {
|
||||
if (!timeText) {
|
||||
return '更早'
|
||||
}
|
||||
|
||||
const messageDate = new Date(timeText)
|
||||
if (Number.isNaN(messageDate.getTime())) {
|
||||
return '更早'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const targetDay = new Date(messageDate.getFullYear(), messageDate.getMonth(), messageDate.getDate())
|
||||
const diffDays = Math.floor((today.getTime() - targetDay.getTime()) / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (diffDays <= 0) {
|
||||
return '今天'
|
||||
}
|
||||
if (diffDays < 7) {
|
||||
return '7 天内'
|
||||
}
|
||||
if (diffDays < 30) {
|
||||
return '30 天内'
|
||||
}
|
||||
|
||||
return `${messageDate.getFullYear()}-${String(messageDate.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function migrateConversationListIds(
|
||||
items: ConversationListItem[],
|
||||
fromConversationId: string,
|
||||
toConversationId: string,
|
||||
) {
|
||||
const latestMap = new Map<string, ConversationListItem>()
|
||||
const deduplicated: ConversationListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of items) {
|
||||
const nextItem =
|
||||
item.conversation_id === fromConversationId ? { ...item, conversation_id: toConversationId } : item
|
||||
latestMap.set(nextItem.conversation_id, nextItem)
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const nextId = item.conversation_id === fromConversationId ? toConversationId : item.conversation_id
|
||||
if (seen.has(nextId)) {
|
||||
continue
|
||||
}
|
||||
seen.add(nextId)
|
||||
deduplicated.push(latestMap.get(nextId)!)
|
||||
}
|
||||
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
export function mergeConversationListItems(
|
||||
currentItems: ConversationListItem[],
|
||||
nextItems: ConversationListItem[],
|
||||
) {
|
||||
const merged = [...currentItems, ...nextItems]
|
||||
const latestMap = new Map<string, ConversationListItem>()
|
||||
const deduplicated: ConversationListItem[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const item of merged) {
|
||||
latestMap.set(item.conversation_id, item)
|
||||
}
|
||||
|
||||
for (const item of merged) {
|
||||
if (seen.has(item.conversation_id)) {
|
||||
continue
|
||||
}
|
||||
seen.add(item.conversation_id)
|
||||
deduplicated.push(latestMap.get(item.conversation_id) ?? item)
|
||||
}
|
||||
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
export function buildConversationPreviewItem(
|
||||
conversationId: string,
|
||||
previewText: string,
|
||||
createdAt: string,
|
||||
current: ConversationListItem | undefined,
|
||||
messageCount: number,
|
||||
): ConversationListItem {
|
||||
return {
|
||||
conversation_id: conversationId,
|
||||
title: current?.title || previewText.slice(0, 24),
|
||||
has_title: current?.has_title ?? false,
|
||||
message_count: Math.max(current?.message_count ?? 0, messageCount),
|
||||
last_message_at: createdAt,
|
||||
status: current?.status || 'active',
|
||||
created_at: current?.created_at || createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeTaskDateForApi(value: Date | string | null) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return typeof value === 'string' ? value : value.toISOString()
|
||||
}
|
||||
|
||||
export function formatTaskDeadlineForStatus(value: Date | string | null) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value instanceof Date
|
||||
? value.toLocaleDateString('zh-CN').replace(/\//g, '-')
|
||||
: String(value).split('T')[0]
|
||||
}
|
||||
|
||||
export function buildTaskUpdatePayload(taskId: number, taskForm: TaskFormLike) {
|
||||
return {
|
||||
task_id: taskId,
|
||||
title: taskForm.title.trim(),
|
||||
priority_group: taskForm.priority_group,
|
||||
deadline_at: serializeTaskDateForApi(taskForm.deadline_at),
|
||||
urgency_threshold_at: serializeTaskDateForApi(taskForm.urgency_threshold_at),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAssistantChatRequestExtra(
|
||||
planningTaskClassIds: number[] = [],
|
||||
executionMode: 'manual' | 'always',
|
||||
): ChatRequestExtra | undefined {
|
||||
const extra: ChatRequestExtra = {}
|
||||
|
||||
// 1. 任务类别过滤:将智能编排所需的 task_class_ids 透传给后端。
|
||||
if (planningTaskClassIds.length > 0) {
|
||||
extra.task_class_ids = [...planningTaskClassIds]
|
||||
}
|
||||
|
||||
// 2. 执行模式控制:若开启“自动执行”,则透传 always_execute 标志,跳过工具调用确认逻辑。
|
||||
if (executionMode === 'always') {
|
||||
extra.always_execute = true
|
||||
}
|
||||
|
||||
return Object.keys(extra).length > 0 ? extra : undefined
|
||||
}
|
||||
|
||||
export function isManualThinkingEnabled(mode: ThinkingModeType) {
|
||||
return mode === 'true'
|
||||
}
|
||||
|
||||
export function getThinkingBackendKey(opts: Pick<ApplyThinkingSummaryOptions, 'backendBlockId' | 'stage'>) {
|
||||
const backendKeyRaw = (opts.backendBlockId || opts.stage || 'thinking').trim()
|
||||
return backendKeyRaw || 'thinking'
|
||||
}
|
||||
|
||||
export function buildThinkingSummarySignature(summary: ThinkingSummaryPayload) {
|
||||
return [
|
||||
typeof summary.summary_seq === 'number' ? summary.summary_seq : '',
|
||||
(summary.short_summary || '').trim(),
|
||||
(summary.detail_summary || '').trim(),
|
||||
typeof summary.duration_seconds === 'number' ? summary.duration_seconds : '',
|
||||
summary.final === true ? '1' : '0',
|
||||
].join('\u001f')
|
||||
}
|
||||
|
||||
export function mapToolEventState(rawStatus?: string): ToolTraceState {
|
||||
const normalized = `${rawStatus || ''}`.trim().toLowerCase()
|
||||
if (normalized === 'start' || normalized === 'calling' || normalized === 'called') {
|
||||
return 'called'
|
||||
}
|
||||
if (normalized === 'create' || normalized === 'created') {
|
||||
return 'create'
|
||||
}
|
||||
if (normalized === 'blocked') {
|
||||
return 'blocked'
|
||||
}
|
||||
if (normalized === 'failed' || normalized === 'error') {
|
||||
return 'blocked'
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
export function normalizeToolSummary(extra: StreamToolExtraPayload): string {
|
||||
const summary = `${extra.summary || ''}`.trim()
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
const toolName = `${extra.name || ''}`.trim()
|
||||
if (!toolName) {
|
||||
return '工具事件'
|
||||
}
|
||||
return `已调用工具:${toolName}`
|
||||
}
|
||||
|
||||
export function buildToolDetail(extra: StreamToolExtraPayload): string {
|
||||
const argsPreview = `${extra.arguments_preview || ''}`.trim()
|
||||
if (!argsPreview || argsPreview === '{}') {
|
||||
return ''
|
||||
}
|
||||
return argsPreview
|
||||
}
|
||||
|
||||
export function normalizeStatusCode(rawCode?: string) {
|
||||
const code = `${rawCode || ''}`.trim().toLowerCase()
|
||||
if (!code) {
|
||||
return 'status'
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
export function mapStatusCodeLabel(code: string) {
|
||||
const labelMap: Record<string, string> = {
|
||||
accepted: '请求已接收',
|
||||
planning: '正在规划',
|
||||
resumed: '继续处理中',
|
||||
confirmed: '确认后继续执行',
|
||||
rejected: '已取消并重新规划',
|
||||
executing: '正在执行',
|
||||
plan_confirm: '等待计划确认',
|
||||
tool_confirm: '等待操作确认',
|
||||
ask_user: '等待补充信息',
|
||||
confirm: '等待用户确认',
|
||||
interrupted: '会话已中断',
|
||||
summarizing: '正在生成总结',
|
||||
done: '流程已结束',
|
||||
rough_building: '正在生成初始排课方案',
|
||||
rough_build_failed: '初始排课失败',
|
||||
rough_build_done: '初始排课已完成',
|
||||
rough_build_done_no_refine: '初始排课已完成',
|
||||
order_guard_initialized: '已记录顺序基线',
|
||||
order_guard_passed: '顺序校验通过',
|
||||
order_guard_restored: '顺序已自动恢复',
|
||||
order_guard_restore_skipped: '顺序恢复已跳过',
|
||||
context_compact_start: '正在压缩上下文',
|
||||
context_compact_done: '上下文压缩完成',
|
||||
plan_auto_confirmed: '计划已自动确认',
|
||||
}
|
||||
return labelMap[code] || '状态已更新'
|
||||
}
|
||||
|
||||
export function buildStatusSummary(extra: StreamExtraPayload): string {
|
||||
const summary = `${extra.status?.summary || ''}`.trim()
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
return mapStatusCodeLabel(normalizeStatusCode(extra.status?.code))
|
||||
}
|
||||
|
||||
export function isLegacyToolStatusCode(code: string) {
|
||||
return code === 'tool_call' || code === 'tool_result' || code === 'tool_blocked'
|
||||
}
|
||||
|
||||
export function mapLegacyToolStatusToState(code: string): ToolTraceState {
|
||||
if (code === 'tool_call') {
|
||||
return 'called'
|
||||
}
|
||||
if (code === 'tool_blocked') {
|
||||
return 'blocked'
|
||||
}
|
||||
return 'completed'
|
||||
}
|
||||
|
||||
export function shouldSkipStatusEvent(code: string, stage = '') {
|
||||
// confirm_request 已有专属卡片,避免重复显示同语义状态行。
|
||||
if (stage === 'confirm' && (code === 'plan_confirm' || code === 'tool_confirm' || code === 'confirm')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const hiddenStatusCodes = new Set([
|
||||
'accepted',
|
||||
'ask_user',
|
||||
'planning',
|
||||
'resumed',
|
||||
'confirmed',
|
||||
'rejected',
|
||||
'executing',
|
||||
'summarizing',
|
||||
'done',
|
||||
'rough_building',
|
||||
'order_guard_initialized',
|
||||
'order_guard_passed',
|
||||
'order_guard_restored',
|
||||
'order_guard_restore_skipped',
|
||||
'context_compact_start',
|
||||
'context_compact_done',
|
||||
'plan_auto_confirmed',
|
||||
])
|
||||
|
||||
if (hiddenStatusCodes.has(code)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isAssistantTimelineKind(kind: string) {
|
||||
const assistantKinds = new Set([
|
||||
'assistant_text',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'confirm_request',
|
||||
'schedule_completed',
|
||||
'interrupt',
|
||||
'status',
|
||||
'business_card',
|
||||
'thinking_summary',
|
||||
])
|
||||
return assistantKinds.has(kind)
|
||||
}
|
||||
Reference in New Issue
Block a user