Files
cmroubao/backend-api/internal/domain/task.go
T

464 lines
12 KiB
Go

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 ExecutionEvent struct {
ID string
TaskID string
ExecutionID string
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionEvidenceAsset struct {
ID string
TaskID string
ExecutionID string
MediaType string
SizeBytes int64
SHA256 string
StorageKey string
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionCandidateBatch struct {
TaskID string
ExecutionID string
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
ProvenanceJSON *string
CandidatesJSON string
RecommendationJSON *string
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionOutcome struct {
TaskID string
ExecutionID string
ResultType string
ExecutionMode *string
TaskContentSHA256 *string
Outcome *string
OperatorReason *string
SelectedCandidateJSON *string
EvidenceAssetIDsJSON *string
ErrorCode *string
ErrorMessage *string
ErrorStep *string
Retryable *bool
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type CandidateSearchRun struct {
TaskID string
ExecutionID string
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
AppVersion *string
AndroidVersion *string
PDDVersion *string
StartedAt time.Time
ReceivedAt time.Time
ObservationCount int
CollectionComplete bool
ReceivedAfterExecutionExpiry bool
}
type CandidateObservation struct {
TaskID string
ExecutionID string
Ordinal int
Title string
SKUText string
PriceText string
ProductURL string
ImageURL string
EvidenceAssetIDs []string
CollectionStatus string
ObservedAt time.Time
Identity *CandidateObservationIdentity
}
type CandidateObservationIdentity struct {
CandidateKey string
ExecutionID string
CandidateOrdinal int
CardSignature string
DetailSignature string
DetailEvidenceSHA256 string
SpecificationEvidenceSHA256 string
IdentityVersion int
CreatedAt time.Time
}
type CandidateModelRun struct {
ExecutionID string
ProviderID string
Model string
PromptVersion string
SchemaVersion int
RecommendationThreshold float64
RequestSHA256 string
ResultSHA256 string
CreatedAt time.Time
}
type CandidateEvaluationRecord struct {
ExecutionID string
CandidateOrdinal int
Decision string
Score float64
Confidence float64
MatchedJSON string
MissingOrUncertainJSON string
RejectionReasonsJSON string
HardConstraintsJSON string
CreatedAt time.Time
}
type CandidateRecommendationRecord struct {
ExecutionID string
CandidateOrdinal int
Conclusion string
PolicyVersion string
ReasonsJSON string
CreatedAt time.Time
}
type CandidateHumanReview struct {
ID string
TaskID string
ExecutionID string
TaskContentSHA256 string
Version int
ReasonSchemaVersion int
Outcome string
SelectedCandidateOrdinal *int
PrimaryReasonCode string
Note string
SupersedesReviewID *string
ActorUserID string
ActorDeviceID *string
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
Items []CandidateHumanReviewItem
}
type CandidateHumanReviewItem struct {
CandidateOrdinal int
Label string
PrimaryReasonCode string
ReasonCodes []string
Note string
}
type CandidateDecisionDataset struct {
SearchRun *CandidateSearchRun
Observations []CandidateObservation
ModelRun *CandidateModelRun
Evaluations []CandidateEvaluationRecord
Recommendation *CandidateRecommendationRecord
HumanReviews []CandidateHumanReview
}
type ExecutionReport struct {
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch
DecisionDataset *CandidateDecisionDataset
Outcome *ExecutionOutcome
}
type TaskDetail struct {
Task PurchaseTask
Asset Asset
Execution *TaskExecution
Events []TaskEvent
Report *ExecutionReport
}
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 &cents, 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
}