feat: 支持ZIP压缩包上传(含密码保护)
This commit is contained in:
159
server/service/archive.go
Normal file
159
server/service/archive.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yeka/zip"
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
)
|
||||
|
||||
// ExtractResult 解压结果
|
||||
type ExtractResult struct {
|
||||
ExtractedFiles []string // 解压出的文件路径
|
||||
BillFile string // 账单文件路径(csv 或 xlsx)
|
||||
BillType string // 检测到的账单类型
|
||||
}
|
||||
|
||||
// ExtractZip 解压 ZIP 文件,支持密码
|
||||
// 返回解压后的账单文件路径
|
||||
func ExtractZip(zipPath, destDir, password string) (*ExtractResult, error) {
|
||||
reader, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法打开 ZIP 文件: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
result := &ExtractResult{
|
||||
ExtractedFiles: make([]string, 0),
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
|
||||
for _, file := range reader.File {
|
||||
// 处理文件名编码(可能是 GBK)
|
||||
fileName := decodeFileName(file.Name)
|
||||
|
||||
// 安全检查:防止路径遍历
|
||||
if strings.Contains(fileName, "..") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
|
||||
// 生成安全的目标文件名(避免编码问题)
|
||||
// 使用时间戳+序号+扩展名的格式
|
||||
safeFileName := fmt.Sprintf("extracted_%s_%d%s", timestamp, len(result.ExtractedFiles), ext)
|
||||
destPath := filepath.Join(destDir, safeFileName)
|
||||
|
||||
if file.FileInfo().IsDir() {
|
||||
os.MkdirAll(destPath, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
// 确保目录存在
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置密码(如果有)
|
||||
if file.IsEncrypted() {
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("ZIP 文件已加密,请提供密码")
|
||||
}
|
||||
file.SetPassword(password)
|
||||
}
|
||||
|
||||
// 打开文件
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
if file.IsEncrypted() {
|
||||
return nil, fmt.Errorf("密码错误或无法解密文件")
|
||||
}
|
||||
return nil, fmt.Errorf("无法读取文件 %s: %w", fileName, err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, fmt.Errorf("创建文件失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = io.Copy(destFile, rc)
|
||||
rc.Close()
|
||||
destFile.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("写入文件失败: %w", err)
|
||||
}
|
||||
|
||||
result.ExtractedFiles = append(result.ExtractedFiles, destPath)
|
||||
|
||||
// 检测账单文件
|
||||
if ext == ".csv" || ext == ".xlsx" {
|
||||
result.BillFile = destPath
|
||||
|
||||
// 检测账单类型(从原始文件名检测)
|
||||
if strings.Contains(fileName, "支付宝") || strings.Contains(strings.ToLower(fileName), "alipay") {
|
||||
result.BillType = "alipay"
|
||||
} else if strings.Contains(fileName, "微信") || strings.Contains(strings.ToLower(fileName), "wechat") {
|
||||
result.BillType = "wechat"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.BillFile == "" {
|
||||
return nil, fmt.Errorf("ZIP 文件中未找到账单文件(.csv 或 .xlsx)")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// decodeFileName 尝试将 GBK 编码的文件名转换为 UTF-8
|
||||
func decodeFileName(name string) string {
|
||||
// 如果文件名只包含 ASCII 字符,直接返回
|
||||
isAscii := true
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] > 127 {
|
||||
isAscii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isAscii {
|
||||
return name
|
||||
}
|
||||
|
||||
// 尝试 GBK 解码
|
||||
// Windows 上创建的 ZIP 文件通常使用 GBK 编码中文文件名
|
||||
decoded, _, err := transform.String(simplifiedchinese.GBK.NewDecoder(), name)
|
||||
if err == nil && len(decoded) > 0 {
|
||||
return decoded
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// IsSupportedArchive 检查文件是否为支持的压缩格式
|
||||
func IsSupportedArchive(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".zip")
|
||||
}
|
||||
|
||||
// IsBillFile 检查文件是否为账单文件
|
||||
func IsBillFile(filename string) bool {
|
||||
lower := strings.ToLower(filename)
|
||||
return strings.HasSuffix(lower, ".csv") || strings.HasSuffix(lower, ".xlsx")
|
||||
}
|
||||
|
||||
// CleanupExtractedFiles 清理解压的临时文件
|
||||
func CleanupExtractedFiles(files []string) {
|
||||
for _, f := range files {
|
||||
os.Remove(f)
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ func DeduplicateRawFile(filePath, uploadBatch string) (*DeduplicateResult, error
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
@@ -183,6 +184,7 @@ func SaveRawBillsFromFile(filePath, billType, sourceFile, uploadBatch string) (i
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
@@ -249,6 +251,7 @@ func saveCleanedBillsFromCSV(filePath, billType, sourceFile, uploadBatch string)
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("读取 CSV 失败: %w", err)
|
||||
|
||||
@@ -20,6 +20,13 @@ func RunCleanScript(inputPath, outputPath string, opts *CleanOptions) (*CleanRes
|
||||
return cleaner.Clean(inputPath, outputPath, opts)
|
||||
}
|
||||
|
||||
// ConvertBillFile 转换账单文件格式(xlsx -> csv,处理编码)
|
||||
// 返回转换后的文件路径和检测到的账单类型
|
||||
func ConvertBillFile(inputPath string) (outputPath string, billType string, err error) {
|
||||
cleaner := adapter.GetCleaner()
|
||||
return cleaner.Convert(inputPath)
|
||||
}
|
||||
|
||||
// DetectBillTypeFromOutput 从脚本输出中检测账单类型
|
||||
// 保留此函数以兼容其他调用
|
||||
func DetectBillTypeFromOutput(output string) string {
|
||||
|
||||
@@ -27,6 +27,7 @@ func extractFromCSV(filePath string) []model.ReviewRecord {
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1 // 允许变长记录
|
||||
rows, err := reader.ReadAll()
|
||||
if err != nil || len(rows) < 2 {
|
||||
return records
|
||||
|
||||
Reference in New Issue
Block a user