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

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -18,6 +19,8 @@ import (
)
// Upload 处理账单上传和清理请求
// 支持直接上传 CSV 文件,或上传 ZIP 压缩包(支持密码保护)
// ZIP 包内可以是 CSV 或 XLSX 格式的账单文件
func Upload(c *gin.Context) {
// 1. 获取上传的文件
file, header, err := c.Request.FormFile("file")
@@ -37,32 +40,12 @@ func Upload(c *gin.Context) {
req.Format = "csv"
}
// 验证 type 参数
if req.Type == "" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "请指定账单类型 (type: alipay 或 wechat)",
})
return
}
if req.Type != "alipay" && req.Type != "wechat" {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "账单类型无效,仅支持 alipay 或 wechat",
})
return
}
billType := req.Type
// 3. 保存上传的文件添加唯一ID避免覆盖
// 3. 保存上传的文件
timestamp := time.Now().Format("20060102_150405")
uniqueID := generateShortID()
// 获取文件扩展名和基础名
ext := filepath.Ext(header.Filename)
baseName := header.Filename[:len(header.Filename)-len(ext)]
// 文件名格式: 时间戳_唯一ID_原始文件名
inputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, uniqueID, baseName, ext)
uploadDirAbs := config.ResolvePath(config.Global.UploadDir)
inputPath := filepath.Join(uploadDirAbs, inputFileName)
@@ -76,12 +59,117 @@ func Upload(c *gin.Context) {
return
}
defer dst.Close()
io.Copy(dst, file)
if _, err := io.Copy(dst, file); err != nil {
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: "保存文件失败: " + err.Error(),
})
return
}
dst.Close() // 关闭文件以便后续处理
// 4. 对原始数据进行去重检查
// 4. 处理文件:如果是 ZIP 则解压,否则直接处理
var billFilePath string
var billType string
var extractedFiles []string
var needConvert bool // 是否需要格式转换xlsx -> csv
if service.IsSupportedArchive(header.Filename) {
// 解压 ZIP 文件
fmt.Printf("📦 检测到 ZIP 文件,开始解压...\n")
extractResult, err := service.ExtractZip(inputPath, uploadDirAbs, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "解压失败: " + err.Error(),
})
return
}
billFilePath = extractResult.BillFile
extractedFiles = extractResult.ExtractedFiles
// 使用从文件名检测到的账单类型(如果用户未指定)
if req.Type == "" && extractResult.BillType != "" {
billType = extractResult.BillType
}
fmt.Printf(" 解压完成,账单文件: %s\n", filepath.Base(billFilePath))
// ZIP 中提取的文件需要格式转换xlsx 需要转 csvcsv 可能需要编码转换)
needConvert = true
} else {
// 直接使用上传的文件
billFilePath = inputPath
// 检查是否为 xlsx 格式
if strings.HasSuffix(strings.ToLower(header.Filename), ".xlsx") {
needConvert = true
}
}
// 5. 如果需要格式/编码转换,调用 analyzer 服务
if needConvert {
fmt.Printf("📊 调用分析服务进行格式/编码转换...\n")
convertedPath, detectedType, err := service.ConvertBillFile(billFilePath)
if err != nil {
// 清理临时文件
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "文件转换失败: " + err.Error(),
})
return
}
// 如果转换后的路径与原路径不同,删除原始文件
if convertedPath != billFilePath {
os.Remove(billFilePath)
}
billFilePath = convertedPath
// 使用检测到的账单类型
if req.Type == "" && detectedType != "" {
billType = detectedType
}
fmt.Printf(" 转换完成: %s\n", filepath.Base(convertedPath))
}
// 6. 确定账单类型
if req.Type != "" {
billType = req.Type
}
if billType == "" {
// 尝试从文件名检测
fileName := strings.ToLower(filepath.Base(billFilePath))
if strings.Contains(fileName, "支付宝") || strings.Contains(fileName, "alipay") {
billType = "alipay"
} else if strings.Contains(fileName, "微信") || strings.Contains(fileName, "wechat") {
billType = "wechat"
}
}
if billType == "" {
// 清理临时文件
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "无法识别账单类型,请指定 type 参数 (alipay 或 wechat)",
})
return
}
if billType != "alipay" && billType != "wechat" {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusBadRequest, model.UploadResponse{
Result: false,
Message: "账单类型无效,仅支持 alipay 或 wechat",
})
return
}
// 7. 对原始数据进行去重检查
fmt.Printf("📋 开始去重检查...\n")
dedupResult, dedupErr := service.DeduplicateRawFile(inputPath, timestamp)
dedupResult, dedupErr := service.DeduplicateRawFile(billFilePath, timestamp)
if dedupErr != nil {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: "去重检查失败: " + dedupErr.Error(),
@@ -97,6 +185,7 @@ func Upload(c *gin.Context) {
// 如果全部重复,返回提示
if dedupResult.NewCount == 0 {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusOK, model.UploadResponse{
Result: true,
Message: fmt.Sprintf("文件中的 %d 条记录全部已存在,无需重复导入", dedupResult.OriginalCount),
@@ -113,7 +202,7 @@ func Upload(c *gin.Context) {
// 使用去重后的文件路径进行后续处理
processFilePath := dedupResult.DedupFilePath
// 5. 构建输出文件路径时间_type_编号
// 8. 构建输出文件路径
outputExt := ".csv"
if req.Format == "json" {
outputExt = ".json"
@@ -123,7 +212,7 @@ func Upload(c *gin.Context) {
outputFileName := fmt.Sprintf("%s_%s_%s%s", timestamp, billType, fileSeq, outputExt)
outputPath := filepath.Join(outputDirAbs, outputFileName)
// 6. 执行 Python 清洗脚本
// 9. 执行 Python 清洗脚本
cleanOpts := &service.CleanOptions{
Year: req.Year,
Month: req.Month,
@@ -133,6 +222,7 @@ func Upload(c *gin.Context) {
}
_, cleanErr := service.RunCleanScript(processFilePath, outputPath, cleanOpts)
if cleanErr != nil {
service.CleanupExtractedFiles(extractedFiles)
c.JSON(http.StatusInternalServerError, model.UploadResponse{
Result: false,
Message: cleanErr.Error(),
@@ -140,7 +230,7 @@ func Upload(c *gin.Context) {
return
}
// 7. 将去重后的原始数据存入 MongoDB(原始数据集合)
// 10. 将去重后的原始数据存入 MongoDB
rawCount, rawErr := service.SaveRawBillsFromFile(processFilePath, billType, header.Filename, timestamp)
if rawErr != nil {
fmt.Printf("⚠️ 存储原始数据到 MongoDB 失败: %v\n", rawErr)
@@ -148,7 +238,7 @@ func Upload(c *gin.Context) {
fmt.Printf("✅ 已存储 %d 条原始账单记录到 MongoDB\n", rawCount)
}
// 9. 将清洗后的数据存入 MongoDB(清洗后数据集合)
// 11. 将清洗后的数据存入 MongoDB
cleanedCount, _, cleanedErr := service.SaveCleanedBillsFromFile(outputPath, req.Format, billType, header.Filename, timestamp)
if cleanedErr != nil {
fmt.Printf("⚠️ 存储清洗后数据到 MongoDB 失败: %v\n", cleanedErr)
@@ -156,12 +246,13 @@ func Upload(c *gin.Context) {
fmt.Printf("✅ 已存储 %d 条清洗后账单记录到 MongoDB\n", cleanedCount)
}
// 10. 清理临时的去重文件(如果生成了的话)
// 12. 清理临时文件
if dedupResult.DedupFilePath != inputPath && dedupResult.DedupFilePath != "" {
os.Remove(dedupResult.DedupFilePath)
}
service.CleanupExtractedFiles(extractedFiles)
// 11. 返回成功响应
// 13. 返回成功响应
message := fmt.Sprintf("处理成功,新增 %d 条记录", cleanedCount)
if dedupResult.DuplicateCount > 0 {
message = fmt.Sprintf("处理成功,新增 %d 条,跳过 %d 条重复记录", cleanedCount, dedupResult.DuplicateCount)
@@ -182,7 +273,6 @@ func Upload(c *gin.Context) {
}
// generateFileSequence 生成文件序号
// 根据当前目录下同一时间戳和类型的文件数量生成序号
func generateFileSequence(dir, timestamp, billType, ext string) string {
pattern := fmt.Sprintf("%s_%s_*%s", timestamp, billType, ext)
matches, err := filepath.Glob(filepath.Join(dir, pattern))
@@ -194,9 +284,8 @@ func generateFileSequence(dir, timestamp, billType, ext string) string {
// generateShortID 生成 6 位随机唯一标识符
func generateShortID() string {
bytes := make([]byte, 3) // 3 字节 = 6 个十六进制字符
bytes := make([]byte, 3)
if _, err := rand.Read(bytes); err != nil {
// 如果随机数生成失败,使用时间纳秒作为备选
return fmt.Sprintf("%06x", time.Now().UnixNano()%0xFFFFFF)
}
return hex.EncodeToString(bytes)