From 35260bcc4b35bd1f6bd3be390996593b541171c4 Mon Sep 17 00:00:00 2001 From: xuans Date: Tue, 29 Sep 2026 20:10:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=A4=B1=E8=B4=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=87=8D=E8=AF=95=E6=9D=83=E9=99=90=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增失败证书任务重试接口、事务处理、权限校验、审计记录及前端重试入口,并补充后端测试。 --- backend/internal/domain/project_access.go | 3 +- backend/internal/httpapi/server.go | 1 + backend/internal/httpapi/task_api.go | 81 +++++++++++++- backend/internal/httpapi/task_api_test.go | 40 +++++++ .../repository/project_task_reader.go | 105 ++++++++++++++++++ .../repository/project_task_reader_test.go | 103 +++++++++++++++++ frontend/src/views/Dashboard/Dashboard.js | 16 +++ .../src/views/Dashboard/RuntimeOverview.vue | 7 +- 8 files changed, 350 insertions(+), 6 deletions(-) create mode 100644 backend/internal/repository/project_task_reader_test.go diff --git a/backend/internal/domain/project_access.go b/backend/internal/domain/project_access.go index af2deb4..ce4fa29 100644 --- a/backend/internal/domain/project_access.go +++ b/backend/internal/domain/project_access.go @@ -32,6 +32,7 @@ const ( PermissionAuditRead ProjectPermission = "audit.read" PermissionTaskRead ProjectPermission = "task.read" PermissionTaskCancel ProjectPermission = "task.cancel" + PermissionTaskRetry ProjectPermission = "task.retry" ) func (r ProjectRole) Valid() bool { @@ -50,7 +51,7 @@ func (p ProjectPermission) Valid() bool { PermissionServerRead, PermissionServerManage, PermissionDeploymentRead, PermissionDeploymentExecute, PermissionDeploymentRollback, PermissionDNSRead, PermissionDNSManage, PermissionDNSCredentialUse, PermissionAuditRead, - PermissionTaskRead, PermissionTaskCancel: + PermissionTaskRead, PermissionTaskCancel, PermissionTaskRetry: return true } return false diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 27826ec..4dfc242 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -217,6 +217,7 @@ func (s *Server) registerRoutes() { api.GET("/tasks", s.listTasks) api.GET("/tasks/:id", s.getTask) api.DELETE("/tasks/:id", s.cancelTask) + api.POST("/tasks/:id/retry", s.retryTask) api.GET("/tasks/:id/attempts", s.listTaskAttempts) adminAPI.GET("/settings", s.getSettings) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index b2e7b20..1ecfb75 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -116,6 +116,11 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "创建任务尝试失败", err) return } + s.executeCertificateIssueTask(c, task, attempt, "certificate_issue") +} + +func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Task, attempt repository.TaskAttempt, auditAction string) { + now := time.Now().UTC() if err := s.tasks.UpdateTask(c.Request.Context(), task.ID, "running", "certificate_issue", "正在申请证书并部署", &now, nil); err != nil { finished := time.Now().UTC() failureMessage := "任务启动失败" @@ -124,7 +129,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { if s.audit != nil { _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ TaskID: task.ID, OperatorID: task.OperatorID, - Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID, + Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: "error", Message: failureMessage, }) } @@ -137,7 +142,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { cancel() s.unregisterTaskCancel(task.ID) }() - certificateResult, deploymentReport, issueErr := s.issueCertificate(executionCtx, domainID) + certificateResult, deploymentReport, issueErr := s.issueCertificate(executionCtx, task.DomainID) deploymentReport = sanitizeDeploymentReport(deploymentReport) finished := time.Now().UTC() status := "success" @@ -147,7 +152,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { status = "failed" message = certificateTaskErrorMessage(issueErr) if s.logger != nil { - s.logger.Error("证书任务执行失败", "task_id", task.ID, "domain_id", domainID, "error", issueErr) + s.logger.Error("证书任务执行失败", "task_id", task.ID, "domain_id", task.DomainID, "error", issueErr) } if errors.Is(issueErr, context.Canceled) { status = "cancelled" @@ -178,7 +183,7 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { } _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ TaskID: task.ID, OperatorID: task.OperatorID, - Action: "certificate_issue", ResourceType: "domain", ResourceID: domainID, Status: auditStatus, Message: message, + Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: auditStatus, Message: message, }) } response := gin.H{ @@ -295,6 +300,74 @@ func (s *Server) cancelTask(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"task": item}) } + +func (s *Server) retryTask(c *gin.Context) { + if s.issueCertificate == nil { + s.writeError(c, http.StatusNotImplemented, "证书申请接口尚未配置", errors.New("证书申请工作流未配置")) + return + } + reader, ok := s.projectTaskReader(c) + if !ok { + return + } + actorID := c.GetString(authenticatedActorKey) + taskID := strings.TrimSpace(c.Param("id")) + item, err := reader.GetVisibleTask(c.Request.Context(), actorID, taskID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusForbidden, "无权访问此任务", nil) + return + } + s.writeError(c, http.StatusInternalServerError, "读取任务失败", err) + return + } + if item.TaskType != "certificate_issue" { + c.JSON(http.StatusConflict, gin.H{"error": "当前任务类型不支持手动重试", "task": item}) + return + } + if item.Status != "failed" && item.Status != "cancelled" { + c.JSON(http.StatusConflict, gin.H{"error": "仅失败或已取消的证书申请任务可以重试", "task": item}) + return + } + authorizer, ok := s.projectMembers.(repository.ProjectAuthorizer) + if !ok { + s.writeError(c, http.StatusServiceUnavailable, "任务重试权限服务尚未就绪", errors.New("项目授权服务未配置")) + return + } + projectID := item.ProjectID + for _, permission := range []domain.ProjectPermission{ + domain.PermissionTaskRetry, + domain.PermissionCertificateManage, + domain.PermissionDeploymentExecute, + } { + if err := authorizer.AuthorizeProject(c.Request.Context(), actorID, projectID, permission); err != nil { + if errors.Is(err, repository.ErrProjectAccessDenied) { + s.writeError(c, http.StatusForbidden, "无权重试此项目的证书任务", nil) + return + } + s.writeError(c, http.StatusInternalServerError, "检查任务重试权限失败", err) + return + } + } + if !s.authorizeDNSCredentialUse(c, projectID) { + return + } + startedAt := time.Now().UTC() + retryTask, attempt, err := reader.RetryVisibleTask(c.Request.Context(), actorID, taskID, &startedAt) + if err != nil { + switch { + case errors.Is(err, repository.ErrProjectAccessDenied), errors.Is(err, sql.ErrNoRows): + s.writeError(c, http.StatusForbidden, "无权重试此任务", nil) + case errors.Is(err, repository.ErrTaskNotRetryable): + s.writeError(c, http.StatusConflict, "任务状态已变化,当前无法重试", nil) + default: + s.writeError(c, http.StatusInternalServerError, "创建任务重试失败", err) + } + return + } + s.executeCertificateIssueTask(c, retryTask, attempt, "certificate_issue_retry") +} + func validTaskIdempotencyKey(value string) bool { if len(value) < 8 || len(value) > 128 { return false diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 0c9389d..f7cdb93 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -453,3 +453,43 @@ func TestAuditLogResponseUsesDashboardFieldNames(t *testing.T) { } } } + +func TestTaskAPIRetriesFailedCertificateIssue(t *testing.T) { + calls := 0 + server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + calls++ + if calls == 1 { + return certificate.IssueResult{DomainID: id}, deployment.Report{}, errors.New("temporary certificate failure") + } + return certificate.IssueResult{DomainID: id}, deployment.Report{Status: "success"}, nil + }) + defer closeDB() + + first := httptest.NewRecorder() + serveAdminHTTP(server, first, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if first.Code != http.StatusBadGateway { + t.Fatalf("initial failure response: %d %s", first.Code, first.Body.String()) + } + tasks, err := taskRepo.ListTasks(context.Background(), "domain-1", 10) + if err != nil || len(tasks) != 1 || tasks[0].Status != "failed" { + t.Fatalf("failed task was not persisted: %+v %v", tasks, err) + } + + retry := httptest.NewRecorder() + serveAdminHTTP(server, retry, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+tasks[0].ID+"/retry", nil)) + if retry.Code != http.StatusOK { + t.Fatalf("retry response: %d %s", retry.Code, retry.Body.String()) + } + updated, err := taskRepo.GetTask(context.Background(), tasks[0].ID) + if err != nil || updated.Status != "success" || updated.OperatorID != "test-admin" { + t.Fatalf("retried task did not converge: %+v %v", updated, err) + } + attempts, err := taskRepo.ListAttempts(context.Background(), tasks[0].ID) + if err != nil || len(attempts) != 2 || attempts[0].AttemptNumber != 1 || attempts[0].Status != "failed" || attempts[1].AttemptNumber != 2 || attempts[1].Status != "success" { + t.Fatalf("retry attempts were not recorded correctly: %+v %v", attempts, err) + } + logs, err := auditRepo.ListAuditLogs(context.Background(), 10) + if err != nil || len(logs) != 2 || logs[0].Action != "certificate_issue_retry" || logs[0].OperatorID != "test-admin" { + t.Fatalf("retry audit was not recorded correctly: %+v %v", logs, err) + } +} diff --git a/backend/internal/repository/project_task_reader.go b/backend/internal/repository/project_task_reader.go index 67a9c0a..116af7d 100644 --- a/backend/internal/repository/project_task_reader.go +++ b/backend/internal/repository/project_task_reader.go @@ -17,8 +17,11 @@ type ProjectTaskReader interface { GetVisibleTask(context.Context, string, string) (Task, error) ListVisibleTaskAttempts(context.Context, string, string) ([]TaskAttempt, error) CancelVisibleTask(context.Context, string, string, string, *time.Time) error + RetryVisibleTask(context.Context, string, string, *time.Time) (Task, TaskAttempt, error) } +var ErrTaskNotRetryable = errors.New("任务当前不可重试") + var _ ProjectTaskReader = (*SQLTaskRepository)(nil) func taskVisibilityPredicate(alias string, permission domain.ProjectPermission) (string, []any) { @@ -30,6 +33,9 @@ func taskVisibilityPredicate(alias string, permission domain.ProjectPermission) domain.ProjectRoleReadOnly, } } + if permission == domain.PermissionTaskRetry { + roles = []domain.ProjectRole{domain.ProjectRoleAdmin} + } placeholders := make([]string, 0, len(roles)) args := []any{""} for _, role := range roles { @@ -178,3 +184,102 @@ func (r *SQLTaskRepository) CancelVisibleTask(ctx context.Context, actorID, task } return nil } + +func (r *SQLTaskRepository) RetryVisibleTask(ctx context.Context, actorID, taskID string, startedAt *time.Time) (Task, TaskAttempt, error) { + if r == nil || r.db == nil { + return Task{}, TaskAttempt{}, errors.New("数据库连接不能为空") + } + actorID = strings.TrimSpace(actorID) + taskID = strings.TrimSpace(taskID) + if actorID == "" || taskID == "" { + return Task{}, TaskAttempt{}, ErrProjectAccessDenied + } + if startedAt == nil { + now := time.Now().UTC() + startedAt = &now + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return Task{}, TaskAttempt{}, fmt.Errorf("开始重试任务事务失败:%w", err) + } + rollback := func() { + _ = tx.Rollback() + } + predicate, predicateArgs := taskVisibilityPredicate("tasks", domain.PermissionTaskRetry) + predicateArgs[0] = actorID + updateArgs := []any{ + actorID, "certificate_issue", "正在重试证书申请", nullableTime(startedAt), time.Now().UTC(), taskID, + } + updateArgs = append(updateArgs, predicateArgs...) + result, err := tx.ExecContext(ctx, r.query(`UPDATE tasks SET operator_id = ?, status = 'running', stage = ?, message = ?, + started_at = ?, finished_at = NULL, updated_at = ? + WHERE id = ? AND task_type = 'certificate_issue' AND status IN ('failed', 'cancelled') AND `+predicate), updateArgs...) + if err != nil { + rollback() + return Task{}, TaskAttempt{}, fmt.Errorf("更新重试任务状态失败:%w", err) + } + count, err := result.RowsAffected() + if err != nil { + rollback() + return Task{}, TaskAttempt{}, fmt.Errorf("读取重试任务更新结果失败:%w", err) + } + if count != 1 { + item, getErr := scanTask(tx.QueryRowContext(ctx, r.query(taskSelect+` WHERE id = ?`), taskID)) + rollback() + if errors.Is(getErr, sql.ErrNoRows) { + return Task{}, TaskAttempt{}, sql.ErrNoRows + } + if getErr != nil { + return Task{}, TaskAttempt{}, getErr + } + if item.Status != "failed" && item.Status != "cancelled" { + return Task{}, TaskAttempt{}, ErrTaskNotRetryable + } + return Task{}, TaskAttempt{}, ErrProjectAccessDenied + } + + var attemptNumber int + if err := tx.QueryRowContext(ctx, r.query(`SELECT COALESCE(MAX(attempt_number), 0) + 1 FROM task_attempts WHERE task_id = ?`), taskID).Scan(&attemptNumber); err != nil { + rollback() + return Task{}, TaskAttempt{}, fmt.Errorf("读取任务重试次数失败:%w", err) + } + attempt := TaskAttempt{ + HistoryScope: HistoryScope{ScopeType: ScopeProject}, + ID: domain.NewID(), + TaskID: taskID, + AttemptNumber: attemptNumber, + Stage: "certificate_issue", + Status: "running", + Message: "开始重试证书申请", + StartedAt: startedAt, + CreatedAt: startedAt.UTC(), + } + insertResult, err := tx.ExecContext(ctx, r.query(`INSERT INTO task_attempts + (id, task_id, attempt_number, stage, status, message, started_at, finished_at, created_at) + SELECT ?, id, ?, ?, ?, ?, ?, NULL, ? FROM tasks WHERE id = ?`), + attempt.ID, attempt.AttemptNumber, attempt.Stage, attempt.Status, attempt.Message, + nullableTime(attempt.StartedAt), attempt.CreatedAt, taskID) + if err != nil { + rollback() + return Task{}, TaskAttempt{}, fmt.Errorf("创建重试任务尝试失败:%w", err) + } + if count, err := insertResult.RowsAffected(); err != nil { + rollback() + return Task{}, TaskAttempt{}, fmt.Errorf("读取重试任务尝试创建结果失败:%w", err) + } else if count != 1 { + rollback() + return Task{}, TaskAttempt{}, ErrTaskNotRetryable + } + + task, err := scanTask(tx.QueryRowContext(ctx, r.query(taskSelect+` WHERE id = ?`), taskID)) + if err != nil { + rollback() + return Task{}, TaskAttempt{}, err + } + attempt.HistoryScope = task.HistoryScope + if err := tx.Commit(); err != nil { + return Task{}, TaskAttempt{}, fmt.Errorf("提交重试任务事务失败:%w", err) + } + return task, attempt, nil +} diff --git a/backend/internal/repository/project_task_reader_test.go b/backend/internal/repository/project_task_reader_test.go new file mode 100644 index 0000000..f8acfb4 --- /dev/null +++ b/backend/internal/repository/project_task_reader_test.go @@ -0,0 +1,103 @@ +package repository + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSQLTaskRepositoryRetryChecksPermissionAndCreatesAttempt(t *testing.T) { + repository, db := newTaskRepositoryTest(t) + defer db.Close() + ctx := context.Background() + for _, item := range []struct { + id string + role string + }{ + {id: "retry-admin", role: "admin"}, + {id: "retry-reader", role: "user"}, + } { + if _, err := db.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role, status) VALUES (?, ?, 'test', ?, 'active')`, item.id, item.id, item.role); err != nil { + t.Fatal(err) + } + } + if _, err := db.ExecContext(ctx, `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'retry-admin', 'project_admin'), ('default', 'retry-reader', 'read_only')`); err != nil { + t.Fatal(err) + } + created := time.Now().UTC().Add(-time.Minute) + if err := repository.CreateTask(ctx, Task{ + ID: "task-retry", DomainID: "domain-1", TaskType: "certificate_issue", Status: "failed", + Stage: "certificate_issue", Message: "申请失败", CreatedAt: created, UpdatedAt: created, + }); err != nil { + t.Fatal(err) + } + if err := repository.CreateAttempt(ctx, TaskAttempt{ + ID: "attempt-retry-1", TaskID: "task-retry", AttemptNumber: 1, Stage: "certificate_issue", + Status: "failed", Message: "申请失败", CreatedAt: created, + }); err != nil { + t.Fatal(err) + } + started := time.Now().UTC() + if _, _, err := repository.RetryVisibleTask(ctx, "retry-reader", "task-retry", &started); !errors.Is(err, ErrProjectAccessDenied) { + t.Fatalf("read-only member should not retry: %v", err) + } + task, attempt, err := repository.RetryVisibleTask(ctx, "retry-admin", "task-retry", &started) + if err != nil { + t.Fatal(err) + } + if task.Status != "running" || task.OperatorID != "retry-admin" || attempt.AttemptNumber != 2 || attempt.Status != "running" || attempt.ProjectID != task.ProjectID { + t.Fatalf("retry state is incorrect: task=%+v attempt=%+v", task, attempt) + } + if _, _, err := repository.RetryVisibleTask(ctx, "retry-admin", "task-retry", &started); !errors.Is(err, ErrTaskNotRetryable) { + t.Fatalf("running task should not be retried again: %v", err) + } +} + +func TestSQLTaskRepositoryConcurrentRetryAllowsOnlyOneWinner(t *testing.T) { + repository, db := newTaskRepositoryTest(t) + defer db.Close() + ctx := context.Background() + if _, err := db.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role, status) VALUES ('retry-admin', 'retry-admin', 'test', 'admin', 'active')`); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, `INSERT INTO project_members (project_id, user_id, role) VALUES ('default', 'retry-admin', 'project_admin')`); err != nil { + t.Fatal(err) + } + created := time.Now().UTC().Add(-time.Minute) + if err := repository.CreateTask(ctx, Task{ + ID: "task-concurrent-retry", DomainID: "domain-1", TaskType: "certificate_issue", Status: "failed", + Stage: "certificate_issue", Message: "申请失败", CreatedAt: created, UpdatedAt: created, + }); err != nil { + t.Fatal(err) + } + if err := repository.CreateAttempt(ctx, TaskAttempt{ + ID: "attempt-concurrent-retry-1", TaskID: "task-concurrent-retry", AttemptNumber: 1, + Stage: "certificate_issue", Status: "failed", Message: "申请失败", CreatedAt: created, + }); err != nil { + t.Fatal(err) + } + results := make(chan error, 2) + for i := 0; i < 2; i++ { + go func() { + started := time.Now().UTC() + _, _, err := repository.RetryVisibleTask(ctx, "retry-admin", "task-concurrent-retry", &started) + results <- err + }() + } + var successCount int + for i := 0; i < 2; i++ { + if err := <-results; err == nil { + successCount++ + } else if !errors.Is(err, ErrTaskNotRetryable) { + t.Fatalf("unexpected concurrent retry error: %v", err) + } + } + if successCount != 1 { + t.Fatalf("expected exactly one retry winner, got %d", successCount) + } + attempts, err := repository.ListAttempts(ctx, "task-concurrent-retry") + if err != nil || len(attempts) != 2 || attempts[1].AttemptNumber != 2 || attempts[1].Status != "running" { + t.Fatalf("concurrent retry attempts are incorrect: %+v %v", attempts, err) + } +} diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 5c0fa21..2ea9422 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -848,6 +848,21 @@ export function useDashboard() { error.value = err.message || "取消任务失败"; } } + async function retryTask(item) { + if (!window.confirm("确定重新执行这项失败的证书任务吗?")) return; + error.value = ""; + notice.value = ""; + try { + const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" }); + 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 = ""; @@ -1313,6 +1328,7 @@ export function useDashboard() { validateDomain, deployDomain, cancelTask, + retryTask, openTaskDetails, closeTaskDetails, showTaskDetails, diff --git a/frontend/src/views/Dashboard/RuntimeOverview.vue b/frontend/src/views/Dashboard/RuntimeOverview.vue index 93d0453..57b60a5 100644 --- a/frontend/src/views/Dashboard/RuntimeOverview.vue +++ b/frontend/src/views/Dashboard/RuntimeOverview.vue @@ -6,7 +6,7 @@ const props = defineProps({ state: { type: Object, required: true } }); const emit = defineEmits(["navigate"]); const { loading, error, auditLogs, taskTypeLabel, taskStatusLabels, - statusClass, displayLabel, auditActionLabel, openTaskDetails, cancelTask, + statusClass, displayLabel, auditActionLabel, openTaskDetails, cancelTask, retryTask, } = props.state; const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(props.state); @@ -96,6 +96,11 @@ const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(pro type="button" @click="cancelTask(item)" >取消任务 +