feat(backend): implement task creation and admin web

This commit is contained in:
QiuSW
2026-07-26 14:03:32 +08:00
parent 2b265c92fc
commit c5d3b215ff
58 changed files with 8773 additions and 83 deletions
@@ -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
}
}
@@ -0,0 +1,201 @@
package usecase
import (
"bytes"
"context"
"errors"
"io"
"strings"
"testing"
"cmroubao/backend-api/internal/domain"
)
func TestAssetServiceUploadPersistsNormalizedMetadata(t *testing.T) {
repository := &fakeAssetRepository{}
store := &fakeReferenceImageStore{
result: NormalizedReferenceImage{
StorageKey: "aa/file.jpg",
InputMediaType: "image/png",
InputSHA256: strings.Repeat("1", 64),
MediaType: "image/jpeg",
SizeBytes: 123,
SHA256: strings.Repeat("2", 64),
},
}
service := mustAssetService(t, repository, store)
result, err := service.UploadTaskReference(
context.Background(),
UploadTaskReferenceCommand{
CreatorSubject: " local-admin ",
IdempotencyKey: " upload-1 ",
DeclaredMediaType: "image/png",
Content: bytes.NewReader([]byte("fixture")),
},
)
if err != nil {
t.Fatalf("UploadTaskReference() error = %v", err)
}
if result.Replayed ||
result.Asset.CreatorSubject != "local-admin" ||
result.Asset.Purpose != domain.AssetPurposeTaskReference ||
result.Asset.StorageKey != store.result.StorageKey ||
len(repository.hash) != 64 {
t.Fatalf("result/repository = %+v/%+v", result, repository)
}
}
func TestAssetServiceDeletesNewFileWhenRepositoryReplaysOrFails(
t *testing.T,
) {
for _, test := range []struct {
name string
replay bool
createErr error
wantErr bool
}{
{name: "replay", replay: true},
{name: "failure", createErr: ErrRepositoryUnavailable, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
repository := &fakeAssetRepository{
replay: test.replay,
createErr: test.createErr,
}
store := &fakeReferenceImageStore{
result: NormalizedReferenceImage{
StorageKey: "aa/candidate.jpg",
InputMediaType: "image/jpeg",
InputSHA256: strings.Repeat("1", 64),
MediaType: "image/jpeg",
SizeBytes: 123,
SHA256: strings.Repeat("2", 64),
},
}
service := mustAssetService(t, repository, store)
_, err := service.UploadTaskReference(
context.Background(),
UploadTaskReferenceCommand{
CreatorSubject: "local-admin",
IdempotencyKey: "upload-1",
DeclaredMediaType: "image/jpeg",
Content: bytes.NewReader([]byte("fixture")),
},
)
if (err != nil) != test.wantErr {
t.Fatalf("UploadTaskReference() error = %v", err)
}
if store.deleted != "aa/candidate.jpg" {
t.Fatalf("deleted key = %q", store.deleted)
}
})
}
}
func TestAssetServiceMapsImageValidationError(t *testing.T) {
service := mustAssetService(
t,
&fakeAssetRepository{},
&fakeReferenceImageStore{
err: &ImageStoreError{
Kind: ImageStoreErrorInvalid,
Cause: errors.New("private decoder detail"),
},
},
)
_, err := service.UploadTaskReference(
context.Background(),
UploadTaskReferenceCommand{
CreatorSubject: "local-admin",
IdempotencyKey: "upload-1",
DeclaredMediaType: "image/jpeg",
Content: bytes.NewReader([]byte("bad")),
},
)
assertUsecaseError(t, err, ErrorKindInvalid, "ASSET_IMAGE_INVALID")
if strings.Contains(err.Error(), "private") {
t.Fatal("public error leaked decoder detail")
}
}
type fakeAssetRepository struct {
asset domain.Asset
replay bool
createErr error
hash string
}
func (repository *fakeAssetRepository) CreateAssetIdempotent(
_ context.Context,
asset domain.Asset,
_ string,
hash string,
) (domain.Asset, bool, error) {
repository.asset = asset
repository.hash = hash
if repository.createErr != nil {
return asset, false, repository.createErr
}
if repository.replay {
return asset, false, nil
}
return asset, true, nil
}
func (repository *fakeAssetRepository) GetAsset(
context.Context,
string,
string,
) (domain.Asset, error) {
return repository.asset, nil
}
type fakeReferenceImageStore struct {
result NormalizedReferenceImage
err error
deleted string
}
func (store *fakeReferenceImageStore) Put(
context.Context,
string,
string,
io.Reader,
) (NormalizedReferenceImage, error) {
return store.result, store.err
}
func (store *fakeReferenceImageStore) Open(
context.Context,
string,
) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(nil)), nil
}
func (store *fakeReferenceImageStore) Delete(
_ context.Context,
key string,
) error {
store.deleted = key
return nil
}
func mustAssetService(
t *testing.T,
repository AssetRepository,
store ReferenceImageStore,
) *AssetService {
t.Helper()
service, err := NewAssetService(
repository,
store,
fakeClock{},
&sequenceIDs{},
)
if err != nil {
t.Fatalf("NewAssetService() error = %v", err)
}
return service
}
var _ Clock = fakeClock{}
+126
View File
@@ -0,0 +1,126 @@
package usecase
import (
"errors"
"fmt"
)
type ErrorKind string
const (
ErrorKindInvalid ErrorKind = "INVALID"
ErrorKindNotFound ErrorKind = "NOT_FOUND"
ErrorKindConflict ErrorKind = "CONFLICT"
ErrorKindUnavailable ErrorKind = "UNAVAILABLE"
ErrorKindInternal ErrorKind = "INTERNAL"
)
type Error struct {
Kind ErrorKind
Code string
Message string
Retryable bool
Fields map[string]string
Cause error
}
func (e *Error) Error() string {
if e.Message != "" {
return e.Message
}
return "use case failed"
}
func (e *Error) Unwrap() error {
return e.Cause
}
func newError(
kind ErrorKind,
code string,
message string,
cause error,
) *Error {
return &Error{
Kind: kind,
Code: code,
Message: message,
Cause: cause,
Fields: map[string]string{},
}
}
func invalidError(code, message string, fields map[string]string) *Error {
return &Error{
Kind: ErrorKindInvalid,
Code: code,
Message: message,
Fields: fields,
}
}
var (
ErrRepositoryNotFound = errors.New("repository resource not found")
ErrIdempotencyConflict = errors.New("idempotency key payload conflict")
ErrSourceReferenceConflict = errors.New("source reference conflict")
ErrAssetUnavailable = errors.New("asset is unavailable")
ErrTaskStateConflict = errors.New("task state conflict")
ErrRepositoryUnavailable = errors.New("repository unavailable")
ErrRepositoryInvariant = errors.New("repository invariant failed")
)
func wrapRepositoryError(err error) error {
switch {
case errors.Is(err, ErrRepositoryNotFound):
return newError(
ErrorKindNotFound,
"RESOURCE_NOT_FOUND",
"resource not found",
err,
)
case errors.Is(err, ErrIdempotencyConflict):
return newError(
ErrorKindConflict,
"IDEMPOTENCY_KEY_CONFLICT",
"idempotency key was already used for a different request",
err,
)
case errors.Is(err, ErrSourceReferenceConflict):
return newError(
ErrorKindConflict,
"TASK_SOURCE_REF_CONFLICT",
"source reference already exists",
err,
)
case errors.Is(err, ErrAssetUnavailable):
return newError(
ErrorKindConflict,
"TASK_ASSET_UNAVAILABLE",
"reference image is not available for this task",
err,
)
case errors.Is(err, ErrTaskStateConflict):
return newError(
ErrorKindConflict,
"TASK_STATE_CONFLICT",
"task state does not allow this operation",
err,
)
case errors.Is(err, ErrRepositoryUnavailable):
result := newError(
ErrorKindUnavailable,
"STORAGE_UNAVAILABLE",
"storage is temporarily unavailable",
err,
)
result.Retryable = true
return result
default:
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
fmt.Errorf("%w: %v", ErrRepositoryInvariant, err),
)
}
}
+93
View File
@@ -0,0 +1,93 @@
package usecase
import (
"context"
"io"
"time"
"cmroubao/backend-api/internal/domain"
)
type Clock interface {
Now() time.Time
}
type IDGenerator interface {
NewID() (string, error)
}
type NormalizedReferenceImage struct {
StorageKey string
InputMediaType string
InputSHA256 string
MediaType string
SizeBytes int64
SHA256 string
}
type ReferenceImageStore interface {
Put(
context.Context,
string,
string,
io.Reader,
) (NormalizedReferenceImage, error)
Open(context.Context, string) (io.ReadCloser, error)
Delete(context.Context, string) error
}
type AssetRepository interface {
CreateAssetIdempotent(
context.Context,
domain.Asset,
string,
string,
) (domain.Asset, bool, error)
GetAsset(
context.Context,
string,
string,
) (domain.Asset, error)
}
type TaskCursor struct {
CreatedAt time.Time
ID string
}
type TaskListFilter struct {
CreatorSubject string
Status *domain.TaskStatus
Query string
CreatedFrom *time.Time
CreatedTo *time.Time
Limit int
After *TaskCursor
}
type TaskRepository interface {
CreateTaskIdempotent(
context.Context,
domain.PurchaseTask,
domain.TaskEvent,
string,
string,
) (domain.PurchaseTask, bool, error)
ListTasks(
context.Context,
TaskListFilter,
) ([]domain.PurchaseTask, error)
GetTaskDetail(
context.Context,
string,
string,
) (domain.TaskDetail, error)
CancelPendingTask(
context.Context,
string,
string,
string,
time.Time,
domain.TaskEvent,
) (domain.PurchaseTask, error)
}
+38
View File
@@ -0,0 +1,38 @@
package usecase
import (
"crypto/rand"
"encoding/binary"
"fmt"
"sync/atomic"
"time"
)
type SystemClock struct{}
func (SystemClock) Now() time.Time {
return time.Now().UTC()
}
type UUIDGenerator struct{}
func (UUIDGenerator) NewID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
now := uint64(time.Now().UnixNano())
binary.BigEndian.PutUint64(value[:8], now)
binary.BigEndian.PutUint64(value[8:], fallbackUUID.Add(1))
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
return fmt.Sprintf(
"%08x-%04x-%04x-%04x-%012x",
value[0:4],
value[4:6],
value[6:8],
value[8:10],
value[10:16],
), nil
}
var fallbackUUID atomic.Uint64
@@ -0,0 +1,459 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"strings"
"time"
"cmroubao/backend-api/internal/domain"
)
const (
createTaskOperation = "CREATE_PURCHASE_TASK"
defaultTaskListLimit = 20
maxTaskListLimit = 100
maxTaskQueryBytes = 256
)
type TaskService struct {
repository TaskRepository
clock Clock
ids IDGenerator
}
type CreateTaskCommand struct {
CreatorSubject string
IdempotencyKey string
SourceRef *string
Title string
Description string
SKU string
ImageAssetID string
Quantity int
MaxBudget *string
}
type CreateTaskResult struct {
Task domain.PurchaseTask
Replayed bool
}
type ListTasksQuery struct {
CreatorSubject string
Status *string
Query string
CreatedFrom *time.Time
CreatedTo *time.Time
Limit int
Cursor string
}
type TaskPage struct {
Items []domain.PurchaseTask
NextCursor string
}
type CancelTaskCommand struct {
CreatorSubject string
TaskID string
Reason string
}
func NewTaskService(
repository TaskRepository,
clock Clock,
ids IDGenerator,
) (*TaskService, error) {
if repository == nil || clock == nil || ids == nil {
return nil, errors.New("task service dependencies are required")
}
return &TaskService{
repository: repository,
clock: clock,
ids: ids,
}, nil
}
func (s *TaskService) Create(
ctx context.Context,
command CreateTaskCommand,
) (CreateTaskResult, error) {
if err := validateWriteIdentity(
command.CreatorSubject,
command.IdempotencyKey,
); err != nil {
return CreateTaskResult{}, err
}
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.Title = strings.TrimSpace(command.Title)
command.SKU = strings.TrimSpace(command.SKU)
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
if command.SourceRef != nil {
value := strings.TrimSpace(*command.SourceRef)
command.SourceRef = &value
}
if !isUUID(command.ImageAssetID) {
return CreateTaskResult{}, invalidError(
"TASK_VALIDATION_FAILED",
"task validation failed",
map[string]string{"image_asset_id": "must be a UUID"},
)
}
if err := domain.ValidateTaskInput(
command.CreatorSubject,
command.SourceRef,
command.Title,
command.Description,
command.SKU,
command.ImageAssetID,
command.Quantity,
); err != nil {
var validationError *domain.TaskValidationError
if errors.As(err, &validationError) {
return CreateTaskResult{}, invalidError(
"TASK_VALIDATION_FAILED",
"task validation failed",
validationError.Fields,
)
}
return CreateTaskResult{}, newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
budget, err := domain.ParseOptionalCNY(command.MaxBudget)
if err != nil {
return CreateTaskResult{}, invalidError(
"TASK_VALIDATION_FAILED",
"task validation failed",
map[string]string{"max_budget": "must be a positive CNY amount with at most two decimals"},
)
}
taskID, err := s.ids.NewID()
if err != nil {
return CreateTaskResult{}, newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
eventID, err := s.ids.NewID()
if err != nil {
return CreateTaskResult{}, newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
now := s.clock.Now().UTC()
task := domain.PurchaseTask{
ID: taskID,
CreatorSubject: command.CreatorSubject,
SourceRef: command.SourceRef,
Title: command.Title,
Description: command.Description,
SKU: command.SKU,
ImageAssetID: command.ImageAssetID,
Quantity: command.Quantity,
MaxBudgetCents: budget,
Currency: domain.CurrencyCNY,
Status: domain.TaskStatusPending,
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
event := domain.TaskEvent{
ID: eventID,
TaskID: taskID,
Type: "TASK_CREATED",
Message: "task created",
OccurredAt: now,
}
requestHash, err := hashCreateTaskCommand(command, budget)
if err != nil {
return CreateTaskResult{}, newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
createdTask, created, err := s.repository.CreateTaskIdempotent(
ctx,
task,
event,
strings.TrimSpace(command.IdempotencyKey),
requestHash,
)
if err != nil {
return CreateTaskResult{}, wrapRepositoryError(err)
}
return CreateTaskResult{
Task: createdTask,
Replayed: !created,
}, nil
}
func (s *TaskService) List(
ctx context.Context,
query ListTasksQuery,
) (TaskPage, error) {
query.CreatorSubject = strings.TrimSpace(query.CreatorSubject)
if query.CreatorSubject == "" {
return TaskPage{}, invalidError(
"REQUEST_VALIDATION_FAILED",
"request validation failed",
map[string]string{"creator_subject": "required"},
)
}
filter := TaskListFilter{
CreatorSubject: query.CreatorSubject,
Query: strings.TrimSpace(query.Query),
CreatedFrom: query.CreatedFrom,
CreatedTo: query.CreatedTo,
Limit: query.Limit,
}
if len([]byte(filter.Query)) > maxTaskQueryBytes {
return TaskPage{}, invalidError(
"TASK_LIST_FILTER_INVALID",
"task list filter is invalid",
map[string]string{"q": "too long"},
)
}
if filter.Limit == 0 {
filter.Limit = defaultTaskListLimit
}
if filter.Limit < 1 || filter.Limit > maxTaskListLimit {
return TaskPage{}, invalidError(
"TASK_LIST_FILTER_INVALID",
"task list filter is invalid",
map[string]string{"limit": "must be between 1 and 100"},
)
}
if query.Status != nil {
status := domain.TaskStatus(strings.TrimSpace(*query.Status))
if !domain.IsValidTaskStatus(status) {
return TaskPage{}, invalidError(
"TASK_LIST_FILTER_INVALID",
"task list filter is invalid",
map[string]string{"status": "unknown status"},
)
}
filter.Status = &status
}
if query.CreatedFrom != nil && query.CreatedTo != nil &&
query.CreatedFrom.After(*query.CreatedTo) {
return TaskPage{}, invalidError(
"TASK_LIST_FILTER_INVALID",
"task list filter is invalid",
map[string]string{"created_from": "must not be after created_to"},
)
}
if query.Cursor != "" {
cursor, err := decodeTaskCursor(query.Cursor)
if err != nil {
return TaskPage{}, invalidError(
"TASK_CURSOR_INVALID",
"task cursor is invalid",
map[string]string{"cursor": "invalid"},
)
}
filter.After = &cursor
}
filter.Limit++
items, err := s.repository.ListTasks(ctx, filter)
if err != nil {
return TaskPage{}, wrapRepositoryError(err)
}
page := TaskPage{Items: items}
if len(items) >= filter.Limit {
page.Items = items[:filter.Limit-1]
last := page.Items[len(page.Items)-1]
page.NextCursor = encodeTaskCursor(TaskCursor{
CreatedAt: last.CreatedAt,
ID: last.ID,
})
}
return page, nil
}
func (s *TaskService) Get(
ctx context.Context,
creatorSubject string,
taskID string,
) (domain.TaskDetail, error) {
creatorSubject = strings.TrimSpace(creatorSubject)
if creatorSubject == "" || !isUUID(taskID) {
return domain.TaskDetail{}, newError(
ErrorKindNotFound,
"TASK_NOT_FOUND",
"task not found",
nil,
)
}
detail, err := s.repository.GetTaskDetail(
ctx,
creatorSubject,
taskID,
)
if err != nil {
result := wrapRepositoryError(err)
if typed, ok := result.(*Error); ok &&
typed.Kind == ErrorKindNotFound {
typed.Code = "TASK_NOT_FOUND"
typed.Message = "task not found"
}
return domain.TaskDetail{}, result
}
return detail, nil
}
func (s *TaskService) Cancel(
ctx context.Context,
command CancelTaskCommand,
) (domain.PurchaseTask, error) {
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
command.TaskID = strings.TrimSpace(command.TaskID)
command.Reason = strings.TrimSpace(command.Reason)
fields := make(map[string]string)
if command.CreatorSubject == "" {
fields["creator_subject"] = "required"
}
if !isUUID(command.TaskID) {
fields["task_id"] = "must be a UUID"
}
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
fields["reason"] = "too long"
}
if len(fields) > 0 {
return domain.PurchaseTask{}, invalidError(
"TASK_CANCEL_INVALID",
"task cancellation is invalid",
fields,
)
}
eventID, err := s.ids.NewID()
if err != nil {
return domain.PurchaseTask{}, newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
now := s.clock.Now().UTC()
event := domain.TaskEvent{
ID: eventID,
TaskID: command.TaskID,
Type: "TASK_CANCELED",
Message: "task canceled",
OccurredAt: now,
}
task, err := s.repository.CancelPendingTask(
ctx,
command.CreatorSubject,
command.TaskID,
command.Reason,
now,
event,
)
if err != nil {
return domain.PurchaseTask{}, wrapRepositoryError(err)
}
return task, nil
}
func hashCreateTaskCommand(
command CreateTaskCommand,
budget *int64,
) (string, error) {
payload := struct {
SourceRef *string `json:"source_ref"`
Title string `json:"title"`
Description string `json:"description"`
SKU string `json:"sku"`
ImageAssetID string `json:"image_asset_id"`
Quantity int `json:"quantity"`
MaxBudgetCents *int64 `json:"max_budget_cents"`
}{
SourceRef: command.SourceRef,
Title: command.Title,
Description: command.Description,
SKU: command.SKU,
ImageAssetID: command.ImageAssetID,
Quantity: command.Quantity,
MaxBudgetCents: budget,
}
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
}
hash := sha256.Sum256(encoded)
return hex.EncodeToString(hash[:]), nil
}
func encodeTaskCursor(cursor TaskCursor) string {
payload := struct {
CreatedAt string `json:"created_at"`
ID string `json:"id"`
}{
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
ID: cursor.ID,
}
encoded, _ := json.Marshal(payload)
return base64.RawURLEncoding.EncodeToString(encoded)
}
func decodeTaskCursor(value string) (TaskCursor, error) {
encoded, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return TaskCursor{}, err
}
var payload struct {
CreatedAt string `json:"created_at"`
ID string `json:"id"`
}
decoder := json.NewDecoder(strings.NewReader(string(encoded)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
return TaskCursor{}, err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return TaskCursor{}, errors.New("task cursor has trailing data")
}
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
if err != nil || !isUUID(payload.ID) {
return TaskCursor{}, errors.New("invalid task cursor")
}
return TaskCursor{CreatedAt: createdAt.UTC(), ID: payload.ID}, nil
}
func isUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, char := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if char != '-' {
return false
}
continue
}
if !((char >= '0' && char <= '9') ||
(char >= 'a' && char <= 'f') ||
(char >= 'A' && char <= 'F')) {
return false
}
}
return true
}
@@ -0,0 +1,232 @@
package usecase
import (
"context"
"errors"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
)
func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
repository := &fakeTaskRepository{}
service := mustTaskService(t, repository)
budget := "20.00"
sourceRef := " source-1 "
result, err := service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: " local-admin ",
IdempotencyKey: " create-1 ",
SourceRef: &sourceRef,
Title: " Demo title ",
Description: "description",
SKU: " SKU-1 ",
ImageAssetID: "00000000-0000-4000-8000-000000000001",
Quantity: 2,
MaxBudget: &budget,
})
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if result.Task.Status != domain.TaskStatusPending ||
result.Task.Title != "Demo title" ||
result.Task.SKU != "SKU-1" ||
result.Task.SourceRef == nil ||
*result.Task.SourceRef != "source-1" ||
result.Task.MaxBudgetCents == nil ||
*result.Task.MaxBudgetCents != 2000 {
t.Fatalf("created task = %+v", result.Task)
}
if repository.key != "create-1" || len(repository.hash) != 64 {
t.Fatalf(
"idempotency key/hash = %q/%q",
repository.key,
repository.hash,
)
}
if repository.event.Type != "TASK_CREATED" ||
repository.event.TaskID != result.Task.ID {
t.Fatalf("event = %+v", repository.event)
}
}
func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
repository := &fakeTaskRepository{createErr: ErrAssetUnavailable}
service := mustTaskService(t, repository)
_, err := service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: "local-admin",
IdempotencyKey: "create-1",
Title: "title",
SKU: "sku",
ImageAssetID: "00000000-0000-4000-8000-000000000001",
Quantity: 1,
})
assertUsecaseError(t, err, ErrorKindConflict, "TASK_ASSET_UNAVAILABLE")
_, err = service.Create(context.Background(), CreateTaskCommand{
CreatorSubject: "local-admin",
IdempotencyKey: "create-2",
Title: "",
SKU: "",
ImageAssetID: "bad",
Quantity: 0,
})
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_VALIDATION_FAILED")
}
func TestTaskServiceListUsesStableOpaqueCursor(t *testing.T) {
createdAt := time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
repository := &fakeTaskRepository{
listResult: []domain.PurchaseTask{
{ID: "00000000-0000-4000-8000-000000000003", CreatedAt: createdAt},
{ID: "00000000-0000-4000-8000-000000000002", CreatedAt: createdAt},
{ID: "00000000-0000-4000-8000-000000000001", CreatedAt: createdAt},
},
}
service := mustTaskService(t, repository)
page, err := service.List(context.Background(), ListTasksQuery{
CreatorSubject: "local-admin",
Limit: 2,
})
if err != nil {
t.Fatalf("List() error = %v", err)
}
if len(page.Items) != 2 || page.NextCursor == "" {
t.Fatalf("page = %+v", page)
}
if repository.filter.Limit != 3 {
t.Fatalf("repository limit = %d", repository.filter.Limit)
}
cursor, err := decodeTaskCursor(page.NextCursor)
if err != nil {
t.Fatalf("decodeTaskCursor() error = %v", err)
}
if cursor.ID != page.Items[1].ID || !cursor.CreatedAt.Equal(createdAt) {
t.Fatalf("cursor = %+v", cursor)
}
}
func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
repository := &fakeTaskRepository{cancelErr: ErrTaskStateConflict}
service := mustTaskService(t, repository)
_, err := service.Cancel(context.Background(), CancelTaskCommand{
CreatorSubject: "local-admin",
TaskID: "00000000-0000-4000-8000-000000000001",
Reason: "no longer needed",
})
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
}
type fakeClock struct{}
func (fakeClock) Now() time.Time {
return time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
}
type sequenceIDs struct {
next int
}
func (generator *sequenceIDs) NewID() (string, error) {
generator.next++
return "00000000-0000-4000-8000-" +
pad12(generator.next), nil
}
func pad12(value int) string {
result := "000000000000"
digits := []byte{}
for value > 0 {
digits = append([]byte{byte('0' + value%10)}, digits...)
value /= 10
}
if len(digits) == 0 {
digits = []byte{'0'}
}
return result[:12-len(digits)] + string(digits)
}
type fakeTaskRepository struct {
createErr error
cancelErr error
listResult []domain.PurchaseTask
task domain.PurchaseTask
event domain.TaskEvent
key string
hash string
filter TaskListFilter
}
func (repository *fakeTaskRepository) CreateTaskIdempotent(
_ context.Context,
task domain.PurchaseTask,
event domain.TaskEvent,
key string,
hash string,
) (domain.PurchaseTask, bool, error) {
repository.task = task
repository.event = event
repository.key = key
repository.hash = hash
return task, true, repository.createErr
}
func (repository *fakeTaskRepository) ListTasks(
_ context.Context,
filter TaskListFilter,
) ([]domain.PurchaseTask, error) {
repository.filter = filter
return repository.listResult, nil
}
func (repository *fakeTaskRepository) GetTaskDetail(
context.Context,
string,
string,
) (domain.TaskDetail, error) {
return domain.TaskDetail{}, errors.New("not implemented")
}
func (repository *fakeTaskRepository) CancelPendingTask(
_ context.Context,
_ string,
_ string,
_ string,
_ time.Time,
_ domain.TaskEvent,
) (domain.PurchaseTask, error) {
return domain.PurchaseTask{}, repository.cancelErr
}
func mustTaskService(
t *testing.T,
repository TaskRepository,
) *TaskService {
t.Helper()
service, err := NewTaskService(
repository,
fakeClock{},
&sequenceIDs{},
)
if err != nil {
t.Fatalf("NewTaskService() error = %v", err)
}
return service
}
func assertUsecaseError(
t *testing.T,
err error,
kind ErrorKind,
code string,
) {
t.Helper()
var typed *Error
if !errors.As(err, &typed) {
t.Fatalf("error = %v, want *Error", err)
}
if typed.Kind != kind || typed.Code != code {
t.Fatalf("error = %+v, want kind=%s code=%s", typed, kind, code)
}
}