feat(t216): deliver recoverable device order commands
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user