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:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

49
server/Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Go 服务 Dockerfile
# 多阶段构建:编译阶段 + 运行阶段
# ===== 编译阶段 =====
FROM golang:1.21-alpine AS builder
WORKDIR /build
# 配置 Go 代理(国内镜像)
ENV GOPROXY=https://goproxy.cn,direct
# 先复制依赖文件,利用 Docker 缓存
COPY go.mod go.sum ./
RUN go mod download
# 复制源代码并编译
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o billai-server .
# ===== 运行阶段 =====
FROM alpine:latest
WORKDIR /app
# 配置 Alpine 镜像源(国内)
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
# 安装必要工具
RUN apk --no-cache add ca-certificates tzdata curl
# 设置时区
ENV TZ=Asia/Shanghai
# 从编译阶段复制二进制文件
COPY --from=builder /build/billai-server .
COPY --from=builder /build/config.yaml .
# 创建必要目录
RUN mkdir -p uploads outputs
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=10s --timeout=5s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
# 启动服务
CMD ["./billai-server"]

28
server/adapter/adapter.go Normal file
View File

@@ -0,0 +1,28 @@
// Package adapter 定义与外部系统交互的抽象接口
// 这样可以方便后续更换通信方式(如从子进程调用改为 HTTP/gRPC/消息队列等)
package adapter
// CleanOptions 清洗选项
type CleanOptions struct {
Year string // 年份筛选
Month string // 月份筛选
Start string // 起始日期
End string // 结束日期
Format string // 输出格式: csv/json
}
// CleanResult 清洗结果
type CleanResult struct {
BillType string // 检测到的账单类型: alipay/wechat
Output string // 脚本输出信息
}
// Cleaner 账单清洗器接口
// 负责将原始账单数据清洗为标准格式
type Cleaner interface {
// Clean 执行账单清洗
// inputPath: 输入文件路径
// outputPath: 输出文件路径
// opts: 清洗选项
Clean(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error)
}

15
server/adapter/global.go Normal file
View File

@@ -0,0 +1,15 @@
// Package adapter 全局适配器实例管理
package adapter
// 全局清洗器实例
var globalCleaner Cleaner
// SetCleaner 设置全局清洗器实例
func SetCleaner(c Cleaner) {
globalCleaner = c
}
// GetCleaner 获取全局清洗器实例
func GetCleaner() Cleaner {
return globalCleaner
}

View File

