diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 17b9d4b..f8deeb9 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -83,6 +83,8 @@ type Server struct { resetStatePath string audit repository.AuditRepository tasks repository.TaskRepository + taskMu sync.Mutex + taskCancels map[string]context.CancelFunc resetMu sync.Mutex resetClosedHash string resetLimiter *resetRateLimiter @@ -120,6 +122,7 @@ func New(deps Dependencies) *Server { resetStatePath: deps.ResetStatePath, audit: deps.Audit, tasks: deps.Tasks, + taskCancels: make(map[string]context.CancelFunc), resetLimiter: newResetRateLimiter(), authSessions: make(map[string]authSession), loginAttempts: make(map[string]loginAttempt), @@ -155,6 +158,7 @@ func (s *Server) registerRoutes() { adminAPI.POST("/domains/:id/issue", s.issueDomain) adminAPI.GET("/tasks", s.listTasks) adminAPI.GET("/tasks/:id", s.getTask) + adminAPI.DELETE("/tasks/:id", s.cancelTask) adminAPI.GET("/tasks/:id/attempts", s.listTaskAttempts) adminAPI.GET("/audit-logs", s.listAuditLogs) adminAPI.GET("/settings", s.getSettings) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index 880c2fc..d75c451 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -29,6 +29,24 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "读取域名失败", err) return } + idempotencyKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) + if idempotencyKey != "" { + if !validTaskIdempotencyKey(idempotencyKey) { + s.writeError(c, http.StatusBadRequest, "幂等键格式无效", errors.New("幂等键长度或字符无效")) + return + } + if existing, err := s.tasks.FindTaskByIdempotencyKey(c.Request.Context(), domainID, idempotencyKey); err == nil { + status := http.StatusOK + if existing.Status == "queued" || existing.Status == "running" { + status = http.StatusConflict + } + c.JSON(status, gin.H{"task": existing, "replayed": true}) + return + } else if !errors.Is(err, sql.ErrNoRows) { + s.writeError(c, http.StatusInternalServerError, "检查幂等任务失败", err) + return + } + } if active, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), domainID, "certificate_issue"); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "该域名已有正在执行的证书任务", "task": active}) return @@ -38,28 +56,16 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { } now := time.Now().UTC() task := repository.Task{ - ID: domain.NewID(), - DomainID: domainID, - TaskType: "certificate_issue", - Status: "queued", - Stage: "queued", - Message: "等待执行证书申请", - CreatedAt: now, - UpdatedAt: now, + ID: domain.NewID(), DomainID: domainID, TaskType: "certificate_issue", Status: "queued", Stage: "queued", + Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now, } if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil { s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err) return } attempt := repository.TaskAttempt{ - ID: domain.NewID(), - TaskID: task.ID, - AttemptNumber: 1, - Stage: "certificate_issue", - Status: "running", - Message: "开始申请证书", - StartedAt: &now, - CreatedAt: now, + ID: domain.NewID(), TaskID: task.ID, AttemptNumber: 1, Stage: "certificate_issue", Status: "running", + Message: "开始申请证书", StartedAt: &now, CreatedAt: now, } if err := s.tasks.CreateAttempt(c.Request.Context(), attempt); err != nil { _ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "certificate_issue", "创建任务尝试失败", &now, &now) @@ -70,15 +76,39 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "更新任务状态失败", err) return } - certificateResult, deploymentReport, issueErr := s.issueCertificate(c.Request.Context(), domainID) + executionCtx, cancel := context.WithTimeout(c.Request.Context(), certificateTaskTimeout) + s.registerTaskCancel(task.ID, cancel) + defer func() { + cancel() + s.unregisterTaskCancel(task.ID) + }() + certificateResult, deploymentReport, issueErr := s.issueCertificate(executionCtx, domainID) finished := time.Now().UTC() status := "success" message := "证书申请和部署完成" + cancelled := false if issueErr != nil { status = "failed" message = issueErr.Error() + if errors.Is(issueErr, context.Canceled) { + status = "cancelled" + message = "证书任务已取消" + } + if errors.Is(issueErr, context.DeadlineExceeded) { + message = "证书任务执行超时" + } + } + if persisted, err := s.tasks.GetTask(context.Background(), task.ID); err == nil && persisted.Status == "cancelled" { + status = "cancelled" + message = persisted.Message + cancelled = true + if issueErr == nil { + issueErr = errors.New(message) + } + } + if !cancelled { + _ = s.tasks.UpdateTask(context.Background(), task.ID, status, "certificate_issue", message, &now, &finished) } - _ = s.tasks.UpdateTask(context.Background(), task.ID, status, "certificate_issue", message, &now, &finished) _ = s.tasks.FinishAttempt(context.Background(), attempt.ID, status, message, &finished) task.Status = status task.Stage = "certificate_issue" @@ -88,33 +118,101 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { task.UpdatedAt = finished if s.audit != nil { auditStatus := status - if issueErr != nil { + if issueErr != nil && status != "cancelled" { auditStatus = "error" } _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ - Action: "certificate_issue", - ResourceType: "domain", - ResourceID: domainID, - Status: auditStatus, - Message: message, + Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID, Status: auditStatus, Message: message, }) } response := gin.H{ - "task": task, - "certificate": certificateResult, - "deployment": deploymentReport, - "task_status": status, - "finished_at": finished, + "task": task, "certificate": certificateResult, "deployment": deploymentReport, + "task_status": status, "finished_at": finished, } if issueErr != nil { - response["error"] = issueErr.Error() - c.JSON(http.StatusBadGateway, response) + response["error"] = message + responseStatus := http.StatusBadGateway + if status == "cancelled" { + responseStatus = http.StatusConflict + } else if errors.Is(issueErr, context.DeadlineExceeded) { + responseStatus = http.StatusGatewayTimeout + } + c.JSON(responseStatus, response) return } - response["task"] = task c.JSON(http.StatusOK, response) } +const certificateTaskTimeout = 30 * time.Minute + +func (s *Server) registerTaskCancel(taskID string, cancel context.CancelFunc) { + s.taskMu.Lock() + defer s.taskMu.Unlock() + if s.taskCancels == nil { + s.taskCancels = make(map[string]context.CancelFunc) + } + s.taskCancels[taskID] = cancel +} + +func (s *Server) unregisterTaskCancel(taskID string) { + s.taskMu.Lock() + defer s.taskMu.Unlock() + delete(s.taskCancels, taskID) +} + +func (s *Server) cancelTask(c *gin.Context) { + if s.tasks == nil { + s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置")) + return + } + taskID := strings.TrimSpace(c.Param("id")) + item, err := s.tasks.GetTask(c.Request.Context(), taskID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + s.writeError(c, http.StatusNotFound, "任务不存在", err) + return + } + s.writeError(c, http.StatusInternalServerError, "读取任务失败", err) + return + } + if item.Status != "queued" && item.Status != "running" { + c.JSON(http.StatusConflict, gin.H{"error": "任务当前无法取消", "task": item}) + return + } + finished := time.Now().UTC() + if err := s.tasks.CancelTask(c.Request.Context(), taskID, "任务已由用户取消", &finished); err != nil { + s.writeError(c, http.StatusConflict, "取消任务失败", err) + return + } + s.taskMu.Lock() + cancel := s.taskCancels[taskID] + s.taskMu.Unlock() + if cancel != nil { + cancel() + } + item.Status = "cancelled" + item.Message = "任务已由用户取消" + item.FinishedAt = &finished + item.UpdatedAt = finished + if s.audit != nil { + _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ + Action: "task_cancel", ResourceType: "task", ResourceID: taskID, Status: "success", Message: item.Message, + }) + } + c.JSON(http.StatusOK, gin.H{"task": item}) +} + +func validTaskIdempotencyKey(value string) bool { + if len(value) < 8 || len(value) > 128 { + return false + } + for _, char := range value { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '-' && char != '_' && char != '.' { + return false + } + } + return true +} func (s *Server) listTasks(c *gin.Context) { if s.tasks == nil { s.writeError(c, http.StatusNotImplemented, "任务管理接口尚未配置", errors.New("任务仓储未配置")) diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index b3d7693..9a65038 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/database" @@ -149,3 +150,77 @@ func TestTaskAPIRejectsUnknownTask(t *testing.T) { t.Fatalf("未知任务应返回 404:%d %s", response.Code, response.Body.String()) } } + +func TestTaskAPICanCancelRunningIssue(t *testing.T) { + started := make(chan struct{}) + server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(ctx context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + close(started) + <-ctx.Done() + return certificate.IssueResult{DomainID: id}, deployment.Report{}, ctx.Err() + }) + defer closeDB() + + issueResponse := httptest.NewRecorder() + issueDone := make(chan struct{}) + go func() { + serveAdminHTTP(server, issueResponse, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + close(issueDone) + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("证书任务没有进入执行状态") + } + items, err := taskRepo.ListTasks(context.Background(), "", 10) + if err != nil || len(items) != 1 { + t.Fatalf("读取运行中任务失败:%+v %v", items, err) + } + cancelResponse := httptest.NewRecorder() + serveAdminHTTP(server, cancelResponse, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+items[0].ID, nil)) + if cancelResponse.Code != http.StatusOK || !strings.Contains(cancelResponse.Body.String(), "cancelled") { + t.Fatalf("取消任务响应错误:%d %s", cancelResponse.Code, cancelResponse.Body.String()) + } + select { + case <-issueDone: + case <-time.After(2 * time.Second): + t.Fatal("取消后证书任务没有结束") + } + if issueResponse.Code != http.StatusConflict { + t.Fatalf("取消任务的申请请求应返回 409:%d %s", issueResponse.Code, issueResponse.Body.String()) + } + item, err := taskRepo.GetTask(context.Background(), items[0].ID) + if err != nil || item.Status != "cancelled" { + t.Fatalf("任务取消状态没有持久化:%+v %v", item, err) + } +} + +func TestTaskAPIReplaysIdempotentIssue(t *testing.T) { + calls := 0 + server, taskRepo, _, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + calls++ + return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "success"}, nil + }) + defer closeDB() + + firstRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil) + firstRequest.Header.Set("Idempotency-Key", "issue-key-20260830") + firstResponse := httptest.NewRecorder() + serveAdminHTTP(server, firstResponse, firstRequest) + if firstResponse.Code != http.StatusOK { + t.Fatalf("首次幂等请求失败:%d %s", firstResponse.Code, firstResponse.Body.String()) + } + secondRequest := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil) + secondRequest.Header.Set("Idempotency-Key", "issue-key-20260830") + secondResponse := httptest.NewRecorder() + serveAdminHTTP(server, secondResponse, secondRequest) + if secondResponse.Code != http.StatusOK || !strings.Contains(secondResponse.Body.String(), "replayed") { + t.Fatalf("重复幂等请求响应错误:%d %s", secondResponse.Code, secondResponse.Body.String()) + } + if calls != 1 { + t.Fatalf("幂等请求重复执行了证书申请:%d", calls) + } + items, err := taskRepo.ListTasks(context.Background(), "", 10) + if err != nil || len(items) != 1 || items[0].IdempotencyKey != "issue-key-20260830" { + t.Fatalf("幂等键没有正确保存:%+v %v", items, err) + } +} diff --git a/backend/internal/repository/task_repository.go b/backend/internal/repository/task_repository.go index 50c4e64..9824c11 100644 --- a/backend/internal/repository/task_repository.go +++ b/backend/internal/repository/task_repository.go @@ -44,7 +44,9 @@ type TaskRepository interface { GetTask(context.Context, string) (Task, error) ListTasks(context.Context, string, int) ([]Task, error) UpdateTask(context.Context, string, string, string, string, *time.Time, *time.Time) error + CancelTask(context.Context, string, string, *time.Time) error FindActiveTaskByDomain(context.Context, string, string) (Task, error) + FindTaskByIdempotencyKey(context.Context, string, string) (Task, error) CreateAttempt(context.Context, TaskAttempt) error FinishAttempt(context.Context, string, string, string, *time.Time) error ListAttempts(context.Context, string) ([]TaskAttempt, error) @@ -157,6 +159,34 @@ func (r *SQLTaskRepository) UpdateTask(ctx context.Context, id, status, stage, m return nil } +func (r *SQLTaskRepository) CancelTask(ctx context.Context, id, message string, finishedAt *time.Time) error { + if r == nil || r.db == nil { + return errors.New("数据库连接不能为空") + } + id = strings.TrimSpace(id) + if id == "" { + return errors.New("任务 ID 不能为空") + } + result, err := r.db.ExecContext(ctx, `UPDATE tasks SET status = 'cancelled', message = ?, finished_at = ?, updated_at = ? WHERE id = ? AND status IN ('queued', 'running')`, message, nullableTime(finishedAt), time.Now().UTC(), id) + if err != nil { + return fmt.Errorf("取消任务失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("读取任务取消结果失败:%w", err) + } + if count == 0 { + item, getErr := r.GetTask(ctx, id) + if getErr != nil { + return getErr + } + if item.Status == "cancelled" { + return nil + } + return errors.New("任务当前无法取消") + } + return nil +} func (r *SQLTaskRepository) FindActiveTaskByDomain(ctx context.Context, domainID, taskType string) (Task, error) { if r == nil || r.db == nil { return Task{}, errors.New("数据库连接不能为空") @@ -169,6 +199,18 @@ func (r *SQLTaskRepository) FindActiveTaskByDomain(ctx context.Context, domainID return scanTask(r.db.QueryRowContext(ctx, taskSelect+` WHERE domain_id = ? AND task_type = ? AND status IN ('queued', 'running') ORDER BY created_at DESC LIMIT 1`, domainID, taskType)) } +func (r *SQLTaskRepository) FindTaskByIdempotencyKey(ctx context.Context, domainID, key string) (Task, error) { + if r == nil || r.db == nil { + return Task{}, errors.New("数据库连接不能为空") + } + domainID = strings.TrimSpace(domainID) + key = strings.TrimSpace(key) + if domainID == "" || key == "" { + return Task{}, errors.New("域名 ID 和幂等键不能为空") + } + return scanTask(r.db.QueryRowContext(ctx, taskSelect+` WHERE domain_id = ? AND idempotency_key = ? ORDER BY created_at DESC LIMIT 1`, domainID, key)) +} + func (r *SQLTaskRepository) CreateAttempt(ctx context.Context, item TaskAttempt) error { if r == nil || r.db == nil { return errors.New("数据库连接不能为空") diff --git a/frontend/src/views/Dashboard/Dashboard.css b/frontend/src/views/Dashboard/Dashboard.css index 949367f..7a747d6 100644 --- a/frontend/src/views/Dashboard/Dashboard.css +++ b/frontend/src/views/Dashboard/Dashboard.css @@ -183,7 +183,13 @@ h2 { border-radius: 11px; background: #fbfcfe; } -.record-row > div { +.record-row-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; +}.record-row > div { display: grid; gap: 5px; min-width: 0; diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index c6283d8..4f21d6e 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -92,7 +92,13 @@ export function useDashboard() { wildcard: "泛域名", san: "SAN 多域名", }; - const domainStatusLabels = { + const taskStatusLabels = { + queued: "排队中", + running: "执行中", + success: "已完成", + failed: "失败", + cancelled: "已取消", + }; const domainStatusLabels = { pending: "等待申请", active: "正常", error: "错误", @@ -484,9 +490,12 @@ export function useDashboard() { error.value = ""; notice.value = ""; try { - const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, { - method: "POST", - }); + const options = { method: "POST" }; + if (action === "issue") { + const suffix = crypto.randomUUID ? crypto.randomUUID() : createSSHKeyRandomSuffix(); + options.headers = { "Idempotency-Key": `issue-${item.id}-${suffix}` }; + } + const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "域名操作失败"); notice.value = successMessage; @@ -495,7 +504,6 @@ export function useDashboard() { error.value = err.message || "域名操作失败"; } } - function issueDomain(item) { return runDomainAction(item, "issue", `已提交 ${item.name} 的证书申请`); } @@ -508,6 +516,20 @@ export function useDashboard() { return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`); } + async function cancelTask(item) { + if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return; + error.value = ""; + notice.value = ""; + try { + const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" }); + const result = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(result.error || "取消任务失败"); + notice.value = "证书任务已取消"; + await loadData(); + } catch (err) { + error.value = err.message || "取消任务失败"; + } + } async function runRenewalNow() { error.value = ""; notice.value = ""; @@ -821,6 +843,7 @@ export function useDashboard() { sshKeyFormError, certificateTypeLabels, domainStatusLabels, + taskStatusLabels, httpsModeLabels, environmentLabels, webServerLabels, @@ -870,6 +893,14 @@ export function useDashboard() { deleteServer, saveDomain, deleteDomain, + issueDomain, + validateDomain, + deployDomain, + cancelTask, + runRenewalNow, + openSettings, + closeSettings, + saveSettings, loadData, displayLabel, formatDate, diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 4ae8055..3a4a0f2 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -30,6 +30,7 @@ const { sshKeyFormError, certificateTypeLabels, domainStatusLabels, + taskStatusLabels, httpsModeLabels, environmentLabels, webServerLabels, @@ -78,6 +79,7 @@ const { validateDomain, deployDomain, runRenewalNow, + cancelTask, displayLabel, formatDate, daysRemaining, @@ -199,7 +201,17 @@ const { {{ item.task_type }} {{ item.message || "无附加信息" }} - {{ item.status }} +
+ {{ displayLabel(taskStatusLabels, item.status) }} + +