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 CreatedByUserID *string SourceRef *string Title string Description string SKU string ImageAssetID string Quantity int MaxBudgetCents *int64 Currency string Status TaskStatus Version int64 ClaimedByUserID *string ClaimedByDeviceID *string ClaimGeneration int64 ClaimTokenHash *string ClaimIssuedAt *time.Time ClaimExpiresAt *time.Time CancelReason *string CancelRequestedAt *time.Time CancelRequestedByUserID *string CanceledAt *time.Time CreatedAt time.Time UpdatedAt time.Time } type TaskEvent struct { ID string TaskID string ActorUserID *string ActorDeviceID *string Type string Message string OccurredAt time.Time } type TaskExecution struct { ID string TaskID string AttemptNo int64 ClaimGeneration int64 UserID string DeviceID string CurrentStep string OrderSubmitted bool StartedAt time.Time LastHeartbeatAt *time.Time FinishedAt *time.Time } type TaskDetail struct { Task PurchaseTask Asset Asset Execution *TaskExecution 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 CanAdminCancelImmediately(status) || CanRequestCancel(status) } func IsActiveTaskStatus(status TaskStatus) bool { switch status { case TaskStatusClaimed, TaskStatusRunning, TaskStatusWaitingConfirmation: return true default: return false } } func CanStart(status TaskStatus) bool { return status == TaskStatusClaimed } func CanHeartbeat(status TaskStatus) bool { return status == TaskStatusRunning || status == TaskStatusWaitingConfirmation } func CanRelease(status TaskStatus) bool { return status == TaskStatusClaimed } func CanAdminCancelImmediately(status TaskStatus) bool { return status == TaskStatusPending || status == TaskStatusClaimed } func CanRequestCancel(status TaskStatus) bool { return status == TaskStatusRunning || status == TaskStatusWaitingConfirmation } func CanAcknowledgeCancel(status TaskStatus) bool { return CanRequestCancel(status) } 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 }