Files
monibuca/pkg/storage/cos.go
2025-09-25 09:34:17 +08:00

367 lines
8.5 KiB
Go
Raw Permalink 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.

//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)
// 生成预签名URL24小时有效期
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)
}
}