Files
cmroubao/backend-api/internal/transport/httpapi/device_handlers.go
T

808 lines
23 KiB
Go

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
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil || services.Results == 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/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
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),
})
}
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
}