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