Files
cmroubao/backend-api/internal/usecase/order_dry_run_service.go
T

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
}