feat: 完善项目架构并增强分析页面功能
- 新增项目文档和 Docker 配置 - 添加 README.md 和 TODO.md 项目文档 - 为各服务添加 Dockerfile 和 docker-compose 配置 - 重构后端架构 - 新增 adapter 层(HTTP/Python 适配器) - 新增 repository 层(数据访问抽象) - 新增 router 模块统一管理路由 - 新增账单处理 handler - 扩展前端 UI 组件库 - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件 - 集成 shadcn-svelte 组件库 - 增强分析页面功能 - 添加时间范围筛选器(支持本月默认值) - 修复 DateRangePicker 默认值显示问题 - 优化数据获取和展示逻辑 - 完善分析器服务 - 新增 FastAPI 服务接口 - 改进账单清理器实现
This commit is contained in:
165
server/handler/bills.go
Normal file
165
server/handler/bills.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"billai-server/model"
|
||||
"billai-server/repository"
|
||||
)
|
||||
|
||||
// ListBillsRequest 账单列表请求参数
|
||||
type ListBillsRequest struct {
|
||||
Page int `form:"page"` // 页码,从 1 开始
|
||||
PageSize int `form:"page_size"` // 每页数量,默认 20
|
||||
StartDate string `form:"start_date"` // 开始日期 YYYY-MM-DD
|
||||
EndDate string `form:"end_date"` // 结束日期 YYYY-MM-DD
|
||||
Category string `form:"category"` // 分类筛选
|
||||
Type string `form:"type"` // 账单类型 alipay/wechat
|
||||
IncomeExpense string `form:"income_expense"` // 收支类型 收入/支出
|
||||
}
|
||||
|
||||
// ListBillsResponse 账单列表响应
|
||||
type ListBillsResponse struct {
|
||||
Result bool `json:"result"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Data *ListBillsData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ListBillsData 账单列表数据
|
||||
type ListBillsData struct {
|
||||
Total int64 `json:"total"` // 总记录数
|
||||
TotalExpense float64 `json:"total_expense"` // 筛选条件下的总支出
|
||||
TotalIncome float64 `json:"total_income"` // 筛选条件下的总收入
|
||||
Page int `json:"page"` // 当前页码
|
||||
PageSize int `json:"page_size"` // 每页数量
|
||||
Pages int `json:"pages"` // 总页数
|
||||
Bills []model.CleanedBill `json:"bills"` // 账单列表
|
||||
}
|
||||
|
||||
// ListBills 获取清洗后的账单列表
|
||||
func ListBills(c *gin.Context) {
|
||||
var req ListBillsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ListBillsResponse{
|
||||
Result: false,
|
||||
Message: "参数解析失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Page < 1 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize < 1 {
|
||||
req.PageSize = 20
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100 // 限制最大每页数量
|
||||
}
|
||||
|
||||
// 构建筛选条件
|
||||
filter := make(map[string]interface{})
|
||||
|
||||
// 时间范围筛选
|
||||
if req.StartDate != "" || req.EndDate != "" {
|
||||
timeFilter := make(map[string]interface{})
|
||||
if req.StartDate != "" {
|
||||
startTime, err := time.Parse("2006-01-02", req.StartDate)
|
||||
if err == nil {
|
||||
timeFilter["$gte"] = startTime
|
||||
}
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
endTime, err := time.Parse("2006-01-02", req.EndDate)
|
||||
if err == nil {
|
||||
// 结束日期包含当天,所以加一天
|
||||
endTime = endTime.Add(24 * time.Hour)
|
||||
timeFilter["$lt"] = endTime
|
||||
}
|
||||
}
|
||||
if len(timeFilter) > 0 {
|
||||
filter["time"] = timeFilter
|
||||
}
|
||||
}
|
||||
|
||||
// 分类筛选
|
||||
if req.Category != "" {
|
||||
filter["category"] = req.Category
|
||||
}
|
||||
|
||||
// 账单类型筛选
|
||||
if req.Type != "" {
|
||||
filter["bill_type"] = req.Type
|
||||
}
|
||||
|
||||
// 收支类型筛选
|
||||
if req.IncomeExpense != "" {
|
||||
filter["income_expense"] = req.IncomeExpense
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
repo := repository.GetRepository()
|
||||
if repo == nil {
|
||||
c.JSON(http.StatusInternalServerError, ListBillsResponse{
|
||||
Result: false,
|
||||
Message: "数据库未连接",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取账单列表(带分页)
|
||||
bills, total, err := repo.GetCleanedBillsPaged(filter, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ListBillsResponse{
|
||||
Result: false,
|
||||
Message: "查询失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取聚合统计
|
||||
totalExpense, totalIncome, err := repo.GetBillsAggregate(filter)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ListBillsResponse{
|
||||
Result: false,
|
||||
Message: "统计失败: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 计算总页数
|
||||
pages := int(total) / req.PageSize
|
||||
if int(total)%req.PageSize > 0 {
|
||||
pages++
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ListBillsResponse{
|
||||
Result: true,
|
||||
Data: &ListBillsData{
|
||||
Total: total,
|
||||
TotalExpense: totalExpense,
|
||||
TotalIncome: totalIncome,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
Pages: pages,
|
||||
Bills: bills,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// parsePageParam 解析分页参数
|
||||
func parsePageParam(s string, defaultVal int) int {
|
||||
if s == "" {
|
||||
return defaultVal
|
||||
}
|
||||
val, err := strconv.Atoi(s)
|
||||
if err != nil || val < 1 {
|
||||
return defaultVal
|
||||
}
|
||||
return val
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -36,6 +35,23 @@ func Upload(c *gin.Context) {
|
||||
req.Format = "csv"
|
||||
}
|
||||
|
||||
// 验证 type 参数
|
||||
if req.Type == "" {
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "请指定账单类型 (type: alipay 或 wechat)",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Type != "alipay" && req.Type != "wechat" {
|
||||
c.JSON(http.StatusBadRequest, model.UploadResponse{
|
||||
Result: false,
|
||||
Message: "账单类型无效,仅支持 alipay 或 wechat",
|
||||
})
|
||||
return
|
||||
}
|
||||
billType := req.Type
|
||||
|
||||
// 3. 保存上传的文件
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
|
||||
@@ -64,9 +80,6 @@ func Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 账单类型从去重结果获取
|
||||
billType := dedupResult.BillType
|
||||
|
||||
fmt.Printf(" 原始记录: %d 条\n", dedupResult.OriginalCount)
|
||||
if dedupResult.DuplicateCount > 0 {
|
||||
fmt.Printf(" 重复记录: %d 条(已跳过)\n", dedupResult.DuplicateCount)
|
||||
@@ -91,14 +104,14 @@ func Upload(c *gin.Context) {
|
||||
// 使用去重后的文件路径进行后续处理
|
||||
processFilePath := dedupResult.DedupFilePath
|
||||
|
||||
// 5. 构建输出文件路径
|
||||
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
|
||||
// 5. 构建输出文件路径:时间_type_编号
|
||||
outputExt := ".csv"
|
||||
if req.Format == "json" {
|
||||
outputExt = ".json"
|
||||
}
|
||||
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
|
||||
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
|
||||
fileSeq := generateFileSequence(outputDirAbs, timestamp, billType, outputExt)
|
||||
outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt)
|
||||
outputPath := filepath.Join(outputDirAbs, outputFileName)
|
||||
|
||||
// 6. 执行 Python 清洗脚本
|
||||
@@ -109,7 +122,7 @@ func Upload(c *gin.Context) {
|
||||
End: req.End,
|
||||
Format: req.Format,
|
||||
}
|
||||
cleanResult, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
|
||||
if cleanErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.UploadResponse{
|
||||
Result: false,
|
||||
@@ -118,12 +131,7 @@ func Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 7. 如果去重检测没有识别出类型,从 Python 输出中检测
|
||||
if billType == "" {
|
||||
billType = cleanResult.BillType
|
||||
}
|
||||
|
||||
// 8. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
||||
// 7. 将去重后的原始数据存入 MongoDB(原始数据集合)
|
||||
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
|
||||
if rawErr != nil {
|
||||
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
|
||||
@@ -163,3 +171,14 @@ func Upload(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// generateFileSequence 生成文件序号
|
||||
// 根据当前目录下同一时间戳和类型的文件数量生成序号
|
||||
func generateFileSequence(dir, timestamp, billType, ext string) string {
|
||||
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
|
||||
matches, err := filepath.Glob(filepath.Join(dir, pattern))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return "001"
|
||||
}
|
||||
return fmt.Sprintf("%03d", len(matches)+1)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user