后端: 1. 主动调度预览确认主链路落地——新增主动调度数据模型、DAO 与事件契约;接入 dry-run pipeline 与任务触发的 job upsert/cancel;新增 preview 查询与 confirm API,支持 apply_id 幂等确认并同步写入 task_pool 日程 2. 同步更新主动调度实施文档的阶段状态与验收记录 前端: 3. AssistantPanel 脚本层继续解耦——私有类型迁移到独立类型文件,并抽离会话、工具轨迹、思考摘要、任务表单等纯函数辅助逻辑;保持助手面板模板与样式不变,降低表现层回归风险
262 lines
9.3 KiB
Go
262 lines
9.3 KiB
Go
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
|
||
}
|