feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -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}$`,
|
||||
)
|
||||
|
||||
@@ -299,9 +299,9 @@ func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||
)
|
||||
return
|
||||
}
|
||||
_, err := h.service.CancelPending(
|
||||
task, err := h.service.CancelTask(
|
||||
ctx.Request.Context(),
|
||||
CancelPendingInput{
|
||||
CancelTaskInput{
|
||||
TaskID: taskID,
|
||||
IdempotencyKey: cancelKey,
|
||||
},
|
||||
@@ -317,9 +317,13 @@ func (h *Handler) CancelTask(ctx *gin.Context) {
|
||||
h.renderServiceError(ctx, err, "取消失败,请稍后重试。")
|
||||
return
|
||||
}
|
||||
notice := "cancel-requested"
|
||||
if task.Status == "CANCELED" {
|
||||
notice = "canceled"
|
||||
}
|
||||
ctx.Redirect(
|
||||
http.StatusSeeOther,
|
||||
"/tasks/"+pathEscape(taskID)+"?notice=canceled",
|
||||
"/tasks/"+pathEscape(taskID)+"?notice="+notice,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -644,6 +648,8 @@ func detailNotice(value string) string {
|
||||
switch value {
|
||||
case "canceled":
|
||||
return "任务已取消,不会自动恢复。"
|
||||
case "cancel-requested":
|
||||
return "已请求设备安全停止;设备确认前任务仍保持当前执行状态。"
|
||||
case "cancel-conflict":
|
||||
return "任务状态已变化,当前不能取消。"
|
||||
default:
|
||||
@@ -736,19 +742,20 @@ type newTaskPageView struct {
|
||||
}
|
||||
|
||||
type taskDetailView struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CanCancel bool
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
CanCancel bool
|
||||
CancelRequiresAck bool
|
||||
}
|
||||
|
||||
type taskDetailPage struct {
|
||||
@@ -779,7 +786,18 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
||||
ReferenceAssetID: task.ReferenceAssetID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
CanCancel: task.Status == "PENDING",
|
||||
CanCancel: canCancelTaskStatus(task.Status),
|
||||
CancelRequiresAck: task.Status == "RUNNING" ||
|
||||
task.Status == "WAITING_CONFIRMATION",
|
||||
}
|
||||
}
|
||||
|
||||
func canCancelTaskStatus(status string) bool {
|
||||
switch status {
|
||||
case "PENDING", "CLAIMED", "RUNNING", "WAITING_CONFIRMATION":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,7 @@ func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) {
|
||||
CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC),
|
||||
},
|
||||
cancelResult: Task{Status: "CANCELED"},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
detail := performRequest(
|
||||
@@ -508,6 +509,81 @@ func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
status string
|
||||
canCancel bool
|
||||
requiresAck bool
|
||||
}{
|
||||
{status: "PENDING", canCancel: true},
|
||||
{status: "CLAIMED", canCancel: true},
|
||||
{status: "RUNNING", canCancel: true, requiresAck: true},
|
||||
{
|
||||
status: "WAITING_CONFIRMATION",
|
||||
canCancel: true,
|
||||
requiresAck: true,
|
||||
},
|
||||
{status: "SUCCEEDED"},
|
||||
{status: "FAILED"},
|
||||
{status: "CANCELED"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.status, func(t *testing.T) {
|
||||
view := taskDetailViewFrom(Task{Status: test.status})
|
||||
if view.CanCancel != test.canCancel ||
|
||||
view.CancelRequiresAck != test.requiresAck {
|
||||
t.Fatalf("task detail view = %+v", view)
|
||||
}
|
||||
})
|
||||
}
|
||||
if notice := detailNotice("cancel-requested"); !strings.Contains(
|
||||
notice,
|
||||
"安全停止",
|
||||
) {
|
||||
t.Fatalf("cancel requested notice = %q", notice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningTaskDetailExplainsCancelAcknowledgement(t *testing.T) {
|
||||
service := &fakeService{
|
||||
getResult: Task{
|
||||
ID: testTaskID,
|
||||
Title: "运行中任务",
|
||||
SKU: "RUNNING-SKU",
|
||||
Quantity: 1,
|
||||
Status: "RUNNING",
|
||||
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
|
||||
CreatedAt: time.Now().UTC(),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("running task detail status = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{
|
||||
"请求安全停止任务?",
|
||||
"设备确认前任务仍保持当前执行状态",
|
||||
"确认请求停止",
|
||||
} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("running detail missing %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "采购执行员将不能再领取") {
|
||||
t.Fatal("running detail uses immediate cancellation copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
for _, route := range []string{"/static/admin.css", "/static/admin.js"} {
|
||||
@@ -554,7 +630,7 @@ type fakeService struct {
|
||||
createCalls int
|
||||
cancelResult Task
|
||||
cancelErr error
|
||||
cancelInput CancelPendingInput
|
||||
cancelInput CancelTaskInput
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
@@ -595,9 +671,9 @@ func (service *fakeService) CreateTask(
|
||||
return service.createResult, service.createErr
|
||||
}
|
||||
|
||||
func (service *fakeService) CancelPending(
|
||||
func (service *fakeService) CancelTask(
|
||||
_ context.Context,
|
||||
input CancelPendingInput,
|
||||
input CancelTaskInput,
|
||||
) (Task, error) {
|
||||
service.cancelInput = input
|
||||
return service.cancelResult, service.cancelErr
|
||||
|
||||
@@ -76,26 +76,40 @@
|
||||
{{if .Task.CanCancel}}
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
|
||||
{{if .Task.CancelRequiresAck}}
|
||||
<h2 id="noscript-cancel-heading">请求安全停止任务</h2>
|
||||
<p>设备确认安全停止前,任务仍保持当前执行状态。</p>
|
||||
{{else}}
|
||||
<h2 id="noscript-cancel-heading">确认取消任务</h2>
|
||||
<p>取消后任务不会自动恢复。</p>
|
||||
{{end}}
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit">确认取消</button>
|
||||
<button class="button danger" type="submit">
|
||||
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</noscript>
|
||||
|
||||
<dialog class="confirm-dialog" data-cancel-dialog aria-labelledby="cancel-title">
|
||||
{{if .Task.CancelRequiresAck}}
|
||||
<h2 id="cancel-title">请求安全停止任务?</h2>
|
||||
<p>系统将通知采购 App 在安全检查点停止;设备确认前任务仍保持当前执行状态。</p>
|
||||
{{else}}
|
||||
<h2 id="cancel-title">确认取消任务?</h2>
|
||||
<p>任务取消后不会自动恢复,采购执行员将不能再领取。</p>
|
||||
{{end}}
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button" data-keep-task>保留任务</button>
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel" data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit" data-submit-button
|
||||
data-loading-label="正在取消…">确认取消</button>
|
||||
data-loading-label="{{if .Task.CancelRequiresAck}}正在请求停止…{{else}}正在取消…{{end}}">
|
||||
{{if .Task.CancelRequiresAck}}确认请求停止{{else}}确认取消{{end}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -23,7 +23,7 @@ type Service interface {
|
||||
GetTask(context.Context, string) (Task, error)
|
||||
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
||||
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
||||
CancelPending(context.Context, CancelPendingInput) (Task, error)
|
||||
CancelTask(context.Context, CancelTaskInput) (Task, error)
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
@@ -81,7 +81,7 @@ type CreateTaskInput struct {
|
||||
ImageAssetID string
|
||||
}
|
||||
|
||||
type CancelPendingInput struct {
|
||||
type CancelTaskInput struct {
|
||||
TaskID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
@@ -128,9 +128,9 @@ func actorUserID(ctx context.Context) string {
|
||||
return principal.UserID
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CancelPending(
|
||||
func (adapter *UsecaseAdapter) CancelTask(
|
||||
ctx context.Context,
|
||||
input CancelPendingInput,
|
||||
input CancelTaskInput,
|
||||
) (Task, error) {
|
||||
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
|
||||
Reference in New Issue
Block a user