feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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": executionResponse(result.Execution),
|
||||
"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": executionResponse(result.Execution),
|
||||
"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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user