Files
Cyrene/backend/gateway/internal/handler/briefing_handler.go
T
AskaEth 1fc2b41d36 fix: 将管理员 user_id 从动态 admin_{username} 改为固定 admin
根因:admin user_id 由 admin_ + req.Username 动态拼接,
当 .env 中 ADMIN_USERNAME 更改时,新登录会生成不同的 user_id,
导致旧会话成为孤儿且消息历史不可见。

修复方案 (Plan A):
- auth_handler.go: Login 时 userID 固定为 admin
- auth.go: IsAdminKey 从 HasPrefix(admin_) 改为 == admin
- chat_handler.go: 主对话管理员检查改为 userID == admin
- memory_handler.go: 3处 admin_ 前缀检查改为 == admin
- briefing_handler.go: 3处 admin_ 前缀检查改为 != admin
- sessionStore.ts: isAdminUser 从 startsWith 改为 ===
- MessageBubble.tsx: UserAvatar 管理员判断改为 ===
- main.go: 添加旧管理员用户清理逻辑 (ListUsers+DeleteUser)
- user_store.go: 新增 ListUsers 和 DeleteUser 函数
- ai-core/main.go: adminUserID 从 admin_admin 改为 admin
- memory-service/store.go: 默认 user_id 改为 admin
- memory-service/memory_service.go: 默认 UserID 改为 admin
- devtools/src/index.js: URL 参数 user_id=admin

验证: Go build 通过 (gateway/ai-core/memory-service),
tsc --noEmit 通过, vite build 通过
2026-05-20 22:13:21 +08:00

714 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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 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{},
CreatedAt: time.Now(),
}
// 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]) + "..."
}