- 新增项目文档和 Docker 配置 - 添加 README.md 和 TODO.md 项目文档 - 为各服务添加 Dockerfile 和 docker-compose 配置 - 重构后端架构 - 新增 adapter 层(HTTP/Python 适配器) - 新增 repository 层(数据访问抽象) - 新增 router 模块统一管理路由 - 新增账单处理 handler - 扩展前端 UI 组件库 - 新增 Calendar、DateRangePicker、Drawer、Popover 等组件 - 集成 shadcn-svelte 组件库 - 增强分析页面功能 - 添加时间范围筛选器(支持本月默认值) - 修复 DateRangePicker 默认值显示问题 - 优化数据获取和展示逻辑 - 完善分析器服务 - 新增 FastAPI 服务接口 - 改进账单清理器实现
205 lines
5.2 KiB
Go
205 lines
5.2 KiB
Go
// 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)
|