feat: 支持ZIP压缩包上传(含密码保护)

This commit is contained in:
CHE LIANG ZHAO
2026-01-23 13:46:45 +08:00
parent 49e3176e6b
commit a97a8d6a20
22 changed files with 973 additions and 72 deletions

159
server/service/archive.go Normal file
View 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)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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