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
}

View 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
}

View 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 ""
}

View 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)
}

View 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
}