后端:
1.阶段 6 CP4/CP5 目录收口与共享边界纯化
- 将 backend 根目录收口为 services、client、gateway、cmd、shared 五个一级目录
- 收拢 bootstrap、inits、infra/kafka、infra/outbox、conv、respond、pkg、middleware,移除根目录旧实现与空目录
- 将 utils 下沉到 services/userauth/internal/auth,将 logic 下沉到 services/schedule/core/planning
- 将迁移期 runtime 桥接实现统一收拢到 services/runtime/{conv,dao,eventsvc,model},删除 shared/legacy 与未再被 import 的旧 service 实现
- 将 gateway/shared/respond 收口为 HTTP/Gin 错误写回适配,shared/respond 仅保留共享错误语义与状态映射
- 将 HTTP IdempotencyMiddleware 与 RateLimitMiddleware 收口到 gateway/middleware
- 将 GormCachePlugin 下沉到 shared/infra/gormcache,将共享 RateLimiter 下沉到 shared/infra/ratelimit,将 agent token budget 下沉到 services/agent/shared
- 删除 InitEino 兼容壳,收缩 cmd/internal/coreinit 仅保留旧组合壳残留域初始化语义
- 更新微服务迁移计划与桌面 checklist,补齐 CP4/CP5 当前切流点、目录终态与验证结果
- 完成 go test ./...、git diff --check 与最终真实 smoke;health、register/login、task/create+get、schedule/today、task-class/list、memory/items、agent chat/meta/timeline/context-stats 全部 200,SSE 合并结果为 CP5_OK 且 [DONE] 只有 1 个
144 lines
5.2 KiB
Go
144 lines
5.2 KiB
Go
package sv
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
|
||
agentconv "github.com/LoveLosita/smartflow/backend/services/agent/conv"
|
||
agentmodel "github.com/LoveLosita/smartflow/backend/services/agent/model"
|
||
agentshared "github.com/LoveLosita/smartflow/backend/services/agent/shared"
|
||
"github.com/LoveLosita/smartflow/backend/services/runtime/model"
|
||
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
||
)
|
||
|
||
// SaveScheduleState 处理前端拖拽后的“暂存排程状态”请求。
|
||
//
|
||
// 职责边界:
|
||
// 1. 负责把前端绝对坐标写回当前会话的 ScheduleState 快照;
|
||
// 2. 负责刷新 Redis 预览缓存,保证后续预览读取与最新拖拽一致;
|
||
// 3. 不负责写 MySQL 正式课表,也不负责触发新一轮 graph 执行。
|
||
func (s *AgentService) SaveScheduleState(
|
||
ctx context.Context,
|
||
userID int,
|
||
conversationID string,
|
||
items []model.SaveScheduleStatePlacedItem,
|
||
) error {
|
||
// 1. 加载会话快照;没有快照说明当前会话不在可微调窗口内。
|
||
if s.agentStateStore == nil {
|
||
return errors.New("agent state store 未初始化")
|
||
}
|
||
snapshot, ok, err := s.agentStateStore.Load(ctx, conversationID)
|
||
if err != nil {
|
||
return fmt.Errorf("加载快照失败: %w", err)
|
||
}
|
||
if !ok || snapshot == nil || snapshot.ScheduleState == nil {
|
||
return respond.ScheduleStateSnapshotNotFound
|
||
}
|
||
|
||
// 2. 做会话归属校验,防止跨用户写入别人的会话快照。
|
||
if snapshot.RuntimeState != nil {
|
||
cs := snapshot.RuntimeState.EnsureCommonState()
|
||
if cs.UserID != 0 && cs.UserID != userID {
|
||
return fmt.Errorf("会话归属校验失败:快照 user_id=%d,请求 user_id=%d", cs.UserID, userID)
|
||
}
|
||
}
|
||
|
||
// 3. 将前端绝对坐标应用到内存态 ScheduleState。
|
||
// 3.1 这里只修改 source=task_item 任务;
|
||
// 3.2 source=event 课程位保持不变;
|
||
// 3.3 坐标非法时由 ApplyPlacedItems 返回明确错误。
|
||
if err := agentconv.ApplyPlacedItems(snapshot.ScheduleState, items); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 4. 先写回运行态快照,确保“拖拽后的状态”成为后续读链路真值。
|
||
if err := s.agentStateStore.Save(ctx, conversationID, snapshot); err != nil {
|
||
return fmt.Errorf("保存快照失败: %w", err)
|
||
}
|
||
|
||
// 5. 再刷新预览缓存,避免 GetSchedulePlanPreview 读到拖拽前旧缓存。
|
||
if err := s.refreshSchedulePreviewAfterStateSave(ctx, userID, conversationID, snapshot); err != nil {
|
||
return err
|
||
}
|
||
|
||
log.Printf("[INFO] schedule state saved chat=%s user=%d item_count=%d", conversationID, userID, len(items))
|
||
return nil
|
||
}
|
||
|
||
// refreshSchedulePreviewAfterStateSave 按“最新快照”重建并覆盖 Redis 预览缓存。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只处理 Redis 预览缓存,不负责 MySQL 快照;
|
||
// 2. 以最新 ScheduleState 为准,修复“预览读到旧拖拽结果”的回滚问题;
|
||
// 3. 尽量保留旧预览中的 trace_id/candidate_plans,避免前端字段突变。
|
||
func (s *AgentService) refreshSchedulePreviewAfterStateSave(
|
||
ctx context.Context,
|
||
userID int,
|
||
conversationID string,
|
||
snapshot *agentmodel.AgentStateSnapshot,
|
||
) error {
|
||
// 1. 依赖不完整时直接跳过,避免写入不完整缓存。
|
||
if s == nil || s.cacheDAO == nil || snapshot == nil || snapshot.ScheduleState == nil {
|
||
return nil
|
||
}
|
||
normalizedConversationID := strings.TrimSpace(conversationID)
|
||
if normalizedConversationID == "" {
|
||
return nil
|
||
}
|
||
|
||
// 2. 从运行态提取 task_class_ids,保证预览过滤口径与会话一致。
|
||
taskClassIDs := make([]int, 0)
|
||
if snapshot.RuntimeState != nil {
|
||
flowState := snapshot.RuntimeState.EnsureCommonState()
|
||
taskClassIDs = append(taskClassIDs, flowState.TaskClassIDs...)
|
||
}
|
||
|
||
// 3. 基于最新 ScheduleState 生成预览主干(hybrid_entries 为最新真值)。
|
||
preview := agentconv.ScheduleStateToPreview(
|
||
snapshot.ScheduleState,
|
||
userID,
|
||
normalizedConversationID,
|
||
taskClassIDs,
|
||
"",
|
||
)
|
||
if preview == nil {
|
||
return nil
|
||
}
|
||
|
||
// 4. 合并旧预览里需要保留的字段,避免前端依赖字段突然丢失。
|
||
existingPreview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedConversationID)
|
||
if err != nil {
|
||
return fmt.Errorf("读取排程预览缓存失败: %w", err)
|
||
}
|
||
if existingPreview != nil {
|
||
preview.TraceID = strings.TrimSpace(existingPreview.TraceID)
|
||
if len(existingPreview.CandidatePlans) > 0 {
|
||
preview.CandidatePlans = agentshared.CloneWeekSchedules(existingPreview.CandidatePlans)
|
||
}
|
||
if len(existingPreview.AllocatedItems) > 0 {
|
||
preview.AllocatedItems = agentshared.CloneTaskClassItems(existingPreview.AllocatedItems)
|
||
}
|
||
if len(preview.TaskClassIDs) == 0 && len(existingPreview.TaskClassIDs) > 0 {
|
||
preview.TaskClassIDs = append([]int(nil), existingPreview.TaskClassIDs...)
|
||
}
|
||
}
|
||
if preview.CandidatePlans == nil {
|
||
preview.CandidatePlans = make([]model.UserWeekSchedule, 0)
|
||
}
|
||
if preview.HybridEntries == nil {
|
||
preview.HybridEntries = make([]model.HybridScheduleEntry, 0)
|
||
}
|
||
if preview.TaskClassIDs == nil {
|
||
preview.TaskClassIDs = make([]int, 0)
|
||
}
|
||
|
||
// 5. 回写 Redis 预览缓存;失败则返回错误,让前端可感知并重试。
|
||
if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedConversationID, preview); err != nil {
|
||
return fmt.Errorf("刷新排程预览缓存失败: %w", err)
|
||
}
|
||
return nil
|
||
}
|