diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index 6fccd98..4722923 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -14,12 +14,12 @@ import ( func main() { // Load .env file from project root (try multiple locations) envPaths := []string{ - ".env", // Current directory - "../.env", // Parent directory (when running from backend/) - "../../.env", // Two levels up (when running from backend/cmd/migrate/) + ".env", // Current directory + "../.env", // Parent directory (when running from backend/) + "../../.env", // Two levels up (when running from backend/cmd/migrate/) filepath.Join("..", "..", ".env"), // Explicit path } - + for _, envPath := range envPaths { if err := godotenv.Load(envPath); err == nil { log.Printf("Loaded environment from: %s", envPath) diff --git a/internal/service/ai_bookkeeping_service.go b/internal/service/ai_bookkeeping_service.go index 3f28cec..37a56e1 100644 --- a/internal/service/ai_bookkeeping_service.go +++ b/internal/service/ai_bookkeeping_service.go @@ -874,6 +874,9 @@ func (s *AIBookkeepingService) ProcessChat(ctx context.Context, userID uint, ses Content: message, }) + // 检测是否为消费建议意图(想吃/想买/想喝等) + isSpendingAdvice := s.isSpendingAdviceIntent(message) + // Parse intent params, responseMsg, err := s.llmService.ParseIntent(ctx, message, session.Messages[:len(session.Messages)-1]) if err != nil { @@ -907,7 +910,6 @@ func (s *AIBookkeepingService) ProcessChat(ctx context.Context, userID uint, ses defaultCategoryID, defaultCategoryName := s.getDefaultCategory(userID, session.Params.Type) if defaultCategoryID != nil { session.Params.CategoryID = defaultCategoryID - // Keep the original category name from AI, just set the ID if session.Params.Category == "" { session.Params.Category = defaultCategoryName } @@ -923,7 +925,7 @@ func (s *AIBookkeepingService) ProcessChat(ctx context.Context, userID uint, ses } } - // Check if we have all required params + // 初始化响应 response := &AIChatResponse{ SessionID: session.ID, Message: responseMsg, @@ -931,23 +933,46 @@ func (s *AIBookkeepingService) ProcessChat(ctx context.Context, userID uint, ses Params: session.Params, } - // Check what's missing + // 如果是消费建议意图且有金额,获取财务上下文并综合分析 + if isSpendingAdvice && session.Params.Amount != nil { + response.Intent = "spending_advice" + + // 获取财务上下文 + fc, _ := s.GetUserFinancialContext(ctx, userID) + + // 生成综合分析建议 + advice := s.generateSpendingAdvice(ctx, message, session.Params, fc) + if advice != "" { + response.Message = advice + } + } + + // Check what's missing for transaction creation missingFields := s.getMissingFields(session.Params) if len(missingFields) > 0 { response.NeedsFollowUp = true response.FollowUpQuestion = s.generateFollowUpQuestion(missingFields) - if responseMsg == "" { + if response.Message == "" || response.Message == responseMsg { response.Message = response.FollowUpQuestion } } else { // All params complete, generate confirmation card card := s.GenerateConfirmationCard(session) response.ConfirmationCard = card - response.Message = fmt.Sprintf("请确认:%s %.2f元,分类:%s,账户:%s", - s.getTypeLabel(session.Params.Type), - *session.Params.Amount, - session.Params.Category, - session.Params.Account) + + // 如果不是消费建议,使用标准确认消息 + if !isSpendingAdvice { + response.Message = fmt.Sprintf("请确认:%s %.2f元,分类:%s,账户:%s", + s.getTypeLabel(session.Params.Type), + *session.Params.Amount, + session.Params.Category, + session.Params.Account) + } else { + // 消费建议场景,在建议后附加确认提示 + response.Message += fmt.Sprintf("\n\n📝 需要记账吗?%s %.2f元", + s.getTypeLabel(session.Params.Type), + *session.Params.Amount) + } } // Add assistant response to history @@ -959,6 +984,79 @@ func (s *AIBookkeepingService) ProcessChat(ctx context.Context, userID uint, ses return response, nil } +// isSpendingAdviceIntent 检测是否为消费建议意图 +func (s *AIBookkeepingService) isSpendingAdviceIntent(message string) bool { + keywords := []string{"想吃", "想喝", "想买", "想花", "打算买", "准备买", "要不要", "可以买", "能买", "想要"} + for _, kw := range keywords { + if strings.Contains(message, kw) { + return true + } + } + return false +} + +// generateSpendingAdvice 生成消费建议 +func (s *AIBookkeepingService) generateSpendingAdvice(ctx context.Context, message string, params *AITransactionParams, fc *FinancialContext) string { + if s.config.OpenAIAPIKey == "" || fc == nil { + // 无 API 或无上下文,返回简单建议 + if params.Amount != nil { + return fmt.Sprintf("记下来!%.0f元的%s", *params.Amount, params.Note) + } + return "" + } + + // 构建综合分析 prompt + fcJSON, _ := json.Marshal(fc) + + prompt := fmt.Sprintf(`你是「小金」,用户的贴心理财助手。性格活泼、接地气、偶尔毒舌但心软。 + +用户说:「%s」 + +用户财务状况: +%s + +请综合分析后给出建议,要求: +1. 根据预算剩余和消费趋势判断是否应该消费 +2. 如果预算紧张,委婉劝阻或建议替代方案 +3. 如果预算充足,可以鼓励适度消费 +4. 用轻松幽默的语气,像朋友聊天一样 +5. 回复60-100字左右,不要太长 + +直接输出建议,不要加前缀。`, message, string(fcJSON)) + + messages := []ChatMessage{ + {Role: "user", Content: prompt}, + } + + reqBody := ChatCompletionRequest{ + Model: s.config.ChatModel, + Messages: messages, + Temperature: 0.7, + } + + jsonBody, _ := json.Marshal(reqBody) + req, err := http.NewRequestWithContext(ctx, "POST", s.config.OpenAIBaseURL+"/chat/completions", bytes.NewReader(jsonBody)) + if err != nil { + return "" + } + + req.Header.Set("Authorization", "Bearer "+s.config.OpenAIAPIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.llmService.httpClient.Do(req) + if err != nil || resp.StatusCode != http.StatusOK { + return "" + } + defer resp.Body.Close() + + var chatResp ChatCompletionResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil || len(chatResp.Choices) == 0 { + return "" + } + + return strings.TrimSpace(chatResp.Choices[0].Message.Content) +} + // mergeParams merges new params into existing params func (s *AIBookkeepingService) mergeParams(existing, new *AITransactionParams) { if new.Amount != nil { @@ -1221,3 +1319,276 @@ func (s *AIBookkeepingService) GetSession(sessionID string) (*AISession, bool) { } return session, true } + +// FinancialContext 用户财务上下文,供 AI 综合分析 +type FinancialContext struct { + // 账户信息 + TotalBalance float64 `json:"total_balance"` // 总余额 + AccountSummary []AccountBrief `json:"account_summary"` // 账户摘要 + + // 最近消费 + Last30DaysSpend float64 `json:"last_30_days_spend"` // 近30天支出 + Last7DaysSpend float64 `json:"last_7_days_spend"` // 近7天支出 + TodaySpend float64 `json:"today_spend"` // 今日支出 + TopCategories []CategorySpend `json:"top_categories"` // 消费大类TOP3 + RecentTransactions []TransactionBrief `json:"recent_transactions"` // 最近5笔交易 + + // 预算信息 + ActiveBudgets []BudgetBrief `json:"active_budgets"` // 活跃预算 + BudgetWarnings []string `json:"budget_warnings"` // 预算警告 + + // 历史对比 + LastMonthSpend float64 `json:"last_month_spend"` // 上月同期支出 + SpendTrend string `json:"spend_trend"` // 消费趋势: up/down/stable +} + +// AccountBrief 账户摘要 +type AccountBrief struct { + Name string `json:"name"` + Balance float64 `json:"balance"` + Type string `json:"type"` +} + +// CategorySpend 分类消费 +type CategorySpend struct { + Category string `json:"category"` + Amount float64 `json:"amount"` + Percent float64 `json:"percent"` +} + +// TransactionBrief 交易摘要 +type TransactionBrief struct { + Amount float64 `json:"amount"` + Category string `json:"category"` + Note string `json:"note"` + Date string `json:"date"` + Type string `json:"type"` +} + +// BudgetBrief 预算摘要 +type BudgetBrief struct { + Name string `json:"name"` + Amount float64 `json:"amount"` + Spent float64 `json:"spent"` + Remaining float64 `json:"remaining"` + Progress float64 `json:"progress"` // 0-100 + IsNearLimit bool `json:"is_near_limit"` + Category string `json:"category,omitempty"` +} + +// GetUserFinancialContext 获取用户财务上下文 +func (s *AIBookkeepingService) GetUserFinancialContext(ctx context.Context, userID uint) (*FinancialContext, error) { + fc := &FinancialContext{} + + // 1. 获取账户信息 + accounts, err := s.accountRepo.GetAll(userID) + if err == nil { + for _, acc := range accounts { + fc.TotalBalance += acc.Balance + fc.AccountSummary = append(fc.AccountSummary, AccountBrief{ + Name: acc.Name, + Balance: acc.Balance, + Type: string(acc.Type), + }) + } + } + + // 2. 获取最近交易统计 + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + last7Days := today.AddDate(0, 0, -7) + last30Days := today.AddDate(0, 0, -30) + lastMonthStart := today.AddDate(0, -1, -today.Day()+1) + lastMonthEnd := today.AddDate(0, 0, -today.Day()) + + // 获取近30天交易 + transactions, err := s.transactionRepo.GetByDateRange(userID, last30Days, now) + if err == nil { + categorySpend := make(map[string]float64) + + for _, tx := range transactions { + if tx.Type == models.TransactionTypeExpense { + fc.Last30DaysSpend += tx.Amount + + if tx.TransactionDate.After(last7Days) || tx.TransactionDate.Equal(last7Days) { + fc.Last7DaysSpend += tx.Amount + } + if tx.TransactionDate.After(today) || tx.TransactionDate.Equal(today) { + fc.TodaySpend += tx.Amount + } + + // 分类统计 + catName := "其他" + if tx.Category.ID != 0 { + catName = tx.Category.Name + } + categorySpend[catName] += tx.Amount + } + } + + // 计算 TOP3 分类 + type catAmount struct { + name string + amount float64 + } + var cats []catAmount + for name, amount := range categorySpend { + cats = append(cats, catAmount{name, amount}) + } + // 简单排序取 TOP3 + for i := 0; i < len(cats)-1; i++ { + for j := i + 1; j < len(cats); j++ { + if cats[j].amount > cats[i].amount { + cats[i], cats[j] = cats[j], cats[i] + } + } + } + for i := 0; i < len(cats) && i < 3; i++ { + percent := 0.0 + if fc.Last30DaysSpend > 0 { + percent = cats[i].amount / fc.Last30DaysSpend * 100 + } + fc.TopCategories = append(fc.TopCategories, CategorySpend{ + Category: cats[i].name, + Amount: cats[i].amount, + Percent: percent, + }) + } + + // 最近5笔交易 + count := 0 + for i := len(transactions) - 1; i >= 0 && count < 5; i-- { + tx := transactions[i] + catName := "其他" + if tx.Category.ID != 0 { + catName = tx.Category.Name + } + fc.RecentTransactions = append(fc.RecentTransactions, TransactionBrief{ + Amount: tx.Amount, + Category: catName, + Note: tx.Note, + Date: tx.TransactionDate.Format("01-02"), + Type: string(tx.Type), + }) + count++ + } + } + + // 3. 获取上月同期支出用于对比 + lastMonthTx, err := s.transactionRepo.GetByDateRange(userID, lastMonthStart, lastMonthEnd) + if err == nil { + for _, tx := range lastMonthTx { + if tx.Type == models.TransactionTypeExpense { + fc.LastMonthSpend += tx.Amount + } + } + } + + // 计算消费趋势 + if fc.LastMonthSpend > 0 { + ratio := fc.Last30DaysSpend / fc.LastMonthSpend + if ratio > 1.1 { + fc.SpendTrend = "up" + } else if ratio < 0.9 { + fc.SpendTrend = "down" + } else { + fc.SpendTrend = "stable" + } + } else { + fc.SpendTrend = "stable" + } + + // 4. 获取预算信息(通过直接查询数据库) + var budgets []models.Budget + if err := s.db.Where("user_id = ?", userID). + Where("start_date <= ?", now). + Where("end_date IS NULL OR end_date >= ?", now). + Preload("Category"). + Find(&budgets).Error; err == nil { + + for _, budget := range budgets { + // 计算当期支出 + periodStart, periodEnd := s.calculateBudgetPeriod(&budget, now) + spent := 0.0 + + // 查询当期支出 + var totalSpent float64 + query := s.db.Model(&models.Transaction{}). + Where("user_id = ?", userID). + Where("type = ?", models.TransactionTypeExpense). + Where("transaction_date >= ? AND transaction_date <= ?", periodStart, periodEnd) + + if budget.CategoryID != nil { + query = query.Where("category_id = ?", *budget.CategoryID) + } + if budget.AccountID != nil { + query = query.Where("account_id = ?", *budget.AccountID) + } + query.Select("COALESCE(SUM(amount), 0)").Scan(&totalSpent) + spent = totalSpent + + progress := 0.0 + if budget.Amount > 0 { + progress = spent / budget.Amount * 100 + } + + isNearLimit := progress >= 80.0 + + catName := "" + if budget.Category != nil { + catName = budget.Category.Name + } + + fc.ActiveBudgets = append(fc.ActiveBudgets, BudgetBrief{ + Name: budget.Name, + Amount: budget.Amount, + Spent: spent, + Remaining: budget.Amount - spent, + Progress: progress, + IsNearLimit: isNearLimit, + Category: catName, + }) + + // 生成预算警告 + if progress >= 100 { + fc.BudgetWarnings = append(fc.BudgetWarnings, + fmt.Sprintf("【%s】预算已超支!", budget.Name)) + } else if isNearLimit { + fc.BudgetWarnings = append(fc.BudgetWarnings, + fmt.Sprintf("【%s】预算已用%.0f%%,请注意控制", budget.Name, progress)) + } + } + } + + return fc, nil +} + +// calculateBudgetPeriod 计算预算当前周期 +func (s *AIBookkeepingService) calculateBudgetPeriod(budget *models.Budget, now time.Time) (time.Time, time.Time) { + switch budget.PeriodType { + case models.PeriodTypeDaily: + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 0, 1).Add(-time.Second) + return start, end + case models.PeriodTypeWeekly: + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 + } + start := time.Date(now.Year(), now.Month(), now.Day()-weekday+1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 0, 7).Add(-time.Second) + return start, end + case models.PeriodTypeMonthly: + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 1, 0).Add(-time.Second) + return start, end + case models.PeriodTypeYearly: + start := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(1, 0, 0).Add(-time.Second) + return start, end + default: + start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + end := start.AddDate(0, 1, 0).Add(-time.Second) + return start, end + } +}