高可靠 ZIP 压缩方案兼容 Office、PDF、TXT 和图片的二阶段回退机制

一、引言

在企业级应用中,经常需要将多种类型的文件(如 Office 文档、PDF、纯文本、图片等)打包成 ZIP 并提供给用户下载。但由于文件路径过长、特殊字符或权限等问题,Go 标准库的 archive/zip 有时会出现“压缩成功却实际未写入”或直接打开失败的情况。本文将介绍一种“二阶段压缩”方案,既能在正常情况下使用 Go 标准库高效打包,又能在失败时无缝回退到系统命令,保证所有文件都能出现在最终的 ZIP 中。

二、背景与挑战

  • 多文件类型:Office(.docx/.xlsx)、PDF、TXT、图片(.png/.jpg)等二进制文件都需支持。
  • 路径复杂:含空格、中文、特殊符号(如 []')的路径常导致 Go 打开失败。
  • 失败容忍:一旦某个文件压缩失败,不应导致整个打包过程崩溃。

传统做法往往只用 Go 的 archive/zip,一旦某个文件无法打开或写入,就直接返回错误,无法满足高可用性需求。

三、方案概览

  1. 路径规范化
    • 统一处理用户传入的正斜杠、反斜杠、相对路径,转换成系统绝对路径,减少“找不到文件”错误。
  2. 阶段一:Go 标准库压缩
    • 对大多数文件使用 archive/zip API 进行压缩,保持高性能和纯 Go 实现。
    • 遇到打开或写入错误时,记录到回退列表(fallback)但不终止流程。
  3. 阶段二:外部命令回退压缩
    • 关闭 Go 的 zip.Writer 后,针对所有回退列表中的文件,借助系统命令追加到已生成的 ZIP。
    • Linux/macOS 使用 zip -u;Windows 使用 PowerShell 的 Compress-Archive -Update

四、详细实现

package utils

import (
    "archive/zip"
    "fmt"
    "io"
    "log"
    "os"
    "os/exec"
    "path/filepath"
    "runtime"
    "strings"
)

// ExportZipRequest 保持原来参数结构
type ExportZipRequest struct {
    Files []map[string][]string `json:"files"`
}

// CreateZipFileFromGroups 二阶段压缩入口
func CreateZipFileFromGroups(zipFilePath string, groups []map[string][]string) error {
    log.Printf("开始压缩,目标 ZIP:%s\n", zipFilePath)
    // 确保输出目录存在
    if err := os.MkdirAll(filepath.Dir(zipFilePath), os.ModePerm); err != nil {
        return fmt.Errorf("创建目录失败: %v", err)
    }
    // 创建空 ZIP 文件
    zipF, err := os.Create(zipFilePath)
    if err != nil {
        return fmt.Errorf("无法创建 ZIP 文件: %v", err)
    }
    // 用 Go 标准库写入
    zw := zip.NewWriter(zipF)

    // 收集需要回退的文件路径
    var fallback []string

    // 阶段一:遍历所有 eml 和附件
    for _, group := range groups {
        for eml, atts := range group {
            emlPath := normalizePath(eml)
            if _, err := os.Stat(emlPath); os.IsNotExist(err) {
                zipF.Close()
                return fmt.Errorf("源文件不存在: %s", emlPath)
            }
            folder := strings.TrimSuffix(filepath.Base(emlPath), filepath.Ext(emlPath))
            entry := filepath.ToSlash(filepath.Join(folder, filepath.Base(emlPath)))

            // Go API 压缩 eml
            if err := addFileGo(zw, entry, emlPath); err != nil {
                log.Printf("Go API 压缩 %s 失败: %v", emlPath, err)
                fallback = append(fallback, emlPath)
            } else {
                log.Printf("Go API 压缩成功: %s", emlPath)
            }

            // Go API 压缩附件
            for _, att := range atts {
                attPath := normalizePath(att)
                if _, err := os.Stat(attPath); os.IsNotExist(err) {
                    log.Printf("附件不存在,跳过:%s", attPath)
                    continue
                }
                attEntry := filepath.ToSlash(filepath.Join(folder, filepath.Base(attPath)))
                if err := addFileGo(zw, attEntry, attPath); err != nil {
                    log.Printf("Go API 压缩附件 %s 失败: %v", attPath, err)
                    fallback = append(fallback, attPath)
                } else {
                    log.Printf("Go API 压缩成功: %s", attPath)
                }
            }
        }
    }

    // 关闭 Go 的 zip.Writer
    if err := zw.Close(); err != nil {
        zipF.Close()
        return fmt.Errorf("关闭 ZIP Writer 失败: %v", err)
    }
    zipF.Close()

    // 阶段二:对回退列表中的文件走外部命令追加
    for _, src := range fallback {
        log.Printf("外部命令追加:%s", src)
        if err := addFileExternal(zipFilePath, src); err != nil {
            return fmt.Errorf("回退压缩失败: %s: %v", src, err)
        }
        log.Printf("外部命令压缩成功: %s", src)
    }

    log.Println("ZIP 压缩完成。")
    return nil
}

// normalizePath 清理并转为绝对路径
func normalizePath(p string) string {
    p = filepath.FromSlash(p)
    p = filepath.Clean(p)
    if abs, err := filepath.Abs(p); err == nil {
        p = abs
    }
    return p
}

// addFileGo 用 Go 标准库写入单文件
func addFileGo(zw *zip.Writer, entryName, srcPath string) error {
    f, err := os.Open(srcPath)
    if err != nil {
        return err
    }
    defer f.Close()

    info, err := f.Stat()
    if err != nil {
        return err
    }
    hdr, err := zip.FileInfoHeader(info)
    if err != nil {
        return err
    }
    hdr.Name = entryName
    hdr.Method = zip.Deflate

    w, err := zw.CreateHeader(hdr)
    if err != nil {
        return err
    }
    if _, err := io.Copy(w, f); err != nil {
        return err
    }
    return nil
}

// addFileExternal 调用系统命令追加文件到已有 ZIP
func addFileExternal(zipPath, srcPath string) error {
    absZip, _ := filepath.Abs(zipPath)
    absSrc, _ := filepath.Abs(srcPath)

    if runtime.GOOS == "windows" {
        // PowerShell:Compress-Archive -Update
        cmd := exec.Command(
            "powershell", "-NoProfile", "-Command",
            "Compress-Archive", "-Path", absSrc,
            "-Update", "-DestinationPath", absZip,
        )
        out, err := cmd.CombinedOutput()
        if err != nil {
            return fmt.Errorf("PowerShell 压缩失败: %v, %s", err, string(out))
        }
    } else {
        // zip -u 追加
        cmd := exec.Command("zip", "-u", absZip, absSrc)
        out, err := cmd.CombinedOutput()
        if err != nil {
            return fmt.Errorf("zip 命令失败: %v, %s", err, string(out))
        }
    }
    return nil
}

五、运行效果与日志示例

2025/04/18 22:59:43 开始压缩,目标 ZIP:C:\files\tmp\xxx_export.zip
2025/04/18 22:59:43 Go API 压缩成功: C:\files\eml\sample.eml
2025/04/18 22:59:43 Go API 压缩失败: C:\files\filelist\复杂文档.docx: open ...: The system cannot find the path specified.
2025/04/18 22:59:43 外部命令追加:C:\files\filelist\复杂文档.docx
2025/04/18 22:59:44 外部命令压缩成功: C:\files\filelist\复杂文档.docx
2025/04/18 22:59:44 ZIP 压缩完成。

从日志可见,Office 文档在 Go API 失败后,通过 PowerShell(或 zip -u)被成功追加到 ZIP 中。

六、总结与最佳实践

  • 统一路径处理normalizePath 将各种格式的路径标准化为绝对路径,减少文件不存在等错误。
  • 分层压缩策略:正常情况下首选 Go 标准库,兼顾性能与纯 Go 实现;遇到特殊情况再回退到系统命令,保证打包完整性。
  • 日志和容错:全程打日志,并对附件「不存在」或「压缩失败」做跳过或回退,不让单个异常影响整体。
  • 跨平台支持:兼容 Windows 和 Linux/macOS,分别使用 PowerShell 和 zip 命令。

通过上述方案,能够高效、可靠地将各种类型文件打包成 ZIP,并确保任何单个文件的特殊问题都不会导致整体打包失败,是企业级文件下载服务的理想选择。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Hello.Reader

请我喝杯咖啡吧😊

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值