package usecase import ( "context" "crypto/sha256" "encoding/hex" "errors" "io" "strings" "cmroubao/backend-api/internal/domain" ) const ( assetUploadOperation = "UPLOAD_TASK_REFERENCE" maxIdempotencyKeyBytes = 128 ) type AssetService struct { repository AssetRepository store ReferenceImageStore clock Clock ids IDGenerator } type UploadTaskReferenceCommand struct { CreatorSubject string IdempotencyKey string DeclaredMediaType string Content io.Reader } type UploadTaskReferenceResult struct { Asset domain.Asset Replayed bool } type AssetContent struct { Asset domain.Asset Content io.ReadCloser } func NewAssetService( repository AssetRepository, store ReferenceImageStore, clock Clock, ids IDGenerator, ) (*AssetService, error) { if repository == nil || store == nil || clock == nil || ids == nil { return nil, errors.New("asset service dependencies are required") } return &AssetService{ repository: repository, store: store, clock: clock, ids: ids, }, nil } func (s *AssetService) UploadTaskReference( ctx context.Context, command UploadTaskReferenceCommand, ) (UploadTaskReferenceResult, error) { if err := validateWriteIdentity( command.CreatorSubject, command.IdempotencyKey, ); err != nil { return UploadTaskReferenceResult{}, err } if command.Content == nil { return UploadTaskReferenceResult{}, invalidError( "ASSET_FILE_REQUIRED", "reference image is required", map[string]string{"file": "required"}, ) } assetID, err := s.ids.NewID() if err != nil { return UploadTaskReferenceResult{}, newError( ErrorKindInternal, "INTERNAL_ERROR", "internal server error", err, ) } normalized, err := s.store.Put( ctx, assetID, command.DeclaredMediaType, command.Content, ) if err != nil { return UploadTaskReferenceResult{}, mapImageStoreError(err) } cleanup := func() { _ = s.store.Delete(context.Background(), normalized.StorageKey) } requestHash := sha256.Sum256([]byte( domain.AssetPurposeTaskReference + "\x00" + normalized.InputMediaType + "\x00" + normalized.InputSHA256, )) candidate := domain.Asset{ ID: assetID, CreatorSubject: strings.TrimSpace(command.CreatorSubject), Purpose: domain.AssetPurposeTaskReference, MediaType: normalized.MediaType, SizeBytes: normalized.SizeBytes, SHA256: normalized.SHA256, StorageKey: normalized.StorageKey, CreatedAt: s.clock.Now().UTC(), } asset, created, err := s.repository.CreateAssetIdempotent( ctx, candidate, strings.TrimSpace(command.IdempotencyKey), hex.EncodeToString(requestHash[:]), ) if err != nil { cleanup() return UploadTaskReferenceResult{}, wrapRepositoryError(err) } if !created { cleanup() } return UploadTaskReferenceResult{ Asset: asset, Replayed: !created, }, nil } func (s *AssetService) OpenTaskReference( ctx context.Context, creatorSubject string, assetID string, ) (AssetContent, error) { if strings.TrimSpace(creatorSubject) == "" || !isUUID(assetID) { return AssetContent{}, newError( ErrorKindNotFound, "ASSET_NOT_FOUND", "asset not found", nil, ) } asset, err := s.repository.GetAsset( ctx, strings.TrimSpace(creatorSubject), assetID, ) if err != nil { result := wrapRepositoryError(err) if typed, ok := result.(*Error); ok && typed.Kind == ErrorKindNotFound { typed.Code = "ASSET_NOT_FOUND" typed.Message = "asset not found" } return AssetContent{}, result } content, err := s.store.Open(ctx, asset.StorageKey) if err != nil { return AssetContent{}, mapImageStoreError(err) } return AssetContent{Asset: asset, Content: content}, nil } func validateWriteIdentity(subject, key string) error { if strings.TrimSpace(subject) == "" { return invalidError( "REQUEST_VALIDATION_FAILED", "request validation failed", map[string]string{"creator_subject": "required"}, ) } trimmedKey := strings.TrimSpace(key) if trimmedKey == "" { return invalidError( "IDEMPOTENCY_KEY_REQUIRED", "idempotency key is required", map[string]string{"idempotency_key": "required"}, ) } if len([]byte(trimmedKey)) > maxIdempotencyKeyBytes || !isPrintableASCII(trimmedKey) { return invalidError( "REQUEST_VALIDATION_FAILED", "request validation failed", map[string]string{"idempotency_key": "invalid"}, ) } return nil } func isPrintableASCII(value string) bool { for _, char := range value { if char < 0x21 || char > 0x7e { return false } } return true } type ImageStoreErrorKind string const ( ImageStoreErrorTooLarge ImageStoreErrorKind = "TOO_LARGE" ImageStoreErrorUnsupported ImageStoreErrorKind = "UNSUPPORTED" ImageStoreErrorInvalid ImageStoreErrorKind = "INVALID" ImageStoreErrorUnavailable ImageStoreErrorKind = "UNAVAILABLE" ImageStoreErrorNotFound ImageStoreErrorKind = "NOT_FOUND" ) type ImageStoreError struct { Kind ImageStoreErrorKind Cause error } func (e *ImageStoreError) Error() string { return "reference image store operation failed" } func (e *ImageStoreError) Unwrap() error { return e.Cause } func mapImageStoreError(err error) error { var storeError *ImageStoreError if !errors.As(err, &storeError) { return newError( ErrorKindInternal, "INTERNAL_ERROR", "internal server error", err, ) } switch storeError.Kind { case ImageStoreErrorTooLarge: return newError( ErrorKindInvalid, "ASSET_TOO_LARGE", "reference image exceeds the allowed size", err, ) case ImageStoreErrorUnsupported: return newError( ErrorKindInvalid, "ASSET_MEDIA_TYPE_UNSUPPORTED", "reference image media type is not supported", err, ) case ImageStoreErrorInvalid: return invalidError( "ASSET_IMAGE_INVALID", "reference image is invalid", map[string]string{"file": "must be a decodable image"}, ) case ImageStoreErrorNotFound: return newError( ErrorKindNotFound, "ASSET_NOT_FOUND", "asset not found", err, ) default: result := newError( ErrorKindUnavailable, "ASSET_STORAGE_UNAVAILABLE", "asset storage is temporarily unavailable", err, ) result.Retryable = true return result } }