2026-01-19 16:14:08 +08:00
|
|
|
|
package utils
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-01-19 20:21:09 +08:00
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
2026-07-14 10:05:33 +08:00
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/hex"
|
2026-01-19 16:14:08 +08:00
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"mime/multipart"
|
2026-01-19 20:21:09 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
2026-01-19 16:14:08 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-01-19 20:21:09 +08:00
|
|
|
|
|
|
|
|
|
|
"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"
|
2026-01-19 16:14:08 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// StorageType 存储类型
|
|
|
|
|
|
type StorageType string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
StorageLocal StorageType = "local"
|
|
|
|
|
|
StorageQCloud StorageType = "qcloud"
|
|
|
|
|
|
StorageAliyun StorageType = "aliyun"
|
|
|
|
|
|
StorageQiniu StorageType = "qiniu"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// OSSConfig OSS配置
|
|
|
|
|
|
type OSSConfig struct {
|
|
|
|
|
|
StorageType string
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 通用字段(向后兼容)
|
|
|
|
|
|
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
|
2026-01-19 16:14:08 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// OSSUploader OSS上传接口
|
|
|
|
|
|
type OSSUploader interface {
|
|
|
|
|
|
Upload(file multipart.File, filename string, size int64) (string, string, error) // 返回 filePath, fileURL, error
|
|
|
|
|
|
Delete(filePath string) error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 10:05:33 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:14:08 +08:00
|
|
|
|
// GetOSSUploader 根据存储类型获取上传器
|
|
|
|
|
|
func GetOSSUploader(config *OSSConfig) (OSSUploader, error) {
|
|
|
|
|
|
switch StorageType(config.StorageType) {
|
|
|
|
|
|
case StorageLocal:
|
2026-01-20 10:36:27 +08:00
|
|
|
|
// 从环境变量读取配置,如果未设置则使用默认值
|
|
|
|
|
|
basePath := os.Getenv("UPLOADS_BASE_PATH")
|
|
|
|
|
|
if basePath == "" {
|
|
|
|
|
|
basePath = "./uploads"
|
|
|
|
|
|
}
|
|
|
|
|
|
baseURL := os.Getenv("UPLOADS_BASE_URL")
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
baseURL = "/uploads"
|
|
|
|
|
|
}
|
2026-01-19 16:14:08 +08:00
|
|
|
|
return &LocalUploader{
|
2026-01-20 10:36:27 +08:00
|
|
|
|
BasePath: basePath,
|
|
|
|
|
|
BaseURL: baseURL,
|
2026-01-19 16:14:08 +08:00
|
|
|
|
}, nil
|
|
|
|
|
|
case StorageQCloud:
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
|
|
|
|
|
|
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
|
2026-01-19 16:14:08 +08:00
|
|
|
|
case StorageAliyun:
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-07-14 10:05:33 +08:00
|
|
|
|
endpoint = NormalizeOSSEndpoint(endpoint)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-01-19 16:14:08 +08:00
|
|
|
|
case StorageQiniu:
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 优先使用专用字段,如果为空则使用通用字段(向后兼容)
|
|
|
|
|
|
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
|
2026-01-19 16:14:08 +08:00
|
|
|
|
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) {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
objectKey := BuildBlogObjectKey(filename)
|
|
|
|
|
|
filePath := filepath.Join(l.BasePath, filepath.FromSlash(objectKey))
|
|
|
|
|
|
uploadDir := filepath.Dir(filePath)
|
2026-01-19 16:14:08 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 10:05:33 +08:00
|
|
|
|
fileURL := fmt.Sprintf("%s/%s", strings.TrimSuffix(l.BaseURL, "/"), objectKey)
|
2026-01-19 16:14:08 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 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) {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
objectKey := BuildBlogObjectKey(filename)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
|
2026-07-14 10:05:33 +08:00
|
|
|
|
err := a.Bucket.PutObject(objectKey, file, oss.ObjectACL(oss.ACLPublicRead))
|
2026-01-19 20:21:09 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return "", "", fmt.Errorf("failed to upload to aliyun oss: %v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var fileURL string
|
|
|
|
|
|
if a.Domain != "" {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(NormalizeOSSEndpoint(a.Domain), "/"), objectKey)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
} else {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
endpoint := NormalizeOSSEndpoint(a.Bucket.Client.Config.Endpoint)
|
|
|
|
|
|
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), objectKey)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-14 10:05:33 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 20:21:09 +08:00
|
|
|
|
// 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) {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
objectKey := BuildBlogObjectKey(filename)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
|
|
|
|
|
|
_, 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 {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
fileURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(q.Client.BaseURL.BucketURL.String(), "/"), objectKey)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-14 10:05:33 +08:00
|
|
|
|
key := BuildBlogObjectKey(filename)
|
2026-01-19 20:21:09 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-19 16:14:08 +08:00
|
|
|
|
// 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"
|
|
|
|
|
|
}
|