feat: 添加数据库迁移工具并创建AI记账服务文件。
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user