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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user