fix: 修复微信账单金额解析问题(半角¥符号支持)

- 修复 parse_amount 函数同时支持全角¥和半角¥
- 新增 MonthRangePicker 日期选择组件
- 新增 /api/monthly-stats 接口获取月度统计
- 分析页面月度趋势使用全量数据
- 新增健康检查路由
This commit is contained in:
2026-01-10 19:21:24 +08:00
parent 9247e1ec7f
commit eb76c3a8dc
20 changed files with 597 additions and 44 deletions

View File

@@ -1,5 +1,8 @@
# BillAI 服务器配置文件
# 应用版本
version: "0.0.1"
# 服务配置
server:
port: 8080

View File

@@ -11,6 +11,7 @@ import (
// Config 服务配置
type Config struct {
Version string // 应用版本
Port string // 服务端口
ProjectRoot string // 项目根目录
PythonPath string // Python 解释器路径
@@ -31,6 +32,7 @@ type Config struct {
// configFile YAML 配置文件结构
type configFile struct {
Version string `yaml:"version"`
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
@@ -117,6 +119,7 @@ func Load() {
flag.Parse()
// 设置默认值
Global.Version = "0.0.1"
Global.Port = getEnvOrDefault("PORT", "8080")
Global.ProjectRoot = getDefaultProjectRoot()
Global.PythonPath = getDefaultPythonPath()
@@ -145,6 +148,9 @@ func Load() {
// 加载配置文件
if cfg := loadConfigFile(configPath); cfg != nil {
fmt.Printf("📄 加载配置文件: %s\n", configPath)
if cfg.Version != "" {
Global.Version = cfg.Version
}
if cfg.Server.Port > 0 {
Global.Port = fmt.Sprintf("%d", cfg.Server.Port)
}

View File

@@ -165,3 +165,36 @@ func parsePageParam(s string, defaultVal int) int {
}
return val
}
// MonthlyStatsResponse 月度统计响应
type MonthlyStatsResponse struct {
Result bool `json:"result"`
Message string `json:"message,omitempty"`
Data []model.MonthlyStat `json:"data,omitempty"`
}
// MonthlyStats 获取月度统计数据(全部数据,不受筛选条件影响)
func MonthlyStats(c *gin.Context) {
repo := repository.GetRepository()
if repo == nil {
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
Result: false,
Message: "数据库未连接",
})
return
}
stats, err := repo.GetMonthlyStats()
if err != nil {
c.JSON(http.StatusInternalServerError, MonthlyStatsResponse{
Result: false,
Message: "查询失败: " + err.Error(),
})
return
}
c.JSON(http.StatusOK, MonthlyStatsResponse{
Result: true,
Data: stats,
})
}

View File

@@ -65,8 +65,8 @@ func main() {
// 注册路由
router.Setup(r, router.Config{
OutputDir: outputDirAbs,
PythonPath: pythonPathAbs,
OutputDir: outputDirAbs,
Version: config.Global.Version,
})
// 监听系统信号

View File

@@ -38,3 +38,10 @@ type CleanedBill struct {
SourceFile string `bson:"source_file" json:"source_file"` // 来源文件名
UploadBatch string `bson:"upload_batch" json:"upload_batch"` // 上传批次(时间戳)
}
// MonthlyStat 月度统计数据
type MonthlyStat struct {
Month string `bson:"month" json:"month"` // 月份 YYYY-MM
Expense float64 `bson:"expense" json:"expense"` // 月支出总额
Income float64 `bson:"income" json:"income"` // 月收入总额
}

View File

@@ -303,6 +303,86 @@ func (r *Repository) GetBillsNeedReview() ([]model.CleanedBill, error) {
return r.GetCleanedBills(filter)
}
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
func (r *Repository) GetMonthlyStats() ([]model.MonthlyStat, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 使用聚合管道按月份分组统计
// 先按月份和收支类型分组,再汇总
pipeline := mongo.Pipeline{
// 添加月份字段
{{Key: "$addFields", Value: bson.D{
{Key: "month", Value: bson.D{
{Key: "$dateToString", Value: bson.D{
{Key: "format", Value: "%Y-%m"},
{Key: "date", Value: "$time"},
}},
}},
}}},
// 按月份和收支类型分组
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: bson.D{
{Key: "month", Value: "$month"},
{Key: "income_expense", Value: "$income_expense"},
}},
{Key: "total", Value: bson.D{{Key: "$sum", Value: "$amount"}}},
}}},
// 按月份重新分组,汇总收入和支出
{{Key: "$group", Value: bson.D{
{Key: "_id", Value: "$_id.month"},
{Key: "expense", Value: bson.D{
{Key: "$sum", Value: bson.D{
{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "支出"}}},
"$total",
0,
}},
}},
}},
{Key: "income", Value: bson.D{
{Key: "$sum", Value: bson.D{
{Key: "$cond", Value: bson.A{
bson.D{{Key: "$eq", Value: bson.A{"$_id.income_expense", "收入"}}},
"$total",
0,
}},
}},
}},
}}},
// 按月份排序
{{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},
}
cursor, err := r.cleanedCollection.Aggregate(ctx, pipeline)
if err != nil {
return nil, fmt.Errorf("月度统计聚合失败: %w", err)
}
defer cursor.Close(ctx)
// 解析结果
var results []struct {
Month string `bson:"_id"`
Expense float64 `bson:"expense"`
Income float64 `bson:"income"`
}
if err := cursor.All(ctx, &results); err != nil {
return nil, fmt.Errorf("解析月度统计结果失败: %w", err)
}
// 转换为 MonthlyStat
stats := make([]model.MonthlyStat, len(results))
for i, r := range results {
stats[i] = model.MonthlyStat{
Month: r.Month,
Expense: r.Expense,
Income: r.Income,
}
}
return stats, nil
}
// GetClient 获取 MongoDB 客户端(用于兼容旧代码)
func (r *Repository) GetClient() *mongo.Client {
return r.client

View File

@@ -36,6 +36,10 @@ type BillRepository interface {
// 返回: 总支出、总收入、错误
GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error)
// GetMonthlyStats 获取月度统计(全部数据,不受筛选条件影响)
// 返回: 月度统计列表、错误
GetMonthlyStats() ([]model.MonthlyStat, error)
// GetBillsNeedReview 获取需要复核的账单
GetBillsNeedReview() ([]model.CleanedBill, error)

View File

@@ -11,14 +11,14 @@ import (
// Config 路由配置参数
type Config struct {
OutputDir string // 输出目录(用于静态文件服务)
PythonPath string // Python 路径(用于健康检查显示)
OutputDir string // 输出目录(用于静态文件服务)
Version string // 应用版本
}
// Setup 设置所有路由
func Setup(r *gin.Engine, cfg Config) {
// 健康检查
r.GET("/health", healthCheck(cfg.PythonPath))
r.GET("/health", healthCheck(cfg.Version))
// API 路由组
setupAPIRoutes(r)
@@ -28,11 +28,11 @@ func Setup(r *gin.Engine, cfg Config) {
}
// healthCheck 健康检查处理器
func healthCheck(pythonPath string) gin.HandlerFunc {
func healthCheck(version string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"python_path": pythonPath,
"status": "ok",
"version": version,
})
}
}
@@ -49,5 +49,8 @@ func setupAPIRoutes(r *gin.Engine) {
// 账单查询
api.GET("/bills", handler.ListBills)
// 月度统计(全部数据)
api.GET("/monthly-stats", handler.MonthlyStats)
}
}