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:
49
server/Dockerfile
Normal file
49
server/Dockerfile
Normal 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
28
server/adapter/adapter.go
Normal 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
15
server/adapter/global.go
Normal 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
|
||||
}
|
||||
204
server/adapter/http/cleaner.go
Normal file
204
server/adapter/http/cleaner.go
Normal 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)
|
||||
94
server/adapter/python/cleaner.go
Normal file
94
server/adapter/python/cleaner.go
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
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)
|
||||
}
|
||||
|
||||
105
server/main.go
105
server/main.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
14
server/repository/global.go
Normal file
14
server/repository/global.go
Normal 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
|
||||
}
|
||||
44
server/repository/repository.go
Normal file
44
server/repository/repository.go
Normal 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
53
server/router/router.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user