一、引言
在企业级应用中,经常需要将多种类型的文件(如 Office 文档、PDF、纯文本、图片等)打包成 ZIP 并提供给用户下载。但由于文件路径过长、特殊字符或权限等问题,Go 标准库的 archive/zip
有时会出现“压缩成功却实际未写入”或直接打开失败的情况。本文将介绍一种“二阶段压缩”方案,既能在正常情况下使用 Go 标准库高效打包,又能在失败时无缝回退到系统命令,保证所有文件都能出现在最终的 ZIP 中。
二、背景与挑战
- 多文件类型:Office(
.docx/.xlsx
)、PDF、TXT、图片(.png/.jpg
)等二进制文件都需支持。 - 路径复杂:含空格、中文、特殊符号(如
[]'
)的路径常导致 Go 打开失败。 - 失败容忍:一旦某个文件压缩失败,不应导致整个打包过程崩溃。
传统做法往往只用 Go 的 archive/zip
,一旦某个文件无法打开或写入,就直接返回错误,无法满足高可用性需求。
三、方案概览
- 路径规范化
- 统一处理用户传入的正斜杠、反斜杠、相对路径,转换成系统绝对路径,减少“找不到文件”错误。
- 阶段一:Go 标准库压缩
- 对大多数文件使用
archive/zip
API 进行压缩,保持高性能和纯 Go 实现。 - 遇到打开或写入错误时,记录到回退列表(
fallback
)但不终止流程。
- 对大多数文件使用
- 阶段二:外部命令回退压缩
- 关闭 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,并确保任何单个文件的特殊问题都不会导致整体打包失败,是企业级文件下载服务的理想选择。