1054 lines
31 KiB
Go
1054 lines
31 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
|
|
Commands *usecase.DeviceOrderCommandService
|
|
DryRuns *usecase.OrderDryRunService
|
|
}
|
|
|
|
func (services DeviceServices) validate() error {
|
|
if services.Lifecycle == nil || services.Assets == nil ||
|
|
services.Results == nil || services.Commands == nil ||
|
|
services.DryRuns == 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/complete", handler.completeTask)
|
|
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
|
|
return nil
|
|
}, nil
|
|
}
|
|
|
|
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
|
|
}
|