// 账单接口 package app import ( "encoding/json" "fmt" "math/rand" "net/http" "os" "simple-memo/global" "simple-memo/models" "simple-memo/utils" "time" "go.uber.org/zap" "gorm.io/gorm" "github.com/gin-gonic/gin" ) // -------------------------- 1. 定义 Handler 接口 -------------------------- // BillHandler 账单模块接口定义 type BillHandler interface { AddBill(c *gin.Context) // 添加账单 EditBill(c *gin.Context) // 编辑账单 BillList(c *gin.Context) // 账单列表 DeleteBill(c *gin.Context) // 删除账单 MonthStat(c *gin.Context) // 月度统计(mine.vue 使用) CateStat(c *gin.Context) // 分类统计 ExportBill(c *gin.Context) // 导出账单 AggregateStat(c *gin.Context) // 聚合统计 } // -------------------------- 2. 实现结构体 -------------------------- type billHandler struct { db *gorm.DB } func NewBillHandler() BillHandler { return &billHandler{ db: global.DB, } } // -------------------------- 3. 请求结构体 -------------------------- // AddBillReq 添加账单 type AddBillReq struct { Type int `json:"type"` // 1-支出 2-收入 3-转账 Money float64 `json:"money"` // 金额 Cate string `json:"cate"` // 分类 Note string `json:"note"` // 备注 Channel string `json:"channel"` // 渠道 Date string `json:"date"` // 日期 YYYY-MM-DD FromAccount string `json:"from_account"` ToAccount string `json:"to_account"` } // EditBillReq 编辑账单 type EditBillReq struct { ID uint `json:"id"` Type int `json:"type"` Money float64 `json:"money"` Cate string `json:"cate"` Note string `json:"note"` Channel string `json:"channel"` Date string `json:"date"` FromAccount string `json:"from_account"` ToAccount string `json:"to_account"` } // BillListReq 账单列表 type BillListReq struct { Date string `json:"date"` // 日期 YYYY-MM-DD StartTime string `json:"start_time"` // 开始时间 YYYY-MM-DD EndTime string `json:"end_time"` // 结束时间 YYYY-MM-DD } // DeleteBillReq 删除账单 type DeleteBillReq struct { ID uint `json:"id"` } // MonthStatReq 月度统计 type MonthStatReq struct { Month string `form:"month"` // YYYY-MM } // AggregateStatReq 聚合统计请求 type AggregateStatReq struct { StartDate string `json:"start_date" binding:"required"` // 开始日期 EndDate string `json:"end_date" binding:"required"` // 结束日期 Type int `json:"type"` // 类型筛选 1-支出 2-收入,不传则全部 } // -------------------------- 4. 接口实现 -------------------------- // GetBillListForCalendar 获取日历月份的账单列表(内部方法) func (h *billHandler) GetBillListForCalendar(c *gin.Context, year, month int) []interface{} { userID := c.GetUint("userID") // 计算月份范围 startDate := fmt.Sprintf("%d-%02d-01", year, month) _, lastDay := utils.GetMonthLastDay(year, month) endDate := fmt.Sprintf("%d-%02d-%02d", year, month, lastDay) var list []models.Bill h.db.Model(&models.Bill{}). Where("user_id = ? AND date BETWEEN ? AND ?", userID, startDate, endDate). Order("date ASC, id ASC"). Find(&list) result := make([]interface{}, len(list)) for i, v := range list { result[i] = v } return result } // AddBill 添加账单 func (h *billHandler) AddBill(c *gin.Context) { userID := c.GetUint("userID") var req AddBillReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } // 检查每日限制(最多100个账单/天) today := time.Now().Format("2006-01-02") var todayCount int64 if err := h.db.Model(&models.Bill{}).Where("user_id = ? AND date = ? AND deleted_at IS NULL", userID, today).Count(&todayCount).Error; err != nil { utils.Fail(c, "系统错误") return } if todayCount >= 100 { utils.Fail(c, "今日账单数量已达上限(100个)") return } bill := models.Bill{ UserID: userID, Type: req.Type, Money: req.Money, Cate: req.Cate, Note: utils.FilterSensitive(req.Note), // 敏感词过滤 Channel: req.Channel, Date: req.Date, FromAccount: req.FromAccount, ToAccount: req.ToAccount, } if err := h.db.Create(&bill).Error; err != nil { utils.Fail(c, "添加失败") return } var growthResult any debugMode := os.Getenv("DEBUG_MODE") // ==================== 调试模式 ==================== if debugMode == "true" { rand.Seed(time.Now().UnixNano()) levelUp := true // 20%概率升级 hasAchievement := true // 30%概率获得成就 growthResult = MockGrowthRewards(levelUp, hasAchievement) } else { // 生产模式:真实调用 growthHandler := NewGrowthHandler() growthResult = growthHandler.AddExpInternal(userID, "bill", fmt.Sprintf("%d", bill.ID), nil) } // ================================================== utils.Ok(c, gin.H{ "success": true, "growth": growthResult, "bill": bill, "debugMode": debugMode, "isDebug": debugMode == "true", }) } // EditBill 编辑账单 func (h *billHandler) EditBill(c *gin.Context) { userID := c.GetUint("userID") var req EditBillReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } data := map[string]any{ "type": req.Type, "money": req.Money, "cate": req.Cate, "note": utils.FilterSensitive(req.Note), // 敏感词过滤 "date": req.Date, "channel": req.Channel, "from_account": req.FromAccount, "to_account": req.ToAccount, } err := h.db.Model(&models.Bill{}). Where("id = ? AND user_id = ?", req.ID, userID). Updates(data).Error if err != nil { utils.Fail(c, "编辑失败") return } utils.Ok(c, nil) } // BillList 获取账单列表 func (h *billHandler) BillList(c *gin.Context) { userID := c.GetUint("userID") // date := c.DefaultQuery("date", time.Now().Format("2006-01-02")) var req BillListReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } paramsJson, err := json.Marshal(req) if err != nil { global.Logger.Warn("序列化请求参数失败", append(utils.LogContextFields(c), zap.Error(err))..., ) } else { global.Logger.Info("【请求参数】", append(utils.LogContextFields(c), zap.String("req", string(paramsJson)), )..., ) } // 定义时间格式化模板 const dateLayout = "2006-01-02" var queryDate, queryStart, queryEnd string now := time.Now() today := now.Format(dateLayout) date := req.Date startTime := req.StartTime endTime := req.EndTime switch { case startTime != "" && endTime != "": if _, err := time.Parse(dateLayout, startTime); err != nil { utils.Fail(c, "start_time格式错误,需为:2006-01-02", http.StatusBadRequest) return } if _, err := time.Parse(dateLayout, endTime); err != nil { utils.Fail(c, "end_time格式错误,需为:2006-01-02", http.StatusBadRequest) return } // 校验结束时间不能早于开始时间 start, _ := time.Parse(dateLayout, startTime) end, _ := time.Parse(dateLayout, endTime) if end.Before(start) { utils.Fail(c, "结束日期不能早于开始日期", http.StatusBadRequest) return } queryStart = startTime queryEnd = endTime case date != "" && startTime == "" && endTime == "": queryDate = date default: // 默认查询今日 queryDate = today } var list []models.Bill tx := h.db.Model(&models.Bill{}).Where("user_id = ?", userID) // 根据参数类型构建时间查询条件 if queryStart != "" && queryEnd != "" { // 时间范围查询:date BETWEEN start AND end tx = tx.Where("date BETWEEN ? AND ?", queryStart, queryEnd) } else { // 单日查询 tx = tx.Where("date = ?", queryDate) } err = tx.Order("id DESC").Find(&list).Error if err != nil { utils.Fail(c, "获取失败") return } utils.Ok(c, list) } // DeleteBill 删除账单 func (h *billHandler) DeleteBill(c *gin.Context) { userID := c.GetUint("userID") var req DeleteBillReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } // 查询账单信息(用于扣除积分) var bill models.Bill err := h.db.Where("id = ? AND user_id = ?", req.ID, userID).First(&bill).Error if err != nil { utils.Fail(c, "账单不存在") return } // 删除账单 err = h.db.Where("id = ? AND user_id = ?", req.ID, userID). Delete(&models.Bill{}).Error if err != nil { utils.Fail(c, "删除失败") return } // 扣除今天添加时获得的积分 growthHandler := NewGrowthHandler() today := time.Now().Format("2006-01-02") deducted := growthHandler.DeductExpInternal(userID, "bill", fmt.Sprintf("%d", req.ID), today) utils.Ok(c, gin.H{ "deducted_exp": deducted, }) } // MonthStat 月度统计(mine.vue 核心接口) func (h *billHandler) MonthStat(c *gin.Context) { userID := c.GetUint("userID") var req MonthStatReq _ = c.ShouldBindQuery(&req) now := time.Now() month := req.Month if month == "" { month = now.Format("2006-01") } // 收入 var income float64 h.db.Model(&models.Bill{}). Where("user_id = ? AND type = 2 AND date LIKE ?", userID, month+"%"). Select("COALESCE(SUM(money), 0)").Scan(&income) // 支出 var expend float64 h.db.Model(&models.Bill{}). Where("user_id = ? AND type = 1 AND date LIKE ?", userID, month+"%"). Select("COALESCE(SUM(money), 0)").Scan(&expend) // 待办完成数(需要关联 todo 表) var totalTask int64 var finishTask int64 h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? ", userID, month+"%").Count(&totalTask) h.db.Model(&models.Todo{}).Where("user_id = ? AND date LIKE ? AND done = 1", userID, month+"%").Count(&finishTask) utils.Ok(c, gin.H{ "income": income, "expend": expend, "totalTask": totalTask, "finishTask": finishTask, }) } // CateStat 分类统计 func (h *billHandler) CateStat(c *gin.Context) { utils.Ok(c, []any{}) } // ExportBill 导出账单 func (h *billHandler) ExportBill(c *gin.Context) { utils.Ok(c, "导出功能开发中") } // AggregateStat 聚合统计 func (h *billHandler) AggregateStat(c *gin.Context) { userID := c.GetUint("userID") var req AggregateStatReq if err := c.ShouldBindJSON(&req); err != nil { utils.Fail(c, "参数解析失败") return } // 构建基础查询 baseQuery := h.db.Model(&models.Bill{}). Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate) // 类型筛选 if req.Type > 0 { baseQuery = baseQuery.Where("type = ?", req.Type) } // ========== 1. 总体统计 ========== var totalExpend, totalIncome float64 h.db.Model(&models.Bill{}). Where("user_id = ? AND date BETWEEN ? AND ? AND type = 1", userID, req.StartDate, req.EndDate). Select("COALESCE(SUM(money), 0)").Scan(&totalExpend) h.db.Model(&models.Bill{}). Where("user_id = ? AND date BETWEEN ? AND ? AND type = 2", userID, req.StartDate, req.EndDate). Select("COALESCE(SUM(money), 0)").Scan(&totalIncome) balance := totalIncome - totalExpend // ========== 2. 预算数据 ========== // 获取月份(假设StartDate和EndDate在同一月) month := req.StartDate[:7] // YYYY-MM var budget models.Budget h.db.Where("user_id = ? AND month = ?", userID, month).First(&budget) budgetData := gin.H{ "amount": 0, "used": totalExpend, "remaining": 0, "progress": 0.0, } if budget.ID > 0 { budgetData = gin.H{ "amount": budget.Amount, "used": totalExpend, "remaining": budget.Amount - totalExpend, "progress": totalExpend / budget.Amount * 100, } } // ========== 3. 按分类统计 ========== type CategoryStat struct { Cate string `json:"cate"` Label string `json:"label"` Icon string `json:"icon"` Color string `json:"color"` Expend float64 `json:"expend"` Income float64 `json:"income"` ExpendRate float64 `json:"expend_rate"` IncomeRate float64 `json:"income_rate"` } var categoryResults []struct { Cate string Expend float64 Income float64 } h.db.Model(&models.Bill{}). Select(` cate, COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend, COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income `). Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). Group("cate"). Scan(&categoryResults) byCategory := []CategoryStat{} for _, r := range categoryResults { expendRate := 0.0 incomeRate := 0.0 if totalExpend > 0 { expendRate = r.Expend / totalExpend * 100 } if totalIncome > 0 { incomeRate = r.Income / totalIncome * 100 } byCategory = append(byCategory, CategoryStat{ Cate: r.Cate, Label: r.Cate, // 可以后续从配置中读取中文标签 Icon: "💰", Color: "#ff6b6b", Expend: r.Expend, Income: r.Income, ExpendRate: expendRate, IncomeRate: incomeRate, }) } // ========== 4. 按渠道统计 ========== var channelResults []struct { Channel string Amount float64 } h.db.Model(&models.Bill{}). Select("channel, SUM(money) as amount"). Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). Group("channel"). Scan(&channelResults) byChannel := []gin.H{} for _, r := range channelResults { byChannel = append(byChannel, gin.H{ "channel": r.Channel, "label": r.Channel, "amount": r.Amount, }) } // ========== 5. 日趋势 ========== var dailyResults []struct { Date string Expend float64 Income float64 } h.db.Model(&models.Bill{}). Select(` date, COALESCE(SUM(CASE WHEN type = 1 THEN money ELSE 0 END), 0) as expend, COALESCE(SUM(CASE WHEN type = 2 THEN money ELSE 0 END), 0) as income `). Where("user_id = ? AND date BETWEEN ? AND ?", userID, req.StartDate, req.EndDate). Group("date"). Order("date ASC"). Scan(&dailyResults) dailyTrend := []gin.H{} for _, r := range dailyResults { dailyTrend = append(dailyTrend, gin.H{ "date": r.Date, "expend": r.Expend, "income": r.Income, }) } utils.Ok(c, gin.H{ "total_expend": totalExpend, "total_income": totalIncome, "balance": balance, "budget": budgetData, "by_category": byCategory, "by_channel": byChannel, "daily_trend": dailyTrend, }) }