package agentsvc import ( "context" "errors" "log" "strings" "time" agentmodel "github.com/LoveLosita/smartflow/backend/agent/model" agentshared "github.com/LoveLosita/smartflow/backend/agent/shared" "github.com/LoveLosita/smartflow/backend/model" "github.com/LoveLosita/smartflow/backend/respond" ) // saveSchedulePlanPreview 负责把排程结果同步写入“查询预览”所需的缓存与快照。 // // 职责边界: // 1. 负责把 graph 最终状态映射为统一预览 DTO,并先写 Redis、再写 MySQL 快照。 // 2. 负责执行“失败不阻断主回复”的旁路持久化策略,避免影响聊天主链路。 // 3. 不负责 SSE 输出,不负责聊天消息落库,也不负责 refine 状态到 plan 状态的转换。 func (s *AgentService) saveSchedulePlanPreview(ctx context.Context, userID int, chatID string, finalState *agentmodel.SchedulePlanState) { // 1. 先做最小前置校验,避免把空状态或空会话写成脏快照。 if s == nil || finalState == nil { return } normalizedChatID := strings.TrimSpace(chatID) if normalizedChatID == "" { return } // 2. 组装统一预览缓存结构。 // 2.1 summary 为空时使用统一兜底文案,保证查询接口始终有稳定输出。 // 2.2 所有切片字段都做深拷贝,避免缓存与 graph state 共享底层数组。 preview := &model.SchedulePlanPreviewCache{ UserID: userID, ConversationID: normalizedChatID, TraceID: strings.TrimSpace(finalState.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(finalState.FinalSummary)), CandidatePlans: cloneWeekSchedules(finalState.CandidatePlans), TaskClassIDs: append([]int(nil), finalState.TaskClassIDs...), HybridEntries: cloneHybridEntries(finalState.HybridEntries), AllocatedItems: cloneTaskClassItems(finalState.AllocatedItems), GeneratedAt: time.Now(), } // 3. 先写 Redis 预览,保证前端查询链路优先命中低时延缓存。 // 3.1 Redis 写失败只记日志,不中断主流程; // 3.2 真正兜底由后续 MySQL 快照承担。 if s.cacheDAO != nil { if err := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, preview); err != nil { log.Printf("写入排程预览缓存失败 chat_id=%s: %v", normalizedChatID, err) } } // 4. 再写 MySQL 快照,保证缓存失效后仍能恢复预览与连续微调上下文。 // 4.1 这里继续采用“同步写快照”的策略,因为下一轮 refine 依赖强一致读取; // 4.2 写库失败同样只记日志,避免让用户侧回复因为旁路持久化失败而中断。 if s.repo != nil { snapshot := buildSchedulePlanSnapshotFromState(userID, normalizedChatID, finalState) if err := s.repo.UpsertScheduleStateSnapshot(ctx, snapshot); err != nil { log.Printf("写入排程状态快照失败 chat_id=%s: %v", normalizedChatID, err) } } } // GetSchedulePlanPreview 按 conversation_id 读取结构化排程预览。 // // 职责边界: // 1. 负责参数归一化、缓存优先读取、会话归属校验和 DB 兜底。 // 2. 负责把缓存/快照 DTO 转成接口响应 DTO。 // 3. 不负责触发排程,不负责补算结果,也不负责消息链路落库。 func (s *AgentService) GetSchedulePlanPreview(ctx context.Context, userID int, chatID string) (*model.GetSchedulePlanPreviewResponse, error) { // 1. 先校验会话参数,避免无效请求打到缓存或数据库。 normalizedChatID := strings.TrimSpace(chatID) if normalizedChatID == "" { return nil, respond.MissingParam } if s == nil { return nil, errors.New("agent service is not initialized") } // 2. 优先查 Redis。 // 2.1 命中后立即校验 user_id,避免把别人的会话预览泄露给当前用户; // 2.2 缓存异常直接上抛,由接口层统一处理错误响应。 if s.cacheDAO != nil { preview, err := s.cacheDAO.GetSchedulePlanPreviewFromCache(ctx, userID, normalizedChatID) if err != nil { return nil, err } if preview != nil { if preview.UserID > 0 && preview.UserID != userID { return nil, respond.SchedulePlanPreviewNotFound } plans := cloneWeekSchedules(preview.CandidatePlans) if plans == nil { plans = make([]model.UserWeekSchedule, 0) } return &model.GetSchedulePlanPreviewResponse{ ConversationID: normalizedChatID, TraceID: strings.TrimSpace(preview.TraceID), Summary: strings.TrimSpace(preview.Summary), CandidatePlans: plans, HybridEntries: cloneHybridEntries(preview.HybridEntries), GeneratedAt: preview.GeneratedAt, }, nil } } // 3. Redis 未命中时回源 MySQL。 // 3.1 命中快照后顺手回填 Redis,提高后续命中率; // 3.2 DB 未命中才真正返回 not found,避免缓存过期造成假阴性。 if s.repo != nil { snapshot, err := s.repo.GetScheduleStateSnapshot(ctx, userID, normalizedChatID) if err != nil { return nil, err } if snapshot != nil { response := snapshotToSchedulePlanPreviewResponse(snapshot) if s.cacheDAO != nil { cachePreview := snapshotToSchedulePlanPreviewCache(snapshot) if setErr := s.cacheDAO.SetSchedulePlanPreviewToCache(ctx, userID, normalizedChatID, cachePreview); setErr != nil { log.Printf("回填排程预览缓存失败 chat_id=%s: %v", normalizedChatID, setErr) } } return response, nil } } return nil, respond.SchedulePlanPreviewNotFound } // cloneWeekSchedules 负责深拷贝周视图排程,避免缓存与运行态共享底层切片。 func cloneWeekSchedules(src []model.UserWeekSchedule) []model.UserWeekSchedule { return agentshared.CloneWeekSchedules(src) } // cloneHybridEntries 负责深拷贝混合排程条目,避免跨请求污染。 func cloneHybridEntries(src []model.HybridScheduleEntry) []model.HybridScheduleEntry { return agentshared.CloneHybridEntries(src) } // cloneTaskClassItems 负责深拷贝任务项切片,包含内部指针字段的安全复制。 func cloneTaskClassItems(src []model.TaskClassItem) []model.TaskClassItem { return agentshared.CloneTaskClassItems(src) } // buildSchedulePlanSnapshotFromState 把 graph 最终状态映射成可持久化的快照 DTO。 // // 职责边界: // 1. 负责字段归一化、深拷贝和 state_version 补齐。 // 2. 不负责数据库写入,也不负责生成业务摘要文案。 func buildSchedulePlanSnapshotFromState(userID int, conversationID string, st *agentmodel.SchedulePlanState) *model.SchedulePlanStateSnapshot { if st == nil { return nil } return &model.SchedulePlanStateSnapshot{ UserID: userID, ConversationID: conversationID, StateVersion: model.SchedulePlanStateVersionV1, TaskClassIDs: append([]int(nil), st.TaskClassIDs...), Constraints: append([]string(nil), st.Constraints...), HybridEntries: cloneHybridEntries(st.HybridEntries), AllocatedItems: cloneTaskClassItems(st.AllocatedItems), CandidatePlans: cloneWeekSchedules(st.CandidatePlans), UserIntent: strings.TrimSpace(st.UserIntent), Strategy: strings.TrimSpace(st.Strategy), AdjustmentScope: strings.TrimSpace(st.AdjustmentScope), RestartRequested: st.RestartRequested, FinalSummary: strings.TrimSpace(st.FinalSummary), Completed: st.Completed, TraceID: strings.TrimSpace(st.TraceID), } } // snapshotToSchedulePlanPreviewCache 把 MySQL 快照映射成 Redis 预览缓存结构。 func snapshotToSchedulePlanPreviewCache(snapshot *model.SchedulePlanStateSnapshot) *model.SchedulePlanPreviewCache { if snapshot == nil { return nil } generatedAt := snapshot.UpdatedAt if generatedAt.IsZero() { generatedAt = time.Now() } return &model.SchedulePlanPreviewCache{ UserID: snapshot.UserID, ConversationID: snapshot.ConversationID, TraceID: strings.TrimSpace(snapshot.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), CandidatePlans: cloneWeekSchedules(snapshot.CandidatePlans), TaskClassIDs: append([]int(nil), snapshot.TaskClassIDs...), HybridEntries: cloneHybridEntries(snapshot.HybridEntries), AllocatedItems: cloneTaskClassItems(snapshot.AllocatedItems), GeneratedAt: generatedAt, } } // snapshotToSchedulePlanPreviewResponse 把 MySQL 快照映射成查询接口响应结构。 func snapshotToSchedulePlanPreviewResponse(snapshot *model.SchedulePlanStateSnapshot) *model.GetSchedulePlanPreviewResponse { if snapshot == nil { return nil } plans := cloneWeekSchedules(snapshot.CandidatePlans) if plans == nil { plans = make([]model.UserWeekSchedule, 0) } generatedAt := snapshot.UpdatedAt if generatedAt.IsZero() { generatedAt = time.Now() } return &model.GetSchedulePlanPreviewResponse{ ConversationID: snapshot.ConversationID, TraceID: strings.TrimSpace(snapshot.TraceID), Summary: schedulePlanSummaryOrFallback(strings.TrimSpace(snapshot.FinalSummary)), CandidatePlans: plans, HybridEntries: cloneHybridEntries(snapshot.HybridEntries), GeneratedAt: generatedAt, } } // schedulePlanSummaryOrFallback 统一收口排程摘要兜底文案,避免各处重复维护默认值。 func schedulePlanSummaryOrFallback(summary string) string { if strings.TrimSpace(summary) == "" { return "排程流程已完成,但未生成结果摘要。" } return summary }