@@ -0,0 +1,204 @@
// Package http 实现通过 HTTP API 调用 Python 服务的清洗器
package http
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
"billai-server/adapter"
)
// CleanRequest HTTP 清洗请求
type CleanRequest struct {
InputPath string `json:"input_path"`
OutputPath string `json:"output_path"`
Year string `json:"year,omitempty"`
Month string `json:"month,omitempty"`
Start string `json:"start,omitempty"`
End string `json:"end,omitempty"`
Format string `json:"format,omitempty"`
BillType string `json:"bill_type,omitempty"`
}
// CleanResponse HTTP 清洗响应
type CleanResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Detail string `json:"detail"`
}
// Cleaner 通过 HTTP API 调用 Python 服务的清洗器实现
type Cleaner struct {
baseURL string // Python 服务基础 URL
httpClient *http.Client // HTTP 客户端
}
// NewCleaner 创建 HTTP 清洗器
func NewCleaner(baseURL string) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 60 * time.Second, // 清洗可能需要较长时间
},
}
}
// NewCleanerWithClient 使用自定义 HTTP 客户端创建清洗器
func NewCleanerWithClient(baseURL string, client *http.Client) *Cleaner {
return &Cleaner{
baseURL: baseURL,
httpClient: client,
}
}
// Clean 执行账单清洗(使用文件上传方式)
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
// 打开输入文件
file, err := os.Open(inputPath)
if err != nil {
return nil, fmt.Errorf("打开文件失败: %w", err)
}
defer file.Close()
// 创建 multipart form
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 添加文件
part, err := writer.CreateFormFile("file", filepath.Base(inputPath))
if err != nil {
return nil, fmt.Errorf("创建表单文件失败: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return nil, fmt.Errorf("复制文件内容失败: %w", err)
}
// 添加其他参数
if opts != nil {
if opts.Year != "" {
writer.WriteField("year", opts.Year)
}
if opts.Month != "" {
writer.WriteField("month", opts.Month)
}
if opts.Start != "" {
writer.WriteField("start", opts.Start)
}
if opts.End != "" {
writer.WriteField("end", opts.End)
}
if opts.Format != "" {
writer.WriteField("format", opts.Format)
}
}
writer.WriteField("bill_type", "auto")
writer.Close()
// 发送上传请求
fmt.Printf("🌐 调用清洗服务: %s/clean/upload\n", c.baseURL)
req, err := http.NewRequest("POST", c.baseURL+"/clean/upload", &body)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
// 处理错误响应
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return nil, fmt.Errorf("清洗失败: %s", errResp.Detail)
}
return nil, fmt.Errorf("清洗失败: HTTP %d - %s", resp.StatusCode, string(respBody))
}
// 解析成功响应
var cleanResp CleanResponse
if err := json.Unmarshal(respBody, &cleanResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 下载清洗后的文件
if cleanResp.OutputPath != "" {
if err := c.downloadFile(cleanResp.OutputPath, outputPath); err != nil {
return nil, fmt.Errorf("下载清洗结果失败: %w", err)
}
}
return &adapter.CleanResult{
BillType: cleanResp.BillType,
Output: cleanResp.Message,
}, nil
}
// downloadFile 下载清洗后的文件
func (c *Cleaner) downloadFile(remotePath, localPath string) error {
// 构建下载 URL
downloadURL := fmt.Sprintf("%s/clean/download/%s", c.baseURL, remotePath)
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("下载请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode)
}
// 创建本地文件
out, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("创建文件失败: %w", err)
}
defer out.Close()
// 写入文件内容
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}
// HealthCheck 检查 Python 服务健康状态
func (c *Cleaner) HealthCheck() error {
resp, err := c.httpClient.Get(c.baseURL + "/health")
if err != nil {
return fmt.Errorf("健康检查失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("服务不健康: HTTP %d", resp.StatusCode)
}
return nil
}
// 确保 Cleaner 实现了 adapter.Cleaner 接口
var _ adapter.Cleaner = (*Cleaner)(nil)

View File

@@ -0,0 +1,94 @@
// Package python 实现通过子进程调用 Python 脚本的清洗器
package python
import (
"fmt"
"os/exec"
"strings"
"billai-server/adapter"
"billai-server/config"
)
// Cleaner 通过子进程调用 Python 脚本的清洗器实现
type Cleaner struct {
pythonPath string // Python 解释器路径
scriptPath string // 清洗脚本路径
workDir string // 工作目录
}
// NewCleaner 创建 Python 清洗器
func NewCleaner() *Cleaner {
return &Cleaner{
pythonPath: config.ResolvePath(config.Global.PythonPath),
scriptPath: config.ResolvePath(config.Global.CleanScript),
workDir: config.Global.ProjectRoot,
}
}
// NewCleanerWithConfig 使用自定义配置创建 Python 清洗器
func NewCleanerWithConfig(pythonPath, scriptPath, workDir string) *Cleaner {
return &Cleaner{
pythonPath: pythonPath,
scriptPath: scriptPath,
workDir: workDir,
}
}
// Clean 执行 Python 清洗脚本
func (c *Cleaner) Clean(inputPath, outputPath string, opts *adapter.CleanOptions) (*adapter.CleanResult, error) {
// 构建命令参数
args := []string{c.scriptPath, inputPath, outputPath}
if opts != nil {
if opts.Year != "" {
args = append(args, "--year", opts.Year)
}
if opts.Month != "" {
args = append(args, "--month", opts.Month)
}
if opts.Start != "" {
args = append(args, "--start", opts.Start)
}
if opts.End != "" {
args = append(args, "--end", opts.End)
}
if opts.Format != "" {
args = append(args, "--format", opts.Format)
}
}
// 执行 Python 脚本
fmt.Printf("🐍 执行清洗脚本...\n")
cmd := exec.Command(c.pythonPath, args...)
cmd.Dir = c.workDir
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
}
// 从输出中检测账单类型
billType := detectBillTypeFromOutput(outputStr)
return &adapter.CleanResult{
BillType: billType,
Output: outputStr,
}, nil
}
// detectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
func detectBillTypeFromOutput(output string) string {
if strings.Contains(output, "支付宝") {
return "alipay"
}
if strings.Contains(output, "微信") {
return "wechat"
}
return ""
}
// 确保 Cleaner 实现了 adapter.Cleaner 接口
var _ adapter.Cleaner = (*Cleaner)(nil)

