高可靠 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

四、详细实现

go 复制代码
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,并确保任何单个文件的特殊问题都不会导致整体打包失败,是企业级文件下载服务的理想选择。

相关推荐
m0_748708055 分钟前
C++中的观察者模式实战
开发语言·c++·算法
qq_5375626717 分钟前
跨语言调用C++接口
开发语言·c++·算法
wjs202428 分钟前
DOM CDATA
开发语言
Tingjct29 分钟前
【初阶数据结构-二叉树】
c语言·开发语言·数据结构·算法
猷咪1 小时前
C++基础
开发语言·c++
IT·小灰灰1 小时前
30行PHP,利用硅基流动API,网页客服瞬间上线
开发语言·人工智能·aigc·php
快点好好学习吧1 小时前
phpize 依赖 php-config 获取 PHP 信息的庖丁解牛
android·开发语言·php
秦老师Q1 小时前
php入门教程(超详细,一篇就够了!!!)
开发语言·mysql·php·db
烟锁池塘柳01 小时前
解决Google Scholar “We‘re sorry... but your computer or network may be sending automated queries.”的问题
开发语言
是誰萆微了承諾1 小时前
php 对接deepseek
android·开发语言·php