feat(tasks): implement atomic claims and leases

This commit is contained in:
QiuSW
2026-07-26 16:16:36 +08:00
parent 49db5b8305
commit ce875af889
50 changed files with 7589 additions and 207 deletions
@@ -160,18 +160,7 @@ func (h *adminHandlers) assetContent(ctx *gin.Context) {
return
}
defer result.Content.Close()
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Asset.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Asset.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Asset.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
writeAssetContent(ctx, result)
}
func (h *adminHandlers) createTask(ctx *gin.Context) {
@@ -302,13 +291,32 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
events := make([]gin.H, 0, len(detail.Events))
for _, event := range detail.Events {
events = append(events, gin.H{
"id": event.ID,
"actor_user_id": event.ActorUserID,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
"id": event.ID,
"actor_user_id": event.ActorUserID,
"actor_device_id": event.ActorDeviceID,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
})
}
var claim any
if detail.Task.ClaimGeneration > 0 {
claim = gin.H{
"user_id": detail.Task.ClaimedByUserID,
"device_id": detail.Task.ClaimedByDeviceID,
"generation": detail.Task.ClaimGeneration,
"issued_at": formatOptionalTime(detail.Task.ClaimIssuedAt),
"expires_at": formatOptionalTime(detail.Task.ClaimExpiresAt),
"cancel_requested_at": formatOptionalTime(
detail.Task.CancelRequestedAt,
),
"cancel_requested_by_user_id": detail.Task.CancelRequestedByUserID,
}
}
var execution any
if detail.Execution != nil {
execution = executionResponse(*detail.Execution)
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"id": detail.Task.ID,
@@ -327,8 +335,8 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"currency": detail.Task.Currency,
},
"derived_requirement": nil,
"claim": nil,
"execution": nil,
"claim": claim,
"execution": execution,
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
@@ -441,6 +449,8 @@ func writeUsecaseError(ctx *gin.Context, err error) {
}
case usecase.ErrorKindNotFound:
status = http.StatusNotFound
case usecase.ErrorKindForbidden:
status = http.StatusForbidden
case usecase.ErrorKindConflict:
status = http.StatusConflict
case usecase.ErrorKindUnavailable:
@@ -501,15 +511,19 @@ func assetResponse(asset domain.Asset) gin.H {
func taskSummaryResponse(task domain.PurchaseTask) gin.H {
return gin.H{
"id": task.ID,
"status": task.Status,
"title": task.Title,
"sku": task.SKU,
"quantity": task.Quantity,
"max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents),
"created_at": formatTime(task.CreatedAt),
"updated_at": formatTime(task.UpdatedAt),
"version": task.Version,
"id": task.ID,
"status": task.Status,
"title": task.Title,
"sku": task.SKU,
"quantity": task.Quantity,
"max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents),
"created_at": formatTime(task.CreatedAt),
"updated_at": formatTime(task.UpdatedAt),
"version": task.Version,
"cancel_requested": task.CancelRequestedAt != nil,
"cancel_requested_at": formatOptionalTime(
task.CancelRequestedAt,
),
}
}
@@ -522,3 +536,41 @@ func taskListItemResponse(task domain.PurchaseTask) gin.H {
func formatTime(value time.Time) string {
return value.UTC().Format(time.RFC3339Nano)
}
func formatOptionalTime(value *time.Time) any {
if value == nil {
return nil
}
return formatTime(*value)
}
func executionResponse(execution domain.TaskExecution) gin.H {
return gin.H{
"id": execution.ID,
"attempt_no": execution.AttemptNo,
"claim_generation": execution.ClaimGeneration,
"user_id": execution.UserID,
"device_id": execution.DeviceID,
"current_step": execution.CurrentStep,
"order_submitted": execution.OrderSubmitted,
"started_at": formatTime(execution.StartedAt),
"last_heartbeat_at": formatOptionalTime(
execution.LastHeartbeatAt,
),
"finished_at": formatOptionalTime(execution.FinishedAt),
}
}
func writeAssetContent(ctx *gin.Context, result usecase.AssetContent) {
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Asset.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Asset.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Asset.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
}
@@ -360,7 +360,9 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
Database: db,
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: registrar,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: discardEvent,
})
if err != nil {
@@ -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,
}
}
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,9 @@ type RouterDependencies struct {
Database DatabasePinger
RegisterPublicRoutes RouteRegistrar
RegisterAdminRoutes RouteRegistrar
RegisterDeviceRoutes RouteRegistrar
AdminSessions AdminAuthenticator
DeviceAccess DeviceAuthenticator
LogEvent EventLogger
}
@@ -62,9 +64,15 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
if dependencies.RegisterPublicRoutes == nil {
return nil, errors.New("public route registrar is required")
}
if dependencies.RegisterDeviceRoutes == nil {
return nil, errors.New("device route registrar is required")
}
if dependencies.AdminSessions == nil {
return nil, errors.New("admin authenticator is required")
}
if dependencies.DeviceAccess == nil {
return nil, errors.New("device authenticator is required")
}
if dependencies.LogEvent == nil {
return nil, errors.New("event logger is required")
}
@@ -86,6 +94,11 @@ func NewRouter(dependencies RouterDependencies) (http.Handler, error) {
if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil {
return nil, err
}
deviceRoutes := router.Group("")
deviceRoutes.Use(RequireDeviceAccess(dependencies.DeviceAccess))
if err := dependencies.RegisterDeviceRoutes(deviceRoutes); err != nil {
return nil, err
}
router.NoRoute(func(ctx *gin.Context) {
ctx.JSON(http.StatusNotFound, errorResponse(
ctx,
@@ -120,7 +120,9 @@ func TestRouterRequiresDependencies(t *testing.T) {
Database: fakePinger{},
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: discardRoutes,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: discardEvent,
}
missingDatabase := valid
@@ -138,11 +140,21 @@ func TestRouterRequiresDependencies(t *testing.T) {
if _, err := NewRouter(missingPublicRoutes); err == nil {
t.Fatal("NewRouter(nil public routes) error = nil")
}
missingDeviceRoutes := valid
missingDeviceRoutes.RegisterDeviceRoutes = nil
if _, err := NewRouter(missingDeviceRoutes); err == nil {
t.Fatal("NewRouter(nil device routes) error = nil")
}
missingAuth := valid
missingAuth.AdminSessions = nil
if _, err := NewRouter(missingAuth); err == nil {
t.Fatal("NewRouter(nil admin auth) error = nil")
}
missingDeviceAuth := valid
missingDeviceAuth.DeviceAccess = nil
if _, err := NewRouter(missingDeviceAuth); err == nil {
t.Fatal("NewRouter(nil device auth) error = nil")
}
missingLogger := valid
missingLogger.LogEvent = nil
if _, err := NewRouter(missingLogger); err == nil {
@@ -158,7 +170,9 @@ func newTestRouter(
Database: database,
RegisterPublicRoutes: discardRoutes,
RegisterAdminRoutes: discardRoutes,
RegisterDeviceRoutes: discardRoutes,
AdminSessions: allowAdminAuthenticator{},
DeviceAccess: allowAdminAuthenticator{},
LogEvent: logEvent,
})
}
@@ -255,6 +269,19 @@ func (allowAdminAuthenticator) AuthenticateAdmin(
}, nil
}
func (allowAdminAuthenticator) AuthenticateAccessToken(
context.Context,
string,
) (domain.AuthPrincipal, error) {
return domain.AuthPrincipal{
UserID: "00000000-0000-4000-8000-000000000098",
Username: "buyer",
Role: domain.UserRoleBuyer,
DeviceID: "00000000-0000-4000-8000-000000000097",
ExpiresAt: time.Now().Add(time.Hour),
}, nil
}
var requestIDPattern = regexp.MustCompile(
`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`,
)