package httpapi import ( "errors" "net/http" "strconv" "strings" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) const claimTokenHeader = "X-Claim-Token" type DeviceServices struct { 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.Submissions == nil { return errors.New("device services are required") } return nil } type deviceHandlers struct { services DeviceServices } func NewDeviceRouteRegistrar( services DeviceServices, ) (RouteRegistrar, error) { if err := services.validate(); err != nil { return nil, err } handler := &deviceHandlers{services: services} return func(routes gin.IRoutes) error { routes.POST( "/api/v1/devices/heartbeat", handler.heartbeatDevice, ) routes.POST( "/api/v1/tasks/claim-next", handler.claimNext, ) routes.POST( "/api/v1/tasks/:id/start", handler.startTask, ) routes.POST( "/api/v1/tasks/:id/heartbeat", handler.heartbeatTask, ) routes.GET( "/api/v1/tasks/:id/reference-image", handler.referenceImage, ) routes.POST( "/api/v1/tasks/:id/release", handler.releaseTask, ) routes.POST( "/api/v1/tasks/:id/cancel-ack", handler.acknowledgeCancellation, ) routes.POST("/api/v1/tasks/:id/events", handler.appendEvents) 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/order-dry-runs/start", handler.startOrderDryRun, ) routes.POST( "/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 { 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"` } if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.DryRuns.Start( ctx.Request.Context(), usecase.StartOrderDryRunCommand{ 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, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "dry_run": orderDryRunResponse(result.DryRun), "replayed": result.Replayed, }) } func (handler *deviceHandlers) readyOrderDryRun(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"` CardSignature string `json:"card_signature"` DetailSignature string `json:"detail_signature"` 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"` 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.DryRuns.Ready( ctx.Request.Context(), usecase.ReadyOrderDryRunCommand{ 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, CardSignature: request.CardSignature, DetailSignature: request.DetailSignature, ObservedTitle: request.ObservedTitle, SelectedSKU: request.SelectedSKU, Quantity: request.Quantity, UnitPriceCents: request.UnitPriceCents, TotalPriceCents: request.TotalPriceCents, EvidenceAssetID: request.EvidenceAssetID, EvidenceSHA256: request.EvidenceSHA256, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "dry_run": orderDryRunResponse(result.DryRun), "replayed": result.Replayed, }) } func orderDryRunResponse(dryRun domain.OrderDryRun) gin.H { return gin.H{ "id": dryRun.ID, "command_id": dryRun.AuthorizationID, "task_id": dryRun.TaskID, "execution_id": dryRun.ExecutionID, "command_sha256": dryRun.CommandSHA256, "status": dryRun.Status, "card_signature": dryRun.CardSignature, "detail_signature": dryRun.DetailSignature, "observed_title": dryRun.ObservedTitle, "selected_sku": dryRun.SelectedSKU, "quantity": dryRun.Quantity, "unit_price_cents": dryRun.UnitPriceCents, "total_price_cents": dryRun.TotalPriceCents, "evidence_asset_id": dryRun.EvidenceAssetID, "evidence_sha256": dryRun.EvidenceSHA256, "started_at": formatTime(dryRun.StartedAt), "ready_at": formatOptionalTime(dryRun.ReadyAt), } } 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 { return } generation, err := strconv.ParseInt( strings.TrimSpace(ctx.Query("claim_generation")), 10, 64, ) if err != nil || generation < 1 { writePublicError( ctx, http.StatusUnprocessableEntity, "TASK_REFERENCE_IMAGE_INVALID", "task reference image request is invalid", false, fieldDetails("claim_generation", "must be a positive integer"), ) return } task, err := handler.services.Lifecycle.AuthorizeReferenceImage( ctx.Request.Context(), usecase.ReferenceImageCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ClaimGeneration: generation, ClaimToken: ctx.GetHeader(claimTokenHeader), }, ) if err != nil { writeUsecaseError(ctx, err) return } result, err := handler.services.Assets.OpenTaskReference( ctx.Request.Context(), localAdminSubject, task.ImageAssetID, ) if err != nil { writeUsecaseError(ctx, err) return } defer result.Content.Close() writeAssetContent(ctx, result) } func (handler *deviceHandlers) heartbeatDevice(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { DeviceID string `json:"device_id"` AppVersion string `json:"app_version"` AndroidVersion string `json:"android_version"` PDDVersion string `json:"pdd_version"` Readiness struct { AccessibilityEnabled bool `json:"accessibility_enabled"` PDDInstalled bool `json:"pdd_installed"` ActiveTaskID *string `json:"active_task_id"` } `json:"readiness"` } if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.Lifecycle.HeartbeatDevice( ctx.Request.Context(), usecase.DeviceHeartbeatCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, AppVersion: request.AppVersion, AndroidVersion: request.AndroidVersion, PDDVersion: request.PDDVersion, AccessibilityEnabled: request.Readiness.AccessibilityEnabled, PDDInstalled: request.Readiness.PDDInstalled, ClientActiveTaskID: request.Readiness.ActiveTaskID, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "device_id": result.Device.ID, "readiness": gin.H{ "reported_at": formatOptionalTime(result.Device.ReadinessAt), "accessibility_enabled": result.Device.AccessibilityEnabled, "pdd_installed": result.Device.PDDInstalled, }, "active_task_id": result.ActiveTaskID, "client_state_matches": result.ClientStateMatches, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) claimNext(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { DeviceID string `json:"device_id"` } if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.Lifecycle.ClaimNext( ctx.Request.Context(), usecase.ClaimNextCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), ClaimToken: ctx.GetHeader(claimTokenHeader), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") if result.Task == nil { ctx.Status(http.StatusNoContent) return } ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(*result.Task), "replayed": result.Replayed, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) startTask(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request lifecycleTransitionRequest if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.Lifecycle.StartTask( ctx.Request.Context(), usecase.StartTaskCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ClaimGeneration: request.ClaimGeneration, ClaimToken: ctx.GetHeader(claimTokenHeader), ExpectedVersion: request.ExpectedVersion, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result.Task), "execution": deviceExecutionResponse( result.Execution, result.Task.ClaimExpiresAt, ), "replayed": result.Replayed, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) heartbeatTask(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"` Step string `json:"step"` } if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.Lifecycle.HeartbeatTask( ctx.Request.Context(), usecase.TaskHeartbeatCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ExecutionID: request.ExecutionID, ClaimGeneration: request.ClaimGeneration, ClaimToken: ctx.GetHeader(claimTokenHeader), Step: request.Step, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result.Task), "execution": deviceExecutionResponse( result.Execution, result.Task.ClaimExpiresAt, ), "cancel_requested": result.CancelRequested, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) releaseTask(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request lifecycleTransitionRequest if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches(ctx, request.DeviceID, principal.DeviceID) { return } result, err := handler.services.Lifecycle.ReleaseTask( ctx.Request.Context(), usecase.ReleaseTaskCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ClaimGeneration: request.ClaimGeneration, ClaimToken: ctx.GetHeader(claimTokenHeader), ExpectedVersion: request.ExpectedVersion, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result.Task), "replayed": result.Replayed, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) acknowledgeCancellation( ctx *gin.Context, ) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { lifecycleTransitionRequest ExecutionID string `json:"execution_id"` } if !decodeDeviceJSON(ctx, &request) || !deviceIDMatches( ctx, request.DeviceID, principal.DeviceID, ) { return } result, err := handler.services.Lifecycle.AcknowledgeCancellation( ctx.Request.Context(), usecase.AcknowledgeCancellationCommand{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ExecutionID: request.ExecutionID, ClaimGeneration: request.ClaimGeneration, ClaimToken: ctx.GetHeader(claimTokenHeader), ExpectedVersion: request.ExpectedVersion, IdempotencyKey: ctx.GetHeader("Idempotency-Key"), }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result.Task), "replayed": result.Replayed, "server_time": formatTime(result.ServerTime), }) } func (handler *deviceHandlers) appendEvents(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { ExecutionID string `json:"execution_id"` ClaimGeneration int64 `json:"claim_generation"` Events []usecase.ClientExecutionEvent `json:"events"` } if !decodeDeviceJSON(ctx, &request) { return } replayed, err := handler.services.Results.AppendEvents( ctx.Request.Context(), usecase.AppendExecutionEventsCommand{ Identity: handler.executionResultIdentity( ctx, principal, request.ExecutionID, request.ClaimGeneration, ), Events: request.Events, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{"replayed": replayed}) } func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) { writePublicError( ctx, http.StatusUnsupportedMediaType, "ASSET_MEDIA_TYPE_UNSUPPORTED", "JPEG, PNG, or WebP evidence is required", false, gin.H{}, ) return } generation, err := strconv.ParseInt( strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")), 10, 64, ) if err != nil { writePublicError( ctx, http.StatusUnprocessableEntity, "EXECUTION_RESULT_INVALID", "execution result request is invalid", false, fieldDetails("claim_generation", "must be a positive integer"), ) return } ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes) result, err := handler.services.Results.UploadEvidence( ctx.Request.Context(), usecase.UploadExecutionEvidenceCommand{ Identity: handler.executionResultIdentity( ctx, principal, ctx.GetHeader("X-Execution-ID"), generation, ), DeclaredMediaType: ctx.GetHeader("Content-Type"), Content: ctx.Request.Body, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusCreated, gin.H{ "evidence": deviceEvidenceResponse(result.Evidence), "replayed": result.Replayed, }) } func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { ExecutionID string `json:"execution_id"` ClaimGeneration int64 `json:"claim_generation"` TaskContentSHA256 string `json:"task_content_sha256"` ExecutionMode string `json:"execution_mode"` SearchQuery string `json:"search_query"` Provenance *usecase.ExecutionProvenance `json:"provenance"` Candidates []usecase.ExecutionCandidate `json:"candidates"` Recommendation *usecase.CandidateRecommendation `json:"recommendation"` } if !decodeDeviceJSON(ctx, &request) { return } replayed, err := handler.services.Results.StoreCandidates( ctx.Request.Context(), usecase.StoreExecutionCandidatesCommand{ Identity: handler.executionResultIdentity( ctx, principal, request.ExecutionID, request.ClaimGeneration, ), TaskContentSHA256: request.TaskContentSHA256, ExecutionMode: request.ExecutionMode, SearchQuery: request.SearchQuery, Provenance: request.Provenance, Candidates: request.Candidates, Recommendation: request.Recommendation, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{"replayed": replayed}) } func (handler *deviceHandlers) storeHumanReview(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { ExecutionID string `json:"execution_id"` ClaimGeneration int64 `json:"claim_generation"` TaskContentSHA256 string `json:"task_content_sha256"` ReasonSchemaVersion int `json:"reason_schema_version"` Outcome string `json:"outcome"` SelectedCandidateOrdinal *int `json:"selected_candidate_ordinal"` PrimaryReasonCode string `json:"primary_reason_code"` Note string `json:"note"` SupersedesReviewID *string `json:"supersedes_review_id"` Items []usecase.CandidateHumanReviewItemInput `json:"items"` } if !decodeDeviceJSON(ctx, &request) { return } result, err := handler.services.Results.StoreHumanReview( ctx.Request.Context(), usecase.StoreCandidateHumanReviewCommand{ Identity: handler.executionResultIdentity( ctx, principal, request.ExecutionID, request.ClaimGeneration, ), TaskContentSHA256: request.TaskContentSHA256, ReasonSchemaVersion: request.ReasonSchemaVersion, Outcome: request.Outcome, SelectedCandidateOrdinal: request.SelectedCandidateOrdinal, PrimaryReasonCode: request.PrimaryReasonCode, Note: request.Note, SupersedesReviewID: request.SupersedesReviewID, Items: request.Items, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "review": candidateHumanReviewResponse(result.Review), "replayed": result.Replayed, }) } func (handler *deviceHandlers) completeTask(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { ExecutionID string `json:"execution_id"` ClaimGeneration int64 `json:"claim_generation"` TaskContentSHA256 string `json:"task_content_sha256"` ExecutionMode string `json:"execution_mode"` Outcome string `json:"outcome"` OperatorReason string `json:"operator_reason"` Candidate *usecase.ExecutionCandidate `json:"candidate"` OrderSubmitted bool `json:"order_submitted"` } if !decodeDeviceJSON(ctx, &request) { return } result, replayed, err := handler.services.Results.Complete( ctx.Request.Context(), usecase.CompleteExecutionCommand{ Identity: handler.executionResultIdentity( ctx, principal, request.ExecutionID, request.ClaimGeneration, ), TaskContentSHA256: request.TaskContentSHA256, ExecutionMode: request.ExecutionMode, Outcome: request.Outcome, OperatorReason: request.OperatorReason, Candidate: request.Candidate, OrderSubmitted: request.OrderSubmitted, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result), "replayed": replayed, }) } func (handler *deviceHandlers) failTask(ctx *gin.Context) { principal, ok := devicePrincipal(ctx) if !ok { return } var request struct { ExecutionID string `json:"execution_id"` ClaimGeneration int64 `json:"claim_generation"` Error struct { Code string `json:"code"` Message string `json:"message"` Step string `json:"step"` Retryable bool `json:"retryable"` } `json:"error"` EvidenceAssetIDs []string `json:"evidence_asset_ids"` } if !decodeDeviceJSON(ctx, &request) { return } result, replayed, err := handler.services.Results.Fail( ctx.Request.Context(), usecase.FailExecutionCommand{ Identity: handler.executionResultIdentity( ctx, principal, request.ExecutionID, request.ClaimGeneration, ), ErrorCode: request.Error.Code, ErrorMessage: request.Error.Message, ErrorStep: request.Error.Step, Retryable: request.Error.Retryable, EvidenceAssetIDs: request.EvidenceAssetIDs, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusOK, gin.H{ "task": deviceTaskResponse(result), "replayed": replayed, }) } func (handler *deviceHandlers) executionResultIdentity( ctx *gin.Context, principal domain.AuthPrincipal, executionID string, claimGeneration int64, ) usecase.ExecutionResultIdentity { return usecase.ExecutionResultIdentity{ UserID: principal.UserID, DeviceID: principal.DeviceID, TaskID: ctx.Param("id"), ExecutionID: executionID, ClaimGeneration: claimGeneration, ClaimToken: ctx.GetHeader(claimTokenHeader), IdempotencyKey: ctx.GetHeader("Idempotency-Key"), } } func isEvidenceMediaType(value string) bool { return hasEvidenceMediaType(value, "image/jpeg") || hasEvidenceMediaType(value, "image/png") || hasEvidenceMediaType(value, "image/webp") } func hasEvidenceMediaType(value string, expected string) bool { return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected) } func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H { return gin.H{ "id": evidence.ID, "media_type": evidence.MediaType, "size_bytes": evidence.SizeBytes, "sha256": evidence.SHA256, "created_at": formatTime(evidence.CreatedAt), "received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry, } } func candidateHumanReviewResponse(review domain.CandidateHumanReview) gin.H { items := make([]gin.H, 0, len(review.Items)) for _, item := range review.Items { items = append(items, gin.H{ "candidate_ordinal": item.CandidateOrdinal, "label": item.Label, "primary_reason_code": item.PrimaryReasonCode, "reason_codes": item.ReasonCodes, "note": item.Note, }) } return gin.H{ "id": review.ID, "task_id": review.TaskID, "execution_id": review.ExecutionID, "task_content_sha256": review.TaskContentSHA256, "version": review.Version, "reason_schema_version": review.ReasonSchemaVersion, "outcome": review.Outcome, "selected_candidate_ordinal": review.SelectedCandidateOrdinal, "primary_reason_code": review.PrimaryReasonCode, "note": review.Note, "supersedes_review_id": review.SupersedesReviewID, "actor_user_id": review.ActorUserID, "actor_device_id": review.ActorDeviceID, "created_at": formatTime(review.CreatedAt), "received_after_execution_expiry": review.ReceivedAfterExecutionExpiry, "items": items, } } type lifecycleTransitionRequest struct { DeviceID string `json:"device_id"` ClaimGeneration int64 `json:"claim_generation"` ExpectedVersion int64 `json:"expected_version"` } func devicePrincipal( ctx *gin.Context, ) (domain.AuthPrincipal, bool) { principal, ok := authcommon.Principal(ctx.Request.Context()) if ok && principal.Role == domain.UserRoleBuyer && principal.UserID != "" && principal.DeviceID != "" { return principal, true } writePublicError( ctx, http.StatusUnauthorized, "DEVICE_ACCESS_REQUIRED", "device access token required", false, gin.H{}, ) return domain.AuthPrincipal{}, false } func decodeDeviceJSON(ctx *gin.Context, target any) bool { if !hasMediaType(ctx, "application/json") { writePublicError( ctx, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", "application/json is required", false, gin.H{}, ) return false } if err := decodeJSON(ctx, target); err != nil { writePublicError( ctx, http.StatusBadRequest, "INVALID_JSON", "request body must be valid JSON", false, gin.H{}, ) return false } return true } func deviceIDMatches( ctx *gin.Context, presented string, authoritative string, ) bool { if presented == "" || presented == authoritative { return true } writePublicError( ctx, http.StatusForbidden, "DEVICE_ID_MISMATCH", "request device does not match access token", false, gin.H{}, ) return false } func deviceTaskResponse(task domain.PurchaseTask) gin.H { referenceImageURL := "/api/v1/tasks/" + task.ID + "/reference-image?claim_generation=" + strconv.FormatInt(task.ClaimGeneration, 10) return gin.H{ "id": task.ID, "status": task.Status, "version": task.Version, "claim_generation": task.ClaimGeneration, "claim_issued_at": formatOptionalTime(task.ClaimIssuedAt), "claim_expires_at": formatOptionalTime(task.ClaimExpiresAt), "title": task.Title, "description": task.Description, "sku": task.SKU, "image_asset_id": task.ImageAssetID, "reference_image_url": referenceImageURL, "quantity": task.Quantity, "max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents), "currency": task.Currency, } } func deviceExecutionResponse( execution domain.TaskExecution, expiresAt *time.Time, ) gin.H { response := executionResponse(execution) response["execution_expires_at"] = formatOptionalTime(expiresAt) return response }