Files

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
}