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" } else if strings.Contains(fileName, "京东") || strings.Contains(strings.ToLower(fileName), "jd") { result.BillType = "jd" } } } 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) } }