feat(t217): verify authorized order dry runs
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user