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
+26
View File
@@ -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
}