package upload import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "os" "path/filepath" "strings" "time" "tyc-server/app/main/api/internal/svc" "tyc-server/app/main/api/internal/types" "tyc-server/common/xerr" "github.com/pkg/errors" "github.com/zeromicro/go-zero/core/logx" ) const maxImageSize = 3 * 1024 * 1024 // 3MB const defaultTempFileMaxAgeH = 24 type UploadImageLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUploadImageLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadImageLogic { return &UploadImageLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } func (l *UploadImageLogic) UploadImage(req *types.UploadImageReq) (resp *types.UploadImageResp, err error) { decoded, decErr := base64.StdEncoding.DecodeString(req.ImageBase64) if decErr != nil { return nil, errors.Wrapf(xerr.NewErrMsg("图片 base64 格式错误"), "%v", decErr) } if len(decoded) > maxImageSize { return nil, errors.Wrapf(xerr.NewErrMsg("图片不能超过 3M"), "size=%d", len(decoded)) } // 按文件内容 hash 命名,相同文件复用同一 URL,避免重复传输与刷流量 hashSum := sha256.Sum256(decoded) hashHex := hex.EncodeToString(hashSum[:]) fileName := hashHex + ".jpg" dir := l.uploadStoragePath() if err := os.MkdirAll(dir, 0755); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "创建上传目录失败: %v", err) } filePath := filepath.Join(dir, fileName) // 若已存在同 hash 文件,直接返回 URL,不重复写入 if _, statErr := os.Stat(filePath); statErr == nil { url := l.buildURL(fileName) logx.Infof("upload image dedup by hash, file=%s", fileName) return &types.UploadImageResp{Url: url}, nil } if err := os.WriteFile(filePath, decoded, 0644); err != nil { return nil, errors.Wrapf(xerr.NewErrCode(xerr.SERVER_COMMON_ERROR), "保存图片失败: %v", err) } // 异步清理过期临时文件,不阻塞响应 go l.deleteOldUploads(dir) url := l.buildURL(fileName) logx.Infof("upload image ok, file=%s", fileName) return &types.UploadImageResp{Url: url}, nil } func (l *UploadImageLogic) buildURL(fileName string) string { baseURL := l.svcCtx.Config.Upload.FileBaseURL if baseURL == "" && l.svcCtx.Config.PublicBaseURL != "" { // 兜底:如果未单独配置 Upload.FileBaseURL,则使用公共域名拼接默认上传路径 baseURL = strings.TrimRight(l.svcCtx.Config.PublicBaseURL, "/") + "/api/v1/upload/file" } if baseURL == "" { return "" } return fmt.Sprintf("%s/%s", baseURL, fileName) } func (l *UploadImageLogic) uploadStoragePath() string { candidates := []string{ "data/uploads", "../data/uploads", "../../data/uploads", "../../../data/uploads", } for _, c := range candidates { abs, _ := filepath.Abs(c) if err := os.MkdirAll(abs, 0755); err == nil { return abs } } abs, _ := filepath.Abs(candidates[0]) return abs } // deleteOldUploads 删除目录下超过保留时长的临时文件 func (l *UploadImageLogic) deleteOldUploads(dir string) { maxAgeH := l.svcCtx.Config.Upload.TempFileMaxAgeH if maxAgeH <= 0 { maxAgeH = defaultTempFileMaxAgeH } cutoff := time.Now().Add(-time.Duration(maxAgeH) * time.Hour) entries, err := os.ReadDir(dir) if err != nil { l.Errorf("deleteOldUploads ReadDir: %v", err) return } for _, e := range entries { if e.IsDir() { continue } path := filepath.Join(dir, e.Name()) info, err := os.Stat(path) if err != nil { continue } if info.ModTime().Before(cutoff) { if err := os.Remove(path); err != nil { l.Errorf("deleteOldUploads Remove %s: %v", path, err) } else { l.Infof("deleteOldUploads removed %s", path) } } } }