package utils import ( "bytes" "context" "crypto/rand" "encoding/hex" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "strings" "time" "github.com/aliyun/aliyun-oss-go-sdk/oss" "github.com/qiniu/go-sdk/v7/auth/qbox" "github.com/qiniu/go-sdk/v7/storage" cos "github.com/tencentyun/cos-go-sdk-v5" ) // StorageType 存储类型 type StorageType string const ( StorageLocal StorageType = "local" StorageQCloud StorageType = "qcloud" StorageAliyun StorageType = "aliyun" StorageQiniu StorageType = "qiniu" ) // OSSConfig OSS配置 type OSSConfig struct { StorageType string // 通用字段(向后兼容) AccessKey string SecretKey string Bucket string Region string Domain string // 阿里云OSS专用字段 OSSAccessKeyID string OSSAccessKeySecret string OSSEndpoint string OSSBucket string OSSDomain string // 腾讯云COS专用字段 QCloudSecretID string QCloudSecretKey string QCloudRegion string QCloudBucket string QCloudDomain string // 七牛云专用字段 QiniuAccessKey string QiniuSecretKey string QiniuBucket string QiniuRegion string QiniuDomain string } // OSSUploader OSS上传接口 type OSSUploader interface { Upload(file multipart.File, filename string, size int64) (string, string, error) // 返回 filePath, fileURL, error Delete(filePath string) error } // NormalizeOSSEndpoint 补全 endpoint 协议前缀 func NormalizeOSSEndpoint(endpoint string) string { endpoint = strings.TrimSpace(endpoint) if endpoint == "" { return "" } if !strings.HasPrefix(endpoint, "http://") && !strings.HasPrefix(endpoint, "https://") { return "https://" + endpoint } return endpoint } // LoadAliyunConfigFromEnv 从环境变量加载阿里云 OSS 配置;前 4 项齐全返回 true func LoadAliyunConfigFromEnv() (*OSSConfig, bool) { accessKeyID := strings.TrimSpace(os.Getenv("OSS_ACCESS_KEY_ID")) accessKeySecret := strings.TrimSpace(os.Getenv("OSS_ACCESS_KEY_SECRET")) endpoint := NormalizeOSSEndpoint(os.Getenv("OSS_ENDPOINT")) bucket := strings.TrimSpace(os.Getenv("OSS_BUCKET")) domain := strings.TrimSpace(os.Getenv("OSS_DOMAIN")) if accessKeyID == "" || accessKeySecret == "" || endpoint == "" || bucket == "" { return nil, false } return &OSSConfig{ StorageType: string(StorageAliyun), OSSAccessKeyID: accessKeyID, OSSAccessKeySecret: accessKeySecret, OSSEndpoint: endpoint, OSSBucket: bucket, OSSDomain: domain, }, true } // HasAliyunEnvConfig 环境变量中是否具备完整的阿里云 OSS 配置 func HasAliyunEnvConfig() bool { _, ok := LoadAliyunConfigFromEnv() return ok } // BuildBlogObjectKey 生成博客上传对象键:blog/{YYYYMMDD}/{随机名}{ext} func BuildBlogObjectKey(originalFilename string) string { ext := strings.ToLower(filepath.Ext(originalFilename)) randomName := randomHex(16) dateDir := time.Now().Format("20060102") return fmt.Sprintf("blog/%s/%s%s", dateDir, randomName, ext) } func randomHex(nBytes int) string { buf := make([]byte, nBytes) if _, err := rand.Read(buf); err != nil { // 退化到时间戳,仍保证文件名唯一性 return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(buf) } // GetOSSUploader 根据存储类型获取上传器 func GetOSSUploader(config *OSSConfig) (OSSUploader, error) { switch StorageType(config.StorageType) { case StorageLocal: // 从环境变量读取配置,如果未设置则使用默认值 basePath := os.Getenv("UPLOADS_BASE_PATH") if basePath == "" { basePath = "./uploads" } baseURL := os.Getenv("UPLOADS_BASE_URL") if baseURL == "" { baseURL = "/uploads" } return &LocalUploader{ BasePath: basePath, BaseURL: baseURL, }, nil case StorageQCloud: // 优先使用专用字段,如果为空则使用通用字段(向后兼容) secretID := config.QCloudSecretID secretKey := config.QCloudSecretKey region := config.QCloudRegion bucket := config.QCloudBucket domain := config.QCloudDomain if secretID == "" { secretID = config.AccessKey } if secretKey == "" { secretKey = config.SecretKey } if region == "" { region = config.Region } if bucket == "" { bucket = config.Bucket } if domain == "" { domain = config.Domain } if secretID == "" || secretKey == "" || bucket == "" || region == "" { return nil, fmt.Errorf("qcloud config incomplete: secretID, secretKey, bucket, region are required") } u, err := url.Parse(fmt.Sprintf("https://%s.cos.%s.myqcloud.com", bucket, region)) if err != nil { return nil, fmt.Errorf("invalid qcloud config: %v", err) } b := &cos.BaseURL{BucketURL: u} client := cos.NewClient(b, &http.Client{ Transport: &cos.AuthorizationTransport{ SecretID: secretID, SecretKey: secretKey, }, }) return &QCloudUploader{ Client: client, Bucket: bucket, Domain: domain, }, nil case StorageAliyun: // 优先使用专用字段,如果为空则使用通用字段(向后兼容) accessKeyID := config.OSSAccessKeyID accessKeySecret := config.OSSAccessKeySecret endpoint := config.OSSEndpoint bucket := config.OSSBucket domain := config.OSSDomain if accessKeyID == "" { accessKeyID = config.AccessKey } if accessKeySecret == "" { accessKeySecret = config.SecretKey } if bucket == "" { bucket = config.Bucket } if domain == "" { domain = config.Domain } if accessKeyID == "" || accessKeySecret == "" || bucket == "" { return nil, fmt.Errorf("aliyun config incomplete: accessKeyID, accessKeySecret, bucket are required") } // 如果endpoint为空,从region构建(向后兼容) if endpoint == "" { region := config.Region if region == "" { return nil, fmt.Errorf("aliyun config incomplete: endpoint or region is required") } endpoint = fmt.Sprintf("https://oss-%s.aliyuncs.com", region) } endpoint = NormalizeOSSEndpoint(endpoint) client, err := oss.New(endpoint, accessKeyID, accessKeySecret) if err != nil { return nil, fmt.Errorf("failed to create aliyun oss client: %v", err) } ossBucket, err := client.Bucket(bucket) if err != nil { return nil, fmt.Errorf("failed to get aliyun bucket: %v", err) } return &AliyunUploader{ Bucket: ossBucket, Domain: domain, }, nil case StorageQiniu: // 优先使用专用字段,如果为空则使用通用字段(向后兼容) accessKey := config.QiniuAccessKey secretKey := config.QiniuSecretKey bucket := config.QiniuBucket region := config.QiniuRegion domain := config.QiniuDomain if accessKey == "" { accessKey = config.AccessKey } if secretKey == "" { secretKey = config.SecretKey } if bucket == "" { bucket = config.Bucket } if domain == "" { domain = config.Domain } if accessKey == "" || secretKey == "" || bucket == "" { return nil, fmt.Errorf("qiniu config incomplete: accessKey, secretKey, bucket are required") } mac := qbox.NewMac(accessKey, secretKey) // 根据region选择Zone(如果region为空,使用通用字段的region,否则默认华东) var zone *storage.Zone if region != "" { switch region { case "z0", "华东": zone = &storage.ZoneHuadong case "z1", "华北": zone = &storage.ZoneHuabei case "z2", "华南": zone = &storage.ZoneHuanan case "na0", "北美": zone = &storage.ZoneBeimei case "as0", "东南亚": zone = &storage.ZoneXinjiapo default: zone = &storage.ZoneHuadong // 默认华东 } } else if config.Region != "" { // 使用通用字段的region switch config.Region { case "z0", "华东": zone = &storage.ZoneHuadong case "z1", "华北": zone = &storage.ZoneHuabei case "z2", "华南": zone = &storage.ZoneHuanan case "na0", "北美": zone = &storage.ZoneBeimei case "as0", "东南亚": zone = &storage.ZoneXinjiapo default: zone = &storage.ZoneHuadong } } else { zone = &storage.ZoneHuadong // 默认华东 } cfg := storage.Config{ Zone: zone, UseHTTPS: true, UseCdnDomains: false, } formUploader := storage.NewFormUploader(&cfg) bucketManager := storage.NewBucketManager(mac, &cfg) return &QiniuUploader{ FormUploader: formUploader, BucketManager: bucketManager, Bucket: bucket, Domain: domain, Mac: mac, }, nil default: return nil, fmt.Errorf("unsupported storage type: %s", config.StorageType) } } // LocalUploader 本地存储上传器 type LocalUploader struct { BasePath string BaseURL string } // Upload 上传文件到本地 func (l *LocalUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) { objectKey := BuildBlogObjectKey(filename) filePath := filepath.Join(l.BasePath, filepath.FromSlash(objectKey)) uploadDir := filepath.Dir(filePath) if err := os.MkdirAll(uploadDir, 0755); err != nil { return "", "", fmt.Errorf("failed to create upload directory: %v", err) } dst, err := os.Create(filePath) if err != nil { return "", "", fmt.Errorf("failed to create file: %v", err) } defer dst.Close() if _, err := io.Copy(dst, file); err != nil { return "", "", fmt.Errorf("failed to copy file: %v", err) } fileURL := fmt.Sprintf("%s/%s", strings.TrimSuffix(l.BaseURL, "/"), objectKey) return filePath, fileURL, nil } // Delete 删除本地文件 func (l *LocalUploader) Delete(filePath string) error { // 确保文件路径在BasePath内(安全措施) absBasePath, err := filepath.Abs(l.BasePath) if err != nil { return err } absFilePath, err := filepath.Abs(filePath) if err != nil { return err } if !strings.HasPrefix(absFilePath, absBasePath) { return fmt.Errorf("invalid file path: outside base directory") } return os.Remove(filePath) } // AliyunUploader 阿里云OSS上传器 type AliyunUploader struct { Bucket *oss.Bucket Domain string } // Upload 上传文件到阿里云OSS func (a *AliyunUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) { objectKey := BuildBlogObjectKey(filename) err := a.Bucket.PutObject(objectKey, file, oss.ObjectACL(oss.ACLPublicRead)) if err != nil { return "", "", fmt.Errorf("failed to upload to aliyun oss: %v", err) } var fileURL string if a.Domain != "" { fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(NormalizeOSSEndpoint(a.Domain), "/"), objectKey) } else { endpoint := NormalizeOSSEndpoint(a.Bucket.Client.Config.Endpoint) fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), objectKey) } return objectKey, fileURL, nil } // Delete 删除阿里云OSS文件 func (a *AliyunUploader) Delete(objectKey string) error { err := a.Bucket.DeleteObject(objectKey) if err != nil { return fmt.Errorf("failed to delete from aliyun oss: %v", err) } return nil } // TestOSSConnection 验证云存储配置是否可用(ListObjects / 等价只读探测) func TestOSSConnection(config *OSSConfig) error { uploader, err := GetOSSUploader(config) if err != nil { return err } switch u := uploader.(type) { case *LocalUploader: if err := os.MkdirAll(u.BasePath, 0755); err != nil { return fmt.Errorf("local storage path not writable: %v", err) } return nil case *AliyunUploader: _, err := u.Bucket.ListObjects(oss.MaxKeys(1)) if err != nil { return fmt.Errorf("aliyun oss connection failed: %v", err) } return nil case *QCloudUploader: _, _, err := u.Client.Bucket.Get(context.Background(), &cos.BucketGetOptions{MaxKeys: 1}) if err != nil { return fmt.Errorf("qcloud cos connection failed: %v", err) } return nil case *QiniuUploader: _, _, _, _, err := u.BucketManager.ListFiles(u.Bucket, "", "", "", 1) if err != nil { return fmt.Errorf("qiniu connection failed: %v", err) } return nil default: return fmt.Errorf("unsupported storage type: %s", config.StorageType) } } // QCloudUploader 腾讯云COS上传器 type QCloudUploader struct { Client *cos.Client Bucket string Domain string } // Upload 上传文件到腾讯云COS func (q *QCloudUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) { objectKey := BuildBlogObjectKey(filename) _, err := q.Client.Object.Put(context.Background(), objectKey, file, nil) if err != nil { return "", "", fmt.Errorf("failed to upload to qcloud cos: %v", err) } var fileURL string if q.Domain != "" { fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Domain, "/"), objectKey) } else { fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Client.BaseURL.BucketURL.String(), "/"), objectKey) } return objectKey, fileURL, nil } // Delete 删除腾讯云COS文件 func (q *QCloudUploader) Delete(objectKey string) error { _, err := q.Client.Object.Delete(context.Background(), objectKey) if err != nil { return fmt.Errorf("failed to delete from qcloud cos: %v", err) } return nil } // QiniuUploader 七牛云上传器 type QiniuUploader struct { FormUploader *storage.FormUploader BucketManager *storage.BucketManager Bucket string Domain string Mac *qbox.Mac } // Upload 上传文件到七牛云 func (q *QiniuUploader) Upload(file multipart.File, filename string, size int64) (string, string, error) { key := BuildBlogObjectKey(filename) putPolicy := storage.PutPolicy{ Scope: q.Bucket, } upToken := putPolicy.UploadToken(q.Mac) // 读取文件内容 fileData := make([]byte, size) _, err := file.Read(fileData) if err != nil { return "", "", fmt.Errorf("failed to read file: %v", err) } // 上传文件 ret := storage.PutRet{} err = q.FormUploader.Put(context.Background(), &ret, upToken, key, bytes.NewReader(fileData), size, nil) if err != nil { return "", "", fmt.Errorf("failed to upload to qiniu: %v", err) } // 生成访问URL var fileURL string if q.Domain != "" { fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Domain, "/"), key) } else { // 七牛云需要配置域名,如果没有配置则返回key fileURL = key } return key, fileURL, nil } // Delete 删除七牛云文件 func (q *QiniuUploader) Delete(key string) error { err := q.BucketManager.Delete(q.Bucket, key) if err != nil { return fmt.Errorf("failed to delete from qiniu: %v", err) } return nil } // GetFileType 根据MIME类型判断文件类型 func GetFileType(mimeType string) string { if strings.HasPrefix(mimeType, "image/") { return "image" } else if strings.HasPrefix(mimeType, "video/") { return "video" } else if strings.HasPrefix(mimeType, "application/pdf") || strings.HasPrefix(mimeType, "application/msword") || strings.HasPrefix(mimeType, "application/vnd.openxmlformats") || strings.HasPrefix(mimeType, "text/") { return "document" } return "other" }