package handler import ( "bytes" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/yourname/cyrene-ai/gateway/internal/config" "github.com/yourname/cyrene-ai/gateway/internal/middleware" "github.com/yourname/cyrene-ai/gateway/internal/store" "github.com/yourname/cyrene-ai/gateway/internal/ws" ) // BriefingHandler 每日简报处理器 type BriefingHandler struct { cfg *config.Config hub *ws.Hub briefingStore *store.BriefingStore reminderStore *store.ReminderStore httpClient *http.Client } // NewBriefingHandler 创建简报处理器 func NewBriefingHandler(cfg *config.Config, hub *ws.Hub, bs *store.BriefingStore, rs *store.ReminderStore) *BriefingHandler { return &BriefingHandler{ cfg: cfg, hub: hub, briefingStore: bs, reminderStore: rs, httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // GenerateBriefingRequest 手动生成简报请求体 type GenerateBriefingRequest struct { UserID string `json:"user_id" binding:"required"` } // GetBriefing 获取指定日期简报 // GET /api/v1/briefings?user_id=xxx&date=2024-01-01 func (h *BriefingHandler) GetBriefing(c *gin.Context) { authUserID := middleware.GetUserID(c) userID := c.Query("user_id") date := c.Query("date") if !strings.HasPrefix(authUserID, "admin_") || userID == "" { userID = authUserID } if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"}) return } if date == "" { date = time.Now().Format("2006-01-02") } briefing, err := h.briefingStore.GetBriefingByDate(userID, date) if err != nil { log.Printf("[briefing] 查询简报失败: user=%s date=%s err=%v", userID, date, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报失败: " + err.Error()}) return } if briefing == nil { c.JSON(http.StatusOK, gin.H{ "briefing": nil, "message": "当日简报尚未生成", }) return } c.JSON(http.StatusOK, gin.H{"briefing": briefing}) } // GetLatestBriefings 获取最近简报列表 // GET /api/v1/briefings/latest?user_id=xxx&limit=7 func (h *BriefingHandler) GetLatestBriefings(c *gin.Context) { authUserID := middleware.GetUserID(c) userID := c.Query("user_id") if !strings.HasPrefix(authUserID, "admin_") || userID == "" { userID = authUserID } if userID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id参数"}) return } limit := 7 if l := c.Query("limit"); l != "" { if parsed, err := parseInt(l); err == nil && parsed > 0 && parsed <= 30 { limit = parsed } } briefings, err := h.briefingStore.GetLatestBriefings(userID, limit) if err != nil { log.Printf("[briefing] 查询简报列表失败: user=%s err=%v", userID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "查询简报列表失败: " + err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "briefings": briefings, "total": len(briefings), }) } // Generate 手动触发生成今日简报 // POST /api/v1/briefings/generate func (h *BriefingHandler) Generate(c *gin.Context) { authUserID := middleware.GetUserID(c) var req GenerateBriefingRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()}) return } // 非管理员只能为自己生成 if !strings.HasPrefix(authUserID, "admin_") { req.UserID = authUserID } if req.UserID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "缺少user_id"}) return } result, err := h.GenerateDailyBriefing(req.UserID) if err != nil { log.Printf("[briefing] 生成简报失败: user=%s err=%v", req.UserID, err) c.JSON(http.StatusInternalServerError, gin.H{ "error": "生成简报失败: " + err.Error(), "success": false, }) return } // 生成后推送通知 h.pushBriefingNotification(req.UserID, result) c.JSON(http.StatusOK, gin.H{ "success": true, "briefing": result, "message": "简报已生成并推送", }) } // GenerateDailyBriefing 生成每日简报(核心逻辑) func (h *BriefingHandler) GenerateDailyBriefing(userID string) (*store.Briefing, error) { today := time.Now().Format("2006-01-02") briefing := &store.Briefing{ ID: "brief_" + generateID(), UserID: userID, Date: today, Status: "pending", Weather: &store.WeatherData{}, News: []store.NewsItem{}, Reminders: []store.BriefReminder{}, } // 1. 获取天气数据 log.Printf("[briefing] 获取天气数据...") weather, err := h.fetchWeather("Shanghai") if err != nil { log.Printf("[briefing] 天气获取失败 (降级): %v", err) weather = &store.WeatherData{ Location: "未知", Temp: 0, Condition: "获取天气失败", Icon: "❓", } } briefing.Weather = weather log.Printf("[briefing] 天气: %s %.1f°C %s", weather.Location, weather.Temp, weather.Condition) // 2. 获取今日待办提醒 log.Printf("[briefing] 获取待办提醒...") reminders, err := h.reminderStore.GetRemindersByUser(userID, "pending", 10, 0) if err != nil { log.Printf("[briefing] 获取提醒失败: %v", err) } else { now := time.Now() endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) for _, r := range reminders { if r.RemindAt.Before(endOfDay) || r.RemindAt.Equal(endOfDay) { briefing.Reminders = append(briefing.Reminders, store.BriefReminder{ ID: r.ID, Title: r.Title, RemindAt: r.RemindAt.Format(time.RFC3339), }) } } } log.Printf("[briefing] 今日待办: %d 项", len(briefing.Reminders)) // 3. 获取新闻摘要(通过 tool-engine web_search) log.Printf("[briefing] 获取新闻摘要...") news, err := h.fetchNews() if err != nil { log.Printf("[briefing] 新闻获取失败 (降级): %v", err) } briefing.News = news log.Printf("[briefing] 新闻: %d 条", len(news)) // 4. 生成 AI 摘要 log.Printf("[briefing] 生成 AI 摘要...") summary, err := h.generateAISummary(briefing) if err != nil { log.Printf("[briefing] AI 摘要生成失败 (降级): %v", err) summary = h.buildFallbackSummary(briefing) } briefing.Summary = summary // 5. 标记为已生成 now := time.Now() briefing.Status = "generated" briefing.GeneratedAt = &now // 6. 持久化 if err := h.briefingStore.CreateOrUpdateBriefing(briefing); err != nil { return nil, fmt.Errorf("保存简报失败: %w", err) } log.Printf("[briefing] 简报已生成: user=%s date=%s", userID, today) return briefing, nil } // fetchWeather 通过 wttr.in API 获取天气数据 func (h *BriefingHandler) fetchWeather(location string) (*store.WeatherData, error) { url := fmt.Sprintf("https://wttr.in/%s?format=j1", location) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("构建天气请求失败: %w", err) } req.Header.Set("User-Agent", "Cyrene-AI/1.0") resp, err := h.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("请求天气API失败: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("天气API返回状态码 %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取天气响应失败: %w", err) } // 解析 wttr.in JSON 响应 var wttrResp struct { CurrentCondition []struct { TempC string `json:"temp_C"` WeatherDesc []struct { Value string `json:"value"` } `json:"weatherDesc"` WeatherIconURL []struct { Value string `json:"value"` } `json:"weatherIconUrl"` } `json:"current_condition"` NearestArea []struct { AreaName []struct { Value string `json:"value"` } `json:"areaName"` } `json:"nearest_area"` } if err := json.Unmarshal(body, &wttrResp); err != nil { return nil, fmt.Errorf("解析天气数据失败: %w", err) } wd := &store.WeatherData{ Location: location, } if len(wttrResp.NearestArea) > 0 && len(wttrResp.NearestArea[0].AreaName) > 0 { wd.Location = wttrResp.NearestArea[0].AreaName[0].Value } if len(wttrResp.CurrentCondition) > 0 { cc := wttrResp.CurrentCondition[0] wd.Temp = parseFloat(cc.TempC) if len(cc.WeatherDesc) > 0 { wd.Condition = cc.WeatherDesc[0].Value } // 根据天气描述转 emoji wd.Icon = weatherEmoji(wd.Condition) } return wd, nil } // fetchNews 通过 tool-engine web_search 搜索今日新闻 func (h *BriefingHandler) fetchNews() ([]store.NewsItem, error) { if h.cfg.ToolEngineURL == "" { return nil, fmt.Errorf("ToolEngine URL 未配置") } today := time.Now().Format("2006年01月02日") query := fmt.Sprintf("%s 今日要闻 热点新闻", today) reqBody, _ := json.Marshal(map[string]interface{}{ "arguments": map[string]interface{}{ "query": query, "limit": 5, }, }) url := fmt.Sprintf("%s/api/v1/tools/web_search/execute", h.cfg.ToolEngineURL) req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody)) if err != nil { return nil, fmt.Errorf("构建新闻搜索请求失败: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("请求新闻搜索失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取新闻搜索结果失败: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("新闻搜索返回状态码 %d: %s", resp.StatusCode, string(body)) } // 解析 tool-engine 单个工具执行响应: {id, output, error?} var result struct { ID string `json:"id"` Output string `json:"output"` Error string `json:"error,omitempty"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("解析新闻搜索结果失败: %w", err) } if result.Error != "" { log.Printf("[briefing] 新闻搜索失败: %s", result.Error) // 返回降级新闻 return []store.NewsItem{ { Title: "未能获取今日新闻", URL: "", Source: "系统", Summary: "新闻搜索服务暂时不可用,请稍后再试。", }, }, nil } var news []store.NewsItem // 尝试解析搜索结果为结构化数据 var searchResults []struct { Title string `json:"title"` URL string `json:"url"` Snippet string `json:"snippet"` Source string `json:"source"` } if err := json.Unmarshal([]byte(result.Output), &searchResults); err != nil { // 如果不是 JSON 数组,当做纯文本处理 news = append(news, store.NewsItem{ Title: "今日新闻", URL: "", Source: "搜索引擎", Summary: truncateStr(result.Output, 200), }) } else { for _, sr := range searchResults { news = append(news, store.NewsItem{ Title: sr.Title, URL: sr.URL, Source: sr.Source, Summary: sr.Snippet, }) } } if len(news) == 0 { news = []store.NewsItem{ { Title: "未能获取今日新闻", URL: "", Source: "系统", Summary: "新闻搜索服务暂时不可用,请稍后再试。", }, } } // 限制最多 5 条 if len(news) > 5 { news = news[:5] } return news, nil } // generateAISummary 通过 AI-Core 生成人性化摘要 func (h *BriefingHandler) generateAISummary(b *store.Briefing) (string, error) { if h.cfg.AICoreURL == "" { return "", fmt.Errorf("AI-Core URL 未配置") } // 构建提示词 prompt := h.buildSummaryPrompt(b) reqBody, _ := json.Marshal(map[string]interface{}{ "messages": []map[string]interface{}{ { "role": "system", "content": "你是昔涟,一个温柔贴心的AI助手。请用温暖、亲切的语气回复,像朋友一样关心用户。回复使用中文。", }, { "role": "user", "content": prompt, }, }, "max_tokens": 500, "temperature": 0.7, }) url := fmt.Sprintf("%s/api/v1/chat/completions", h.cfg.AICoreURL) req, err := http.NewRequest("POST", url, bytes.NewReader(reqBody)) if err != nil { return "", fmt.Errorf("构建 AI 请求失败: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := h.httpClient.Do(req) if err != nil { return "", fmt.Errorf("请求 AI-Core 失败: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取 AI 响应失败: %w", err) } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("AI-Core 返回状态码 %d: %s", resp.StatusCode, string(body)) } // 解析 OpenAI 兼容响应 var aiResp struct { Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` } `json:"choices"` } if err := json.Unmarshal(body, &aiResp); err != nil { return "", fmt.Errorf("解析 AI 响应失败: %w", err) } if len(aiResp.Choices) == 0 { return "", fmt.Errorf("AI 返回空响应") } return aiResp.Choices[0].Message.Content, nil } // buildSummaryPrompt 构建 AI 摘要提示词 func (h *BriefingHandler) buildSummaryPrompt(b *store.Briefing) string { var sb strings.Builder today := time.Now().Format("2006年01月02日") sb.WriteString(fmt.Sprintf("今天是%s。请根据以下信息,用昔涟温柔的语气为用户生成一份简短的每日简报(控制在200字以内):\n\n", today)) // 天气 if b.Weather != nil && b.Weather.Condition != "" { sb.WriteString(fmt.Sprintf("☁️ 天气:%s,%.0f°C,%s\n", b.Weather.Location, b.Weather.Temp, b.Weather.Condition)) } // 待办 if len(b.Reminders) > 0 { sb.WriteString("📋 今日待办:\n") for _, r := range b.Reminders { sb.WriteString(fmt.Sprintf(" - %s\n", r.Title)) } } // 新闻 if len(b.News) > 0 { sb.WriteString("📰 今日新闻:\n") for _, n := range b.News { if n.Summary != "" { sb.WriteString(fmt.Sprintf(" - %s: %s\n", n.Title, n.Summary)) } else { sb.WriteString(fmt.Sprintf(" - %s\n", n.Title)) } } } sb.WriteString("\n请用昔涟的语气回复,包含:1) 温馨问候 2) 天气提醒 3) 待办提醒 4) 新闻简要 5) 结语祝福。简洁自然即可。") return sb.String() } // buildFallbackSummary 降级摘要(不依赖 AI) func (h *BriefingHandler) buildFallbackSummary(b *store.Briefing) string { today := time.Now().Format("2006年01月02日") var sb strings.Builder sb.WriteString(fmt.Sprintf("早上好!今天是%s ☀️\n\n", today)) if b.Weather != nil && b.Weather.Condition != "" { sb.WriteString(fmt.Sprintf("今日%s天气:%s,%.0f°C。", b.Weather.Location, b.Weather.Condition, b.Weather.Temp)) if b.Weather.Temp < 10 { sb.WriteString("天气有点凉,记得多穿件衣服哦~") } else if b.Weather.Temp > 30 { sb.WriteString("天气比较热,注意防暑降温哦~") } else { sb.WriteString("天气不错,适合出门走走呢~") } sb.WriteString("\n\n") } if len(b.Reminders) > 0 { sb.WriteString(fmt.Sprintf("你今天有 %d 项待办事项,记得按时完成哦!\n", len(b.Reminders))) } else { sb.WriteString("今天没有待办事项,可以轻松一下~\n") } if len(b.News) > 0 && b.News[0].Title != "未能获取今日新闻" { sb.WriteString(fmt.Sprintf("\n今日热点:%s。", b.News[0].Title)) } sb.WriteString("\n\n祝你度过美好的一天!🌸") return sb.String() } // pushBriefingNotification 推送简报通知到用户 func (h *BriefingHandler) pushBriefingNotification(userID string, b *store.Briefing) { bodyPreview := truncateStr(b.Summary, 100) notif := &ws.NotificationInfo{ ID: "briefing_" + b.ID, Type: "info", Title: fmt.Sprintf("📋 今日简报 (%s)", b.Date), Body: bodyPreview, Timestamp: time.Now().UTC().Format(time.RFC3339), Data: map[string]interface{}{ "briefing_id": b.ID, "date": b.Date, "type": "daily_briefing", }, } msg := ws.ServerMessage{ Type: "notification", MessageID: "briefing_" + b.ID, Timestamp: time.Now().UnixMilli(), Notification: notif, } data, err := json.Marshal(msg) if err != nil { log.Printf("[briefing] 序列化简报通知失败: %v", err) return } h.hub.SendToUser(userID, data) // 更新简报状态为已送达 now := time.Now() b.Status = "delivered" b.DeliveredAt = &now if err := h.briefingStore.CreateOrUpdateBriefing(b); err != nil { log.Printf("[briefing] 更新简报送达状态失败: %v", err) } log.Printf("[briefing] 简报通知已推送: user=%s date=%s", userID, b.Date) } // StartBriefingScheduler 启动简报调度器 // briefingTime 格式: "HH:MM",默认 "08:00" func StartBriefingScheduler(handler *BriefingHandler, briefingStore *store.BriefingStore, briefingTime string) { if briefingTime == "" { briefingTime = "08:00" } go func() { // 每 30 秒检查一次是否到达简报时间 ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() log.Printf("[BriefingScheduler] 简报调度器已启动 (简报时间: %s)", briefingTime) // 记录今天是否已触发 lastTriggeredDate := "" for range ticker.C { now := time.Now() currentTime := now.Format("15:04") currentDate := now.Format("2006-01-02") // 检查是否到达简报时间且今天尚未触发 if currentTime == briefingTime && currentDate != lastTriggeredDate { log.Printf("[BriefingScheduler] 触发每日简报生成: %s", currentDate) lastTriggeredDate = currentDate // 获取所有用户 users, err := briefingStore.GetAllUsers() if err != nil { log.Printf("[BriefingScheduler] 获取用户列表失败: %v", err) continue } // 如果没有从 reminders 表获取到用户,也尝试从 briefings 表获取 if len(users) == 0 { users, _ = briefingStore.GetUsersWithBriefings() } if len(users) == 0 { log.Println("[BriefingScheduler] 没有找到用户,跳过简报生成") continue } for _, userID := range users { log.Printf("[BriefingScheduler] 为用户 %s 生成简报...", userID) result, err := handler.GenerateDailyBriefing(userID) if err != nil { log.Printf("[BriefingScheduler] 生成简报失败: user=%s err=%v", userID, err) continue } handler.pushBriefingNotification(userID, result) } log.Printf("[BriefingScheduler] 每日简报已生成完毕,共 %d 个用户", len(users)) } } }() } // ========== 辅助函数 ========== // weatherEmoji 根据天气描述返回对应 emoji func weatherEmoji(condition string) string { c := strings.ToLower(condition) switch { case strings.Contains(c, "sunny") || strings.Contains(c, "clear") || strings.Contains(c, "晴"): return "☀️" case strings.Contains(c, "partly cloudy") || strings.Contains(c, "多云"): return "⛅" case strings.Contains(c, "cloudy") || strings.Contains(c, "阴"): return "☁️" case strings.Contains(c, "rain") || strings.Contains(c, "drizzle") || strings.Contains(c, "雨"): return "🌧️" case strings.Contains(c, "thunder") || strings.Contains(c, "雷"): return "⛈️" case strings.Contains(c, "snow") || strings.Contains(c, "雪"): return "❄️" case strings.Contains(c, "fog") || strings.Contains(c, "mist") || strings.Contains(c, "雾"): return "🌫️" case strings.Contains(c, "wind") || strings.Contains(c, "风"): return "💨" default: return "🌤️" } } // parseFloat 安全解析浮点数 func parseFloat(s string) float64 { var f float64 fmt.Sscanf(s, "%f", &f) return f } // parseInt 安全解析整数 func parseInt(s string) (int, error) { var n int _, err := fmt.Sscanf(s, "%d", &n) return n, err } // truncateStr 截断字符串 func truncateStr(s string, maxLen int) string { runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen]) + "..." }