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
@@ -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 {