Files
nl-blogs/server/utils/oss.go
2026-07-14 10:05:33 +08:00

540 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}