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 ActorUserID 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 ActorUserID 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.ActorUserID = strings.TrimSpace(command.ActorUserID) 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 !isUUID(command.ActorUserID) { return CreateTaskResult{}, invalidError( "TASK_VALIDATION_FAILED", "task validation failed", map[string]string{"actor_user_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() actorUserID := command.ActorUserID task := domain.PurchaseTask{ ID: taskID, CreatorSubject: command.CreatorSubject, CreatedByUserID: &actorUserID, 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, ActorUserID: &actorUserID, 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.ActorUserID = strings.TrimSpace(command.ActorUserID) 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 !isUUID(command.ActorUserID) { fields["actor_user_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() actorUserID := command.ActorUserID event := domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &actorUserID, Type: "TASK_CANCELED", Message: "task canceled", OccurredAt: now, } task, err := s.repository.CancelTask( 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 { ActorUserID string `json:"actor_user_id"` 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"` }{ ActorUserID: command.ActorUserID, 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 }