开发失败任务重试权限闭环
新增失败证书任务重试接口、事务处理、权限校验、审计记录及前端重试入口,并补充后端测试。
This commit is contained in:
parent
35c9d7760c
commit
35260bcc4b
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
103
backend/internal/repository/project_task_reader_test.go
Normal file
103
backend/internal/repository/project_task_reader_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
</script>
|
||||
@ -96,6 +96,11 @@ const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(pro
|
||||
type="button"
|
||||
@click="cancelTask(item)"
|
||||
>取消任务</button>
|
||||
<button
|
||||
v-if="item.task_type === 'certificate_issue' && (item.status === 'failed' || item.status === 'cancelled')"
|
||||
type="button"
|
||||
@click="retryTask(item)"
|
||||
>重试任务</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user