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