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)