276 lines
6.1 KiB
Go
276 lines
6.1 KiB
Go
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
|
||
|
|
}
|
||
|
|
}
|