refactor: 重构项目结构

- 将 Python 代码移至 analyzer/ 目录(含 venv)
- 拆分 Go 服务器代码为模块化结构:
  - config/: 配置加载
  - model/: 请求/响应模型
  - service/: 业务逻辑
  - handler/: API处理器
- 添加 .gitignore 文件
- 删除旧的独立脚本文件
This commit is contained in:
clz
2026-01-07 23:26:32 +08:00
parent b15922a027
commit c40a118a3d
23 changed files with 2060 additions and 557 deletions

18
server/config.yaml Normal file
View File

@@ -0,0 +1,18 @@
# BillAI 服务器配置文件
# 服务配置
server:
port: 8080
# Python 配置
python:
# Python 解释器路径(相对于项目根目录或绝对路径)
path: analyzer/venv/bin/python
# 分析脚本路径(相对于项目根目录)
script: analyzer/clean_bill.py
# 文件目录配置(相对于项目根目录)
directories:
upload: server/uploads
output: server/outputs

152
server/config/config.go Normal file
View File

@@ -0,0 +1,152 @@
package config
import (
"flag"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config 服务配置
type Config struct {
Port string // 服务端口
ProjectRoot string // 项目根目录
PythonPath string // Python 解释器路径
CleanScript string // 清理脚本路径
UploadDir string // 上传文件目录
OutputDir string // 输出文件目录
}
// configFile YAML 配置文件结构
type configFile struct {
Server struct {
Port int `yaml:"port"`
} `yaml:"server"`
Python struct {
Path string `yaml:"path"`
Script string `yaml:"script"`
} `yaml:"python"`
Directories struct {
Upload string `yaml:"upload"`
Output string `yaml:"output"`
} `yaml:"directories"`
}
// Global 全局配置实例
var Global Config
// getEnvOrDefault 获取环境变量,如果不存在则返回默认值
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
// getDefaultProjectRoot 获取默认项目根目录
func getDefaultProjectRoot() string {
if root := os.Getenv("BILLAI_ROOT"); root != "" {
return root
}
exe, err := os.Executable()
if err == nil {
exeDir := filepath.Dir(exe)
if filepath.Base(exeDir) == "server" {
return filepath.Dir(exeDir)
}
}
cwd, _ := os.Getwd()
if filepath.Base(cwd) == "server" {
return filepath.Dir(cwd)
}
return cwd
}
// getDefaultPythonPath 获取默认 Python 路径
func getDefaultPythonPath() string {
if python := os.Getenv("BILLAI_PYTHON"); python != "" {
return python
}
return "analyzer/venv/bin/python"
}
// loadConfigFile 加载 YAML 配置文件
func loadConfigFile(configPath string) *configFile {
data, err := os.ReadFile(configPath)
if err != nil {
return nil
}
var cfg configFile
if err := yaml.Unmarshal(data, &cfg); err != nil {
fmt.Printf("⚠️ 配置文件解析失败: %v\n", err)
return nil
}
return &cfg
}
// Load 加载配置
func Load() {
var configFilePath string
flag.StringVar(&configFilePath, "config", "config.yaml", "配置文件路径")
flag.Parse()
// 设置默认值
Global.Port = getEnvOrDefault("PORT", "8080")
Global.ProjectRoot = getDefaultProjectRoot()
Global.PythonPath = getDefaultPythonPath()
Global.CleanScript = "analyzer/clean_bill.py"
Global.UploadDir = "server/uploads"
Global.OutputDir = "server/outputs"
// 查找配置文件
configPath := configFilePath
if !filepath.IsAbs(configPath) {
if _, err := os.Stat(configPath); os.IsNotExist(err) {
configPath = filepath.Join("server", configFilePath)
}
}
// 加载配置文件
if cfg := loadConfigFile(configPath); cfg != nil {
fmt.Printf("📄 加载配置文件: %s\n", configPath)
if cfg.Server.Port > 0 {
Global.Port = fmt.Sprintf("%d", cfg.Server.Port)
}
if cfg.Python.Path != "" {
Global.PythonPath = cfg.Python.Path
}
if cfg.Python.Script != "" {
Global.CleanScript = cfg.Python.Script
}
if cfg.Directories.Upload != "" {
Global.UploadDir = cfg.Directories.Upload
}
if cfg.Directories.Output != "" {
Global.OutputDir = cfg.Directories.Output
}
}
// 环境变量覆盖
if port := os.Getenv("PORT"); port != "" {
Global.Port = port
}
if python := os.Getenv("BILLAI_PYTHON"); python != "" {
Global.PythonPath = python
}
if root := os.Getenv("BILLAI_ROOT"); root != "" {
Global.ProjectRoot = root
}
}
// ResolvePath 解析路径(相对路径转为绝对路径)
func ResolvePath(path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(Global.ProjectRoot, path)
}

34
server/go.mod Normal file
View File

@@ -0,0 +1,34 @@
module billai-server
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.9.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
)

86
server/go.sum Normal file
View File

@@ -0,0 +1,86 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

72
server/handler/review.go Normal file
View File

