481 lines
12 KiB
Go
481 lines
12 KiB
Go
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
|
|
}
|