Files
billai/server/adapter/http/cleaner.go
2026-01-23 14:17:59 +08:00

287 lines
7.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}
// ConvertResponse 转换响应
type ConvertResponse struct {
Success bool `json:"success"`
BillType string `json:"bill_type"`
Message string `json:"message"`
OutputPath string `json:"output_path,omitempty"`
}
// Convert 转换账单文件格式xlsx -> csv处理 GBK 编码等)
func (c *Cleaner) Convert(inputPath string) (outputPath string, billType string, err error) {
// 打开输入文件
file, err := os.Open(inputPath)
if err != nil {
return "", "", 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 "", "", fmt.Errorf("创建表单文件失败: %w", err)
}
if _, err := io.Copy(part, file); err != nil {
return "", "", fmt.Errorf("复制文件内容失败: %w", err)
}
writer.Close()
// 发送转换请求
fmt.Printf("🌐 调用转换服务: %s/convert\n", c.baseURL)
req, err := http.NewRequest("POST", c.baseURL+"/convert", &body)
if err != nil {
return "", "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
resp, err := c.httpClient.Do(req)
if err != nil {
return "", "", fmt.Errorf("HTTP 请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("读取响应失败: %w", err)
}
// 处理错误响应
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(respBody, &errResp); err == nil {
return "", "", fmt.Errorf("转换失败: %s", errResp.Detail)
}
return "", "", fmt.Errorf("转换失败: HTTP %d - %s", resp.StatusCode, string(respBody))
}
// 解析成功响应
var convertResp ConvertResponse
if err := json.Unmarshal(respBody, &convertResp); err != nil {
return "", "", fmt.Errorf("解析响应失败: %w", err)
}
// 下载转换后的文件到本地(与输入文件同目录,但扩展名改为 .csv
localOutputPath := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))] + ".csv"
fmt.Printf(" 下载转换后文件: %s -> %s\n", convertResp.OutputPath, localOutputPath)
if err := c.downloadFile(convertResp.OutputPath, localOutputPath); err != nil {
return "", "", fmt.Errorf("下载转换结果失败: %w", err)
}
// 验证文件是否存在
if _, err := os.Stat(localOutputPath); err != nil {
return "", "", fmt.Errorf("下载后文件不存在: %s", localOutputPath)
}
fmt.Printf(" 文件下载成功,已保存到: %s\n", localOutputPath)
return localOutputPath, convertResp.BillType, 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)