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