package usecase import ( "context" "errors" "strings" "time" "cmroubao/backend-api/internal/domain" ) type StartOrderDryRunCommand struct { UserID string DeviceID string TaskID string ExecutionID string AuthorizationID string ClaimGeneration int64 ClaimToken string CommandSHA256 string IdempotencyKey string } type StartOrderDryRunWrite struct { StartOrderDryRunCommand DryRunID string ClaimTokenHash string RequestSHA256 string Now time.Time Event domain.TaskEvent } type ReadyOrderDryRunCommand struct { UserID string DeviceID string TaskID string ExecutionID string AuthorizationID string ClaimGeneration int64 ClaimToken string CommandSHA256 string CardSignature string DetailSignature string ObservedTitle string SelectedSKU string Quantity int UnitPriceCents int64 TotalPriceCents int64 EvidenceAssetID string EvidenceSHA256 string IdempotencyKey string } type ReadyOrderDryRunWrite struct { ReadyOrderDryRunCommand ClaimTokenHash string RequestSHA256 string Now time.Time Event domain.TaskEvent } type OrderDryRunResult struct { DryRun domain.OrderDryRun Replayed bool } type OrderDryRunRepository interface { StartOrderDryRun( context.Context, StartOrderDryRunWrite, ) (domain.OrderDryRun, bool, error) ReadyOrderDryRun( context.Context, ReadyOrderDryRunWrite, ) (domain.OrderDryRun, bool, error) } type OrderDryRunService struct { repository OrderDryRunRepository clock Clock ids IDGenerator } func NewOrderDryRunService( repository OrderDryRunRepository, clock Clock, ids IDGenerator, ) (*OrderDryRunService, error) { if repository == nil || clock == nil || ids == nil { return nil, errors.New("order dry-run service dependencies are required") } return &OrderDryRunService{ repository: repository, clock: clock, ids: ids, }, nil } func (service *OrderDryRunService) Start( ctx context.Context, command StartOrderDryRunCommand, ) (OrderDryRunResult, error) { command = normalizeStartOrderDryRunCommand(command) fields := dryRunIdentityFields( command.UserID, command.DeviceID, command.TaskID, command.ExecutionID, command.AuthorizationID, command.ClaimGeneration, command.ClaimToken, command.CommandSHA256, command.IdempotencyKey, ) if len(fields) > 0 { return OrderDryRunResult{}, invalidError( "ORDER_DRY_RUN_START_INVALID", "order dry-run start request is invalid", fields, ) } requestHash, err := lifecycleRequestHash(command) if err != nil { return OrderDryRunResult{}, internalLifecycleFailure(err) } dryRunID, err := service.ids.NewID() if err != nil { return OrderDryRunResult{}, internalLifecycleFailure(err) } eventID, err := service.ids.NewID() if err != nil { return OrderDryRunResult{}, internalLifecycleFailure(err) } now := service.clock.Now().UTC() userID, deviceID := command.UserID, command.DeviceID dryRun, replayed, err := service.repository.StartOrderDryRun( ctx, StartOrderDryRunWrite{ StartOrderDryRunCommand: command, DryRunID: dryRunID, ClaimTokenHash: hashSecret(command.ClaimToken), RequestSHA256: requestHash, Now: now, Event: domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &userID, ActorDeviceID: &deviceID, Type: "ORDER_DRY_RUN_STARTED", Message: "authorized order dry-run started", OccurredAt: now, }, }, ) if err != nil { return OrderDryRunResult{}, wrapLifecycleRepositoryError(err) } return OrderDryRunResult{DryRun: dryRun, Replayed: replayed}, nil } func (service *OrderDryRunService) Ready( ctx context.Context, command ReadyOrderDryRunCommand, ) (OrderDryRunResult, error) { command = normalizeReadyOrderDryRunCommand(command) fields := dryRunIdentityFields( command.UserID, command.DeviceID, command.TaskID, command.ExecutionID, command.AuthorizationID, command.ClaimGeneration, command.ClaimToken, command.CommandSHA256, command.IdempotencyKey, ) for name, value := range map[string]string{ "card_signature": command.CardSignature, "detail_signature": command.DetailSignature, "evidence_sha256": command.EvidenceSHA256, } { if !sha256Pattern.MatchString(value) { fields[name] = "must be lowercase SHA-256" } } if strings.TrimSpace(command.ObservedTitle) == "" || len([]byte(command.ObservedTitle)) > 1024 { fields["observed_title"] = "must be 1 to 1024 UTF-8 bytes" } if strings.TrimSpace(command.SelectedSKU) == "" || len([]byte(command.SelectedSKU)) > 512 { fields["selected_sku"] = "must be 1 to 512 UTF-8 bytes" } if command.Quantity < 1 || command.Quantity > 99 { fields["quantity"] = "must be 1 to 99" } if command.UnitPriceCents < 1 { fields["unit_price_cents"] = "must be positive" } if command.TotalPriceCents < 1 { fields["total_price_cents"] = "must be positive" } if !isUUID(command.EvidenceAssetID) { fields["evidence_asset_id"] = "must be a UUID" } if len(fields) > 0 { return OrderDryRunResult{}, invalidError( "ORDER_DRY_RUN_READY_INVALID", "order dry-run ready request is invalid", fields, ) } requestHash, err := lifecycleRequestHash(command) if err != nil { return OrderDryRunResult{}, internalLifecycleFailure(err) } eventID, err := service.ids.NewID() if err != nil { return OrderDryRunResult{}, internalLifecycleFailure(err) } now := service.clock.Now().UTC() userID, deviceID := command.UserID, command.DeviceID dryRun, replayed, err := service.repository.ReadyOrderDryRun( ctx, ReadyOrderDryRunWrite{ ReadyOrderDryRunCommand: command, ClaimTokenHash: hashSecret(command.ClaimToken), RequestSHA256: requestHash, Now: now, Event: domain.TaskEvent{ ID: eventID, TaskID: command.TaskID, ActorUserID: &userID, ActorDeviceID: &deviceID, Type: "ORDER_DRY_RUN_READY", Message: "authorized order dry-run is ready for submit", OccurredAt: now, }, }, ) if err != nil { return OrderDryRunResult{}, wrapLifecycleRepositoryError(err) } return OrderDryRunResult{DryRun: dryRun, Replayed: replayed}, nil } func dryRunIdentityFields( userID, deviceID, taskID, executionID, authorizationID string, claimGeneration int64, claimToken, commandSHA256, idempotencyKey string, ) map[string]string { fields := lifecycleClaimFields( userID, deviceID, taskID, claimGeneration, claimToken, ) if !isUUID(executionID) { fields["execution_id"] = "must be a UUID" } if !isUUID(authorizationID) { fields["command_id"] = "must be a UUID" } if !sha256Pattern.MatchString(commandSHA256) { fields["command_sha256"] = "must be lowercase SHA-256" } validateIdempotencyField(fields, idempotencyKey) return fields } func normalizeStartOrderDryRunCommand( command StartOrderDryRunCommand, ) StartOrderDryRunCommand { 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.IdempotencyKey = strings.TrimSpace(command.IdempotencyKey) return command } func normalizeReadyOrderDryRunCommand( command ReadyOrderDryRunCommand, ) ReadyOrderDryRunCommand { 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.CardSignature = strings.TrimSpace(command.CardSignature) command.DetailSignature = strings.TrimSpace(command.DetailSignature) 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 }