Files
smartmate/backend/active_scheduler/apply/convert_helpers.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

456 lines
15 KiB
Go

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
}