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