feat: 添加数据库迁移工具并创建AI记账服务文件。

This commit is contained in:
2026-01-29 22:00:15 +08:00
parent cf34f8b3d0
commit 81f814c928
2 changed files with 384 additions and 13 deletions

View File

@@ -14,9 +14,9 @@ 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
}

View File

@@ -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
}
}