Version: 0.9.59.dev.260430

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

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

View File

@@ -0,0 +1,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
}