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
+5
View File
@@ -205,6 +205,10 @@ func buildRouter(
if err != nil {
return nil, err
}
dryRuns, err := usecase.NewOrderDryRunService(store, clock, ids)
if err != nil {
return nil, err
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
@@ -215,6 +219,7 @@ func buildRouter(
Assets: assets,
Results: results,
Commands: commands,
DryRuns: dryRuns,
},
)
if err != nil {
+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
}
@@ -0,0 +1,217 @@
-- +goose Up
CREATE TABLE order_dry_runs (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
authorization_id TEXT NOT NULL UNIQUE
REFERENCES order_authorizations(id)
ON UPDATE RESTRICT ON DELETE RESTRICT,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
command_sha256 TEXT NOT NULL
CHECK (
length(command_sha256) = 64
AND command_sha256 NOT GLOB '*[^0-9a-f]*'
),
status TEXT NOT NULL CHECK (status IN ('PREPARING', 'READY')),
card_signature TEXT
CHECK (
card_signature IS NULL
OR (
length(card_signature) = 64
AND card_signature NOT GLOB '*[^0-9a-f]*'
)
),
detail_signature TEXT
CHECK (
detail_signature IS NULL
OR (
length(detail_signature) = 64
AND detail_signature NOT GLOB '*[^0-9a-f]*'
)
),
observed_title TEXT,
selected_sku TEXT,
quantity INTEGER CHECK (
quantity IS NULL OR quantity BETWEEN 1 AND 99
),
unit_price_cents INTEGER CHECK (
unit_price_cents IS NULL OR unit_price_cents > 0
),
total_price_cents INTEGER CHECK (
total_price_cents IS NULL OR total_price_cents > 0
),
evidence_asset_id TEXT
REFERENCES execution_evidence_assets(id)
ON UPDATE RESTRICT ON DELETE RESTRICT,
evidence_sha256 TEXT
CHECK (
evidence_sha256 IS NULL
OR (
length(evidence_sha256) = 64
AND evidence_sha256 NOT GLOB '*[^0-9a-f]*'
)
),
started_at TEXT NOT NULL,
ready_at TEXT,
CHECK (
status = 'PREPARING'
OR (
status = 'READY'
AND card_signature IS NOT NULL
AND detail_signature IS NOT NULL
AND length(trim(observed_title)) > 0
AND length(trim(selected_sku)) > 0
AND quantity IS NOT NULL
AND unit_price_cents IS NOT NULL
AND total_price_cents IS NOT NULL
AND evidence_asset_id IS NOT NULL
AND evidence_sha256 IS NOT NULL
AND ready_at IS NOT NULL
)
)
);
CREATE INDEX order_dry_runs_task_started_idx
ON order_dry_runs (task_id, started_at DESC, id DESC);
CREATE TABLE device_order_dry_run_requests (
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL CHECK (operation IN ('START', 'READY')),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
dry_run_id TEXT NOT NULL
REFERENCES order_dry_runs(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
created_at TEXT NOT NULL,
PRIMARY KEY (device_id, operation, idempotency_key)
);
ALTER TABLE task_events RENAME TO task_events_v9;
CREATE TABLE task_events (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
event_type TEXT NOT NULL
CHECK (
event_type IN (
'TASK_CREATED',
'TASK_CLAIMED',
'TASK_RECLAIMED',
'TASK_RELEASED',
'TASK_STARTED',
'TASK_CANCEL_REQUESTED',
'TASK_CANCELED',
'CANDIDATES_READY',
'ORDER_AUTHORIZATION_CREATED',
'ORDER_AUTHORIZATION_DELIVERED',
'ORDER_AUTHORIZATION_ACKNOWLEDGED',
'ORDER_DRY_RUN_STARTED',
'ORDER_DRY_RUN_READY'
)
),
message TEXT NOT NULL,
occurred_at TEXT NOT NULL,
actor_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
actor_device_id TEXT
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
);
INSERT INTO task_events (
id, task_id, event_type, message, occurred_at,
actor_user_id, actor_device_id
)
SELECT
id, task_id, event_type, message, occurred_at,
actor_user_id, actor_device_id
FROM task_events_v9;
DROP TABLE task_events_v9;
CREATE INDEX task_events_task_occurred_idx
ON task_events (task_id, occurred_at ASC, id ASC);
CREATE INDEX task_events_actor_user_idx
ON task_events (actor_user_id, occurred_at DESC, id DESC);
CREATE INDEX task_events_actor_device_idx
ON task_events (actor_device_id, occurred_at DESC, id DESC);
-- +goose Down
CREATE TEMP TABLE order_dry_runs_v10_down_guard (
allowed INTEGER NOT NULL CHECK (allowed = 1)
);
INSERT INTO order_dry_runs_v10_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM order_dry_runs)
OR EXISTS (SELECT 1 FROM device_order_dry_run_requests)
OR EXISTS (
SELECT 1 FROM task_events
WHERE event_type IN ('ORDER_DRY_RUN_STARTED', 'ORDER_DRY_RUN_READY')
)
THEN 0
ELSE 1
END;
DROP TABLE order_dry_runs_v10_down_guard;
DROP TABLE device_order_dry_run_requests;
DROP TABLE order_dry_runs;
ALTER TABLE task_events RENAME TO task_events_v10;
CREATE TABLE task_events (
id TEXT PRIMARY KEY NOT NULL CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
event_type TEXT NOT NULL
CHECK (
event_type IN (
'TASK_CREATED',
'TASK_CLAIMED',
'TASK_RECLAIMED',
'TASK_RELEASED',
'TASK_STARTED',
'TASK_CANCEL_REQUESTED',
'TASK_CANCELED',
'CANDIDATES_READY',
'ORDER_AUTHORIZATION_CREATED',
'ORDER_AUTHORIZATION_DELIVERED',
'ORDER_AUTHORIZATION_ACKNOWLEDGED'
)
),
message TEXT NOT NULL,
occurred_at TEXT NOT NULL,
actor_user_id TEXT
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
actor_device_id TEXT
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT
);
INSERT INTO task_events (
id, task_id, event_type, message, occurred_at,
actor_user_id, actor_device_id
)
SELECT
id, task_id, event_type, message, occurred_at,
actor_user_id, actor_device_id
FROM task_events_v10;
DROP TABLE task_events_v10;
CREATE INDEX task_events_task_occurred_idx
ON task_events (task_id, occurred_at ASC, id ASC);
CREATE INDEX task_events_actor_user_idx
ON task_events (actor_user_id, occurred_at DESC, id DESC);
CREATE INDEX task_events_actor_device_idx
ON task_events (actor_device_id, occurred_at DESC, id DESC);