feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusClaimed TaskStatus = "CLAIMED"
|
||||
TaskStatusRunning TaskStatus = "RUNNING"
|
||||
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
|
||||
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
||||
TaskStatusFailed TaskStatus = "FAILED"
|
||||
TaskStatusCanceled TaskStatus = "CANCELED"
|
||||
|
||||
CurrencyCNY = "CNY"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxTitleRunes = 120
|
||||
MaxTitleBytes = 2048
|
||||
MaxSKUBytes = 512
|
||||
MaxDescriptionBytes = 8192
|
||||
MaxSourceRefBytes = 256
|
||||
MaxCancelReasonBytes = 500
|
||||
)
|
||||
|
||||
type PurchaseTask struct {
|
||||
ID string
|
||||
CreatorSubject string
|
||||
SourceRef *string
|
||||
Title string
|
||||
Description string
|
||||
SKU string
|
||||
ImageAssetID string
|
||||
Quantity int
|
||||
MaxBudgetCents *int64
|
||||
Currency string
|
||||
Status TaskStatus
|
||||
Version int64
|
||||
CancelReason *string
|
||||
CanceledAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type TaskEvent struct {
|
||||
ID string
|
||||
TaskID string
|
||||
Type string
|
||||
Message string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type TaskDetail struct {
|
||||
Task PurchaseTask
|
||||
Asset Asset
|
||||
Events []TaskEvent
|
||||
}
|
||||
|
||||
type TaskValidationError struct {
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
func (e *TaskValidationError) Error() string {
|
||||
return "purchase task validation failed"
|
||||
}
|
||||
|
||||
func ValidateTaskInput(
|
||||
creatorSubject string,
|
||||
sourceRef *string,
|
||||
title string,
|
||||
description string,
|
||||
sku string,
|
||||
imageAssetID string,
|
||||
quantity int,
|
||||
) error {
|
||||
fields := make(map[string]string)
|
||||
if strings.TrimSpace(creatorSubject) == "" {
|
||||
fields["creator_subject"] = "required"
|
||||
}
|
||||
if sourceRef != nil {
|
||||
if strings.TrimSpace(*sourceRef) == "" {
|
||||
fields["source_ref"] = "must not be blank"
|
||||
} else if len([]byte(*sourceRef)) > MaxSourceRefBytes {
|
||||
fields["source_ref"] = fmt.Sprintf(
|
||||
"must not exceed %d UTF-8 bytes",
|
||||
MaxSourceRefBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
fields["title"] = "required"
|
||||
} else {
|
||||
if utf8.RuneCountInString(title) > MaxTitleRunes {
|
||||
fields["title"] = fmt.Sprintf(
|
||||
"must not exceed %d characters",
|
||||
MaxTitleRunes,
|
||||
)
|
||||
}
|
||||
if len([]byte(title)) > MaxTitleBytes {
|
||||
fields["title"] = fmt.Sprintf(
|
||||
"must not exceed %d UTF-8 bytes",
|
||||
MaxTitleBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
if len([]byte(description)) > MaxDescriptionBytes {
|
||||
fields["description"] = fmt.Sprintf(
|
||||
"must not exceed %d UTF-8 bytes",
|
||||
MaxDescriptionBytes,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(sku) == "" {
|
||||
fields["sku"] = "required"
|
||||
} else if len([]byte(sku)) > MaxSKUBytes {
|
||||
fields["sku"] = fmt.Sprintf(
|
||||
"must not exceed %d UTF-8 bytes",
|
||||
MaxSKUBytes,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(imageAssetID) == "" {
|
||||
fields["image_asset_id"] = "required"
|
||||
}
|
||||
if quantity <= 0 {
|
||||
fields["quantity"] = "must be a positive integer"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return &TaskValidationError{Fields: fields}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseOptionalCNY(value *string) (*int64, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
trimmed := strings.TrimSpace(*value)
|
||||
if trimmed == "" {
|
||||
return nil, errors.New("budget must be omitted or positive")
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "+") || strings.HasPrefix(trimmed, "-") {
|
||||
return nil, errors.New("budget must be positive")
|
||||
}
|
||||
parts := strings.Split(trimmed, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 {
|
||||
return nil, errors.New("budget format is invalid")
|
||||
}
|
||||
if !allDigits(parts[0]) {
|
||||
return nil, errors.New("budget format is invalid")
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
if fraction == "" || len(fraction) > 2 || !allDigits(fraction) {
|
||||
return nil, errors.New("budget format is invalid")
|
||||
}
|
||||
}
|
||||
for len(fraction) < 2 {
|
||||
fraction += "0"
|
||||
}
|
||||
whole, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || whole > (int64(^uint64(0)>>1)-99)/100 {
|
||||
return nil, errors.New("budget is too large")
|
||||
}
|
||||
centsPart, err := strconv.ParseInt(fraction, 10, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("budget format is invalid")
|
||||
}
|
||||
cents := whole*100 + centsPart
|
||||
if cents <= 0 {
|
||||
return nil, errors.New("budget must be positive")
|
||||
}
|
||||
return ¢s, nil
|
||||
}
|
||||
|
||||
func FormatOptionalCNY(cents *int64) *string {
|
||||
if cents == nil {
|
||||
return nil
|
||||
}
|
||||
value := fmt.Sprintf("%d.%02d", *cents/100, *cents%100)
|
||||
return &value
|
||||
}
|
||||
|
||||
func CanCancel(status TaskStatus) bool {
|
||||
return status == TaskStatusPending
|
||||
}
|
||||
|
||||
func IsValidTaskStatus(status TaskStatus) bool {
|
||||
switch status {
|
||||
case TaskStatusPending,
|
||||
TaskStatusClaimed,
|
||||
TaskStatusRunning,
|
||||
TaskStatusWaitingConfirmation,
|
||||
TaskStatusSucceeded,
|
||||
TaskStatusFailed,
|
||||
TaskStatusCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func allDigits(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if char < '0' || char > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user