mirror of
https://github.com/langhuihui/monibuca.git
synced 2025-09-27 03:25:56 +08:00
367 lines
8.5 KiB
Go
367 lines
8.5 KiB
Go
//go:build cos
|
||
|
||
package storage
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/tencentyun/cos-go-sdk-v5"
|
||
)
|
||
|
||
// COSStorageConfig COS存储配置
|
||
type COSStorageConfig struct {
|
||
SecretID string `yaml:"secret_id" desc:"COS Secret ID"`
|
||
SecretKey string `yaml:"secret_key" desc:"COS Secret Key"`
|
||
Region string `yaml:"region" desc:"COS区域"`
|
||
Bucket string `yaml:"bucket" desc:"COS存储桶名称"`
|
||
PathPrefix string `yaml:"path_prefix" desc:"文件路径前缀"`
|
||
UseHTTPS bool `yaml:"use_https" desc:"是否使用HTTPS" default:"true"`
|
||
Timeout int `yaml:"timeout" desc:"上传超时时间(秒)" default:"30"`
|
||
}
|
||
|
||
func (c *COSStorageConfig) GetType() StorageType {
|
||
return StorageTypeCOS
|
||
}
|
||
|
||
func (c *COSStorageConfig) Validate() error {
|
||
if c.SecretID == "" {
|
||
return fmt.Errorf("secret_id is required for COS storage")
|
||
}
|
||
if c.SecretKey == "" {
|
||
return fmt.Errorf("secret_key is required for COS storage")
|
||
}
|
||
if c.Bucket == "" {
|
||
return fmt.Errorf("bucket is required for COS storage")
|
||
}
|
||
if c.Region == "" {
|
||
return fmt.Errorf("region is required for COS storage")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// COSStorage COS存储实现
|
||
type COSStorage struct {
|
||
config *COSStorageConfig
|
||
client *cos.Client
|
||
}
|
||
|
||
// NewCOSStorage 创建COS存储实例
|
||
func NewCOSStorage(config *COSStorageConfig) (*COSStorage, error) {
|
||
if err := config.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 设置默认值
|
||
if config.Timeout == 0 {
|
||
config.Timeout = 30
|
||
}
|
||
|
||
// 构建存储桶URL
|
||
scheme := "http"
|
||
if config.UseHTTPS {
|
||
scheme = "https"
|
||
}
|
||
bucketURL := fmt.Sprintf("%s://%s.cos.%s.myqcloud.com", scheme, config.Bucket, config.Region)
|
||
|
||
// 创建COS客户端
|
||
client := cos.NewClient(&cos.BaseURL{BucketURL: bucketURL}, &http.Client{
|
||
Transport: &cos.AuthorizationTransport{
|
||
SecretID: config.SecretID,
|
||
SecretKey: config.SecretKey,
|
||
},
|
||
})
|
||
|
||
// 测试连接
|
||
if err := testCOSConnection(client, config.Bucket); err != nil {
|
||
return nil, fmt.Errorf("COS connection test failed: %w", err)
|
||
}
|
||
|
||
return &COSStorage{
|
||
config: config,
|
||
client: client,
|
||
}, nil
|
||
}
|
||
|
||
func (s *COSStorage) CreateFile(ctx context.Context, path string) (File, error) {
|
||
objectKey := s.getObjectKey(path)
|
||
return &COSFile{
|
||
storage: s,
|
||
objectKey: objectKey,
|
||
ctx: ctx,
|
||
}, nil
|
||
}
|
||
|
||
func (s *COSStorage) Delete(ctx context.Context, path string) error {
|
||
objectKey := s.getObjectKey(path)
|
||
_, err := s.client.Object.Delete(ctx, objectKey)
|
||
return err
|
||
}
|
||
|
||
func (s *COSStorage) Exists(ctx context.Context, path string) (bool, error) {
|
||
objectKey := s.getObjectKey(path)
|
||
|
||
_, err := s.client.Object.Head(ctx, objectKey, nil)
|
||
if err != nil {
|
||
// 检查是否是404错误
|
||
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "NoSuchKey") {
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
|
||
return true, nil
|
||
}
|
||
|
||
func (s *COSStorage) GetSize(ctx context.Context, path string) (int64, error) {
|
||
objectKey := s.getObjectKey(path)
|
||
|
||
result, _, err := s.client.Object.Head(ctx, objectKey, nil)
|
||
if err != nil {
|
||
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "NoSuchKey") {
|
||
return 0, ErrFileNotFound
|
||
}
|
||
return 0, err
|
||
}
|
||
|
||
return result.ContentLength, nil
|
||
}
|
||
|
||
func (s *COSStorage) GetURL(ctx context.Context, path string) (string, error) {
|
||
objectKey := s.getObjectKey(path)
|
||
|
||
// 生成预签名URL,24小时有效期
|
||
presignedURL, err := s.client.Object.GetPresignedURL(ctx, http.MethodGet, objectKey,
|
||
s.config.SecretID, s.config.SecretKey, 24*time.Hour, nil)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return presignedURL.String(), nil
|
||
}
|
||
|
||
func (s *COSStorage) List(ctx context.Context, prefix string) ([]FileInfo, error) {
|
||
objectPrefix := s.getObjectKey(prefix)
|
||
|
||
var files []FileInfo
|
||
|
||
opt := &cos.BucketGetOptions{
|
||
Prefix: objectPrefix,
|
||
MaxKeys: 1000,
|
||
}
|
||
|
||
result, _, err := s.client.Bucket.Get(ctx, opt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, obj := range result.Contents {
|
||
// 移除路径前缀
|
||
fileName := obj.Key
|
||
if s.config.PathPrefix != "" {
|
||
fileName = strings.TrimPrefix(fileName, strings.TrimSuffix(s.config.PathPrefix, "/")+"/")
|
||
}
|
||
|
||
files = append(files, FileInfo{
|
||
Name: fileName,
|
||
Size: obj.Size,
|
||
LastModified: obj.LastModified,
|
||
ETag: obj.ETag,
|
||
})
|
||
}
|
||
|
||
return files, nil
|
||
}
|
||
|
||
func (s *COSStorage) Close() error {
|
||
// COS客户端无需显式关闭
|
||
return nil
|
||
}
|
||
|
||
// getObjectKey 获取COS对象键
|
||
func (s *COSStorage) getObjectKey(path string) string {
|
||
if s.config.PathPrefix != "" {
|
||
return strings.TrimSuffix(s.config.PathPrefix, "/") + "/" + path
|
||
}
|
||
return path
|
||
}
|
||
|
||
// testCOSConnection 测试COS连接
|
||
func testCOSConnection(client *cos.Client, bucket string) error {
|
||
// 尝试获取存储桶信息来测试连接
|
||
_, _, err := client.Bucket.Head(context.Background())
|
||
return err
|
||
}
|
||
|
||
// COSFile COS文件读写器
|
||
type COSFile struct {
|
||
storage *COSStorage
|
||
objectKey string
|
||
ctx context.Context
|
||
tempFile *os.File // 本地临时文件,用于支持随机访问
|
||
filePath string // 临时文件路径
|
||
}
|
||
|
||
func (f *COSFile) Name() string {
|
||
return f.objectKey
|
||
}
|
||
|
||
func (f *COSFile) Write(p []byte) (n int, err error) {
|
||
// 如果还没有创建临时文件,先创建
|
||
if f.tempFile == nil {
|
||
if err = f.createTempFile(); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
// 写入到临时文件
|
||
return f.tempFile.Write(p)
|
||
}
|
||
|
||
func (f *COSFile) Read(p []byte) (n int, err error) {
|
||
// 如果还没有创建缓存文件,先下载到本地
|
||
if f.tempFile == nil {
|
||
if err = f.downloadToTemp(); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
// 从本地缓存文件读取
|
||
return f.tempFile.Read(p)
|
||
}
|
||
|
||
func (f *COSFile) WriteAt(p []byte, off int64) (n int, err error) {
|
||
// 如果还没有创建临时文件,先创建
|
||
if f.tempFile == nil {
|
||
if err = f.createTempFile(); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
// 写入到临时文件的指定位置
|
||
return f.tempFile.WriteAt(p, off)
|
||
}
|
||
|
||
func (f *COSFile) ReadAt(p []byte, off int64) (n int, err error) {
|
||
// 如果还没有创建缓存文件,先下载到本地
|
||
if f.tempFile == nil {
|
||
if err = f.downloadToTemp(); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
// 从本地缓存文件的指定位置读取
|
||
return f.tempFile.ReadAt(p, off)
|
||
}
|
||
|
||
func (f *COSFile) Sync() error {
|
||
// 如果使用临时文件,先同步到磁盘
|
||
if f.tempFile != nil {
|
||
if err := f.tempFile.Sync(); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := f.uploadTempFile(); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (f *COSFile) Seek(offset int64, whence int) (int64, error) {
|
||
// 如果还没有创建临时文件,先创建或下载
|
||
if f.tempFile == nil {
|
||
if err := f.downloadToTemp(); err != nil {
|
||
return 0, err
|
||
}
|
||
}
|
||
|
||
// 使用临时文件进行随机访问
|
||
return f.tempFile.Seek(offset, whence)
|
||
}
|
||
|
||
func (f *COSFile) Close() error {
|
||
if err := f.Sync(); err != nil {
|
||
return err
|
||
}
|
||
if f.tempFile != nil {
|
||
f.tempFile.Close()
|
||
}
|
||
// 清理临时文件
|
||
if f.filePath != "" {
|
||
os.Remove(f.filePath)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// createTempFile 创建临时文件
|
||
func (f *COSFile) createTempFile() error {
|
||
// 创建临时文件
|
||
tempFile, err := os.CreateTemp("", "coswriter_*.tmp")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create temp file: %w", err)
|
||
}
|
||
f.tempFile = tempFile
|
||
f.filePath = tempFile.Name()
|
||
return nil
|
||
}
|
||
|
||
func (f *COSFile) Stat() (os.FileInfo, error) {
|
||
return f.tempFile.Stat()
|
||
}
|
||
|
||
// uploadTempFile 上传临时文件到COS
|
||
func (f *COSFile) uploadTempFile() (err error) {
|
||
// 上传到COS
|
||
_, err = f.storage.client.Object.PutFromFile(f.ctx, f.objectKey, f.filePath, nil)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to upload to COS: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// downloadToTemp 下载COS对象到本地临时文件
|
||
func (f *COSFile) downloadToTemp() error {
|
||
// 创建临时文件
|
||
tempFile, err := os.CreateTemp("", "cosreader_*.tmp")
|
||
if err != nil {
|
||
return fmt.Errorf("failed to create temp file: %w", err)
|
||
}
|
||
|
||
f.tempFile = tempFile
|
||
f.filePath = tempFile.Name()
|
||
|
||
// 下载COS对象
|
||
_, err = f.storage.client.Object.GetToFile(f.ctx, f.objectKey, f.filePath, nil)
|
||
if err != nil {
|
||
tempFile.Close()
|
||
os.Remove(f.filePath)
|
||
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "NoSuchKey") {
|
||
return ErrFileNotFound
|
||
}
|
||
return fmt.Errorf("failed to download from COS: %w", err)
|
||
}
|
||
|
||
// 重置文件指针到开始位置
|
||
_, err = tempFile.Seek(0, 0)
|
||
if err != nil {
|
||
tempFile.Close()
|
||
os.Remove(f.filePath)
|
||
return fmt.Errorf("failed to seek temp file: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func init() {
|
||
Factory["cos"] = func(config any) (Storage, error) {
|
||
var cosConfig COSStorageConfig
|
||
config.Parse(&cosConfig, config.(map[string]any))
|
||
return NewCOSStorage(cosConfig)
|
||
}
|
||
}
|