Files
NianAIGC/backend/internal/assets/oss.go
2026-08-19 10:52:55 +08:00

202 lines
5.7 KiB
Go

package assets
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/url"
"path"
"strings"
"time"
)
const OSSACLPublicRead = "public-read"
type OSSConfig struct {
Endpoint string
Bucket string
PublicBaseURL string
PublicRead bool
}
type OSSPutRequest struct {
Endpoint, Bucket, Key, ContentType string
Body io.Reader
Size int64
}
type OSSObjectRequest struct{ Endpoint, Bucket, Key string }
type OSSACLRequest struct{ Endpoint, Bucket, Key, ACL string }
// OSSClient is the SDK-independent seam. Runtime composition can adapt any OSS
// SDK while tests remain deterministic and credential/network free.
type OSSClient interface {
Put(context.Context, OSSPutRequest) error
SetACL(context.Context, OSSACLRequest) error
Get(context.Context, OSSObjectRequest) (Blob, error)
Delete(context.Context, OSSObjectRequest) error
}
type OSSURLSigner interface {
SignGetURL(OSSObjectRequest, time.Duration) (string, error)
}
type OSSError struct {
Status int
Code string
Err error
}
func (e *OSSError) Error() string {
if e.Err != nil {
return e.Err.Error()
}
if e.Code != "" {
return e.Code
}
return "OSS request failed"
}
func (e *OSSError) Unwrap() error { return e.Err }
type OSS struct {
config OSSConfig
client OSSClient
}
func NewOSS(config OSSConfig, client OSSClient) (*OSS, error) {
config.Endpoint = strings.TrimRight(strings.TrimSpace(config.Endpoint), "/")
config.Bucket = strings.TrimSpace(config.Bucket)
config.PublicBaseURL = strings.TrimRight(strings.TrimSpace(config.PublicBaseURL), "/")
if config.Endpoint == "" || config.Bucket == "" || config.PublicBaseURL == "" || client == nil {
return nil, errors.New("OSS endpoint, bucket, public URL, and client are required")
}
if parsed, err := url.Parse(config.PublicBaseURL); err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, errors.New("OSS public URL must be absolute")
}
return &OSS{config: config, client: client}, nil
}
func (s *OSS) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (StoredObject, error) {
if err := validOSSKey(key); err != nil {
return StoredObject{}, err
}
err := s.client.Put(ctx, OSSPutRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key, Body: body, Size: size, ContentType: contentType})
if err != nil {
logOSSOperationFailure("put", err)
return StoredObject{}, mapOSSError(err)
}
if s.config.PublicRead {
if err = s.client.SetACL(ctx, OSSACLRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key, ACL: OSSACLPublicRead}); err != nil {
logOSSOperationFailure("set_acl", err)
return StoredObject{}, mapOSSError(err)
}
}
return StoredObject{Key: key, URL: s.config.PublicBaseURL + "/" + escapeOSSKey(key)}, nil
}
func (s *OSS) Read(ctx context.Context, key string) (Blob, error) {
if err := validOSSKey(key); err != nil {
return Blob{}, err
}
blob, err := s.client.Get(ctx, OSSObjectRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key})
if err != nil {
if !isMissingOSS(err) {
logOSSOperationFailure("get", err)
}
return Blob{}, mapOSSError(err)
}
return blob, nil
}
func (s *OSS) Delete(ctx context.Context, key string) error {
if err := validOSSKey(key); err != nil {
return err
}
err := s.client.Delete(ctx, OSSObjectRequest{Endpoint: s.config.Endpoint, Bucket: s.config.Bucket, Key: key})
if isMissingOSS(err) {
return nil
}
if err != nil {
logOSSOperationFailure("delete", err)
}
return mapOSSError(err)
}
func (s *OSS) SignReadURL(key string, ttl time.Duration) (string, error) {
if err := validOSSKey(key); err != nil {
return "", err
}
signer, ok := s.client.(OSSURLSigner)
if !ok {
return "", errors.New("OSS client does not support signed URLs")
}
return signer.SignGetURL(OSSObjectRequest{Endpoint: s.config.PublicBaseURL, Bucket: s.config.Bucket, Key: key}, ttl)
}
type ossOperationDiagnostic struct {
Operation string
Status int
Code string
ErrorClass string
}
func diagnoseOSSOperation(operation string, err error) ossOperationDiagnostic {
diagnostic := ossOperationDiagnostic{Operation: operation, ErrorClass: "client"}
var ossErr *OSSError
if errors.As(err, &ossErr) {
diagnostic.Status = ossErr.Status
diagnostic.Code = ossErr.Code
if ossErr.Status > 0 || ossErr.Code != "" {
diagnostic.ErrorClass = "service"
} else {
diagnostic.ErrorClass = "transport"
}
return diagnostic
}
if errors.Is(err, context.Canceled) {
diagnostic.ErrorClass = "canceled"
} else if errors.Is(err, context.DeadlineExceeded) {
diagnostic.ErrorClass = "timeout"
}
return diagnostic
}
func logOSSOperationFailure(operation string, err error) {
diagnostic := diagnoseOSSOperation(operation, err)
log.Printf(
"zhinian-api OSS operation failed operation=%s status=%d code=%q errorClass=%s",
diagnostic.Operation,
diagnostic.Status,
diagnostic.Code,
diagnostic.ErrorClass,
)
}
func mapOSSError(err error) error {
if err == nil {
return nil
}
if isMissingOSS(err) {
return ErrBlobNotFound
}
return fmt.Errorf("OSS operation failed: %w", err)
}
func isMissingOSS(err error) bool {
var ossErr *OSSError
return errors.As(err, &ossErr) && (ossErr.Status == 404 || ossErr.Code == "NoSuchKey" || ossErr.Code == "NoSuchObject")
}
func validOSSKey(key string) error {
if key == "" || strings.HasPrefix(key, "/") || strings.Contains(key, "\\") || path.Clean(key) != key || key == "." || strings.HasPrefix(key, "../") {
return ErrUnsafeBlobKey
}
return nil
}
func escapeOSSKey(key string) string {
parts := strings.Split(key, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
var _ BlobStore = (*OSS)(nil)
var _ BlobURLSigner = (*OSS)(nil)