feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user