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

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

262 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}