package usecase import ( "context" "errors" "math" "regexp" "strings" "time" "cmroubao/backend-api/internal/domain" ) const ( orderSubmissionStartOperation = "START" orderSubmissionReconcileOperation = "RECONCILE" orderSubmissionManualReviewOperation = "MANUAL_REVIEW" ) type StartOrderSubmissionCommand struct { UserID string DeviceID string TaskID string ExecutionID string AuthorizationID string ClaimGeneration int64 ClaimToken string CommandSHA256 string DryRunID string DryRunEvidenceSHA256 string ObservedTitle string SelectedSKU string Quantity int UnitPriceCents int64 TotalPriceCents int64 IdempotencyKey string } type StartOrderSubmissionWrite struct { StartOrderSubmissionCommand SubmissionID string ClaimTokenHash string RequestSHA256 string Now time.Time Event domain.TaskEvent } type ReconcileOrderSubmissionCommand struct { UserID string DeviceID string TaskID string ExecutionID string AuthorizationID string ClaimGeneration int64 ClaimToken string CommandSHA256 string SubmissionID string PlatformOrderNo string PlatformOrderedAt string PlatformOrderStatus string ObservedTitle string SelectedSKU string Quantity int TotalPriceCents int64 EvidenceAssetID string EvidenceSHA256 string IdempotencyKey string } type ReconcileOrderSubmissionWrite struct { ReconcileOrderSubmissionCommand ParsedPlatformOrderedAt time.Time ClaimTokenHash string RequestSHA256 string Now time.Time Event domain.TaskEvent } type ManualReviewOrderSubmissionCommand struct { UserID string DeviceID string TaskID string ExecutionID string AuthorizationID string ClaimGeneration int64 ClaimToken string CommandSHA256 string SubmissionID string ReasonCode string EvidenceAssetID string EvidenceSHA256 string IdempotencyKey string } type ManualReviewOrderSubmissionWrite struct { ManualReviewOrderSubmissionCommand ClaimTokenHash string RequestSHA256 string Now time.Time Event domain.TaskEvent } type OrderSubmissionResult struct { Submission domain.OrderSubmission Replayed bool } type OrderSubmissionRepository interface { StartOrderSubmission( context.Context, StartOrderSubmissionWrite, ) (domain.OrderSubmission, bool, error) ReconcileOrderSubmission( context.Context, ReconcileOrderSubmissionWrite, ) (domain.OrderSubmission, bool, error) ManualReviewOrderSubmission( context.Context, ManualReviewOrderSubmissionWrite, ) (domain.OrderSubmission, bool, error) } type OrderSubmissionService struct { repository OrderSubmissionRepository clock Clock ids IDGenerator } func NewOrderSubmissionService( repository OrderSubmissionRepository, clock Clock, ids IDGenerator, ) (*OrderSubmissionService, error) { if repository == nil || clock == nil || ids == nil { return nil, errors.New("order submission service dependencies are required") } return &OrderSubmissionService{ repository: repository, clock: clock, ids: ids, }, nil } func (service *OrderSubmissionService) Start( ctx context.Context, command StartOrderSubmissionCommand, ) (OrderSubmissionResult, error) { command = normalizeStartOrderSubmission(command) fields := orderSubmissionIdentityFields( command.UserID, command.DeviceID, command.TaskID, command.ExecutionID, command.AuthorizationID, command.ClaimGeneration, command.ClaimToken, command.CommandSHA256, command.IdempotencyKey, ) if !isUUID(command.DryRunID) { fields["dry_run_id"] = "must be a UUID" } if !sha256Pattern.MatchString(command.DryRunEvidenceSHA256) { fields["dry_run_evidence_sha256"] = "must be lowercase SHA-256" } validateOrderSnapshot( fields, command.ObservedTitle, command.SelectedSKU, command.Quantity, command.UnitPriceCents, command.TotalPriceCents, ) if command.Quantity > 0 && command.UnitPriceCents <= math.MaxInt64/int64(command.Quantity) && command.TotalPriceCents != command.UnitPriceCents*int64(command.Quantity) { fields["total_price_cents"] = "must equal unit price times quantity" } if len(fields) > 0 { return OrderSubmissionResult{}, invalidError( "ORDER_SUBMISSION_START_INVALID", "order submission start request is invalid", fields, ) } requestHash, err := lifecycleRequestHash(command) if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } submissionID, err := service.ids.NewID() if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } eventID, err := service.ids.NewID() if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } now := service.clock.Now().UTC() userID, deviceID := command.UserID, command.DeviceID submission, replayed, err := service.repository.StartOrderSubmission( ctx, StartOrderSubmissionWrite{ StartOrderSubmissionCommand: command, SubmissionID: submissionID, ClaimTokenHash: hashSecret(command.ClaimToken), RequestSHA256: requestHash, Now: now, Event: domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &userID, ActorDeviceID: &deviceID, Type: "ORDER_SUBMISSION_FENCED", Message: "single order submission fenced", OccurredAt: now, }, }, ) if err != nil { return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) } return OrderSubmissionResult{ Submission: submission, Replayed: replayed, }, nil } func (service *OrderSubmissionService) Reconcile( ctx context.Context, command ReconcileOrderSubmissionCommand, ) (OrderSubmissionResult, error) { command = normalizeReconcileOrderSubmission(command) fields := orderSubmissionIdentityFields( command.UserID, command.DeviceID, command.TaskID, command.ExecutionID, command.AuthorizationID, command.ClaimGeneration, command.ClaimToken, command.CommandSHA256, command.IdempotencyKey, ) if !isUUID(command.SubmissionID) { fields["submission_id"] = "must be a UUID" } if !platformOrderNumberPattern.MatchString(command.PlatformOrderNo) { fields["platform_order_no"] = "must be 8 to 40 digits" } orderedAt, err := time.Parse(time.RFC3339, command.PlatformOrderedAt) if err != nil { fields["platform_ordered_at"] = "must be RFC3339" } if command.PlatformOrderStatus != "PENDING_PAYMENT" { fields["platform_order_status"] = "must be PENDING_PAYMENT" } validateReconciledOrderSnapshot( fields, command.ObservedTitle, command.SelectedSKU, command.Quantity, command.TotalPriceCents, ) if !isUUID(command.EvidenceAssetID) { fields["evidence_asset_id"] = "must be a UUID" } if !sha256Pattern.MatchString(command.EvidenceSHA256) { fields["evidence_sha256"] = "must be lowercase SHA-256" } if len(fields) > 0 { return OrderSubmissionResult{}, invalidError( "ORDER_SUBMISSION_RECONCILE_INVALID", "order submission reconciliation request is invalid", fields, ) } requestHash, err := lifecycleRequestHash(command) if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } eventID, err := service.ids.NewID() if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } now := service.clock.Now().UTC() userID, deviceID := command.UserID, command.DeviceID submission, replayed, err := service.repository.ReconcileOrderSubmission( ctx, ReconcileOrderSubmissionWrite{ ReconcileOrderSubmissionCommand: command, ParsedPlatformOrderedAt: orderedAt.UTC(), ClaimTokenHash: hashSecret(command.ClaimToken), RequestSHA256: requestHash, Now: now, Event: domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &userID, ActorDeviceID: &deviceID, Type: "ORDER_SUBMISSION_RECONCILED", Message: "pending-payment order uniquely reconciled", OccurredAt: now, }, }, ) if err != nil { return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) } return OrderSubmissionResult{ Submission: submission, Replayed: replayed, }, nil } func (service *OrderSubmissionService) ManualReview( ctx context.Context, command ManualReviewOrderSubmissionCommand, ) (OrderSubmissionResult, error) { command = normalizeManualReviewOrderSubmission(command) fields := orderSubmissionIdentityFields( command.UserID, command.DeviceID, command.TaskID, command.ExecutionID, command.AuthorizationID, command.ClaimGeneration, command.ClaimToken, command.CommandSHA256, command.IdempotencyKey, ) if !isUUID(command.SubmissionID) { fields["submission_id"] = "must be a UUID" } if _, ok := orderSubmissionManualReasons[command.ReasonCode]; !ok { fields["reason_code"] = "must be an allowed reason" } if (command.EvidenceAssetID == "") != (command.EvidenceSHA256 == "") { fields["evidence"] = "asset id and SHA-256 must be provided together" } else if command.EvidenceAssetID != "" { if !isUUID(command.EvidenceAssetID) { fields["evidence_asset_id"] = "must be a UUID" } if !sha256Pattern.MatchString(command.EvidenceSHA256) { fields["evidence_sha256"] = "must be lowercase SHA-256" } } if len(fields) > 0 { return OrderSubmissionResult{}, invalidError( "ORDER_SUBMISSION_MANUAL_REVIEW_INVALID", "order submission manual-review request is invalid", fields, ) } requestHash, err := lifecycleRequestHash(command) if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } eventID, err := service.ids.NewID() if err != nil { return OrderSubmissionResult{}, internalLifecycleFailure(err) } now := service.clock.Now().UTC() userID, deviceID := command.UserID, command.DeviceID submission, replayed, err := service.repository.ManualReviewOrderSubmission( ctx, ManualReviewOrderSubmissionWrite{ ManualReviewOrderSubmissionCommand: command, ClaimTokenHash: hashSecret(command.ClaimToken), RequestSHA256: requestHash, Now: now, Event: domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &userID, ActorDeviceID: &deviceID, Type: "ORDER_SUBMISSION_MANUAL_REVIEW", Message: "order submission requires manual reconciliation", OccurredAt: now, }, }, ) if err != nil { return OrderSubmissionResult{}, wrapLifecycleRepositoryError(err) } return OrderSubmissionResult{ Submission: submission, Replayed: replayed, }, nil } func orderSubmissionIdentityFields( userID, deviceID, taskID, executionID, authorizationID string, claimGeneration int64, claimToken, commandSHA256, idempotencyKey string, ) map[string]string { return dryRunIdentityFields( userID, deviceID, taskID, executionID, authorizationID, claimGeneration, claimToken, commandSHA256, idempotencyKey, ) } func validateOrderSnapshot( fields map[string]string, title, sku string, quantity int, unitPriceCents, totalPriceCents int64, ) { if title == "" || len([]byte(title)) > 1024 { fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" } if sku == "" || len([]byte(sku)) > 512 { fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" } if quantity < 1 || quantity > 99 { fields["quantity"] = "must be 1 to 99" } if unitPriceCents < 1 { fields["unit_price_cents"] = "must be positive" } if totalPriceCents < 1 { fields["total_price_cents"] = "must be positive" } if quantity > 0 && unitPriceCents > math.MaxInt64/int64(quantity) { fields["total_price_cents"] = "price multiplication overflows" } } func validateReconciledOrderSnapshot( fields map[string]string, title, sku string, quantity int, totalPriceCents int64, ) { if title == "" || len([]byte(title)) > 1024 { fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" } if sku == "" || len([]byte(sku)) > 512 { fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" } if quantity < 1 || quantity > 99 { fields["quantity"] = "must be 1 to 99" } if totalPriceCents < 1 { fields["total_price_cents"] = "must be positive" } } func normalizeStartOrderSubmission( command StartOrderSubmissionCommand, ) StartOrderSubmissionCommand { command.UserID = strings.TrimSpace(command.UserID) command.DeviceID = strings.TrimSpace(command.DeviceID) command.TaskID = strings.TrimSpace(command.TaskID) command.ExecutionID = strings.TrimSpace(command.ExecutionID) command.AuthorizationID = strings.TrimSpace(command.AuthorizationID) command.ClaimToken = strings.TrimSpace(command.ClaimToken) command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256) command.DryRunID = strings.TrimSpace(command.DryRunID) command.DryRunEvidenceSHA256 = strings.TrimSpace( command.DryRunEvidenceSHA256, ) command.ObservedTitle = strings.TrimSpace(command.ObservedTitle) command.SelectedSKU = strings.TrimSpace(command.SelectedSKU) command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) return command } func normalizeReconcileOrderSubmission( command ReconcileOrderSubmissionCommand, ) ReconcileOrderSubmissionCommand { command.UserID = strings.TrimSpace(command.UserID) command.DeviceID = strings.TrimSpace(command.DeviceID) command.TaskID = strings.TrimSpace(command.TaskID) command.ExecutionID = strings.TrimSpace(command.ExecutionID) command.AuthorizationID = strings.TrimSpace(command.AuthorizationID) command.ClaimToken = strings.TrimSpace(command.ClaimToken) command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256) command.SubmissionID = strings.TrimSpace(command.SubmissionID) command.PlatformOrderNo = strings.TrimSpace(command.PlatformOrderNo) command.PlatformOrderedAt = strings.TrimSpace(command.PlatformOrderedAt) command.PlatformOrderStatus = strings.TrimSpace(command.PlatformOrderStatus) command.ObservedTitle = strings.TrimSpace(command.ObservedTitle) command.SelectedSKU = strings.TrimSpace(command.SelectedSKU) command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID) command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256) command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) return command } func normalizeManualReviewOrderSubmission( command ManualReviewOrderSubmissionCommand, ) ManualReviewOrderSubmissionCommand { command.UserID = strings.TrimSpace(command.UserID) command.DeviceID = strings.TrimSpace(command.DeviceID) command.TaskID = strings.TrimSpace(command.TaskID) command.ExecutionID = strings.TrimSpace(command.ExecutionID) command.AuthorizationID = strings.TrimSpace(command.AuthorizationID) command.ClaimToken = strings.TrimSpace(command.ClaimToken) command.CommandSHA256 = strings.TrimSpace(command.CommandSHA256) command.SubmissionID = strings.TrimSpace(command.SubmissionID) command.ReasonCode = strings.TrimSpace(command.ReasonCode) command.EvidenceAssetID = strings.TrimSpace(command.EvidenceAssetID) command.EvidenceSHA256 = strings.TrimSpace(command.EvidenceSHA256) command.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) return command } var platformOrderNumberPattern = regexp.MustCompile(`^[0-9]{8,40}$`) var orderSubmissionManualReasons = map[string]struct{}{ "ORDER_NOT_FOUND": {}, "ORDER_AMBIGUOUS": {}, "ORDER_FIELDS_INCOMPLETE": {}, "ORDER_PAGE_UNKNOWN": {}, "RISK_OR_PAYMENT_BOUNDARY": {}, "EVIDENCE_UNAVAILABLE": {}, }