feat(tasks): implement atomic claims and leases
This commit is contained in:
@@ -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