feat(t216): deliver recoverable device order commands
This commit is contained in:
@@ -300,6 +300,32 @@ type OrderAuthorization struct {
|
||||
RevokedAt *time.Time
|
||||
FailureCode *string
|
||||
FailureMessage *string
|
||||
CommandSHA256 *string
|
||||
DeliveryAttemptCount int
|
||||
LastDeliveredAt *time.Time
|
||||
}
|
||||
|
||||
type DeviceOrderCommand struct {
|
||||
ID string
|
||||
SchemaVersion int
|
||||
Type string
|
||||
AuthorizationVersion int
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
TaskContentSHA256 string
|
||||
OriginalSKU string
|
||||
Quantity int
|
||||
CandidateKey string
|
||||
ObservedOrdinal int
|
||||
CandidateTitle string
|
||||
CandidateSKUText string
|
||||
CandidatePriceText string
|
||||
CardSignature string
|
||||
DetailSignature string
|
||||
DetailEvidenceSHA256 string
|
||||
SpecificationEvidenceSHA256 string
|
||||
CommandSHA256 string
|
||||
AuthorizationStatus OrderAuthorizationStatus
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
|
||||
@@ -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 != 8 {
|
||||
t.Fatalf("initial Up() applied = %d, want 8", applied)
|
||||
} else if applied != 9 {
|
||||
t.Fatalf("initial Up() applied = %d, want 9", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v8) error = %v", err)
|
||||
@@ -53,9 +56,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v8) over historical data error = %v", err)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("Up(v5-v8) applied = %d, want 4", applied)
|
||||
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)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v9) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -85,9 +93,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v8) error = %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("final Up(v4-v8) applied = %d, want 5", applied)
|
||||
t.Fatalf("final Up(v4-v9) error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("final Up(v4-v9) applied = %d, want 6", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -323,6 +331,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v8) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 8 {
|
||||
t.Fatalf("Up() applied = %d, want 8", applied)
|
||||
if applied != 9 {
|
||||
t.Fatalf("Up() applied = %d, want 9", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -39,6 +39,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -60,7 +61,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
8: false,
|
||||
8: true,
|
||||
9: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -79,6 +81,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
6: true,
|
||||
7: true,
|
||||
8: true,
|
||||
9: 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(v9) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v8) error = %v", err)
|
||||
}
|
||||
@@ -414,9 +417,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-v8) error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("Up(v3-v8) applied = %d, want 6", applied)
|
||||
t.Fatalf("Up(v3-v9) error = %v", err)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("Up(v3-v9) applied = %d, want 7", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func (s *Store) PullDeviceOrderCommand(
|
||||
ctx context.Context,
|
||||
write usecase.PullDeviceOrderCommandWrite,
|
||||
) (*domain.DeviceOrderCommand, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := validateDeviceOrderCommandClaim(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authorizationID, observedOrdinal, candidateTitle, err :=
|
||||
findActiveDeviceOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authorization.UserID != write.UserID ||
|
||||
authorization.DeviceID != write.DeviceID ||
|
||||
authorization.ClaimGeneration != write.ClaimGeneration {
|
||||
return nil, usecase.ErrExecutionMismatch
|
||||
}
|
||||
command := usecase.NewDeviceOrderCommand(
|
||||
authorization,
|
||||
observedOrdinal,
|
||||
candidateTitle,
|
||||
)
|
||||
commandHash, err := usecase.DeviceOrderCommandSHA256(command)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if authorization.CommandSHA256 != nil &&
|
||||
*authorization.CommandSHA256 != commandHash {
|
||||
return nil, usecase.ErrRepositoryInvariant
|
||||
}
|
||||
switch authorization.Status {
|
||||
case domain.OrderAuthorizationPendingDelivery:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET status = 'DELIVERED',
|
||||
command_sha256 = ?,
|
||||
delivered_at = ?,
|
||||
last_delivered_at = ?,
|
||||
delivery_attempt_count = delivery_attempt_count + 1
|
||||
WHERE id = ?
|
||||
AND status = 'PENDING_DELIVERY'
|
||||
AND command_sha256 IS NULL`,
|
||||
commandHash,
|
||||
formatTimestamp(write.Now),
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertTaskEvent(ctx, tx, write.DeliveredEvent); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
command.AuthorizationStatus = domain.OrderAuthorizationDelivered
|
||||
case domain.OrderAuthorizationDelivered,
|
||||
domain.OrderAuthorizationAcknowledged:
|
||||
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 command_sha256 = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
commandHash,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
default:
|
||||
return nil, usecase.ErrTaskStateConflict
|
||||
}
|
||||
command.CommandSHA256 = commandHash
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
return &command, nil
|
||||
}
|
||||
|
||||
func (s *Store) AcknowledgeDeviceOrderCommand(
|
||||
ctx context.Context,
|
||||
write usecase.AcknowledgeDeviceOrderCommandWrite,
|
||||
) (domain.OrderAuthorization, bool, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
record, found, err := getDeviceOrderCommandAckRequest(
|
||||
ctx,
|
||||
tx,
|
||||
write.DeviceID,
|
||||
write.IdempotencyKey,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if found {
|
||||
if record.RequestSHA256 != write.RequestSHA256 ||
|
||||
record.TaskID != write.TaskID ||
|
||||
record.AuthorizationID != write.AuthorizationID {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrIdempotencyConflict
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
record.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
return authorization, true, nil
|
||||
}
|
||||
if err := validateDeviceOrderCommandClaim(
|
||||
ctx,
|
||||
tx,
|
||||
write.UserID,
|
||||
write.DeviceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
write.ClaimGeneration,
|
||||
write.ClaimTokenHash,
|
||||
write.Now,
|
||||
); err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
authorization, err := getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
write.AuthorizationID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if authorization.TaskID != write.TaskID ||
|
||||
authorization.ExecutionID != write.ExecutionID ||
|
||||
authorization.UserID != write.UserID ||
|
||||
authorization.DeviceID != write.DeviceID ||
|
||||
authorization.ClaimGeneration != write.ClaimGeneration {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrExecutionMismatch
|
||||
}
|
||||
if authorization.CommandSHA256 == nil ||
|
||||
*authorization.CommandSHA256 != write.CommandSHA256 {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
switch authorization.Status {
|
||||
case domain.OrderAuthorizationDelivered:
|
||||
result, err := tx.ExecContext(
|
||||
ctx,
|
||||
`UPDATE order_authorizations
|
||||
SET status = 'ACKNOWLEDGED',
|
||||
acknowledged_at = ?
|
||||
WHERE id = ?
|
||||
AND status = 'DELIVERED'
|
||||
AND command_sha256 = ?`,
|
||||
formatTimestamp(write.Now),
|
||||
authorization.ID,
|
||||
write.CommandSHA256,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
if affected != 1 {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
if err := insertTaskEvent(
|
||||
ctx,
|
||||
tx,
|
||||
write.AcknowledgedEvent,
|
||||
); err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
case domain.OrderAuthorizationAcknowledged:
|
||||
default:
|
||||
return domain.OrderAuthorization{}, false,
|
||||
usecase.ErrTaskStateConflict
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO device_order_command_ack_requests (
|
||||
device_id, idempotency_key, request_sha256,
|
||||
task_id, authorization_id, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
write.DeviceID,
|
||||
write.IdempotencyKey,
|
||||
write.RequestSHA256,
|
||||
write.TaskID,
|
||||
write.AuthorizationID,
|
||||
formatTimestamp(write.Now),
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
authorization, err = getOrderAuthorization(
|
||||
ctx,
|
||||
tx,
|
||||
authorization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return domain.OrderAuthorization{}, false, repositoryFailure(err)
|
||||
}
|
||||
return authorization, false, nil
|
||||
}
|
||||
|
||||
func validateDeviceOrderCommandClaim(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
userID string,
|
||||
deviceID string,
|
||||
taskID string,
|
||||
executionID string,
|
||||
claimGeneration int64,
|
||||
claimTokenHash string,
|
||||
now time.Time,
|
||||
) error {
|
||||
task, err := getClaimProtectedTask(ctx, tx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateClaim(
|
||||
task,
|
||||
userID,
|
||||
deviceID,
|
||||
claimGeneration,
|
||||
claimTokenHash,
|
||||
now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != domain.TaskStatusWaitingConfirmation ||
|
||||
task.CancelRequestedAt != nil {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
execution, err := getExecutionByID(ctx, tx, executionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if execution.TaskID != taskID ||
|
||||
execution.UserID != userID ||
|
||||
execution.DeviceID != deviceID ||
|
||||
execution.ClaimGeneration != claimGeneration ||
|
||||
execution.FinishedAt != nil {
|
||||
return usecase.ErrExecutionMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type deviceOrderCommandAckRequest struct {
|
||||
RequestSHA256 string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
}
|
||||
|
||||
func getDeviceOrderCommandAckRequest(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
deviceID string,
|
||||
idempotencyKey string,
|
||||
) (deviceOrderCommandAckRequest, bool, error) {
|
||||
var record deviceOrderCommandAckRequest
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT request_sha256, task_id, authorization_id
|
||||
FROM device_order_command_ack_requests
|
||||
WHERE device_id = ? AND idempotency_key = ?`,
|
||||
deviceID,
|
||||
idempotencyKey,
|
||||
).Scan(
|
||||
&record.RequestSHA256,
|
||||
&record.TaskID,
|
||||
&record.AuthorizationID,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return deviceOrderCommandAckRequest{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return deviceOrderCommandAckRequest{}, false,
|
||||
repositoryFailure(err)
|
||||
}
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
func findActiveDeviceOrderAuthorization(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) (string, int, string, error) {
|
||||
var authorizationID, candidateTitle string
|
||||
var observedOrdinal int
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT
|
||||
oa.id,
|
||||
coi.candidate_ordinal,
|
||||
co.title
|
||||
FROM order_authorizations oa
|
||||
JOIN candidate_observation_identities coi
|
||||
ON coi.candidate_key = oa.candidate_key
|
||||
AND coi.execution_id = oa.execution_id
|
||||
JOIN candidate_observations co
|
||||
ON co.execution_id = coi.execution_id
|
||||
AND co.ordinal = coi.candidate_ordinal
|
||||
WHERE oa.task_id = ?
|
||||
AND oa.execution_id = ?
|
||||
AND oa.status IN (
|
||||
'PENDING_DELIVERY',
|
||||
'DELIVERED',
|
||||
'ACKNOWLEDGED'
|
||||
)
|
||||
ORDER BY oa.authorization_version DESC
|
||||
LIMIT 1`,
|
||||
taskID,
|
||||
executionID,
|
||||
).Scan(
|
||||
&authorizationID,
|
||||
&observedOrdinal,
|
||||
&candidateTitle,
|
||||
)
|
||||
return authorizationID, observedOrdinal, candidateTitle, err
|
||||
}
|
||||
@@ -595,6 +595,7 @@ func scanOrderAuthorization(
|
||||
var supersedes, deliveredAt, acknowledgedAt sql.NullString
|
||||
var executionStartedAt, consumedAt, failedAt, revokedAt sql.NullString
|
||||
var failureCode, failureMessage sql.NullString
|
||||
var commandSHA256, lastDeliveredAt sql.NullString
|
||||
var createdAt string
|
||||
err := scanner.Scan(
|
||||
&authorization.ID,
|
||||
@@ -629,6 +630,9 @@ func scanOrderAuthorization(
|
||||
&revokedAt,
|
||||
&failureCode,
|
||||
&failureMessage,
|
||||
&commandSHA256,
|
||||
&authorization.DeliveryAttemptCount,
|
||||
&lastDeliveredAt,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.OrderAuthorization{}, err
|
||||
@@ -664,6 +668,13 @@ func scanOrderAuthorization(
|
||||
if failureMessage.Valid {
|
||||
authorization.FailureMessage = &failureMessage.String
|
||||
}
|
||||
if commandSHA256.Valid {
|
||||
authorization.CommandSHA256 = &commandSHA256.String
|
||||
}
|
||||
if authorization.LastDeliveredAt, err =
|
||||
parseNullableTimestamp(lastDeliveredAt); err != nil {
|
||||
return domain.OrderAuthorization{}, err
|
||||
}
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
@@ -676,7 +687,8 @@ const orderAuthorizationSelect = `SELECT
|
||||
specification_evidence_sha256, status,
|
||||
supersedes_authorization_id, created_by_user_id, created_at,
|
||||
delivered_at, acknowledged_at, execution_started_at, consumed_at,
|
||||
failed_at, revoked_at, failure_code, failure_message
|
||||
failed_at, revoked_at, failure_code, failure_message,
|
||||
command_sha256, delivery_attempt_count, last_delivered_at
|
||||
FROM order_authorizations`
|
||||
|
||||
const localAdminSubject = "local-admin"
|
||||
|
||||
@@ -484,6 +484,9 @@ func orderAuthorizationResponse(
|
||||
"revoked_at": formatOptionalTime(authorization.RevokedAt),
|
||||
"failure_code": authorization.FailureCode,
|
||||
"failure_message": authorization.FailureMessage,
|
||||
"command_sha256": authorization.CommandSHA256,
|
||||
"delivery_attempt_count": authorization.DeliveryAttemptCount,
|
||||
"last_delivered_at": formatOptionalTime(authorization.LastDeliveredAt),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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("device command migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order authorization migration down succeeded with retained data")
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ type DeviceServices struct {
|
||||
Lifecycle *usecase.LifecycleService
|
||||
Assets *usecase.AssetService
|
||||
Results *usecase.ExecutionResultService
|
||||
Commands *usecase.DeviceOrderCommandService
|
||||
}
|
||||
|
||||
func (services DeviceServices) validate() error {
|
||||
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
|
||||
if services.Lifecycle == nil || services.Assets == nil ||
|
||||
services.Results == nil || services.Commands == nil {
|
||||
return errors.New("device services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -73,12 +75,125 @@ func NewDeviceRouteRegistrar(
|
||||
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
|
||||
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
|
||||
routes.POST("/api/v1/tasks/:id/human-reviews", handler.storeHumanReview)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/commands/next",
|
||||
handler.pullOrderCommand,
|
||||
)
|
||||
routes.POST(
|
||||
"/api/v1/tasks/:id/commands/:command_id/ack",
|
||||
handler.acknowledgeOrderCommand,
|
||||
)
|
||||
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
|
||||
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) pullOrderCommand(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"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
command, err := handler.services.Commands.Pull(
|
||||
ctx.Request.Context(),
|
||||
usecase.PullDeviceOrderCommand{
|
||||
UserID: principal.UserID,
|
||||
DeviceID: principal.DeviceID,
|
||||
TaskID: ctx.Param("id"),
|
||||
ExecutionID: request.ExecutionID,
|
||||
ClaimGeneration: request.ClaimGeneration,
|
||||
ClaimToken: ctx.GetHeader(claimTokenHeader),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
if command == nil {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, deviceOrderCommandResponse(*command))
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) acknowledgeOrderCommand(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"`
|
||||
}
|
||||
if !decodeDeviceJSON(ctx, &request) ||
|
||||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
|
||||
return
|
||||
}
|
||||
result, err := handler.services.Commands.Acknowledge(
|
||||
ctx.Request.Context(),
|
||||
usecase.AcknowledgeDeviceOrderCommand{
|
||||
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,
|
||||
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
writeUsecaseError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.Header("Cache-Control", "no-store")
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"command_id": result.Authorization.ID,
|
||||
"status": result.Authorization.Status,
|
||||
"replayed": result.Replayed,
|
||||
})
|
||||
}
|
||||
|
||||
func deviceOrderCommandResponse(command domain.DeviceOrderCommand) gin.H {
|
||||
return gin.H{
|
||||
"id": command.ID,
|
||||
"schema_version": command.SchemaVersion,
|
||||
"type": command.Type,
|
||||
"authorization_version": command.AuthorizationVersion,
|
||||
"task_id": command.TaskID,
|
||||
"execution_id": command.ExecutionID,
|
||||
"task_content_sha256": command.TaskContentSHA256,
|
||||
"original_sku": command.OriginalSKU,
|
||||
"quantity": command.Quantity,
|
||||
"candidate": gin.H{
|
||||
"candidate_key": command.CandidateKey,
|
||||
"observed_ordinal": command.ObservedOrdinal,
|
||||
"title": command.CandidateTitle,
|
||||
"sku_text": command.CandidateSKUText,
|
||||
"price_text": command.CandidatePriceText,
|
||||
"card_signature": command.CardSignature,
|
||||
"detail_signature": command.DetailSignature,
|
||||
"detail_evidence_sha256": command.DetailEvidenceSHA256,
|
||||
"specification_evidence_sha256": command.SpecificationEvidenceSHA256,
|
||||
},
|
||||
"command_sha256": command.CommandSHA256,
|
||||
"authorization_status": command.AuthorizationStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *deviceHandlers) referenceImage(ctx *gin.Context) {
|
||||
principal, ok := devicePrincipal(ctx)
|
||||
if !ok {
|
||||
|
||||
@@ -713,9 +713,17 @@ 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("device command migration down: %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err == nil {
|
||||
t.Fatal("order workflow migration down succeeded with retained data")
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
|
||||
@@ -784,6 +792,326 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
|
||||
t *testing.T,
|
||||
) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claim := fixture.claimNext(t, "order-command-claim", testOpaqueToken)
|
||||
requireDeviceStatus(t, claim, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claim, &claimed)
|
||||
startPayload := fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)
|
||||
start := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(startPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-start",
|
||||
})
|
||||
requireDeviceStatus(t, start, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, start, &started)
|
||||
candidateKey := seedDeviceOrderCommandCandidate(
|
||||
t,
|
||||
fixture,
|
||||
taskID,
|
||||
started.Execution.ID,
|
||||
)
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get waiting task detail: %v", err)
|
||||
}
|
||||
authorizations, err := usecase.NewOrderAuthorizationService(
|
||||
fixture.store,
|
||||
usecase.SystemClock{},
|
||||
usecase.UUIDGenerator{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOrderAuthorizationService() error = %v", err)
|
||||
}
|
||||
created, err := authorizations.Create(
|
||||
context.Background(),
|
||||
usecase.CreateOrderAuthorizationCommand{
|
||||
ActorUserID: deviceTestAdminID,
|
||||
TaskID: taskID,
|
||||
IdempotencyKey: "order-command-authorization",
|
||||
ExecutionID: started.Execution.ID,
|
||||
TaskContentSHA256: usecase.TaskContentSHA256(detail.Task),
|
||||
ExpectedTaskVersion: detail.Task.Version,
|
||||
CandidateKey: candidateKey,
|
||||
ReasonSchemaVersion: 1,
|
||||
PrimaryReasonCode: "SELECTED_BEST_MATCH",
|
||||
Items: []usecase.OrderAuthorizationItemInput{{
|
||||
CandidateKey: candidateKey,
|
||||
Label: "ACCEPT",
|
||||
PrimaryReasonCode: "SKU_MATCH",
|
||||
ReasonCodes: []string{"SKU_MATCH", "IMAGE_MATCH"},
|
||||
}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("create order authorization: %v", err)
|
||||
}
|
||||
pullPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
)
|
||||
pull := 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, pull, http.StatusOK)
|
||||
var command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
AuthorizationStatus string `json:"authorization_status"`
|
||||
Candidate struct {
|
||||
Key string `json:"candidate_key"`
|
||||
ObservedOrdinal int `json:"observed_ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidence string `json:"detail_evidence_sha256"`
|
||||
SpecificationSHA string `json:"specification_evidence_sha256"`
|
||||
} `json:"candidate"`
|
||||
}
|
||||
decodeResponse(t, pull, &command)
|
||||
if command.ID != created.Authorization.ID ||
|
||||
command.Type != "CREATE_PENDING_ORDER" ||
|
||||
command.SchemaVersion != 1 ||
|
||||
command.TaskID != taskID ||
|
||||
command.ExecutionID != started.Execution.ID ||
|
||||
command.Quantity != 2 ||
|
||||
len(command.CommandSHA256) != 64 ||
|
||||
command.AuthorizationStatus != "DELIVERED" ||
|
||||
command.Candidate.Key != candidateKey ||
|
||||
command.Candidate.ObservedOrdinal != 1 ||
|
||||
command.Candidate.Title != "设备命令候选" ||
|
||||
command.Candidate.SKUText != "TEST-SKU-COMMAND" {
|
||||
t.Fatalf("order command = %+v", command)
|
||||
}
|
||||
replayedPull := 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, replayedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
replayedPull.Body.String(),
|
||||
`"command_sha256":"`+command.CommandSHA256+`"`,
|
||||
) {
|
||||
t.Fatalf("replayed command = %s", replayedPull.Body.String())
|
||||
}
|
||||
badAckPayload := fmt.Sprintf(
|
||||
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_sha256":%q}`,
|
||||
deviceTestDeviceID,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
strings.Repeat("0", 64),
|
||||
)
|
||||
badAck := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(badAckPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack-bad",
|
||||
})
|
||||
requireDeviceStatus(t, badAck, http.StatusConflict)
|
||||
ackPayload := strings.Replace(
|
||||
badAckPayload,
|
||||
strings.Repeat("0", 64),
|
||||
command.CommandSHA256,
|
||||
1,
|
||||
)
|
||||
ack := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ack, http.StatusOK)
|
||||
if !strings.Contains(ack.Body.String(), `"status":"ACKNOWLEDGED"`) {
|
||||
t.Fatalf("ack response = %s", ack.Body.String())
|
||||
}
|
||||
ackReplay := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/commands/" + command.ID + "/ack",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(ackPayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "order-command-ack",
|
||||
})
|
||||
requireDeviceStatus(t, ackReplay, http.StatusOK)
|
||||
if !strings.Contains(ackReplay.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("ack replay = %s", ackReplay.Body.String())
|
||||
}
|
||||
acknowledgedPull := 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, acknowledgedPull, http.StatusOK)
|
||||
if !strings.Contains(
|
||||
acknowledgedPull.Body.String(),
|
||||
`"authorization_status":"ACKNOWLEDGED"`,
|
||||
) {
|
||||
t.Fatalf("acknowledged pull = %s", acknowledgedPull.Body.String())
|
||||
}
|
||||
var deliveredEvents, acknowledgedEvents int
|
||||
for eventType, target := range map[string]*int{
|
||||
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
|
||||
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
|
||||
} {
|
||||
if err := fixture.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM task_events
|
||||
WHERE task_id = ? AND event_type = ?`,
|
||||
taskID,
|
||||
eventType,
|
||||
).Scan(target); err != nil {
|
||||
t.Fatalf("count %s events: %v", eventType, err)
|
||||
}
|
||||
}
|
||||
if deliveredEvents != 1 || acknowledgedEvents != 1 {
|
||||
t.Fatalf(
|
||||
"delivery/ack events = %d/%d",
|
||||
deliveredEvents,
|
||||
acknowledgedEvents,
|
||||
)
|
||||
}
|
||||
runner, err := migration.New(fixture.db)
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func seedDeviceOrderCommandCandidate(
|
||||
t *testing.T,
|
||||
fixture *deviceHTTPFixture,
|
||||
taskID string,
|
||||
executionID string,
|
||||
) string {
|
||||
t.Helper()
|
||||
detail, err := fixture.tasks.Get(
|
||||
context.Background(),
|
||||
localAdminSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("get running task detail: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
candidateKey := strings.Repeat("7", 64)
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_search_runs (
|
||||
execution_id, task_id, task_content_sha256, execution_mode,
|
||||
search_query, started_at, received_at, observation_count,
|
||||
collection_complete, received_after_execution_expiry
|
||||
) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 1, 1, 0)`,
|
||||
executionID,
|
||||
taskID,
|
||||
taskHash,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command search run: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observations (
|
||||
execution_id, task_id, ordinal, title, sku_text, price_text,
|
||||
product_url, image_url, evidence_asset_ids_json,
|
||||
collection_status, observed_at
|
||||
) VALUES (?, ?, 1, '设备命令候选', 'TEST-SKU-COMMAND', '21.50',
|
||||
'', '', '[]', 'COMPLETE', ?)`,
|
||||
executionID,
|
||||
taskID,
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command observation: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`INSERT INTO candidate_observation_identities (
|
||||
candidate_key, execution_id, candidate_ordinal,
|
||||
card_signature, detail_signature, detail_evidence_sha256,
|
||||
specification_evidence_sha256, identity_version, created_at
|
||||
) VALUES (?, ?, 1, ?, ?, ?, ?, 1, ?)`,
|
||||
candidateKey,
|
||||
executionID,
|
||||
strings.Repeat("8", 64),
|
||||
strings.Repeat("9", 64),
|
||||
strings.Repeat("a", 64),
|
||||
strings.Repeat("b", 64),
|
||||
now,
|
||||
); err != nil {
|
||||
t.Fatalf("seed order command identity: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE purchase_tasks
|
||||
SET status = 'WAITING_CONFIRMATION',
|
||||
version = version + 1,
|
||||
updated_at = ?
|
||||
WHERE id = ? AND status = 'RUNNING'`,
|
||||
now,
|
||||
taskID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command task to waiting: %v", err)
|
||||
}
|
||||
if _, err := fixture.db.Exec(
|
||||
`UPDATE task_executions
|
||||
SET current_step = 'WAITING_ADMIN_CONFIRMATION',
|
||||
last_heartbeat_at = ?
|
||||
WHERE id = ?`,
|
||||
now,
|
||||
executionID,
|
||||
); err != nil {
|
||||
t.Fatalf("move order command execution to waiting: %v", err)
|
||||
}
|
||||
return candidateKey
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -1020,11 +1348,16 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
commands, err := usecase.NewDeviceOrderCommandService(store, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewDeviceOrderCommandService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
Commands: commands,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
deviceOrderCommandSchemaVersion = 1
|
||||
deviceOrderCommandType = "CREATE_PENDING_ORDER"
|
||||
)
|
||||
|
||||
type PullDeviceOrderCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
}
|
||||
|
||||
type PullDeviceOrderCommandWrite struct {
|
||||
PullDeviceOrderCommand
|
||||
ClaimTokenHash string
|
||||
Now time.Time
|
||||
DeliveredEvent domain.TaskEvent
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommand struct {
|
||||
UserID string
|
||||
DeviceID string
|
||||
TaskID string
|
||||
ExecutionID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int64
|
||||
ClaimToken string
|
||||
CommandSHA256 string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommandWrite struct {
|
||||
AcknowledgeDeviceOrderCommand
|
||||
ClaimTokenHash string
|
||||
RequestSHA256 string
|
||||
Now time.Time
|
||||
AcknowledgedEvent domain.TaskEvent
|
||||
}
|
||||
|
||||
type AcknowledgeDeviceOrderCommandResult struct {
|
||||
Authorization domain.OrderAuthorization
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type DeviceOrderCommandRepository interface {
|
||||
PullDeviceOrderCommand(
|
||||
context.Context,
|
||||
PullDeviceOrderCommandWrite,
|
||||
) (*domain.DeviceOrderCommand, error)
|
||||
AcknowledgeDeviceOrderCommand(
|
||||
context.Context,
|
||||
AcknowledgeDeviceOrderCommandWrite,
|
||||
) (domain.OrderAuthorization, bool, error)
|
||||
}
|
||||
|
||||
type DeviceOrderCommandService struct {
|
||||
repository DeviceOrderCommandRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
func NewDeviceOrderCommandService(
|
||||
repository DeviceOrderCommandRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*DeviceOrderCommandService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("device order command service dependencies are required")
|
||||
}
|
||||
return &DeviceOrderCommandService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (service *DeviceOrderCommandService) Pull(
|
||||
ctx context.Context,
|
||||
command PullDeviceOrderCommand,
|
||||
) (*domain.DeviceOrderCommand, error) {
|
||||
command = normalizePullDeviceOrderCommand(command)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return nil, invalidError(
|
||||
"ORDER_COMMAND_PULL_INVALID",
|
||||
"order command pull request is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return nil, internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
result, err := service.repository.PullDeviceOrderCommand(
|
||||
ctx,
|
||||
PullDeviceOrderCommandWrite{
|
||||
PullDeviceOrderCommand: command,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
Now: now,
|
||||
DeliveredEvent: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_AUTHORIZATION_DELIVERED",
|
||||
Message: "order authorization delivered to device",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (service *DeviceOrderCommandService) Acknowledge(
|
||||
ctx context.Context,
|
||||
command AcknowledgeDeviceOrderCommand,
|
||||
) (AcknowledgeDeviceOrderCommandResult, error) {
|
||||
command = normalizeAcknowledgeDeviceOrderCommand(command)
|
||||
fields := lifecycleClaimFields(
|
||||
command.UserID,
|
||||
command.DeviceID,
|
||||
command.TaskID,
|
||||
command.ClaimGeneration,
|
||||
command.ClaimToken,
|
||||
)
|
||||
if !isUUID(command.ExecutionID) {
|
||||
fields["execution_id"] = "must be a UUID"
|
||||
}
|
||||
if !isUUID(command.AuthorizationID) {
|
||||
fields["command_id"] = "must be a UUID"
|
||||
}
|
||||
if !sha256Pattern.MatchString(command.CommandSHA256) {
|
||||
fields["command_sha256"] = "must be lowercase SHA-256"
|
||||
}
|
||||
validateIdempotencyField(fields, command.IdempotencyKey)
|
||||
if len(fields) > 0 {
|
||||
return AcknowledgeDeviceOrderCommandResult{}, invalidError(
|
||||
"ORDER_COMMAND_ACK_INVALID",
|
||||
"order command acknowledgement is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
requestSHA256, err := lifecycleRequestHash(struct {
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
ClaimGeneration int64 `json:"claim_generation"`
|
||||
ClaimTokenHash string `json:"claim_token_sha256"`
|
||||
CommandSHA256 string `json:"command_sha256"`
|
||||
}{
|
||||
UserID: command.UserID,
|
||||
DeviceID: command.DeviceID,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
AuthorizationID: command.AuthorizationID,
|
||||
ClaimGeneration: command.ClaimGeneration,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
CommandSHA256: command.CommandSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
internalLifecycleFailure(err)
|
||||
}
|
||||
eventID, err := service.ids.NewID()
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
internalLifecycleFailure(err)
|
||||
}
|
||||
now := service.clock.Now().UTC()
|
||||
userID, deviceID := command.UserID, command.DeviceID
|
||||
authorization, replayed, err :=
|
||||
service.repository.AcknowledgeDeviceOrderCommand(
|
||||
ctx,
|
||||
AcknowledgeDeviceOrderCommandWrite{
|
||||
AcknowledgeDeviceOrderCommand: command,
|
||||
ClaimTokenHash: hashSecret(command.ClaimToken),
|
||||
RequestSHA256: requestSHA256,
|
||||
Now: now,
|
||||
AcknowledgedEvent: domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
ActorUserID: &userID,
|
||||
ActorDeviceID: &deviceID,
|
||||
Type: "ORDER_AUTHORIZATION_ACKNOWLEDGED",
|
||||
Message: "order authorization persisted by device",
|
||||
OccurredAt: now,
|
||||
},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return AcknowledgeDeviceOrderCommandResult{},
|
||||
wrapLifecycleRepositoryError(err)
|
||||
}
|
||||
return AcknowledgeDeviceOrderCommandResult{
|
||||
Authorization: authorization,
|
||||
Replayed: replayed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func DeviceOrderCommandSHA256(
|
||||
command domain.DeviceOrderCommand,
|
||||
) (string, error) {
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Type string `json:"type"`
|
||||
AuthorizationVersion int `json:"authorization_version"`
|
||||
TaskID string `json:"task_id"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
TaskContentSHA256 string `json:"task_content_sha256"`
|
||||
OriginalSKU string `json:"original_sku"`
|
||||
Quantity int `json:"quantity"`
|
||||
Candidate struct {
|
||||
CandidateKey string `json:"candidate_key"`
|
||||
ObservedOrdinal int `json:"observed_ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
PriceText string `json:"price_text"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
|
||||
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
|
||||
} `json:"candidate"`
|
||||
}{
|
||||
ID: command.ID,
|
||||
SchemaVersion: command.SchemaVersion,
|
||||
Type: command.Type,
|
||||
AuthorizationVersion: command.AuthorizationVersion,
|
||||
TaskID: command.TaskID,
|
||||
ExecutionID: command.ExecutionID,
|
||||
TaskContentSHA256: command.TaskContentSHA256,
|
||||
OriginalSKU: command.OriginalSKU,
|
||||
Quantity: command.Quantity,
|
||||
}
|
||||
payload.Candidate.CandidateKey = command.CandidateKey
|
||||
payload.Candidate.ObservedOrdinal = command.ObservedOrdinal
|
||||
payload.Candidate.Title = command.CandidateTitle
|
||||
payload.Candidate.SKUText = command.CandidateSKUText
|
||||
payload.Candidate.PriceText = command.CandidatePriceText
|
||||
payload.Candidate.CardSignature = command.CardSignature
|
||||
payload.Candidate.DetailSignature = command.DetailSignature
|
||||
payload.Candidate.DetailEvidenceSHA256 = command.DetailEvidenceSHA256
|
||||
payload.Candidate.SpecificationEvidenceSHA256 =
|
||||
command.SpecificationEvidenceSHA256
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func NewDeviceOrderCommand(
|
||||
authorization domain.OrderAuthorization,
|
||||
observedOrdinal int,
|
||||
candidateTitle string,
|
||||
) domain.DeviceOrderCommand {
|
||||
return domain.DeviceOrderCommand{
|
||||
ID: authorization.ID,
|
||||
SchemaVersion: deviceOrderCommandSchemaVersion,
|
||||
Type: deviceOrderCommandType,
|
||||
AuthorizationVersion: authorization.AuthorizationVersion,
|
||||
TaskID: authorization.TaskID,
|
||||
ExecutionID: authorization.ExecutionID,
|
||||
TaskContentSHA256: authorization.TaskContentSHA256,
|
||||
OriginalSKU: authorization.OriginalSKU,
|
||||
Quantity: authorization.Quantity,
|
||||
CandidateKey: authorization.CandidateKey,
|
||||
ObservedOrdinal: observedOrdinal,
|
||||
CandidateTitle: candidateTitle,
|
||||
CandidateSKUText: authorization.CandidateSKUText,
|
||||
CandidatePriceText: authorization.CandidatePriceText,
|
||||
CardSignature: authorization.CardSignature,
|
||||
DetailSignature: authorization.DetailSignature,
|
||||
DetailEvidenceSHA256: authorization.DetailEvidenceSHA256,
|
||||
SpecificationEvidenceSHA256: authorization.SpecificationEvidenceSHA256,
|
||||
AuthorizationStatus: authorization.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePullDeviceOrderCommand(
|
||||
command PullDeviceOrderCommand,
|
||||
) PullDeviceOrderCommand {
|
||||
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.ClaimToken = strings.TrimSpace(command.ClaimToken)
|
||||
return command
|
||||
}
|
||||
|
||||
func normalizeAcknowledgeDeviceOrderCommand(
|
||||
command AcknowledgeDeviceOrderCommand,
|
||||
) AcknowledgeDeviceOrderCommand {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestDeviceOrderCommandSHA256MatchesMobileContractVector(t *testing.T) {
|
||||
command := domain.DeviceOrderCommand{
|
||||
ID: "00000000-0000-4000-8000-000000000009",
|
||||
SchemaVersion: 1,
|
||||
Type: "CREATE_PENDING_ORDER",
|
||||
AuthorizationVersion: 3,
|
||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||
ExecutionID: "00000000-0000-4000-8000-000000000002",
|
||||
TaskContentSHA256: repeatForCommandTest("a"),
|
||||
OriginalSKU: "红色 M",
|
||||
Quantity: 2,
|
||||
CandidateKey: "candidate-key-1",
|
||||
ObservedOrdinal: 1,
|
||||
CandidateTitle: "红色连衣裙",
|
||||
CandidateSKUText: "红色 / M",
|
||||
CandidatePriceText: "19.90 CNY",
|
||||
CardSignature: repeatForCommandTest("b"),
|
||||
DetailSignature: repeatForCommandTest("c"),
|
||||
DetailEvidenceSHA256: repeatForCommandTest("d"),
|
||||
SpecificationEvidenceSHA256: repeatForCommandTest("e"),
|
||||
}
|
||||
|
||||
got, err := DeviceOrderCommandSHA256(command)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceOrderCommandSHA256() error = %v", err)
|
||||
}
|
||||
const want = "cf3cd802366d4de49d51940551385f7e5bd3283ef18cbf245a227c679d1058f3"
|
||||
if got != want {
|
||||
t.Fatalf("DeviceOrderCommandSHA256() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func repeatForCommandTest(value string) string {
|
||||
result := ""
|
||||
for len(result) < 64 {
|
||||
result += value
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user