package service import ( "bytes" "context" "errors" "fmt" "mime/multipart" "net/url" "os" "path/filepath" "strings" "sync" "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" "git.iioio.com/freefire/jiang13-forum/config" ) // UploadCategory 上传分类目录名 const ( UploadCategoryAvatars = "avatars" UploadCategoryPosts = "posts" UploadCategorySite = "site" ) // StorageConfig 上传存储配置(管理后台 / 内部使用) type StorageConfig struct { Type string `json:"type"` // local | s3 Endpoint string `json:"endpoint"` Region string `json:"region"` Bucket string `json:"bucket"` AccessKey string `json:"access_key"` SecretKey string `json:"secret_key,omitempty"` // 更新时传入;回显时为空 PublicBaseURL string `json:"public_base_url"` Prefix string `json:"prefix"` ForcePathStyle bool `json:"force_path_style"` HasSecretKey bool `json:"has_secret_key"` Ready bool `json:"ready"` // ImageDelivery 展示方案:webp(默认)| original;上传始终保留原图 ImageDelivery string `json:"image_delivery"` } // UploadStore 统一上传存储(本地或 S3 兼容),支持运行时热切换 type UploadStore struct { mu sync.RWMutex dataDir string settings *ForumSettingsService mode string s3 *s3Backend publicBase string keyPrefix string } type s3Backend struct { client *minio.Client bucket string } // NewUploadStore 创建本地默认存储;调用 Apply / ReloadFromSettings 切换后端 func NewUploadStore(dataDir string, settings *ForumSettingsService) *UploadStore { return &UploadStore{ dataDir: dataDir, settings: settings, mode: config.StorageTypeLocal, } } // ReloadFromSettings 按数据库配置重建存储客户端 func (s *UploadStore) ReloadFromSettings(settings *ForumSettingsService) error { if settings == nil { return errors.New("设置服务未初始化") } return s.Apply(settings.StorageConfig()) } // Apply 应用存储配置(失败时保持原配置不变) func (s *UploadStore) Apply(cfg StorageConfig) error { if s == nil { return errors.New("上传存储未初始化") } typ := normalizeStorageType(cfg.Type) if typ == config.StorageTypeLocal { s.mu.Lock() s.mode = config.StorageTypeLocal s.s3 = nil s.publicBase = "" s.keyPrefix = "" s.mu.Unlock() return nil } if err := validateStorageConfigForApply(cfg); err != nil { return err } endpoint, secure, err := parseS3Endpoint(cfg.Endpoint) if err != nil { return err } region := strings.TrimSpace(cfg.Region) if region == "" { region = "us-east-1" } lookup := minio.BucketLookupDNS if cfg.ForcePathStyle { lookup = minio.BucketLookupPath } client, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(strings.TrimSpace(cfg.AccessKey), cfg.SecretKey, ""), Secure: secure, Region: region, BucketLookup: lookup, }) if err != nil { return fmt.Errorf("初始化 S3 客户端失败: %w", err) } s.mu.Lock() s.mode = config.StorageTypeS3 s.s3 = &s3Backend{client: client, bucket: strings.TrimSpace(cfg.Bucket)} s.publicBase = normalizeRootURL(cfg.PublicBaseURL) s.keyPrefix = normalizeObjectPrefix(cfg.Prefix) s.mu.Unlock() return nil } func validateStorageConfigForApply(cfg StorageConfig) error { if strings.TrimSpace(cfg.Endpoint) == "" { return errors.New("S3 Endpoint 不能为空") } if strings.TrimSpace(cfg.Bucket) == "" { return errors.New("S3 Bucket 不能为空") } if strings.TrimSpace(cfg.AccessKey) == "" { return errors.New("S3 Access Key 不能为空") } if strings.TrimSpace(cfg.SecretKey) == "" { return errors.New("S3 Secret Key 不能为空") } if normalizeRootURL(cfg.PublicBaseURL) == "" { return errors.New("公开访问地址 PUBLIC_BASE_URL 不能为空") } return nil } func normalizeStorageType(raw string) string { t := strings.ToLower(strings.TrimSpace(raw)) if t == config.StorageTypeS3 { return config.StorageTypeS3 } return config.StorageTypeLocal } func normalizeObjectPrefix(raw string) string { p := strings.TrimSpace(raw) p = strings.TrimPrefix(p, "/") if p == "" { return "" } return strings.TrimSuffix(p, "/") + "/" } // snapshot 读取当前后端快照(调用方勿修改返回指针) func (s *UploadStore) snapshot() (mode, publicBase, keyPrefix string, s3 *s3Backend) { s.mu.RLock() defer s.mu.RUnlock() return s.mode, s.publicBase, s.keyPrefix, s.s3 } // IsLocal 是否本地磁盘存储 func (s *UploadStore) IsLocal() bool { if s == nil { return true } mode, _, _, backend := s.snapshot() return mode != config.StorageTypeS3 || backend == nil } // UploadsRoot 本地 uploads 根目录 func (s *UploadStore) UploadsRoot() string { if s == nil { return "" } return filepath.Join(s.dataDir, "uploads") } // SaveImage 保存图片:始终保留原图;静态图额外写 WebP 衍生,按展示方案返回 URL func (s *UploadStore) SaveImage(file *multipart.FileHeader, category, namePrefix string) (string, error) { if s == nil { return "", errors.New("上传存储未初始化") } category = strings.Trim(category, "/") if category == "" { return "", errors.New("无效的上传分类") } prepared, err := prepareUploadImage(file) if err != nil { return "", err } base := fmt.Sprintf("%s_%d", namePrefix, time.Now().UnixNano()) origName := base + prepared.OrigExt webpName := base + ".webp" delivery := ImageDeliveryWebP if s.settings != nil { delivery = s.settings.ImageDelivery() } mode, publicBase, keyPrefix, backend := s.snapshot() useS3 := mode == config.StorageTypeS3 if useS3 && backend == nil { return "", errors.New("对象存储未就绪,请检查管理后台「对象存储」配置") } // 1) 写原图 if useS3 { if err := s.putBytesS3(backend, keyPrefix, category, origName, prepared.OrigContentType, prepared.OrigData); err != nil { return "", err } } else { if err := s.putBytesLocal(category, origName, prepared.OrigData); err != nil { return "", err } } // 2) 写 WebP 衍生(若有) hasWebP := len(prepared.WebPData) > 0 if hasWebP { if useS3 { if err := s.putBytesS3(backend, keyPrefix, category, webpName, "image/webp", prepared.WebPData); err != nil { return "", err } } else { if err := s.putBytesLocal(category, webpName, prepared.WebPData); err != nil { return "", err } } } // 3) 按展示方案选择返回 URL returnName := origName if delivery == ImageDeliveryWebP && (hasWebP || prepared.OrigExt == ".webp") { if hasWebP { returnName = webpName } else { returnName = origName // 原图已是 webp } } publicURL := s.publicURL(useS3, publicBase, category, returnName) // 写入媒体索引(原图 + WebP 衍生均登记) storageType := config.StorageTypeLocal if useS3 { storageType = config.StorageTypeS3 } uploader := parseUploaderID(category, namePrefix) origURL := s.publicURL(useS3, publicBase, category, origName) _ = s.upsertMediaRecord(category, origName, origURL, int64(len(prepared.OrigData)), prepared.OrigContentType, storageType, uploader) if hasWebP { webpURL := s.publicURL(useS3, publicBase, category, webpName) _ = s.upsertMediaRecord(category, webpName, webpURL, int64(len(prepared.WebPData)), "image/webp", storageType, uploader) } // 缩略图优先用 WebP 衍生(更小);否则用返回文件 if category == UploadCategoryPosts && !useS3 { thumbFile := returnName if hasWebP { thumbFile = webpName } rel := filepath.ToSlash(filepath.Join(category, thumbFile)) go WarmPostImageThumb(s.UploadsRoot(), rel) } return publicURL, nil } func (s *UploadStore) publicURL(useS3 bool, publicBase, category, filename string) string { if useS3 { return publicBase + "/" + category + "/" + filename } return "/uploads/" + category + "/" + filename } func (s *UploadStore) putBytesLocal(category, filename string, data []byte) error { dir := filepath.Join(s.UploadsRoot(), category) if err := os.MkdirAll(dir, 0755); err != nil { return err } return os.WriteFile(filepath.Join(dir, filename), data, 0644) } func (s *UploadStore) putBytesS3(backend *s3Backend, keyPrefix, category, filename, contentType string, data []byte) error { key := keyPrefix + category + "/" + filename opts := minio.PutObjectOptions{ContentType: contentType} _, err := backend.client.PutObject( context.Background(), backend.bucket, key, bytes.NewReader(data), int64(len(data)), opts, ) if err != nil { return fmt.Errorf("上传到对象存储失败: %w", err) } return nil } // DeleteByURL 删除本站管理的上传文件(非本站 URL 则忽略) func (s *UploadStore) DeleteByURL(rawURL string) { if s == nil { return } rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return } siblingURLs := s.resolveSiblingPublicURLs(rawURL) // 始终尝试清理本地 /uploads/…(兼容切换到 S3 前的旧文件) s.deleteLocalByURL(rawURL) _, publicBase, keyPrefix, backend := s.snapshot() if backend != nil { s.deleteS3ByURL(backend, publicBase, keyPrefix, rawURL) } s.deleteMediaRecords(siblingURLs) } func (s *UploadStore) deleteLocalByURL(rawURL string) { path := rawURL if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { return } if i := strings.Index(path, "?"); i >= 0 { path = path[:i] } const prefix = "/uploads/" if !strings.HasPrefix(path, prefix) { return } rel := strings.TrimPrefix(path, prefix) rel = filepath.Clean(filepath.FromSlash(rel)) if rel == "." || strings.HasPrefix(rel, "..") { return } relSlash := filepath.ToSlash(rel) for _, candidate := range uploadSiblingRels(relSlash) { full := filepath.Join(s.UploadsRoot(), filepath.FromSlash(candidate)) _ = os.Remove(full) if strings.HasPrefix(candidate, UploadCategoryPosts+"/") { thumbDir := filepath.Join(s.UploadsRoot(), ".thumbs") _ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".webp")) _ = os.Remove(filepath.Join(thumbDir, filepath.FromSlash(candidate)+".jpg")) } } } func (s *UploadStore) deleteS3ByURL(backend *s3Backend, publicBase, keyPrefix, rawURL string) { if backend == nil || publicBase == "" { return } rel, ok := relativeUnderPublicBase(rawURL, publicBase) if !ok { return } parts := strings.SplitN(rel, "/", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return } for _, candidate := range uploadSiblingRels(parts[0] + "/" + parts[1]) { key := keyPrefix + candidate _ = backend.client.RemoveObject(context.Background(), backend.bucket, key, minio.RemoveObjectOptions{}) } } // uploadSiblingRels 返回同一主文件名下的自身与伴生扩展名路径(rel 使用 /) func uploadSiblingRels(rel string) []string { rel = strings.TrimSpace(strings.ReplaceAll(rel, "\\", "/")) ext := "" if i := strings.LastIndex(rel, "."); i >= 0 && i > strings.LastIndex(rel, "/") { ext = strings.ToLower(rel[i:]) } stem := rel if ext != "" { stem = rel[:len(rel)-len(ext)] } if stem == "" { return nil } seen := map[string]bool{} out := make([]string, 0, 6) add := func(e string) { p := stem + e if seen[p] { return } seen[p] = true out = append(out, p) } if ext != "" { add(ext) } for _, e := range siblingUploadExts(ext) { add(e) } return out } func parseS3Endpoint(raw string) (host string, secure bool, err error) { raw = strings.TrimSpace(raw) if raw == "" { return "", false, errors.New("S3 ENDPOINT 不能为空") } secure = true if strings.Contains(raw, "://") { u, err := url.Parse(raw) if err != nil { return "", false, fmt.Errorf("S3 ENDPOINT 无效: %w", err) } if u.Host == "" { return "", false, errors.New("S3 ENDPOINT 无效") } switch strings.ToLower(u.Scheme) { case "http": secure = false case "https": secure = true default: return "", false, fmt.Errorf("S3 ENDPOINT 不支持协议 %q", u.Scheme) } return u.Host, secure, nil } host = raw if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") { secure = false } return host, secure, nil } func relativeUnderPublicBase(rawURL, publicBase string) (string, bool) { publicBase = strings.TrimRight(strings.TrimSpace(publicBase), "/") rawURL = strings.TrimSpace(rawURL) if publicBase == "" || rawURL == "" { return "", false } if strings.HasPrefix(rawURL, publicBase+"/") { rel := strings.TrimPrefix(rawURL, publicBase+"/") if i := strings.Index(rel, "?"); i >= 0 { rel = rel[:i] } rel = strings.TrimPrefix(rel, "/") if rel == "" || strings.Contains(rel, "..") { return "", false } return rel, true } return "", false }