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:
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)
|
||||
Reference in New Issue
Block a user