feat(t218): reconcile single order submissions

This commit is contained in:
QiuSW
2026-07-28 17:48:54 +08:00
parent 682cc7327c
commit a77d8be06d
32 changed files with 4306 additions and 79 deletions
@@ -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("order submission migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order dry-run migration down: %v", err)
}
@@ -17,17 +17,18 @@ import (
const claimTokenHeader = "X-Claim-Token"
type DeviceServices struct {
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
Commands *usecase.DeviceOrderCommandService
DryRuns *usecase.OrderDryRunService
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
Commands *usecase.DeviceOrderCommandService
DryRuns *usecase.OrderDryRunService
Submissions *usecase.OrderSubmissionService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil ||
services.Results == nil || services.Commands == nil ||
services.DryRuns == nil {
services.DryRuns == nil || services.Submissions == nil {
return errors.New("device services are required")
}
return nil
@@ -93,12 +94,215 @@ func NewDeviceRouteRegistrar(
"/api/v1/tasks/:id/order-dry-runs/:command_id/ready",
handler.readyOrderDryRun,
)
routes.POST(
"/api/v1/tasks/:id/order-submissions/start",
handler.startOrderSubmission,
)
routes.POST(
"/api/v1/tasks/:id/order-submissions/:submission_id/reconcile",
handler.reconcileOrderSubmission,
)
routes.POST(
"/api/v1/tasks/:id/order-submissions/:submission_id/manual-review",
handler.manualReviewOrderSubmission,
)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
}, nil
}
func (handler *deviceHandlers) startOrderSubmission(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"`
CommandID string `json:"command_id"`
CommandSHA256 string `json:"command_sha256"`
DryRunID string `json:"dry_run_id"`
DryRunEvidenceSHA256 string `json:"dry_run_evidence_sha256"`
ObservedTitle string `json:"observed_title"`
SelectedSKU string `json:"selected_sku"`
Quantity int `json:"quantity"`
UnitPriceCents int64 `json:"unit_price_cents"`
TotalPriceCents int64 `json:"total_price_cents"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Submissions.Start(
ctx.Request.Context(),
usecase.StartOrderSubmissionCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
AuthorizationID: request.CommandID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
CommandSHA256: request.CommandSHA256,
DryRunID: request.DryRunID,
DryRunEvidenceSHA256: request.DryRunEvidenceSHA256,
ObservedTitle: request.ObservedTitle,
SelectedSKU: request.SelectedSKU,
Quantity: request.Quantity,
UnitPriceCents: request.UnitPriceCents,
TotalPriceCents: request.TotalPriceCents,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
writeOrderSubmission(ctx, result)
}
func (handler *deviceHandlers) reconcileOrderSubmission(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"`
CommandID string `json:"command_id"`
CommandSHA256 string `json:"command_sha256"`
PlatformOrderNo string `json:"platform_order_no"`
PlatformOrderedAt string `json:"platform_ordered_at"`
PlatformOrderStatus string `json:"platform_order_status"`
ObservedTitle string `json:"observed_title"`
SelectedSKU string `json:"selected_sku"`
Quantity int `json:"quantity"`
TotalPriceCents int64 `json:"total_price_cents"`
EvidenceAssetID string `json:"evidence_asset_id"`
EvidenceSHA256 string `json:"evidence_sha256"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Submissions.Reconcile(
ctx.Request.Context(),
usecase.ReconcileOrderSubmissionCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
AuthorizationID: request.CommandID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
CommandSHA256: request.CommandSHA256,
SubmissionID: ctx.Param("submission_id"),
PlatformOrderNo: request.PlatformOrderNo,
PlatformOrderedAt: request.PlatformOrderedAt,
PlatformOrderStatus: request.PlatformOrderStatus,
ObservedTitle: request.ObservedTitle,
SelectedSKU: request.SelectedSKU,
Quantity: request.Quantity,
TotalPriceCents: request.TotalPriceCents,
EvidenceAssetID: request.EvidenceAssetID,
EvidenceSHA256: request.EvidenceSHA256,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
writeOrderSubmission(ctx, result)
}
func (handler *deviceHandlers) manualReviewOrderSubmission(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"`
CommandID string `json:"command_id"`
CommandSHA256 string `json:"command_sha256"`
ReasonCode string `json:"reason_code"`
EvidenceAssetID string `json:"evidence_asset_id"`
EvidenceSHA256 string `json:"evidence_sha256"`
}
if !decodeDeviceJSON(ctx, &request) ||
!deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) {
return
}
result, err := handler.services.Submissions.ManualReview(
ctx.Request.Context(),
usecase.ManualReviewOrderSubmissionCommand{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: request.ExecutionID,
AuthorizationID: request.CommandID,
ClaimGeneration: request.ClaimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
CommandSHA256: request.CommandSHA256,
SubmissionID: ctx.Param("submission_id"),
ReasonCode: request.ReasonCode,
EvidenceAssetID: request.EvidenceAssetID,
EvidenceSHA256: request.EvidenceSHA256,
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
writeOrderSubmission(ctx, result)
}
func writeOrderSubmission(
ctx *gin.Context,
result usecase.OrderSubmissionResult,
) {
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"submission": orderSubmissionResponse(result.Submission),
"replayed": result.Replayed,
})
}
func orderSubmissionResponse(submission domain.OrderSubmission) gin.H {
return gin.H{
"id": submission.ID,
"command_id": submission.AuthorizationID,
"dry_run_id": submission.DryRunID,
"task_id": submission.TaskID,
"execution_id": submission.ExecutionID,
"command_sha256": submission.CommandSHA256,
"dry_run_evidence_sha256": submission.DryRunEvidenceSHA256,
"status": submission.Status,
"expected_title": submission.ExpectedTitle,
"expected_sku": submission.ExpectedSKU,
"expected_quantity": submission.ExpectedQuantity,
"expected_unit_price_cents": submission.ExpectedUnitPriceCents,
"expected_total_price_cents": submission.ExpectedTotalPriceCents,
"platform_order_no": submission.PlatformOrderNo,
"platform_ordered_at": formatOptionalTime(
submission.PlatformOrderedAt,
),
"platform_order_status": submission.PlatformOrderStatus,
"reconciliation_evidence_asset_id": submission.ReconciliationEvidenceAssetID,
"reconciliation_evidence_sha256": submission.ReconciliationEvidenceSHA256,
"manual_reason_code": submission.ManualReasonCode,
"fenced_at": formatTime(submission.FencedAt),
"reconciled_at": formatOptionalTime(submission.ReconciledAt),
"manual_review_at": formatOptionalTime(submission.ManualReviewAt),
}
}
func (handler *deviceHandlers) startOrderDryRun(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
@@ -713,6 +713,9 @@ 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("order submission migration down: %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("order dry-run migration down: %v", err)
}
@@ -724,8 +727,8 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("restore device command migration: %v", err)
} else if applied != 2 {
t.Fatalf("restored migrations = %d, want 2", applied)
} else if applied != 3 {
t.Fatalf("restored migrations = %d, want 3", applied)
}
completePayload := fmt.Sprintf(
@@ -1125,6 +1128,12 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if !strings.Contains(readyDryRun.Body.String(), `"status":"READY"`) {
t.Fatalf("dry-run ready = %s", readyDryRun.Body.String())
}
var readyDryRunResponse struct {
DryRun struct {
ID string `json:"id"`
} `json:"dry_run"`
}
decodeResponse(t, readyDryRun, &readyDryRunResponse)
readyDryRunReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID +
@@ -1139,12 +1148,182 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
if !strings.Contains(readyDryRunReplay.Body.String(), `"replayed":true`) {
t.Fatalf("dry-run ready replay = %s", readyDryRunReplay.Body.String())
}
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents, dryRunReadyEvents int
startSubmissionPayload := fmt.Sprintf(
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"dry_run_id":%q,"dry_run_evidence_sha256":%q,"observed_title":%q,"selected_sku":%q,"quantity":2,"unit_price_cents":2150,"total_price_cents":4300}`,
deviceTestDeviceID,
started.Execution.ID,
started.Task.ClaimGeneration,
command.ID,
command.CommandSHA256,
readyDryRunResponse.DryRun.ID,
dryRunEvidenceSHA,
command.Candidate.Title,
command.OriginalSKU,
)
startSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
contentType: "application/json",
body: strings.NewReader(startSubmissionPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-start",
})
requireDeviceStatus(t, startSubmission, http.StatusOK)
var submissionResponse struct {
Submission struct {
ID string `json:"id"`
} `json:"submission"`
}
decodeResponse(t, startSubmission, &submissionResponse)
if submissionResponse.Submission.ID == "" ||
!strings.Contains(startSubmission.Body.String(), `"status":"FENCED"`) {
t.Fatalf("submission start = %s", startSubmission.Body.String())
}
startSubmissionReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
contentType: "application/json",
body: strings.NewReader(startSubmissionPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-start",
})
requireDeviceStatus(t, startSubmissionReplay, http.StatusOK)
if !strings.Contains(startSubmissionReplay.Body.String(), `"replayed":true`) {
t.Fatalf("submission start replay = %s", startSubmissionReplay.Body.String())
}
secondSubmission := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/start",
contentType: "application/json",
body: strings.NewReader(startSubmissionPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-start-second",
})
requireDeviceStatus(t, secondSubmission, http.StatusConflict)
manualReviewPayload := fmt.Sprintf(
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"reason_code":"ORDER_PAGE_UNKNOWN"}`,
deviceTestDeviceID,
started.Execution.ID,
started.Task.ClaimGeneration,
command.ID,
command.CommandSHA256,
)
manualReview := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
submissionResponse.Submission.ID + "/manual-review",
contentType: "application/json",
body: strings.NewReader(manualReviewPayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-manual",
})
requireDeviceStatus(t, manualReview, http.StatusOK)
if !strings.Contains(manualReview.Body.String(), `"status":"MANUAL_REVIEW"`) {
t.Fatalf("submission manual review = %s", manualReview.Body.String())
}
const reconciliationEvidenceID = "00000000-0000-4000-8000-000000000078"
reconciliationEvidenceSHA := strings.Repeat("d", 64)
if _, err := fixture.db.Exec(
`INSERT INTO execution_evidence_assets (
id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
) VALUES (?, ?, ?, 'image/jpeg', 10, ?, ?, ?, 0)`,
reconciliationEvidenceID,
taskID,
started.Execution.ID,
reconciliationEvidenceSHA,
"orders/reconciliation.jpg",
time.Now().UTC().Format(time.RFC3339Nano),
); err != nil {
t.Fatalf("seed reconciliation evidence: %v", err)
}
reconcilePayload := fmt.Sprintf(
`{"device_id":%q,"execution_id":%q,"claim_generation":%d,"command_id":%q,"command_sha256":%q,"platform_order_no":"12345678901234567890","platform_ordered_at":%q,"platform_order_status":"PENDING_PAYMENT","observed_title":%q,"selected_sku":%q,"quantity":2,"total_price_cents":4300,"evidence_asset_id":%q,"evidence_sha256":%q}`,
deviceTestDeviceID,
started.Execution.ID,
started.Task.ClaimGeneration,
command.ID,
command.CommandSHA256,
time.Now().UTC().Format(time.RFC3339),
command.Candidate.Title,
command.OriginalSKU,
reconciliationEvidenceID,
reconciliationEvidenceSHA,
)
reconciled := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
submissionResponse.Submission.ID + "/reconcile",
contentType: "application/json",
body: strings.NewReader(reconcilePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-reconcile",
})
requireDeviceStatus(t, reconciled, http.StatusOK)
if !strings.Contains(reconciled.Body.String(), `"status":"RECONCILED"`) ||
!strings.Contains(reconciled.Body.String(), `"platform_order_status":"PENDING_PAYMENT"`) {
t.Fatalf("submission reconciliation = %s", reconciled.Body.String())
}
reconciledReplay := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/order-submissions/" +
submissionResponse.Submission.ID + "/reconcile",
contentType: "application/json",
body: strings.NewReader(reconcilePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "order-submission-reconcile",
})
requireDeviceStatus(t, reconciledReplay, http.StatusOK)
if !strings.Contains(reconciledReplay.Body.String(), `"replayed":true`) {
t.Fatalf("submission reconcile replay = %s", reconciledReplay.Body.String())
}
var outcomeSubmitted bool
if err := fixture.db.QueryRow(
`SELECT order_submitted FROM execution_outcomes
WHERE execution_id = ?`,
started.Execution.ID,
).Scan(&outcomeSubmitted); err != nil {
t.Fatalf("query reconciled outcome: %v", err)
}
if !outcomeSubmitted {
t.Fatal("reconciled execution outcome did not record order_submitted")
}
var taskStatus, authorizationStatus string
if err := fixture.db.QueryRow(
`SELECT status FROM purchase_tasks WHERE id = ?`,
taskID,
).Scan(&taskStatus); err != nil {
t.Fatalf("query reconciled task: %v", err)
}
if err := fixture.db.QueryRow(
`SELECT status FROM order_authorizations WHERE id = ?`,
command.ID,
).Scan(&authorizationStatus); err != nil {
t.Fatalf("query consumed authorization: %v", err)
}
if taskStatus != "SUCCEEDED" || authorizationStatus != "CONSUMED" {
t.Fatalf(
"reconciled task/authorization = %s/%s",
taskStatus,
authorizationStatus,
)
}
var deliveredEvents, acknowledgedEvents, dryRunStartedEvents,
dryRunReadyEvents, fencedEvents, manualEvents, reconciledEvents int
for eventType, target := range map[string]*int{
"ORDER_AUTHORIZATION_DELIVERED": &deliveredEvents,
"ORDER_AUTHORIZATION_ACKNOWLEDGED": &acknowledgedEvents,
"ORDER_DRY_RUN_STARTED": &dryRunStartedEvents,
"ORDER_DRY_RUN_READY": &dryRunReadyEvents,
"ORDER_SUBMISSION_FENCED": &fencedEvents,
"ORDER_SUBMISSION_MANUAL_REVIEW": &manualEvents,
"ORDER_SUBMISSION_RECONCILED": &reconciledEvents,
} {
if err := fixture.db.QueryRow(
`SELECT COUNT(*) FROM task_events
@@ -1156,13 +1335,17 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
}
}
if deliveredEvents != 1 || acknowledgedEvents != 1 ||
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 {
dryRunStartedEvents != 1 || dryRunReadyEvents != 1 ||
fencedEvents != 1 || manualEvents != 1 || reconciledEvents != 1 {
t.Fatalf(
"delivery/ack/dry-run events = %d/%d/%d/%d",
"order workflow events = %d/%d/%d/%d/%d/%d/%d",
deliveredEvents,
acknowledgedEvents,
dryRunStartedEvents,
dryRunReadyEvents,
fencedEvents,
manualEvents,
reconciledEvents,
)
}
runner, err := migration.New(fixture.db)
@@ -1170,7 +1353,7 @@ func TestDeviceOrderCommandDeliveryAndAcknowledgementAreRecoverable(
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("order dry-run migration down succeeded with dry-run data")
t.Fatal("order submission migration down succeeded with retained data")
}
}
@@ -1503,13 +1686,18 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil {
t.Fatalf("usecase.NewOrderDryRunService() error = %v", err)
}
submissions, err := usecase.NewOrderSubmissionService(store, clock, ids)
if err != nil {
t.Fatalf("usecase.NewOrderSubmissionService() error = %v", err)
}
deviceRoutes, err := NewDeviceRouteRegistrar(
DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
Commands: commands,
DryRuns: dryRuns,
Lifecycle: lifecycle,
Assets: assets,
Results: results,
Commands: commands,
DryRuns: dryRuns,
Submissions: submissions,
},
)
if err != nil {