Files
simple-memo/api/app/bill.go
T
sunct 3c3bf53ae4 docs(website): 添加简记memo产品原型和UI设计规范文档
- 新增「简记memo」一体化小程序产品原型设计文档
- 新增简记memo完整版UI视觉设计规范和界面细节
- 添加IDEA项目配置文件.gitignore
- 创建404页面HTML文件,包含响应式布局和错误提示
- 添加关于页面HTML文件,展示品牌介绍和团队信息
- 实现AES加解密工具函数,支持请求体加密
- 添加用户协议页面基础框架
2026-07-31 14:12:32 +08:00

540 lines
14 KiB
Go

// 账单接口
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,
})
}