Files
cmroubao/backend-api/internal/usecase/asset_service.go
T

276 lines
6.1 KiB
Go
Raw Normal View History

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
}
}