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}$`,
)
+35 -17
View File
@@ -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,