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 } func (services DeviceServices) validate() error { if services.Lifecycle == nil || services.Assets == 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, ) return nil }, nil } 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), }) } 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 }