feat(t217): verify authorized order dry runs

This commit is contained in:
QiuSW
2026-07-28 16:52:29 +08:00
parent 913107c28d
commit 1e87b6273a
45 changed files with 4358 additions and 128 deletions
+27
View File
@@ -328,6 +328,33 @@ type DeviceOrderCommand struct {
AuthorizationStatus OrderAuthorizationStatus
}
type OrderDryRunStatus string
const (
OrderDryRunPreparing OrderDryRunStatus = "PREPARING"
OrderDryRunReady OrderDryRunStatus = "READY"
)
type OrderDryRun struct {
ID string
AuthorizationID string
TaskID string
ExecutionID string
CommandSHA256 string
Status OrderDryRunStatus
CardSignature *string
DetailSignature *string
ObservedTitle *string
SelectedSKU *string
Quantity *int
UnitPriceCents *int64
TotalPriceCents *int64
EvidenceAssetID *string
EvidenceSHA256 *string
StartedAt time.Time
ReadyAt *time.Time
}
type ExecutionReport struct {
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 9 {
t.Fatalf("initial Up() applied = %d, want 9", applied)
} else if applied != 10 {
t.Fatalf("initial Up() applied = %d, want 10", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v10) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v9) error = %v", err)
@@ -56,9 +59,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v9) over historical data error = %v", err)
} else if applied != 5 {
t.Fatalf("Up(v5-v9) applied = %d, want 5", applied)
t.Fatalf("Up(v5-v10) over historical data error = %v", err)
} else if applied != 6 {
t.Fatalf("Up(v5-v10) applied = %d, want 6", applied)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v10) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
@@ -93,9 +101,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v9) error = %v", err)
} else if applied != 6 {
t.Fatalf("final Up(v4-v9) applied = %d, want 6", applied)
t.Fatalf("final Up(v4-v10) error = %v", err)
} else if applied != 7 {
t.Fatalf("final Up(v4-v10) applied = %d, want 7", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -331,6 +339,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v10) error = %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v9) error = %v", err)
}
@@ -27,19 +27,20 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 9 {
t.Fatalf("Up() applied = %d, want 9", applied)
if applied != 10 {
t.Fatalf("Up() applied = %d, want 10", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
10: true,
})
applied, err = runner.Up(context.Background())
@@ -54,15 +55,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
t.Fatalf("Down() error = %v", err)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: false,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
10: false,
})
applied, err = runner.Up(context.Background())
@@ -73,15 +75,16 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
t.Fatalf("final Up() applied = %d, want 1", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
7: true,
8: true,
9: true,
10: true,
})
}
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v10) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v9) error = %v", err)
}
@@ -417,9 +420,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback")
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v9) error = %v", err)
} else if applied != 7 {
t.Fatalf("Up(v3-v9) applied = %d, want 7", applied)
t.Fatalf("Up(v3-v10) error = %v", err)
} else if applied != 8 {
t.Fatalf("Up(v3-v10) applied = %d, want 8", applied)
}
}
@@ -108,14 +108,15 @@ func (s *Store) PullDeviceOrderCommand(
}
command.AuthorizationStatus = domain.OrderAuthorizationDelivered
case domain.OrderAuthorizationDelivered,
domain.OrderAuthorizationAcknowledged:
domain.OrderAuthorizationAcknowledged,
domain.OrderAuthorizationExecuting:
result, err := tx.ExecContext(
ctx,
`UPDATE order_authorizations
SET last_delivered_at = ?,
delivery_attempt_count = delivery_attempt_count + 1
WHERE id = ?
AND status IN ('DELIVERED', 'ACKNOWLEDGED')
AND status IN ('DELIVERED', 'ACKNOWLEDGED', 'EXECUTING')
AND command_sha256 = ?`,
formatTimestamp(write.Now),
authorization.ID,
@@ -389,7 +390,8 @@ func findActiveDeviceOrderAuthorization(
AND oa.status IN (
'PENDING_DELIVERY',
'DELIVERED',
'ACKNOWLEDGED'
'ACKNOWLEDGED',
'EXECUTING'
)
ORDER BY oa.authorization_version DESC
LIMIT 1`,
@@ -0,0 +1,521 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"math"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
func (s *Store) StartOrderDryRun(
ctx context.Context,
write usecase.StartOrderDryRunWrite,
) (domain.OrderDryRun, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if result, found, err := replayOrderDryRunRequest(
ctx, tx, write.DeviceID, "START", write.IdempotencyKey,
write.RequestSHA256, write.TaskID,
); err != nil || found {
return commitOrderDryRunReplay(tx, result, found, err)
}
if err := validateDeviceOrderCommandClaim(
ctx, tx, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.ClaimTokenHash,
write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
authorization, err := getOrderAuthorization(
ctx, tx, write.AuthorizationID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if err := validateDryRunAuthorization(
authorization, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.CommandSHA256,
); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
switch authorization.Status {
case domain.OrderAuthorizationAcknowledged:
if found {
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
result, err := tx.ExecContext(
ctx,
`UPDATE order_authorizations
SET status = 'EXECUTING', execution_started_at = ?
WHERE id = ? AND status = 'ACKNOWLEDGED'
AND command_sha256 = ?`,
formatTimestamp(write.Now),
authorization.ID,
write.CommandSHA256,
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO order_dry_runs (
id, authorization_id, task_id, execution_id,
command_sha256, status, started_at
) VALUES (?, ?, ?, ?, ?, 'PREPARING', ?)`,
write.DryRunID,
authorization.ID,
write.TaskID,
write.ExecutionID,
write.CommandSHA256,
formatTimestamp(write.Now),
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err = getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
case domain.OrderAuthorizationExecuting:
if !found || dryRun.CommandSHA256 != write.CommandSHA256 {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
default:
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if err := insertOrderDryRunRequest(
ctx, tx, write.DeviceID, "START", write.IdempotencyKey,
write.RequestSHA256, write.TaskID, dryRun.ID, write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return dryRun, false, nil
}
func (s *Store) ReadyOrderDryRun(
ctx context.Context,
write usecase.ReadyOrderDryRunWrite,
) (domain.OrderDryRun, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if result, found, err := replayOrderDryRunRequest(
ctx, tx, write.DeviceID, "READY", write.IdempotencyKey,
write.RequestSHA256, write.TaskID,
); err != nil || found {
return commitOrderDryRunReplay(tx, result, found, err)
}
if err := validateDeviceOrderCommandClaim(
ctx, tx, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.ClaimTokenHash,
write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
authorization, err := getOrderAuthorization(
ctx, tx, write.AuthorizationID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if err := validateDryRunAuthorization(
authorization, write.UserID, write.DeviceID, write.TaskID,
write.ExecutionID, write.ClaimGeneration, write.CommandSHA256,
); err != nil {
return domain.OrderDryRun{}, false, err
}
if authorization.Status != domain.OrderAuthorizationExecuting {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
dryRun, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if !found || dryRun.CommandSHA256 != write.CommandSHA256 {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if dryRun.Status == domain.OrderDryRunReady {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if write.Quantity != authorization.Quantity ||
write.SelectedSKU != authorization.OriginalSKU ||
(write.CardSignature != authorization.CardSignature &&
write.DetailSignature != authorization.DetailSignature) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if write.UnitPriceCents > math.MaxInt64/int64(write.Quantity) ||
write.TotalPriceCents !=
write.UnitPriceCents*int64(write.Quantity) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
var observedTitle string
err = tx.QueryRowContext(
ctx,
`SELECT co.title
FROM candidate_observation_identities coi
JOIN candidate_observations co
ON co.execution_id = coi.execution_id
AND co.ordinal = coi.candidate_ordinal
WHERE coi.candidate_key = ? AND coi.execution_id = ?`,
authorization.CandidateKey,
authorization.ExecutionID,
).Scan(&observedTitle)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if normalizeDryRunTitle(observedTitle) !=
normalizeDryRunTitle(write.ObservedTitle) {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
task, err := getClaimProtectedTask(ctx, tx, write.TaskID)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if task.MaxBudgetCents != nil &&
write.TotalPriceCents > *task.MaxBudgetCents {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if task.MaxBudgetCents == nil {
authorizedPrice, ok := parseDryRunPrice(
authorization.CandidatePriceText,
)
if !ok || write.UnitPriceCents != authorizedPrice {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
}
evidence, err := getExecutionEvidence(
ctx, tx, write.EvidenceAssetID,
)
if err != nil {
return domain.OrderDryRun{}, false, err
}
if evidence.TaskID != write.TaskID ||
evidence.ExecutionID != write.ExecutionID ||
evidence.MediaType != "image/jpeg" ||
evidence.SHA256 != write.EvidenceSHA256 ||
evidence.ReceivedAfterExecutionExpiry {
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
result, err := tx.ExecContext(
ctx,
`UPDATE order_dry_runs
SET status = 'READY',
card_signature = ?,
detail_signature = ?,
observed_title = ?,
selected_sku = ?,
quantity = ?,
unit_price_cents = ?,
total_price_cents = ?,
evidence_asset_id = ?,
evidence_sha256 = ?,
ready_at = ?
WHERE id = ? AND status = 'PREPARING'`,
write.CardSignature,
write.DetailSignature,
write.ObservedTitle,
write.SelectedSKU,
write.Quantity,
write.UnitPriceCents,
write.TotalPriceCents,
write.EvidenceAssetID,
write.EvidenceSHA256,
formatTimestamp(write.Now),
dryRun.ID,
)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return domain.OrderDryRun{}, false, usecase.ErrTaskStateConflict
}
if err := insertTaskEvent(ctx, tx, write.Event); err != nil {
return domain.OrderDryRun{}, false, err
}
if err := insertOrderDryRunRequest(
ctx, tx, write.DeviceID, "READY", write.IdempotencyKey,
write.RequestSHA256, write.TaskID, dryRun.ID, write.Now,
); err != nil {
return domain.OrderDryRun{}, false, err
}
dryRun, found, err = getOrderDryRunByAuthorization(
ctx, tx, authorization.ID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return dryRun, false, nil
}
func validateDryRunAuthorization(
authorization domain.OrderAuthorization,
userID, deviceID, taskID, executionID string,
claimGeneration int64,
commandSHA256 string,
) error {
if authorization.UserID != userID ||
authorization.DeviceID != deviceID ||
authorization.TaskID != taskID ||
authorization.ExecutionID != executionID ||
authorization.ClaimGeneration != claimGeneration ||
authorization.CommandSHA256 == nil ||
*authorization.CommandSHA256 != commandSHA256 {
return usecase.ErrExecutionMismatch
}
return nil
}
func normalizeDryRunTitle(value string) string {
return strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || unicode.IsPunct(character) {
return -1
}
return unicode.ToLower(character)
}, value)
}
func parseDryRunPrice(value string) (int64, bool) {
matches := dryRunPricePattern.FindStringSubmatch(strings.TrimSpace(value))
if len(matches) != 3 {
return 0, false
}
whole, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil {
return 0, false
}
fractionText := matches[2]
if len(fractionText) == 1 {
fractionText += "0"
}
fraction := int64(0)
if fractionText != "" {
fraction, err = strconv.ParseInt(fractionText, 10, 64)
if err != nil {
return 0, false
}
}
cents := whole*100 + fraction
return cents, cents > 0 && cents <= 100_000_000
}
var dryRunPricePattern = regexp.MustCompile(
`^(?:首件)?[¥¥]?(0|[1-9][0-9]{0,6})(?:\.([0-9]{1,2}))?$`,
)
func getOrderDryRunByAuthorization(
ctx context.Context,
queryer queryRower,
authorizationID string,
) (domain.OrderDryRun, bool, error) {
var result domain.OrderDryRun
var card, detail, title, sku sql.NullString
var quantity sql.NullInt64
var unitPrice, totalPrice sql.NullInt64
var evidenceID, evidenceSHA, readyAt sql.NullString
var startedAt string
err := queryer.QueryRowContext(
ctx,
`SELECT id, authorization_id, task_id, execution_id,
command_sha256, status, card_signature, detail_signature,
observed_title, selected_sku, quantity, unit_price_cents,
total_price_cents, evidence_asset_id, evidence_sha256,
started_at, ready_at
FROM order_dry_runs WHERE authorization_id = ?`,
authorizationID,
).Scan(
&result.ID,
&result.AuthorizationID,
&result.TaskID,
&result.ExecutionID,
&result.CommandSHA256,
&result.Status,
&card,
&detail,
&title,
&sku,
&quantity,
&unitPrice,
&totalPrice,
&evidenceID,
&evidenceSHA,
&startedAt,
&readyAt,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, nil
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result.CardSignature = dryRunOptionalString(card)
result.DetailSignature = dryRunOptionalString(detail)
result.ObservedTitle = dryRunOptionalString(title)
result.SelectedSKU = dryRunOptionalString(sku)
if quantity.Valid {
value := int(quantity.Int64)
result.Quantity = &value
}
if unitPrice.Valid {
result.UnitPriceCents = &unitPrice.Int64
}
if totalPrice.Valid {
result.TotalPriceCents = &totalPrice.Int64
}
result.EvidenceAssetID = dryRunOptionalString(evidenceID)
result.EvidenceSHA256 = dryRunOptionalString(evidenceSHA)
parsed, err := parseTimestamp(startedAt)
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result.StartedAt = parsed
if readyAt.Valid {
parsed, parseErr := parseTimestamp(readyAt.String)
if parseErr != nil {
return domain.OrderDryRun{}, false, repositoryFailure(parseErr)
}
result.ReadyAt = &parsed
}
return result, true, nil
}
func dryRunOptionalString(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
func replayOrderDryRunRequest(
ctx context.Context,
tx *sql.Tx,
deviceID, operation, idempotencyKey, requestSHA256, taskID string,
) (domain.OrderDryRun, bool, error) {
var knownHash, knownTask, dryRunID string
err := tx.QueryRowContext(
ctx,
`SELECT request_sha256, task_id, dry_run_id
FROM device_order_dry_run_requests
WHERE device_id = ? AND operation = ? AND idempotency_key = ?`,
deviceID,
operation,
idempotencyKey,
).Scan(&knownHash, &knownTask, &dryRunID)
if errors.Is(err, sql.ErrNoRows) {
return domain.OrderDryRun{}, false, nil
}
if err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
if knownHash != requestSHA256 || knownTask != taskID {
return domain.OrderDryRun{}, false, usecase.ErrIdempotencyConflict
}
var authorizationID string
if err := tx.QueryRowContext(
ctx,
`SELECT authorization_id FROM order_dry_runs WHERE id = ?`,
dryRunID,
).Scan(&authorizationID); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
result, found, err := getOrderDryRunByAuthorization(
ctx, tx, authorizationID,
)
if err != nil || !found {
if err != nil {
return domain.OrderDryRun{}, false, err
}
return domain.OrderDryRun{}, false, usecase.ErrRepositoryInvariant
}
return result, true, nil
}
func commitOrderDryRunReplay(
tx *sql.Tx,
result domain.OrderDryRun,
found bool,
err error,
) (domain.OrderDryRun, bool, error) {
if err != nil || !found {
return domain.OrderDryRun{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.OrderDryRun{}, false, repositoryFailure(err)
}
return result, true, nil
}
func insertOrderDryRunRequest(
ctx context.Context,
tx *sql.Tx,
deviceID, operation, idempotencyKey, requestSHA256, taskID, dryRunID string,
now time.Time,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO device_order_dry_run_requests (
device_id, operation, idempotency_key, request_sha256,
task_id, dry_run_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
deviceID,
operation,
idempotencyKey,
requestSHA256,
taskID,
dryRunID,
formatTimestamp(now.UTC()),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
@@ -410,6 +410,9 @@ func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order dry-run migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("device command migration down: %v", err)
}
@@ -21,11 +21,13 @@ type DeviceServices struct {
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
Commands *usecase.DeviceOrderCommandService
DryRuns *usecase.OrderDryRunService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil ||
services.Results == nil || services.Commands == nil {
services.Results == nil || services.Commands == nil ||
services.DryRuns == nil {
return errors.New("device services are required")
}
return nil
@@ -83,12 +85,141 @@ func NewDeviceRouteRegistrar(
"/api/v1/tasks/:id/commands/:command_id/ack",
handler.acknowledgeOrderCommand,
)
routes.POST(
"/api/v1/tasks/:id/order-dry-runs/start",
handler.startOrderDryRun,
)
routes.POST(
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
handler.readyOrderDryRun,
)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
}, nil
}
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
DeviceID string `json:"device_id"`
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
CommandID string `json:"command_id"`
CommandSHA256 string `json:"command_sha256"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.DryRuns.Start(
ctx.Request.Context(),
usecase.StartOrderDryRunCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
AuthorizationID: request.CommandID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
CommandSHA256: request.CommandSHA256,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"dry_run": orderDryRunResponse(result.DryRun),
"replayed": result.Replayed,
})
}
func (handler *deviceHandlers) readyOrderDryRun(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
DeviceID string `json:"device_id"`
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
CommandSHA256 string `json:"command_sha256"`
CardSignature string `json:"card_signature"`
DetailSignature string `json:"detail_signature"`
ObservedTitle string `json:"observed_title"`
SelectedSKU string `json:"selected_sku"`
Quantity int `json:"quantity"`
UnitPriceCents int64 `json:"unit_price_cents"`
TotalPriceCents int64 `json:"total_price_cents"`
EvidenceAssetID string `json:"evidence_asset_id"`
EvidenceSHA256 string `json:"evidence_sha256"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.DryRuns.Ready(
ctx.Request.Context(),
usecase.ReadyOrderDryRunCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
AuthorizationID: ctx.Param("command_id"),
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
CommandSHA256: request.CommandSHA256,
CardSignature: request.CardSignature,
DetailSignature: request.DetailSignature,
ObservedTitle: request.ObservedTitle,
SelectedSKU: request.SelectedSKU,
Quantity: request.Quantity,
UnitPriceCents: request.UnitPriceCents,
TotalPriceCents: request.TotalPriceCents,
EvidenceAssetID: request.EvidenceAssetID,
EvidenceSHA256: request.EvidenceSHA256,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"dry_run": orderDryRunResponse(result.DryRun),
"replayed": result.Replayed,
})
}
func orderDryRunResponse(dryRun domain.OrderDryRun) gin.H {
return gin.H{
"id": dryRun.ID,
"command_id": dryRun.AuthorizationID,
"task_id": dryRun.TaskID,
"execution_id": dryRun.ExecutionID,
"command_sha256": dryRun.CommandSHA256,
"status": dryRun.Status,
"card_signature": dryRun.CardSignature,
"detail_signature": dryRun.DetailSignature,
"observed_title": dryRun.ObservedTitle,
"selected_sku": dryRun.SelectedSKU,
"quantity": dryRun.Quantity,
"unit_price_cents": dryRun.UnitPriceCents,
"total_price_cents": dryRun.TotalPriceCents,
"evidence_asset_id": dryRun.EvidenceAssetID,
"evidence_sha256": dryRun.EvidenceSHA256,
"started_at": formatTime(dryRun.StartedAt),
"ready_at": formatOptionalTime(dryRun.ReadyAt),
}
}
func (handler *deviceHandlers) pullOrderCommand(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
@@ -713,6 +713,9 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
if err != nil {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order dry-run migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("device command migration down: %v", err)
}
@@ -721,8 +724,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("restore device command migration: %v", err)
} else if applied != 1 {
t.Fatalf("restored migrations = %d, want 1", applied)
} else if applied != 2 {
t.Fatalf("restored migrations = %d, want 2", applied)
}
completePayload := fmt.Sprintf(
@@ -885,6 +888,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
SchemaVersion int `json:"schema_version"`
TaskID string `json:"task_id"`
ExecutionID string `json:"execution_id"`
OriginalSKU string `json:"original_sku"`
Quantity int `json:"quantity"`
CommandSHA256 string `json:"command_sha256"`
AuthorizationStatus string `json:"authorization_status"`
@@ -905,6 +909,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
command.SchemaVersion != 1 ||
command.TaskID != taskID ||
command.ExecutionID != started.Execution.ID ||
command.OriginalSKU == "" ||
command.Quantity != 2 ||
len(command.CommandSHA256) != 64 ||
command.AuthorizationStatus != "DELIVERED" ||
@@ -997,10 +1002,149 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
) {
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
}
var deliveredEvents, acknowledgedEvents int
startDryRunPayload := fmt.Sprintf(
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q}`,
deviceTestDeviceID,
started.Execution.ID,
started.Task.ClaimGeneration,
command.ID,
command.CommandSHA256,
)
startDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
contentType: "application/json",
body: strings.NewReader(startDryRunPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-start",
})
requireDeviceStatus(t, startDryRun, http.StatusOK)
if !strings.Contains(startDryRun.Body.String(), `"status":"PREPARING"`) {
t.Fatalf("dry-run start = %s", startDryRun.Body.String())
}
startDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/start",
contentType: "application/json",
body: strings.NewReader(startDryRunPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-start",
})
requireDeviceStatus(t, startDryRunReplay, http.StatusOK)
if !strings.Contains(startDryRunReplay.Body.String(), `"replayed":true`) {
t.Fatalf("dry-run start replay = %s", startDryRunReplay.Body.String())
}
executingPull := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/commands/next",
contentType: "application/json",
body: strings.NewReader(pullPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
})
requireDeviceStatus(t, executingPull, http.StatusOK)
if !strings.Contains(
executingPull.Body.String(),
`"authorization_status":"EXECUTING"`,
) {
t.Fatalf("executing pull = %s", executingPull.Body.String())
}
const dryRunEvidenceID = "00000000-0000-4000-8000-000000000077"
dryRunEvidenceSHA := strings.Repeat("e", 64)
if _, err := fixture.db.Exec(
`INSERT INTO execution_evidence_assets (
id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
dryRunEvidenceID,
taskID,
started.Execution.ID,
dryRunEvidenceSHA,
"dry-run/order-confirmation.jpg",
time.Now().UTC().Format(time.RFC3339Nano),
); err != nil {
t.Fatalf("seed dry-run evidence: %v", err)
}
readyDryRunPayload := fmt.Sprintf(
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q,"card_signature":%q,"detail_signature":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
deviceTestDeviceID,
started.Execution.ID,
started.Task.ClaimGeneration,
command.CommandSHA256,
command.Candidate.CardSignature,
strings.Repeat("f", 64),
command.Candidate.Title,
command.OriginalSKU,
dryRunEvidenceID,
dryRunEvidenceSHA,
)
badSKUDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID +
"/order-dry-runs/" + command.ID + "/ready",
contentType: "application/json",
body: strings.NewReader(strings.Replace(
readyDryRunPayload,
fmt.Sprintf(`"selected_sku":%q`, command.OriginalSKU),
`"selected_sku":"wrong-sku"`,
1,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-ready-bad-sku",
})
requireDeviceStatus(t, badSKUDryRun, http.StatusConflict)
badTotalDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID +
"/order-dry-runs/" + command.ID + "/ready",
contentType: "application/json",
body: strings.NewReader(strings.Replace(
readyDryRunPayload,
`"total_price_cents":4300`,
`"total_price_cents":4301`,
1,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-ready-bad-total",
})
requireDeviceStatus(t, badTotalDryRun, http.StatusConflict)
readyDryRun := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-dry-runs/" + command.ID + "/ready",
contentType: "application/json",
body: strings.NewReader(readyDryRunPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-ready",
})
requireDeviceStatus(t, readyDryRun, http.StatusOK)
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
}
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID +
"/order-dry-runs/" + command.ID + "/ready",
contentType: "application/json",
body: strings.NewReader(readyDryRunPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-dry-run-ready",
})
requireDeviceStatus(t, readyDryRunReplay, http.StatusOK)
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
}
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
for eventType, target := range map[string]*int{
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
} {
if err := fixture.db.QueryRow(
`SELECT COUNT(*) FROM task_events
@@ -1011,11 +1155,14 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
t.Fatalf("count %s events: %v", eventType, err)
}
}
if deliveredEvents != 1 || acknowledgedEvents != 1 {
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
t.Fatalf(
"delivery/ack events = %d/%d",
"delivery/ack/dry-run events = %d/%d/%d/%d",
deliveredEvents,
acknowledgedEvents,
dryRunStartedEvents,
dryRunReadyEvents,
)
}
runner, err := migration.New(fixture.db)
@@ -1023,7 +1170,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("device command migration down succeeded with command data")
t.Fatal("order dry-run migration down succeeded with dry-run data")
}
}
@@ -1352,12 +1499,17 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil {
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
}
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
if err != nil {
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
}
deviceRoutes, err := NewDeviceRouteRegistrar(
DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
Commands: commands,
DryRuns: dryRuns,
},
)
if err != nil {
@@ -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
}