@@ -0,0 +1,72 @@
package handler
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model"
"billai-server/service"
)
// Review 获取需要复核的记录
func Review(c *gin.Context) {
// 获取文件名参数
fileName := c.Query("file")
if fileName == "" {
c.JSON(http.StatusBadRequest, model.ReviewResponse{
Result: false,
Message: "请提供文件名参数 (file)",
})
return
}
// 构建文件路径
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
filePath := filepath.Join(outputDirAbs, fileName)
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, model.ReviewResponse{
Result: false,
Message: "文件不存在: " + fileName,
})
return
}
// 判断文件格式
format := "csv"
if strings.HasSuffix(fileName, ".json") {
format = "json"
}
// 提取需要复核的记录
records := service.ExtractNeedsReview(filePath, format)
// 统计高低优先级数量
highCount := 0
lowCount := 0
for _, r := range records {
if r.ReviewLevel == "HIGH" {
highCount++
} else if r.ReviewLevel == "LOW" {
lowCount++
}
}
c.JSON(http.StatusOK, model.ReviewResponse{
Result: true,
Message: "获取成功",
Data: &model.ReviewData{
Total: len(records),
High: highCount,
Low: lowCount,
Records: records,
},
})
}

119
server/handler/upload.go Normal file
View File

@@ -0,0 +1,119 @@
package handler
import (
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/model"
)
// Upload 处理账单上传和清理请求
func Upload(c *gin.Context) {
// 1. 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "请上传账单文件 (参数名: file)",
})
return
}
defer file.Close()
// 2. 解析请求参数
var req model.UploadRequest
c.ShouldBind(&req)
if req.Format == "" {
req.Format = "csv"
}
// 3. 保存上传的文件
timestamp := time.Now().Format("20060102_150405")
inputFileName := fmt.Sprintf("%s_%s", timestamp, header.Filename)
uploadDirAbs := config.ResolvePath(config.Global.UploadDir)
inputPath := filepath.Join(uploadDirAbs, inputFileName)
dst, err := os.Create(inputPath)
if err != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: "保存文件失败: " + err.Error(),
})
return
}
defer dst.Close()
io.Copy(dst, file)
// 4. 构建输出文件路径
baseName := strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
}
outputFileName := fmt.Sprintf("%s_%s_cleaned%s", timestamp, baseName, outputExt)
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
outputPath := filepath.Join(outputDirAbs, outputFileName)
// 5. 构建命令参数
cleanScriptAbs := config.ResolvePath(config.Global.CleanScript)
args := []string{cleanScriptAbs, inputPath, outputPath}
if req.Year != "" {
args = append(args, "--year", req.Year)
}
if req.Month != "" {
args = append(args, "--month", req.Month)
}
if req.Start != "" {
args = append(args, "--start", req.Start)
}
if req.End != "" {
args = append(args, "--end", req.End)
}
if req.Format != "" {
args = append(args, "--format", req.Format)
}
// 6. 执行 Python 脚本
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 {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: "处理失败: " + err.Error(),
})
return
}
// 7. 检测账单类型
billType := ""
if strings.Contains(outputStr, "支付宝") {
billType = "alipay"
} else if strings.Contains(outputStr, "微信") {
billType = "wechat"
}
// 8. 返回成功响应
c.JSON(http.StatusOK, model.UploadResponse{
Result: true,
Message: "处理成功",
Data: &model.UploadData{
BillType: billType,
FileURL: fmt.Sprintf("/download/%s", outputFileName),
FileName: outputFileName,
},
})
}

89
server/main.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
"billai-server/config"
"billai-server/handler"
)
func main() {
// 加载配置
config.Load()
// 解析路径
uploadDirAbs := config.ResolvePath(config.Global.UploadDir)
outputDirAbs := config.ResolvePath(config.Global.OutputDir)
pythonPathAbs := config.ResolvePath(config.Global.PythonPath)
// 确保目录存在
os.MkdirAll(uploadDirAbs, 0755)
os.MkdirAll(outputDirAbs, 0755)
// 打印配置信息
printBanner(pythonPathAbs, uploadDirAbs, outputDirAbs)
// 检查 Python 是否存在
if _, err := os.Stat(pythonPathAbs); os.IsNotExist(err) {
fmt.Printf("⚠️ 警告: Python 路径不存在: %s\n", pythonPathAbs)
fmt.Println(" 请在配置文件中指定正确的 Python 路径")
}
// 创建路由
r := gin.Default()
// 注册路由
setupRoutes(r, outputDirAbs, pythonPathAbs)
// 启动服务
printAPIInfo()
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("📂 上传目录: %s\n", uploadDir)
fmt.Printf("📂 输出目录: %s\n", outputDir)
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
}
// printAPIInfo 打印 API 信息
func printAPIInfo() {
fmt.Printf("\n🚀 服务已启动: http://localhost:%s\n", config.Global.Port)
fmt.Println("📝 API 接口:")
fmt.Println(" POST /api/upload - 上传并分析账单")
fmt.Println(" GET /api/review - 获取需要复核的记录")
fmt.Println(" GET /download/* - 下载结果文件")
fmt.Println(" GET /health - 健康检查")
fmt.Println()
}

