// Package storage 封装腾讯云 COS 对象存储访问,统一提供上传、读取、删除和公开地址生成能力。 package storage import ( "context" "fmt" "io" "net/http" "os" "path" "strings" "juhe-factory/api/internal/config" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" ) const publicMediaCacheControl = "public, max-age=31536000, immutable" // COS 保存腾讯云 COS 客户端、存储桶、公开域名和媒体体积限制。 type COS struct { client *s3.Client bucket string publicBaseURL string maxImageBytes int64 maxVideoBytes int64 maxAudioBytes int64 } // NewCOS 根据服务端配置初始化腾讯云 COS;完全未配置时返回 nil,部分配置时返回错误。 func NewCOS(ctx context.Context, cfg config.Config) (*COS, error) { configured := cfg.COSEndpoint != "" || cfg.COSSecretID != "" || cfg.COSSecretKey != "" || cfg.COSBucket != "" || cfg.COSRegion != "" if !configured { return nil, nil } if cfg.COSEndpoint == "" || cfg.COSSecretID == "" || cfg.COSSecretKey == "" || cfg.COSBucket == "" || cfg.COSRegion == "" { return nil, fmt.Errorf("COS 配置不完整,必须同时配置 Region、Endpoint、SecretId、SecretKey 和 Bucket") } if cfg.COSPublicBaseURL == "" { return nil, fmt.Errorf("视频转绘需要可供模型读取的 COS_PUBLIC_BASE_URL") } endpoint := normalizeHTTPURL(cfg.COSEndpoint) publicBaseURL := normalizeHTTPURL(cfg.COSPublicBaseURL) awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(cfg.COSRegion), awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.COSSecretID, cfg.COSSecretKey, "")), ) if err != nil { return nil, err } client := s3.NewFromConfig(awsCfg, func(options *s3.Options) { options.BaseEndpoint = aws.String(endpoint) options.UsePathStyle = false }) return &COS{ client: client, bucket: cfg.COSBucket, publicBaseURL: publicBaseURL, maxImageBytes: int64(cfg.COSMaxImageSizeMB) * 1024 * 1024, maxVideoBytes: int64(cfg.COSMaxVideoSizeMB) * 1024 * 1024, maxAudioBytes: int64(cfg.COSMaxAudioSizeMB) * 1024 * 1024, }, nil } // normalizeHTTPURL 补全缺少协议的 HTTP 地址,并保留显式配置的协议。 func normalizeHTTPURL(value string) string { value = strings.TrimSpace(value) if value != "" && !strings.Contains(value, "://") { return "https://" + value } return value } // MaxVideoBytes 返回允许处理的视频最大字节数。 func (storage *COS) MaxVideoBytes() int64 { if storage == nil { return 0 } return storage.maxVideoBytes } // MaxAudioBytes 返回允许处理的音频最大字节数。 func (storage *COS) MaxAudioBytes() int64 { if storage == nil { return 0 } return storage.maxAudioBytes } // PublicURL 根据对象键生成无需签名的公开访问地址。 func (storage *COS) PublicURL(key string) string { if storage == nil || storage.publicBaseURL == "" { return "" } return strings.TrimRight(storage.publicBaseURL, "/") + "/" + strings.TrimLeft(key, "/") } // MaxImageBytes 返回允许处理的图片最大字节数。 func (storage *COS) MaxImageBytes() int64 { if storage == nil { return 0 } return storage.maxImageBytes } // Put 上传对象并返回其公开访问地址。 func (storage *COS) Put(ctx context.Context, key, contentType string, body io.Reader, size int64) (string, error) { if storage == nil { return "", fmt.Errorf("COS 未配置") } input := &s3.PutObjectInput{ Bucket: &storage.bucket, Key: &key, Body: body, ContentType: &contentType, CacheControl: aws.String(publicMediaCacheControl), } if size >= 0 { input.ContentLength = aws.Int64(size) } if _, err := storage.client.PutObject(ctx, input); err != nil { return "", err } return storage.PublicURL(key), nil } // Open 打开对象读取流,并返回内容类型和内容长度;调用方负责关闭读取流。 func (storage *COS) Open(ctx context.Context, key string) (io.ReadCloser, string, int64, error) { if storage == nil || strings.TrimSpace(key) == "" { return nil, "", 0, fmt.Errorf("COS 未配置或对象不存在") } result, err := storage.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &storage.bucket, Key: &key}) if err != nil { return nil, "", 0, err } return result.Body, aws.ToString(result.ContentType), aws.ToInt64(result.ContentLength), nil } // Delete 删除指定对象;空对象键视为无需处理。 func (storage *COS) Delete(ctx context.Context, key string) error { if storage == nil || strings.TrimSpace(key) == "" { return nil } _, err := storage.client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: &storage.bucket, Key: &key}) return err } // PutFromURL 下载远端结果到临时文件,校验体积后上传到 COS。 func (storage *COS) PutFromURL(ctx context.Context, client *http.Client, sourceURL, key, fallbackContentType string, maxBytes int64) (string, string, string, int64, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) if err != nil { return "", "", "", 0, err } response, err := client.Do(request) if err != nil { return "", "", "", 0, err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return "", "", "", 0, fmt.Errorf("下载生成结果失败: HTTP %d", response.StatusCode) } if maxBytes > 0 && response.ContentLength > maxBytes { return "", "", "", 0, fmt.Errorf("生成结果超过允许大小") } contentType := strings.TrimSpace(strings.Split(response.Header.Get("Content-Type"), ";")[0]) if contentType == "" || contentType == "application/octet-stream" { contentType = fallbackContentType } key = keyWithContentType(key, contentType) temporary, err := os.CreateTemp("", "jcf-generated-*") if err != nil { return "", "", "", 0, err } temporaryName := temporary.Name() defer os.Remove(temporaryName) reader := io.Reader(response.Body) if maxBytes > 0 { reader = io.LimitReader(response.Body, maxBytes+1) } written, copyErr := io.Copy(temporary, reader) if closeErr := temporary.Close(); copyErr == nil { copyErr = closeErr } if copyErr != nil { return "", "", "", 0, copyErr } if maxBytes > 0 && written > maxBytes { return "", "", "", 0, fmt.Errorf("生成结果超过允许大小") } file, err := os.Open(temporaryName) if err != nil { return "", "", "", 0, err } defer file.Close() url, err := storage.Put(ctx, key, contentType, file, written) if err != nil { return "", "", "", 0, err } return url, key, contentType, written, nil } // keyWithContentType 根据真实媒体类型修正对象扩展名。 func keyWithContentType(key, contentType string) string { extension := map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", "video/mp4": ".mp4", "video/webm": ".webm", "video/quicktime": ".mov", }[strings.ToLower(contentType)] if extension == "" { return key } return strings.TrimSuffix(key, path.Ext(key)) + extension }