diff --git a/internal/routes/smoke_routes.go b/internal/routes/smoke_routes.go index e3c31aa..b550658 100644 --- a/internal/routes/smoke_routes.go +++ b/internal/routes/smoke_routes.go @@ -37,6 +37,9 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler. // AI 下次抽烟时间建议(结构化时间节点) smoke.GET("/ai/next_smoke_time", smokeHandler.GetAINextSmokeTime) + // AI 今日总结(分析当天抽烟数据,生成总结和建议) + smoke.GET("/ai/daily_summary", smokeHandler.GetAIDailySummary) + // 分享:用于给新用户只读展示统计与历史记录 smoke.POST("/share", smokeHandler.CreateShare) smoke.GET("/share/:token", smokeHandler.GetShareView) diff --git a/internal/smoke/handler/smoke_ai_handler.go b/internal/smoke/handler/smoke_ai_handler.go index 67d51e7..440b835 100644 --- a/internal/smoke/handler/smoke_ai_handler.go +++ b/internal/smoke/handler/smoke_ai_handler.go @@ -91,3 +91,46 @@ func yesterdayDate() time.Time { y := now.AddDate(0, 0, -1) return time.Date(y.Year(), y.Month(), y.Day(), 0, 0, 0, 0, time.Local) } + +func todayDate() time.Time { + now := time.Now().In(time.Local) + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) +} + +func (h *SmokeHandler) GetAIDailySummary(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + summaryDate := todayDate() + if dateStr := c.Query("date"); dateStr != "" { + parsed, err := time.ParseInLocation(dateLayout, dateStr, time.Local) + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "date 格式错误,应为 YYYY-MM-DD")) + return + } + summaryDate = parsed + } + + record, err := h.smokeAIAdviceService.GetOrGenerateDailySummary(c.Request.Context(), user, summaryDate, smokeservice.DefaultSummaryPromptVersion) + if err != nil { + switch { + case errors.Is(err, smokeservice.ErrAIAdviceLocked): + c.JSON(http.StatusForbidden, model.Error(http.StatusForbidden, "需要会员或观看广告解锁后才可生成总结")) + return + case errors.Is(err, smokeservice.ErrAIServiceDisabled): + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "AI 服务暂不可用,请联系管理员")) + return + case errors.Is(err, smokeservice.ErrNoSmokeLogsToday): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "今天还没有抽烟记录,无法生成总结")) + return + default: + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成总结失败,请稍后重试")) + return + } + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "date": record.AdviceDate.Format(dateLayout), + "content": record.Advice, + "model": record.Model, + })) +} diff --git a/internal/smoke/handler/smoke_home_handler.go b/internal/smoke/handler/smoke_home_handler.go index 92ed633..ba0e965 100644 --- a/internal/smoke/handler/smoke_home_handler.go +++ b/internal/smoke/handler/smoke_home_handler.go @@ -22,6 +22,7 @@ type homeDashboardResponse struct { CampaignCard homeCampaignCard `json:"campaign_card"` Timer homeTimerBlock `json:"timer"` Summary homeSummaryBlock `json:"summary"` + DailySummary *homeDailySummaryBlock `json:"daily_summary,omitempty"` Motivation smokeservice.SmokeMotivation `json:"motivation"` QuickActions []homeQuickAction `json:"quick_actions"` DataSources homeDataSources `json:"data_sources"` @@ -50,14 +51,17 @@ type homeCampaignCard struct { } type homeTimerBlock struct { - Label string `json:"label"` - LastSmokeAt string `json:"last_smoke_at"` - SecondsSinceLast int `json:"seconds_since_last"` - NextSuggestedAt string `json:"next_suggested_at"` - NextSuggestedClock string `json:"next_suggested_clock"` - NotBeforeAt string `json:"not_before_at"` - SuggestionSource string `json:"suggestion_source"` - SuggestionAlgorithm string `json:"suggestion_algorithm"` + Label string `json:"label"` + LastSmokeAt string `json:"last_smoke_at"` + SecondsSinceLast int `json:"seconds_since_last"` + NextSuggestedAt string `json:"next_suggested_at"` + NextSuggestedClock string `json:"next_suggested_clock"` + NotBeforeAt string `json:"not_before_at"` + SuggestionSource string `json:"suggestion_source"` + SuggestionAlgorithm string `json:"suggestion_algorithm"` + AITimeNodes []string `json:"ai_time_nodes,omitempty"` + AIAdvice string `json:"ai_advice,omitempty"` + AIModel string `json:"ai_model,omitempty"` } type homeSummaryBlock struct { @@ -80,6 +84,13 @@ type homeDataSources struct { PlanDate string `json:"plan_date"` } +type homeDailySummaryBlock struct { + Date string `json:"date"` + Content string `json:"content"` + Model string `json:"model,omitempty"` + Status string `json:"status"` // available | locked | no_data | empty +} + func (h *SmokeHandler) Home(c *gin.Context) { user := middleware.MustCurrentUser(c) @@ -149,6 +160,35 @@ func (h *SmokeHandler) Home(c *gin.Context) { SuggestionAlgorithm: defaultSuggestion.Algorithm, } + // 尝试读取 AI 缓存,有则覆盖 timer 的建议时间(默认算法作为保底) + if aiSuggestion, ok, _ := h.smokeAINextService.GetCached(ctx, user, planDate, "v1"); ok { + timerBlock.SuggestionSource = "ai" + timerBlock.SuggestionAlgorithm = "ai_next_smoke_v1" + timerBlock.AITimeNodes = aiSuggestion.TimeNodes + timerBlock.AIAdvice = aiSuggestion.Advice + timerBlock.AIModel = aiSuggestion.Model + if aiSuggestion.SuggestedAt != "" { + timerBlock.NextSuggestedAt = aiSuggestion.SuggestedAt + if t, err := time.Parse(time.RFC3339, aiSuggestion.SuggestedAt); err == nil { + timerBlock.NextSuggestedClock = t.In(time.Local).Format("15:04") + } + } + if aiSuggestion.NotBeforeAt != "" { + timerBlock.NotBeforeAt = aiSuggestion.NotBeforeAt + } + } + + // 尝试读取今日 AI 总结缓存 + var dailySummaryBlock *homeDailySummaryBlock + if summaryRecord, err := h.smokeAIAdviceService.GetCachedByType(ctx, int(user.ID), smokeservice.SmokeAIAdviceTypeDailySummary, planDate, smokeservice.DefaultSummaryPromptVersion); err == nil && summaryRecord != nil { + dailySummaryBlock = &homeDailySummaryBlock{ + Date: summaryRecord.AdviceDate.Format(dateLayout), + Content: summaryRecord.Advice, + Model: summaryRecord.Model, + Status: "available", + } + } + response := homeDashboardResponse{ Greeting: greetingBlock(user.NickName, user.AvatarURL, now), Profile: profileView, @@ -173,7 +213,8 @@ func (h *SmokeHandler) Home(c *gin.Context) { ExceededYesterday: homeSummary.ExceededYesterday, ProfileCompleted: profileView.IsCompleted, }, - Motivation: motivation, + DailySummary: dailySummaryBlock, + Motivation: motivation, QuickActions: []homeQuickAction{ {Type: "log_smoke", Title: "记录抽烟", Primary: false}, {Type: "resist", Title: "想抽忍住了", Primary: true}, diff --git a/internal/smoke/service/smoke_ai_advice_service.go b/internal/smoke/service/smoke_ai_advice_service.go index 38c4ab0..69457b3 100644 --- a/internal/smoke/service/smoke_ai_advice_service.go +++ b/internal/smoke/service/smoke_ai_advice_service.go @@ -31,10 +31,13 @@ const ( ) const ( - SmokeAIAdviceTypeDaily = "daily_advice" - SmokeAIAdviceTypeNextSmoke = "next_smoke_time" + SmokeAIAdviceTypeDaily = "daily_advice" + SmokeAIAdviceTypeNextSmoke = "next_smoke_time" + SmokeAIAdviceTypeDailySummary = "daily_summary" ) +const DefaultSummaryPromptVersion = "v1" + type SmokeAIAdviceService struct { db *gorm.DB cfg config.AIConfig @@ -188,6 +191,11 @@ func (s *SmokeAIAdviceService) getCached(ctx context.Context, uid int, adviceTyp return nil, fmt.Errorf("load cached advice: %w", err) } +// GetCachedByType 公开方法,供 handler 层读取指定类型的 AI 缓存。 +func (s *SmokeAIAdviceService) GetCachedByType(ctx context.Context, uid int, adviceType string, adviceDate time.Time, promptVersion string) (*smokemodel.SmokeAIAdvice, error) { + return s.getCached(ctx, uid, adviceType, adviceDate, promptVersion) +} + func (s *SmokeAIAdviceService) isAllowed(ctx context.Context, user *usermodel.User, adviceDate time.Time) (bool, error) { isVIP, err := hasActiveMembership(ctx, s.db, user.MiniProgramID, user.ID, time.Now()) if err != nil { @@ -466,3 +474,159 @@ func deriveUserSegment(baselineCigsPerDay int, smokingYears float64) string { } return "newbie" } + +// DailySummaryResult 是 AI 今日总结的结构化返回。 +type DailySummaryResult struct { + Summary string `json:"summary"` + Highlights []string `json:"highlights"` + Suggestion string `json:"suggestion"` +} + +var ErrNoSmokeLogsToday = errors.New("today has no smoke logs yet") + +func (s *SmokeAIAdviceService) GetOrGenerateDailySummary( + ctx context.Context, + user *usermodel.User, + summaryDate time.Time, + promptVersion string, +) (*smokemodel.SmokeAIAdvice, error) { + if promptVersion == "" { + promptVersion = DefaultSummaryPromptVersion + } + + cached, err := s.getCached(ctx, int(user.ID), SmokeAIAdviceTypeDailySummary, summaryDate, promptVersion) + if err != nil { + return nil, err + } + if cached != nil { + return cached, nil + } + + allowed, err := s.isAllowed(ctx, user, summaryDate) + if err != nil { + return nil, err + } + if !allowed { + return nil, ErrAIAdviceLocked + } + + snapshot, snapshotJSON, err := s.buildSnapshot(ctx, int(user.ID), summaryDate) + if err != nil { + if errors.Is(err, ErrNoSmokeLogs) { + return nil, ErrNoSmokeLogsToday + } + return nil, err + } + + adviceText, modelName, tokensIn, tokensOut, err := s.callAIDailySummary(ctx, snapshot) + if err != nil { + return nil, err + } + + now := time.Now().Unix() + record := smokemodel.SmokeAIAdvice{ + UID: int(user.ID), + Type: SmokeAIAdviceTypeDailySummary, + AdviceDate: dateOnly(summaryDate), + PromptVersion: promptVersion, + Provider: "openai-compatible", + Model: modelName, + InputSnapshot: snapshotJSON, + Advice: adviceText, + TokensIn: tokensIn, + TokensOut: tokensOut, + CreateTime: &now, + UpdateTime: &now, + } + + if err := s.db.WithContext(ctx).Create(&record).Error; err != nil { + return nil, fmt.Errorf("save daily summary: %w", err) + } + return &record, nil +} + +func (s *SmokeAIAdviceService) callAIDailySummary(ctx context.Context, snap adviceSnapshot) (string, string, *int, *int, error) { + if s.cfg.APIKey == "" || s.cfg.Model == "" || s.cfg.BaseURL == "" { + return "", "", nil, nil, ErrAIServiceDisabled + } + + systemPrompt := strings.TrimSpace(` +你是一名专业的戒烟教练。请基于用户今天的抽烟数据,生成一份简洁的每日总结。 +要求: +1) 用中文输出,语气友好、鼓励为主; +2) 输出严格的 JSON 格式(不要 markdown 代码块),结构如下: +{ + "summary": "一段 50-100 字的今日总结,概括抽烟模式和整体表现", + "highlights": ["亮点或关键发现1", "亮点或关键发现2", "亮点或关键发现3"], + "suggestion": "一条具体的、可执行的明日改进建议(30-60字)" +} +3) summary 应包含:今日总量、时间分布特点、烟瘾等级趋势; +4) highlights 给出 2-4 条关键发现(如高峰时段、触发场景、与昨日对比等); +5) suggestion 给出明天可以尝试的一个具体行动; +6) 如果有 profile 信息,结合用户的戒烟动力和作息来个性化建议。 +`) + + userPrompt := fmt.Sprintf("用户今日抽烟数据(JSON):\n%s", mustJSON(snap)) + + reqBody := chatCompletionRequest{ + Model: s.cfg.Model, + Messages: []chatMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: userPrompt}, + }, + Temperature: defaultTemperature, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return "", "", nil, nil, fmt.Errorf("marshal ai request: %w", err) + } + + endpoint := strings.TrimRight(s.cfg.BaseURL, "/") + "/chat/completions" + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload)) + if err != nil { + return "", "", nil, nil, fmt.Errorf("build ai request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+s.cfg.APIKey) + + resp, err := s.client.Do(httpReq) + if err != nil { + return "", "", nil, nil, fmt.Errorf("call ai: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", nil, nil, fmt.Errorf("read ai response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", "", nil, nil, fmt.Errorf("ai http %d: %s", resp.StatusCode, truncateString(string(body), 512)) + } + + var parsed chatCompletionResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", "", nil, nil, fmt.Errorf("parse ai response: %w", err) + } + if len(parsed.Choices) == 0 { + return "", "", nil, nil, errors.New("ai response has no choices") + } + + content := strings.TrimSpace(parsed.Choices[0].Message.Content) + if content == "" { + return "", "", nil, nil, errors.New("ai response content is empty") + } + + modelName := parsed.Model + if modelName == "" { + modelName = s.cfg.Model + } + + var tokensIn, tokensOut *int + if parsed.Usage != nil { + tokensIn = &parsed.Usage.PromptTokens + tokensOut = &parsed.Usage.CompletionTokens + } + + return content, modelName, tokensIn, tokensOut, nil +}