feat(t216): deliver recoverable device order commands

This commit is contained in:
QiuSW
2026-07-28 13:23:18 +08:00
parent 75fdc63db2
commit 2372ab2280
32 changed files with 2415 additions and 65 deletions
@@ -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"