4b35736f73
P0 (5): crypto/rand session ID, TTS fallback可达性, goroutine defer recover, adminAuth前缀修正 P1 (5): 普通用户密码验证, context传递, priority clamp, 超时重试, 自主思考速率限制 P2 (4): Briefing AI降级, 前端消息类型渲染, Docker Compose补全, PWA 192图标 P3 (5): goroutine错误处理, .gitignore完善, reminder created_at, voice Dockerfile, Go版本更新
713 lines
20 KiB
Go
713 lines
20 KiB
Go
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.SummarySource = "fallback"
|
||
} else {
|
||
briefing.SummarySource = "ai"
|
||
}
|
||
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]) + "..."
|
||
}
|