287 lines
7.8 KiB
Go
287 lines
7.8 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
|
||
}
|
||
|
||
// 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)
|