feat(backend): implement task creation and admin web
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AssetPurposeTaskReference = "TASK_REFERENCE"
|
||||
NormalizedImageMediaType = "image/jpeg"
|
||||
)
|
||||
|
||||
type Asset struct {
|
||||
ID string
|
||||
CreatorSubject string
|
||||
Purpose string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
StorageKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateTaskInputRequiresTitleSKUImageAndPositiveQuantity(t *testing.T) {
|
||||
err := ValidateTaskInput(
|
||||
" ",
|
||||
nil,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
var validation *TaskValidationError
|
||||
if !asTaskValidationError(err, &validation) {
|
||||
t.Fatalf("ValidateTaskInput() error = %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"creator_subject",
|
||||
"title",
|
||||
"sku",
|
||||
"image_asset_id",
|
||||
"quantity",
|
||||
} {
|
||||
if validation.Fields[field] == "" {
|
||||
t.Fatalf("missing validation for %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskInputEnforcesUTF8AndCharacterLimits(t *testing.T) {
|
||||
sourceRef := strings.Repeat("a", MaxSourceRefBytes+1)
|
||||
err := ValidateTaskInput(
|
||||
"local-admin",
|
||||
&sourceRef,
|
||||
strings.Repeat("商", MaxTitleRunes+1),
|
||||
strings.Repeat("a", MaxDescriptionBytes+1),
|
||||
strings.Repeat("货", MaxSKUBytes/3+1),
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
1,
|
||||
)
|
||||
var validation *TaskValidationError
|
||||
if !asTaskValidationError(err, &validation) {
|
||||
t.Fatalf("ValidateTaskInput() error = %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"source_ref",
|
||||
"title",
|
||||
"description",
|
||||
"sku",
|
||||
} {
|
||||
if validation.Fields[field] == "" {
|
||||
t.Fatalf("missing limit validation for %s", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalCNYUsesExactCents(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int64
|
||||
}{
|
||||
{input: "0.01", want: 1},
|
||||
{input: "1", want: 100},
|
||||
{input: "19.9", want: 1990},
|
||||
{input: "200.00", want: 20000},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
got, err := ParseOptionalCNY(&test.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseOptionalCNY() error = %v", err)
|
||||
}
|
||||
if got == nil || *got != test.want {
|
||||
t.Fatalf("ParseOptionalCNY() = %v, want %d", got, test.want)
|
||||
}
|
||||
formatted := FormatOptionalCNY(got)
|
||||
if formatted == nil {
|
||||
t.Fatal("FormatOptionalCNY() = nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalCNYRejectsInvalidAndOverflow(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"",
|
||||
"0",
|
||||
"0.00",
|
||||
"-1",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.001",
|
||||
"1e2",
|
||||
"99999999999999999.99",
|
||||
} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
if _, err := ParseOptionalCNY(&value); err == nil {
|
||||
t.Fatalf("ParseOptionalCNY(%q) error = nil", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanCancelOnlyPending(t *testing.T) {
|
||||
for _, status := range []TaskStatus{
|
||||
TaskStatusClaimed,
|
||||
TaskStatusRunning,
|
||||
TaskStatusWaitingConfirmation,
|
||||
TaskStatusSucceeded,
|
||||
TaskStatusFailed,
|
||||
TaskStatusCanceled,
|
||||
} {
|
||||
if CanCancel(status) {
|
||||
t.Fatalf("CanCancel(%s) = true", status)
|
||||
}
|
||||
}
|
||||
if !CanCancel(TaskStatusPending) {
|
||||
t.Fatal("CanCancel(PENDING) = false")
|
||||
}
|
||||
}
|
||||
|
||||
func asTaskValidationError(
|
||||
err error,
|
||||
target **TaskValidationError,
|
||||
) bool {
|
||||
value, ok := err.(*TaskValidationError)
|
||||
if ok {
|
||||
*target = value
|
||||
}
|
||||
return ok
|
||||
}
|
||||
Reference in New Issue
Block a user