feat: 完善项目架构并增强分析页面功能

- 新增项目文档和 Docker 配置
  - 添加 README.md 和 TODO.md 项目文档
  - 为各服务添加 Dockerfile 和 docker-compose 配置

- 重构后端架构
  - 新增 adapter 层(HTTP/Python 适配器)
  - 新增 repository 层(数据访问抽象)
  - 新增 router 模块统一管理路由
  - 新增账单处理 handler

- 扩展前端 UI 组件库
  - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件
  - 集成 shadcn-svelte 组件库

- 增强分析页面功能
  - 添加时间范围筛选器(支持本月默认值)
  - 修复 DateRangePicker 默认值显示问题
  - 优化数据获取和展示逻辑

- 完善分析器服务
  - 新增 FastAPI 服务接口
  - 改进账单清理器实现
This commit is contained in:
2026-01-10 01:15:52 +08:00
parent 94f8ea12e6
commit 087ae027cc
96 changed files with 4301 additions and 482 deletions

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

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

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

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

View File

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

View File

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