View File

@@ -4,13 +4,20 @@
server:
port: 8080
# Python 配置
# Python 配置 (subprocess 模式使用)
python:
# Python 解释器路径(相对于项目根目录或绝对路径)
path: analyzer/venv/Scripts/python.exe
path: analyzer/venv/bin/python
# 分析脚本路径(相对于项目根目录)
script: analyzer/clean_bill.py
# Analyzer 服务配置 (HTTP 模式使用)
analyzer:
# Python 分析服务 URL
url: http://localhost:8001
# 适配器模式: http (推荐) 或 subprocess
mode: http
# 文件目录配置(相对于项目根目录)
directories:
upload: server/uploads

View File

@@ -18,6 +18,10 @@ type Config struct {
UploadDir string // 上传文件目录
OutputDir string // 输出文件目录
// Analyzer 服务配置 (HTTP 模式)
AnalyzerURL string // Python 分析服务 URL
AnalyzerMode string // 适配器模式: http 或 subprocess
// MongoDB 配置
MongoURI string // MongoDB 连接 URI
MongoDatabase string // 数据库名称
@@ -34,6 +38,10 @@ type configFile struct {
Path string `yaml:"path"`
Script string `yaml:"script"`
} `yaml:"python"`
Analyzer struct {
URL string `yaml:"url"`
Mode string `yaml:"mode"` // http 或 subprocess
} `yaml:"analyzer"`
Directories struct {
Upload string `yaml:"upload"`
Output string `yaml:"output"`
@@ -116,6 +124,10 @@ func Load() {
Global.UploadDir = "server/uploads"
Global.OutputDir = "server/outputs"
// Analyzer 默认值
Global.AnalyzerURL = getEnvOrDefault("ANALYZER_URL", "http://localhost:8001")
Global.AnalyzerMode = getEnvOrDefault("ANALYZER_MODE", "http")
// MongoDB 默认值
Global.MongoURI = getEnvOrDefault("MONGO_URI", "mongodb://localhost:27017")
Global.MongoDatabase = getEnvOrDefault("MONGO_DATABASE", "billai")
@@ -148,6 +160,13 @@ func Load() {
if cfg.Directories.Output != "" {
Global.OutputDir = cfg.Directories.Output
}
// Analyzer 配置
if cfg.Analyzer.URL != "" {
Global.AnalyzerURL = cfg.Analyzer.URL
}
if cfg.Analyzer.Mode != "" {
Global.AnalyzerMode = cfg.Analyzer.Mode
}
// MongoDB 配置
if cfg.MongoDB.URI != "" {
Global.MongoURI = cfg.MongoDB.URI
@@ -173,6 +192,13 @@ func Load() {
if root := os.Getenv("BILLAI_ROOT"); root != "" {
Global.ProjectRoot = root
}
// Analyzer 环境变量覆盖
if url := os.Getenv("ANALYZER_URL"); url != "" {
Global.AnalyzerURL = url
}
if mode := os.Getenv("ANALYZER_MODE"); mode != "" {
Global.AnalyzerMode = mode
}
// MongoDB 环境变量覆盖
if uri := os.Getenv("MONGO_URI"); uri != "" {
Global.MongoURI = uri
@@ -195,4 +221,3 @@ func ResolvePath(path string) string {
}
return filepath.Join(Global.ProjectRoot, path)
}

165
server/handler/bills.go Normal file
View 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
}

View File

@@ -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)
}

View File

@@ -2,16 +2,20 @@ package main
import (
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/gin-gonic/gin"
"billai-server/adapter"
adapterHttp "billai-server/adapter/http"
"billai-server/adapter/python"
"billai-server/config"
"billai-server/database"
"billai-server/handler"
"billai-server/repository"
repoMongo "billai-server/repository/mongo"
"billai-server/router"
)
func main() {
@@ -36,7 +40,17 @@ func main() {
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
}
// 连接 MongoDB
// 初始化适配器(外部服务交互层)
initAdapters()
// 初始化数据层
if err := initRepository(); err != nil {
fmt.Printf("⚠️ 警告: 数据层初始化失败: %v\n", err)
fmt.Println(" 账单数据将不会存储到数据库")
os.Exit(1)
}
// 连接 MongoDB保持兼容旧代码后续可移除
if err := database.Connect(); err != nil {
fmt.Printf("⚠️ 警告: MongoDB 连接失败: %v\n", err)
fmt.Println(" 账单数据将不会存储到数据库")
@@ -50,7 +64,10 @@ func main() {
r := gin.Default()
// 注册路由
setupRoutes(r, outputDirAbs, pythonPathAbs)
router.Setup(r, router.Config{
OutputDir: outputDirAbs,
PythonPath: pythonPathAbs,
})
// 监听系统信号
go func() {
@@ -67,34 +84,18 @@ func main() {
r.Run(":" + config.Global.Port)
}
// setupRoutes 设置路由
func setupRoutes(r *gin.Engine, outputDirAbs, pythonPathAbs string) {
// 健康检查
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"python_path": pythonPathAbs,
})
})
// API 路由
api := r.Group("/api")
{
api.POST("/upload", handler.Upload)
api.GET("/review", handler.Review)
}
// 静态文件下载
r.Static("/download", outputDirAbs)
}
// printBanner 打印启动横幅
func printBanner(pythonPath, uploadDir, outputDir string) {
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println("📦 BillAI 账单分析服务")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("📁 项目根目录: %s\n", config.Global.ProjectRoot)
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
fmt.Printf("<EFBFBD> 适配器模式: %s\n", config.Global.AnalyzerMode)
if config.Global.AnalyzerMode == "http" {
fmt.Printf("🌐 分析服务: %s\n", config.Global.AnalyzerURL)
} else {
fmt.Printf("🐍 Python路径: %s\n", pythonPath)
}
fmt.Printf("📂 上传目录: %s\n", uploadDir)
fmt.Printf("📂 输出目录: %s\n", outputDir)
fmt.Printf("🍃 MongoDB: %s/%s\n", config.Global.MongoURI, config.Global.MongoDatabase)
@@ -106,8 +107,60 @@ func printAPIInfo() {
fmt.Printf("\n🚀 服务已启动: http://localhost:%s\n", config.Global.Port)
fmt.Println("📝 API 接口:")
fmt.Println(" POST /api/upload - 上传并分析账单")
fmt.Println(" GET /api/bills - 获取账单列表(支持分页和时间筛选)")
fmt.Println(" GET /api/review - 获取需要复核的记录")
fmt.Println(" GET /download/* - 下载结果文件")
fmt.Println(" GET /health - 健康检查")
fmt.Println()
}
// initAdapters 初始化适配器(外部服务交互层)
// 在这里配置与外部系统的交互方式
// 支持两种模式: http (推荐) 和 subprocess
func initAdapters() {
var cleaner adapter.Cleaner
switch config.Global.AnalyzerMode {
case "http":
// 使用 HTTP API 调用 Python 服务(推荐)
httpCleaner := adapterHttp.NewCleaner(config.Global.AnalyzerURL)
// 检查服务健康状态
if err := httpCleaner.HealthCheck(); err != nil {
fmt.Printf("⚠️ 警告: Python 分析服务不可用 (%s): %v\n", config.Global.AnalyzerURL, err)
fmt.Println(" 请确保分析服务已启动: cd analyzer && python server.py")
} else {
fmt.Printf("🌐 已连接到分析服务: %s\n", config.Global.AnalyzerURL)
}
cleaner = httpCleaner
case "subprocess":
// 使用子进程调用 Python 脚本(传统模式)
pythonCleaner := python.NewCleaner()
fmt.Println("🐍 使用子进程模式调用 Python")
cleaner = pythonCleaner
default:
// 默认使用 HTTP 模式
cleaner = adapterHttp.NewCleaner(config.Global.AnalyzerURL)
fmt.Printf("🌐 使用 HTTP 模式 (默认): %s\n", config.Global.AnalyzerURL)
}
adapter.SetCleaner(cleaner)
fmt.Println("🔌 适配器初始化完成")
}
// initRepository 初始化数据存储层
// 在这里配置数据持久化方式
// 后续可以通过修改这里来切换不同的存储实现(如 PostgreSQL、MySQL 等)
func initRepository() error {
// 初始化 MongoDB 存储
mongoRepo := repoMongo.NewRepository()
if err := mongoRepo.Connect(); err != nil {
return err
}
repository.SetRepository(mongoRepo)
fmt.Println("💾 数据层初始化完成")
return nil
}

View File

@@ -2,10 +2,10 @@ package model
// UploadRequest 上传请求参数
type UploadRequest struct {
Type string `form:"type"` // 账单类型: alipay/wechat必填
Year string `form:"year"` // 年份筛选
Month string `form:"month"` // 月份筛选
Start string `form:"start"` // 起始日期
End string `form:"end"` // 结束日期
Format string `form:"format"` // 输出格式: csv/json
}

View File

@@ -0,0 +1,14 @@
// Package repository 全局存储实例管理
package repository
var globalRepo BillRepository
// SetRepository 设置全局存储实例
func SetRepository(r BillRepository) {
globalRepo = r
}
// GetRepository 获取全局存储实例
func GetRepository() BillRepository {
return globalRepo
}

View File

@@ -0,0 +1,44 @@
// Package repository 定义数据存储层接口
// 负责所有数据持久化操作的抽象
package repository
import "billai-server/model"
// BillRepository 账单存储接口
type BillRepository interface {
// Connect 建立连接
Connect() error
// Disconnect 断开连接
Disconnect() error
// SaveRawBills 保存原始账单数据
SaveRawBills(bills []model.RawBill) (int, error)
// SaveCleanedBills 保存清洗后的账单数据
// 返回: 保存数量、重复数量、错误
SaveCleanedBills(bills []model.CleanedBill) (saved int, duplicates int, err error)
// CheckRawDuplicate 检查原始数据是否重复
CheckRawDuplicate(fieldName, value string) (bool, error)
// CheckCleanedDuplicate 检查清洗后数据是否重复
CheckCleanedDuplicate(bill *model.CleanedBill) (bool, error)
// GetCleanedBills 获取清洗后的账单列表
GetCleanedBills(filter map[string]interface{}) ([]model.CleanedBill, error)
// GetCleanedBillsPaged 获取清洗后的账单列表(带分页)
// 返回: 账单列表、总数、错误
GetCleanedBillsPaged(filter map[string]interface{}, page, pageSize int) ([]model.CleanedBill, int64, error)
// GetBillsAggregate 获取账单聚合统计(总收入、总支出)
// 返回: 总支出、总收入、错误
GetBillsAggregate(filter map[string]interface{}) (totalExpense float64, totalIncome float64, err error)
// GetBillsNeedReview 获取需要复核的账单
GetBillsNeedReview() ([]model.CleanedBill, error)
// CountRawByField 按字段统计原始数据数量
CountRawByField(fieldName, value string) (int64, error)
}

53
server/router/router.go Normal file
View File

@@ -0,0 +1,53 @@
// Package router 路由配置
package router
import (
"net/http"
"github.com/gin-gonic/gin"
"billai-server/handler"
)
// Config 路由配置参数
type Config struct {
OutputDir string // 输出目录(用于静态文件服务)
PythonPath string // Python 路径(用于健康检查显示)
}
// Setup 设置所有路由
func Setup(r *gin.Engine, cfg Config) {
// 健康检查
r.GET("/health", healthCheck(cfg.PythonPath))
// API 路由组
setupAPIRoutes(r)
// 静态文件下载
r.Static("/download", cfg.OutputDir)
}
// healthCheck 健康检查处理器
func healthCheck(pythonPath string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"python_path": pythonPath,
})
}
}
// setupAPIRoutes 设置 API 路由
func setupAPIRoutes(r *gin.Engine) {
api := r.Group("/api")
{
// 账单上传
api.POST("/upload", handler.Upload)
// 复核相关
api.GET("/review", handler.Review)
// 账单查询
api.GET("/bills", handler.ListBills)
}
}

View File

@@ -312,7 +312,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
// 提取字段 - 订单号(用于去重判断)
if idx, ok := colIdx["交易订单号"]; ok && len(row) > idx {
bill.TransactionID = strings.TrimSpace(row[idx])
} else if idx, ok := colIdx["交易号"]; ok && len(row) > idx {
} else if idx, ok := colIdx["交易号"]; ok && len(row) > idx {
bill.TransactionID = strings.TrimSpace(row[idx])
}
if idx, ok := colIdx["商家订单号"]; ok && len(row) > idx {
@@ -325,24 +325,34 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
}
if idx, ok := colIdx["交易分类"]; ok && len(row) > idx {
bill.Category = row[idx]
} else if idx, ok := colIdx["交易类型"]; ok && len(row) > idx {
bill.Category = row[idx]
}
if idx, ok := colIdx["交易对方"]; ok && len(row) > idx {
bill.Merchant = row[idx]
}
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
bill.Description = row[idx]
} else if idx, ok := colIdx["商品"]; ok && len(row) > idx {
bill.Description = row[idx]
}
if idx, ok := colIdx["收/支"]; ok && len(row) > idx {
bill.IncomeExpense = row[idx]
}
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
bill.Amount = parseAmount(row[idx])
} else if idx, ok := colIdx["金额(元)"]; ok && len(row) > idx {
bill.Amount = parseAmount(row[idx])
}
if idx, ok := colIdx["支付方式"]; ok && len(row) > idx {
if idx, ok := colIdx["收/付款方式"]; ok && len(row) > idx {
bill.PayMethod = row[idx]
} else if idx, ok := colIdx["支付方式"]; ok && len(row) > idx {
bill.PayMethod = row[idx]
}
if idx, ok := colIdx["交易状态"]; ok && len(row) > idx {
bill.Status = row[idx]
} else if idx, ok := colIdx["当前状态"]; ok && len(row) > idx {
bill.Status = row[idx]
}
if idx, ok := colIdx["备注"]; ok && len(row) > idx {
bill.Remark = row[idx]

View File

@@ -1,84 +1,48 @@
// Package service 业务逻辑层
package service
import (
"fmt"
"os/exec"
"strings"
"billai-server/config"
"billai-server/adapter"
)
// CleanOptions 清洗选项
type CleanOptions struct {
Year string // 年份筛选
Month string // 月份筛选
Start string // 起始日期
End string // 结束日期
Format string // 输出格式: csv/json
}
// CleanOptions 清洗选项(保持向后兼容)
type CleanOptions = adapter.CleanOptions
// CleanResult 清洗结果
type CleanResult struct {
BillType string // 检测到的账单类型: alipay/wechat
Output string // Python 脚本输出
}
// CleanResult 清洗结果(保持向后兼容)
type CleanResult = adapter.CleanResult
// RunCleanScript 执行 Python 清洗脚本
// RunCleanScript 执行清洗脚本(使用适配器)
// inputPath: 输入文件路径
// outputPath: 输出文件路径
// opts: 清洗选项
func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanResult, error) {
// 构建命令参数
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
args := []string{cleanScriptAbs, inputPath, outputPath}
if opts != nil {
if opts.Year != "" {
args = append(args, "--year", opts.Year)
}
if opts.Month != "" {
args = append(args, "--month", opts.Month)
}
if opts.Start != "" {
args = append(args, "--start", opts.Start)
}
if opts.End != "" {
args = append(args, "--end", opts.End)
}
if opts.Format != "" {
args = append(args, "--format", opts.Format)
}
}
// 执行 Python 脚本
fmt.Printf("🐍 执行清洗脚本...\n")
pythonPathAbs := config.ResolvePath(config.Global.PythonPath)
cmd := exec.Command(pythonPathAbs, args...)
cmd.Dir = config.Global.ProjectRoot
output, err := cmd.CombinedOutput()
outputStr := string(output)
if err != nil {
return nil, fmt.Errorf("清洗脚本执行失败: %w\n输出: %s", err, outputStr)
}
// 从输出中检测账单类型
billType := DetectBillTypeFromOutput(outputStr)
return &CleanResult{
BillType: billType,
Output: outputStr,
}, nil
cleaner := adapter.GetCleaner()
return cleaner.Clean(inputPath, outputPath, opts)
}
// DetectBillTypeFromOutput 从 Python 脚本输出中检测账单类型
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
// 保留此函数以兼容其他调用
func DetectBillTypeFromOutput(output string) string {
if strings.Contains(output, "支付宝") {
if containsSubstring(output, "支付宝") {
return "alipay"
}
if strings.Contains(output, "微信") {
if containsSubstring(output, "微信") {
return "wechat"
}
return ""
}
// containsSubstring 检查字符串是否包含子串
func containsSubstring(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}