305 lines
8.4 KiB
Go
305 lines
8.4 KiB
Go
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
|
|
}
|