11
server/model/request.go Normal file
View File

@@ -0,0 +1,11 @@
package model
// UploadRequest 上传请求参数
type UploadRequest struct {
Year string `form:"year"` // 年份筛选
Month string `form:"month"` // 月份筛选
Start string `form:"start"` // 起始日期
End string `form:"end"` // 结束日期
Format string `form:"format"` // 输出格式: csv/json
}

43
server/model/response.go Normal file
View File

@@ -0,0 +1,43 @@
package model
// UploadData 上传响应数据
type UploadData struct {
BillType string `json:"bill_type,omitempty"` // alipay/wechat
FileURL string `json:"file_url,omitempty"` // 下载链接
FileName string `json:"file_name,omitempty"` // 文件名
}
// UploadResponse 上传响应
type UploadResponse struct {
Result bool `json:"result"`
Message string `json:"message"`
Data *UploadData `json:"data,omitempty"`
}
// ReviewRecord 需要复核的记录
type ReviewRecord struct {
Time string `json:"time"` // 交易时间
Category string `json:"category"` // 交易分类
Merchant string `json:"merchant"` // 交易对方
Description string `json:"description"` // 商品说明
IncomeExpense string `json:"income_expense"` // 收/支
Amount string `json:"amount"` // 金额
Remark string `json:"remark"` // 备注
ReviewLevel string `json:"review_level"` // 复核等级: HIGH/LOW
}
// ReviewData 复核响应数据
type ReviewData struct {
Total int `json:"total"` // 总数
High int `json:"high"` // 高优先级数量
Low int `json:"low"` // 低优先级数量
Records []ReviewRecord `json:"records,omitempty"` // 需要复核的记录
}
// ReviewResponse 复核记录响应
type ReviewResponse struct {
Result bool `json:"result"`
Message string `json:"message"`
Data *ReviewData `json:"data,omitempty"`
}

134
server/service/extractor.go Normal file
View File

@@ -0,0 +1,134 @@
package service
import (
"encoding/csv"
"encoding/json"
"os"
"billai-server/model"
)
// ExtractNeedsReview 从输出文件中提取需要复核的记录
func ExtractNeedsReview(filePath string, format string) []model.ReviewRecord {
if format == "json" {
return extractFromJSON(filePath)
}
return extractFromCSV(filePath)
}
// extractFromCSV 从 CSV 文件提取需要复核的记录
func extractFromCSV(filePath string) []model.ReviewRecord {
var records []model.ReviewRecord
file, err := os.Open(filePath)
if err != nil {
return records
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil || len(rows) < 2 {
return records
}
// 找到各列的索引
header := rows[0]
colIdx := make(map[string]int)
for i, col := range header {
colIdx[col] = i
}
reviewIdx, ok := colIdx["复核等级"]
if !ok {
return records
}
// 提取需要复核的记录
for _, row := range rows[1:] {
if len(row) > reviewIdx && (row[reviewIdx] == "HIGH" || row[reviewIdx] == "LOW") {
record := model.ReviewRecord{
ReviewLevel: row[reviewIdx],
}
if idx, ok := colIdx["交易时间"]; ok && len(row) > idx {
record.Time = row[idx]
}
if idx, ok := colIdx["交易分类"]; ok && len(row) > idx {
record.Category = row[idx]
}
if idx, ok := colIdx["交易对方"]; ok && len(row) > idx {
record.Merchant = row[idx]
}
if idx, ok := colIdx["商品说明"]; ok && len(row) > idx {
record.Description = row[idx]
}
if idx, ok := colIdx["收/支"]; ok && len(row) > idx {
record.IncomeExpense = row[idx]
}
if idx, ok := colIdx["金额"]; ok && len(row) > idx {
record.Amount = row[idx]
}
if idx, ok := colIdx["备注"]; ok && len(row) > idx {
record.Remark = row[idx]
}
records = append(records, record)
}
}
return records
}
// extractFromJSON 从 JSON 文件提取需要复核的记录
func extractFromJSON(filePath string) []model.ReviewRecord {
var records []model.ReviewRecord
file, err := os.Open(filePath)
if err != nil {
return records
}
defer file.Close()
var data []map[string]interface{}
decoder := json.NewDecoder(file)
if err := decoder.Decode(&data); err != nil {
return records
}
for _, item := range data {
reviewLevel, ok := item["复核等级"].(string)
if !ok || (reviewLevel != "HIGH" && reviewLevel != "LOW") {
continue
}
record := model.ReviewRecord{
ReviewLevel: reviewLevel,
}
if v, ok := item["交易时间"].(string); ok {
record.Time = v
}
if v, ok := item["交易分类"].(string); ok {
record.Category = v
}
if v, ok := item["交易对方"].(string); ok {
record.Merchant = v
}
if v, ok := item["商品说明"].(string); ok {
record.Description = v
}
if v, ok := item["收/支"].(string); ok {
record.IncomeExpense = v
}
if v, ok := item["金额"].(string); ok {
record.Amount = v
}
if v, ok := item["备注"].(string); ok {
record.Remark = v
}
records = append(records, record)
}
return records
}