refactor: 重构项目结构
- 将 Python 代码移至 analyzer/ 目录(含 venv) - 拆分 Go 服务器代码为模块化结构: - config/: 配置加载 - model/: 请求/响应模型 - service/: 业务逻辑 - handler/: API处理器 - 添加 .gitignore 文件 - 删除旧的独立脚本文件
This commit is contained in:
18
server/config.yaml
Normal file
18
server/config.yaml
Normal 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
152
server/config/config.go
Normal 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
34
server/go.mod
Normal 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
86
server/go.sum
Normal 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
72
server/handler/review.go
Normal 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
119
server/handler/upload.go
Normal 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
89
server/main.go
Normal 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
11
server/model/request.go
Normal 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
43
server/model/response.go
Normal 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
134
server/service/extractor.go
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user