From 9e666be5226b4df1b880b5356b940cbcfa5f0d8b Mon Sep 17 00:00:00 2001 From: xuans Date: Fri, 2 Oct 2026 16:47:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=94=E6=8E=A5=E5=BC=82=E6=AD=A5=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=89=8D=E5=90=8E=E7=AB=AF=E5=B9=B6=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=BD=92=E8=BF=9B=E5=BA=A6=EF=BC=8C?= =?UTF-8?q?=E7=AB=9E=E4=BA=89=E5=A4=8D=E5=AE=A1=E5=BE=85=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/certificate_retry_api_test.go | 55 +++-- backend/internal/httpapi/server.go | 4 +- backend/internal/httpapi/task_api.go | 44 ++-- .../httpapi/task_deployment_results_test.go | 32 +-- backend/internal/httpapi/task_executor.go | 11 +- .../internal/httpapi/task_executor_test.go | 124 ++++++++++ deploy/systemd/Scert.service | 3 +- doc/Linux生产流程测试记录.md | 15 ++ doc/Scert整改任务执行清单.md | 13 + doc/Scert整改合并优化清单.md | 9 + frontend/src/views/Dashboard/Dashboard.js | 90 ++++--- .../src/views/Dashboard/Dashboard.test.js | 2 + frontend/src/views/Dashboard/Dashboard.vue | 9 +- .../src/views/Dashboard/DeploymentFeedback.js | 8 + .../src/views/Dashboard/DeploymentResult.vue | 12 +- .../views/Dashboard/TaskDeploymentResults.vue | 24 ++ frontend/src/views/Dashboard/TaskMonitor.js | 190 +++++++++++++++ .../src/views/Dashboard/TaskMonitor.test.js | 226 ++++++++++++++++++ 18 files changed, 767 insertions(+), 104 deletions(-) create mode 100644 frontend/src/views/Dashboard/TaskDeploymentResults.vue create mode 100644 frontend/src/views/Dashboard/TaskMonitor.js create mode 100644 frontend/src/views/Dashboard/TaskMonitor.test.js diff --git a/backend/internal/app/certificate_retry_api_test.go b/backend/internal/app/certificate_retry_api_test.go index d900a1e..ae3fb41 100644 --- a/backend/internal/app/certificate_retry_api_test.go +++ b/backend/internal/app/certificate_retry_api_test.go @@ -12,6 +12,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/deployment" + "git.pcux.cn/xuans/Scert/backend/internal/repository" ) func TestApplicationDeployRetryUsesExistingTask(t *testing.T) { @@ -62,21 +63,43 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) { } cookies = login.Result().Cookies() initial := request("/api/v1/domains/task-domain/deploy", "") - if initial.Code != http.StatusBadGateway || calls.Load() != 1 { - t.Fatalf("首次故障未经过真实部署接线:%d %s", initial.Code, initial.Body.String()) + if initial.Code != http.StatusAccepted { + t.Fatalf("首次部署没有异步接收:%d %s", initial.Code, initial.Body.String()) } - tasks, err := application.Tasks.ListTasks(t.Context(), "task-domain", 10) - if err != nil || len(tasks) != 1 || tasks[0].Status != "failed" { - t.Fatalf("首次部署未生成唯一失败任务:%+v %v", tasks, err) + waitTerminal := func(status string) []repository.Task { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + items, err := application.Tasks.ListTasks(t.Context(), "task-domain", 10) + if err != nil { + t.Fatal(err) + } + if len(items) == 1 && items[0].Status == status { + return items + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("等待任务终态超时:%s", status) + return nil + } + tasks := waitTerminal("failed") + if calls.Load() != 1 { + t.Fatal("首次故障未经过真实部署接线") } task := tasks[0] retryPath := "/api/v1/tasks/" + task.ID + "/retry" - done := make(chan *httptest.ResponseRecorder, 1) - go func() { done <- request(retryPath, "") }() + retry := request(retryPath, "") + // 终态持久化后执行器可能仍在释放资源,收尾期间重试必须保持拒绝。 + deadline := time.Now().Add(5 * time.Second) + for retry.Code == http.StatusConflict && time.Now().Before(deadline) { + time.Sleep(5 * time.Millisecond) + retry = request(retryPath, "") + } + if retry.Code != http.StatusAccepted { + t.Fatalf("真实部署重试未接收:%d %s", retry.Code, retry.Body.String()) + } select { case <-started: - case response := <-done: - t.Fatalf("重试未进入原始部署工作流:%d %s", response.Code, response.Body.String()) case <-time.After(5 * time.Second): t.Fatal("重试工作流启动超时") } @@ -85,17 +108,9 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) { t.Fatalf("并发重试未被阻止:%d %s", concurrent.Code, concurrent.Body.String()) } release <- struct{}{} - select { - case response := <-done: - if response.Code != http.StatusOK { - t.Fatalf("真实应用部署重试失败:%d %s", response.Code, response.Body.String()) - } - case <-time.After(5 * time.Second): - t.Fatal("重试工作流收尾超时") - } - tasks, err = application.Tasks.ListTasks(t.Context(), "task-domain", 10) - if err != nil || len(tasks) != 1 || tasks[0].ID != task.ID || tasks[0].Status != "success" { - t.Fatalf("重试重复创建任务或未成功:%+v %v", tasks, err) + tasks = waitTerminal("success") + if tasks[0].ID != task.ID { + t.Fatalf("重试重复创建任务:%+v", tasks) } attempts, err := application.Tasks.ListAttempts(t.Context(), task.ID) if err != nil || len(attempts) != 2 { diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index abfbce9..cf60e69 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -219,7 +219,7 @@ func (s *Server) registerRoutes() { domainAPI.POST("/domains", s.dnsOperation, s.createDomain) domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain) domainAPI.DELETE("/domains/:id", s.deleteDomain) - domainAPI.POST("/domains/:id/issue", s.longOperation(certificateTaskTimeout), s.issueDomain) + domainAPI.POST("/domains/:id/issue", s.issueDomain) api := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated) api.GET("/tasks", s.listTasks) api.GET("/tasks/:id", s.getTask) @@ -237,7 +237,7 @@ func (s *Server) registerRoutes() { adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow) domainAPI.POST("/domains/:id/certificate/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles) domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate) - domainAPI.POST("/domains/:id/deploy", s.longOperation(certificateTaskTimeout), s.deployCertificateCurrent) + domainAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent) domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion) api.POST("/servers", s.createServer) api.PUT("/servers/:id", s.updateServer) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index e5a3a98..1fb9072 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -170,19 +170,20 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a }) now := time.Now().UTC() if err := executionCtx.Err(); err != nil { - s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止") + s.finishTaskContext(task, attempt, err) return } if err := s.tasks.UpdateTask(executionCtx, task.ID, "running", stage, runningMessage, &now, nil); err != nil { - finished := time.Now().UTC() failureMessage := "任务启动失败" if s.finishCancelledTask(task, attempt, executionCtx) { return } - _ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", stage, failureMessage, &now, &finished) - _ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished) + attempt.Stage = stage + s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage) if s.audit != nil { - _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ + auditCtx, auditCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer auditCancel() + _ = s.audit.CreateAuditLog(auditCtx, repository.AuditLog{ TaskID: task.ID, OperatorID: task.OperatorID, Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: "error", Message: failureMessage, @@ -196,13 +197,12 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Status: "running", Message: runningMessage, }); err != nil { - finished := time.Now().UTC() failureMessage := "任务启动审计记录失败" if s.finishCancelledTask(task, attempt, executionCtx) { return } - _ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "record", failureMessage, &now, &finished) - _ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished) + attempt.Stage = "record" + s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage) return } } @@ -248,15 +248,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a issueErr = errors.New(message) } } - if err := s.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished); err != nil { - status, message = "failed", "任务尝试结果保存失败,请检查任务恢复状态" - } - task.Status = status - task.Stage = stage - task.Message = message - task.StartedAt = &now - task.FinishedAt = &finished - task.UpdatedAt = finished if s.audit != nil { auditStatus := status if issueErr != nil && status != "cancelled" && status != "partial_success" { @@ -269,15 +260,28 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a status, message = "failed", "任务完成审计保存失败,请检查任务恢复状态" } } + if err := s.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished); err != nil { + status, message = "failed", "任务尝试结果保存失败,请检查任务恢复状态" + } if err := s.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &now, &finished); err != nil && s.logger != nil { s.logger.Error("保存任务终态失败", "task_id", task.ID, "error", err) } } func (s *Server) finishCancelledTask(task repository.Task, attempt repository.TaskAttempt, ctx context.Context) bool { - persisted, err := s.tasks.GetTask(context.Background(), task.ID) - if (err == nil && (persisted.Status == "cancelled" || persisted.Status == "cancelling")) || errors.Is(ctx.Err(), context.Canceled) { - s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务已取消,未继续执行") + if ctx.Err() != nil { + s.finishTaskContext(task, attempt, ctx.Err()) + return true + } + checkCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + persisted, err := s.tasks.GetTask(checkCtx, task.ID) + if err != nil { + s.finishTaskBeforeExecution(task, attempt, "failed", "无法确认任务状态,未执行外部操作") + return true + } + if persisted.Status == "cancelled" || persisted.Status == "cancelling" { + s.finishTaskContext(task, attempt, context.Canceled) return true } return false diff --git a/backend/internal/httpapi/task_deployment_results_test.go b/backend/internal/httpapi/task_deployment_results_test.go index 2f00a84..e2c1e61 100644 --- a/backend/internal/httpapi/task_deployment_results_test.go +++ b/backend/internal/httpapi/task_deployment_results_test.go @@ -28,9 +28,10 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) { defer closeDB() response := httptest.NewRecorder() serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) - if response.Code != http.StatusMultiStatus { - t.Fatalf("部分部署失败响应错误:%d %s", response.Code, response.Body.String()) + if response.Code != http.StatusAccepted { + t.Fatalf("任务提交响应错误:%d %s", response.Code, response.Body.String()) } + waitTaskWorkersForTest(t, server) items, err := tasks.ListTasks(t.Context(), "", 10) if err != nil || len(items) != 1 || items[0].Status != "partial_success" { t.Fatalf("部分成功任务记录错误:%+v %v", items, err) @@ -86,9 +87,10 @@ func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) { defer closeDB() response := httptest.NewRecorder() serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) - if response.Code != http.StatusBadGateway || strings.Contains(response.Body.String(), "secret-path") || !strings.Contains(response.Body.String(), "lock_unavailable") { - t.Fatalf("部署接口缺少安全的锁诊断:%d %s", response.Code, response.Body.String()) + if response.Code != http.StatusAccepted || strings.Contains(response.Body.String(), "secret-path") { + t.Fatalf("任务提交失败或泄露诊断原文:%d %s", response.Code, response.Body.String()) } + waitTaskWorkersForTest(t, server) items, err := tasks.ListTasks(t.Context(), "", 10) if err != nil || len(items) != 1 { t.Fatalf("任务未保存:%+v %v", items, err) @@ -116,21 +118,19 @@ func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T defer closeDB() response := httptest.NewRecorder() serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) - if response.Code != http.StatusBadGateway { - t.Fatalf("HTTPS 校验失败应返回失败响应:%d %s", response.Code, response.Body.String()) + if response.Code != http.StatusAccepted { + t.Fatalf("任务提交失败:%d %s", response.Code, response.Body.String()) } - var payload struct { - Deployment deployment.Report `json:"deployment"` - TaskStatus string `json:"task_status"` - } - if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + waitTaskWorkersForTest(t, server) + location := response.Header().Get("Location") + response = httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, location, nil)) + var task repository.Task + if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil { t.Fatal(err) } - if payload.TaskStatus != "failed" || payload.Deployment.Status != "failed" || payload.Deployment.VerificationStatus != "failed" { - t.Fatalf("接口未保留整体失败与站点校验失败:%+v", payload) - } - if payload.Deployment.Success != 1 || payload.Deployment.Results[0].Status != "success" || strings.Contains(response.Body.String(), "/secret-path") { - t.Fatalf("接口丢失真实推送结果或泄露底层错误:%s", response.Body.String()) + if response.Code != http.StatusOK || task.Status != "failed" || strings.Contains(response.Body.String(), "/secret-path") { + t.Fatalf("状态查询未保留整体失败或泄露底层错误:%d %s", response.Code, response.Body.String()) } items, err := tasks.ListTasks(t.Context(), "", 10) if err != nil || len(items) != 1 || items[0].Status != "failed" { diff --git a/backend/internal/httpapi/task_executor.go b/backend/internal/httpapi/task_executor.go index 9ebdf8c..60fbf81 100644 --- a/backend/internal/httpapi/task_executor.go +++ b/backend/internal/httpapi/task_executor.go @@ -2,6 +2,7 @@ package httpapi import ( "context" + "errors" "net/http" "time" @@ -47,7 +48,7 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at case s.taskSlots <- struct{}{}: defer func() { <-s.taskSlots }() case <-ctx.Done(): - s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止") + s.finishTaskContext(task, attempt, ctx.Err()) return } s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage) @@ -56,6 +57,14 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at c.JSON(http.StatusAccepted, gin.H{"task": task, "accepted": true}) } +func (s *Server) finishTaskContext(task repository.Task, attempt repository.TaskAttempt, err error) { + if errors.Is(err, context.DeadlineExceeded) { + s.finishTaskBeforeExecution(task, attempt, "failed", "任务执行超时,未继续执行") + return + } + s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务已取消,未继续执行") +} + func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt repository.TaskAttempt, status, message string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() diff --git a/backend/internal/httpapi/task_executor_test.go b/backend/internal/httpapi/task_executor_test.go index 0a00028..9f46884 100644 --- a/backend/internal/httpapi/task_executor_test.go +++ b/backend/internal/httpapi/task_executor_test.go @@ -6,6 +6,8 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" + "strings" "sync/atomic" "testing" "time" @@ -15,6 +17,128 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/repository" ) +func TestAsyncQueueOverloadClosesPersistedTask(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + t.Error("队列已满时不能执行外部操作") + return certificate.IssueResult{}, deployment.Report{}, nil + }) + defer cleanup() + server.asyncTasks = make(map[string]bool) + for i := 0; i < 32; i++ { + server.asyncTasks[strconv.Itoa(i)] = true + } + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("队列过载未拒绝:%d %s", response.Code, response.Body.String()) + } + items, err := tasks.ListTasks(t.Context(), "", 10) + if err != nil || len(items) != 1 || items[0].Status != "failed" { + t.Fatalf("过载后遗留活动任务:%+v %v", items, err) + } + attempts, err := tasks.ListAttempts(t.Context(), items[0].ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" { + t.Fatalf("过载后尝试未关闭:%+v %v", attempts, err) + } +} + +func TestAsyncQueuedCancellationNeverStartsOperation(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + t.Error("排队期间取消的任务不能执行外部操作") + return certificate.IssueResult{}, deployment.Report{}, nil + }) + defer cleanup() + server.asyncTasks = make(map[string]bool) + server.taskSlots = make(chan struct{}, 4) + for i := 0; i < 4; i++ { + server.taskSlots <- struct{}{} + } + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + task := submittedTask(t, response) + response = httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil)) + if response.Code != http.StatusAccepted { + t.Fatalf("排队取消未接收:%d %s", response.Code, response.Body.String()) + } + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "cancelled" { + t.Fatalf("排队取消终态错误:%+v %v", stored, err) + } +} + +func TestAsyncPanicClosesTaskWithoutExposingPanic(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + panic("private-panic-secret") + }) + defer cleanup() + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + task := submittedTask(t, response) + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "failed" || strings.Contains(stored.Message, "private-panic-secret") { + t.Fatalf("异常任务未安全关闭:%+v %v", stored, err) + } + attempts, err := tasks.ListAttempts(t.Context(), task.ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || strings.Contains(attempts[0].Message, "private-panic-secret") { + t.Fatalf("异常尝试未安全关闭:%+v %v", attempts, err) + } +} + +func TestAsyncDeadlineBeforeExecutionIsFailureNotUserCancellation(t *testing.T) { + server, tasks, _, cleanup := newTaskAPITestServer(t, nil) + defer cleanup() + task := repository.Task{ID: "deadline-task", DomainID: "domain-1", TaskType: "certificate_issue", Status: "queued", Stage: "queued"} + attempt := repository.TaskAttempt{ID: "deadline-attempt", TaskID: task.ID, AttemptNumber: 1, Status: "queued", Stage: "queued"} + if err := tasks.CreateTask(t.Context(), task); err != nil { + t.Fatal(err) + } + if err := tasks.CreateAttempt(t.Context(), attempt); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithDeadline(t.Context(), time.Now().Add(-time.Second)) + defer cancel() + server.runCertificateTask(ctx, task, attempt, "certificate_issue", func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + t.Error("超时任务不能执行外部操作") + return certificate.IssueResult{}, deployment.Report{}, nil + }, "issue", "执行中", "执行完成") + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "failed" || !strings.Contains(stored.Message, "超时") { + t.Fatalf("超时被误记为用户取消:%+v %v", stored, err) + } +} + +type failingTaskFinishAuditRepository struct{ repository.AuditRepository } + +func (r failingTaskFinishAuditRepository) CreateAuditLog(ctx context.Context, item repository.AuditLog) error { + if item.Status == "success" { + return errors.New("模拟完成审计写入失败") + } + return r.AuditRepository.CreateAuditLog(ctx, item) +} + +func TestAsyncFinishAuditFailureDoesNotLeaveSuccessfulAttempt(t *testing.T) { + server, tasks, audit, cleanup := newTaskAPITestServer(t, func(context.Context, string) (certificate.IssueResult, deployment.Report, error) { + return certificate.IssueResult{}, deployment.Report{Status: "success"}, nil + }) + defer cleanup() + server.audit = failingTaskFinishAuditRepository{audit} + response := httptest.NewRecorder() + serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) + task := submittedTask(t, response) + waitTaskWorkersForTest(t, server) + stored, err := tasks.GetTask(t.Context(), task.ID) + if err != nil || stored.Status != "failed" { + t.Fatalf("完成审计失败却标记成功:%+v %v", stored, err) + } + attempts, err := tasks.ListAttempts(t.Context(), task.ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "failed" || attempts[0].Message != stored.Message { + t.Fatalf("任务与尝试终态不一致:%+v %v", attempts, err) + } +} + func submittedTask(t *testing.T, response *httptest.ResponseRecorder) repository.Task { t.Helper() if response.Code != http.StatusAccepted { diff --git a/deploy/systemd/Scert.service b/deploy/systemd/Scert.service index bc96bb2..6edf93d 100644 --- a/deploy/systemd/Scert.service +++ b/deploy/systemd/Scert.service @@ -17,7 +17,8 @@ ExecStart=/scert/scert --config /scert/conf/scert.json serve Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external Environment=SCERT_SERVER_HOST=127.0.0.1 KillSignal=SIGTERM -TimeoutStopSec=30 +# 为任务取消、部署回滚及持久化收尾预留时间,超过预算仍由启动恢复处理。 +TimeoutStopSec=180 Restart=on-failure UMask=0077 RuntimeDirectory=scert diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index 91ae5f2..1af2c5b 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,20 @@ # Linux 生产流程测试记录 +## 2026年10月2日16:47 异步任务开发回归记录(未部署) + +### 已完成的开发回归 + +- [x] 本机前端:npm.cmd test 为 252 项通过、零失败、零跳过;npm.cmd run build 通过。日志为 .runtime/async-frontend-20261002-final.log 与 .runtime/async-frontend-build-20261002-final.log。 +- [x] 第二台隔离源码、非特权用户执行 go test ./... -count=1 和 go vet ./... 均返回零;日志位于 /var/tmp/scert-async-20261002-1556/backend-full.log 与 backend-vet.log。首次旧同步接口断言失败已修复,原始日志保留。 +- [~] 第二台竞争检测因未启用 CGO 且无本机编译器未执行;第一台首次离线代理缺少依赖也未执行用例。已复用第二台公共模块缓存、三端校验归档摘要一致,在第一台启用 CGO 后重新执行异步专项竞争检测二十次,等待结果。 + +### 证据边界和剩余任务 + +- [~] 复核发现分发单元与动态安装模板的停止预算尚未同步,继续修复并回归,不以静态文件修改代替实际安装行为。 +- [!] 本批未替换线上候选,未执行浏览器首次初始化,未清理线上数据库,未新增辅助验收脚本,也未降低 TLS 或 SSH 主机身份校验。 +- [ ] 正式候选统一部署后仍需验证长任务超过反代等待窗口、断线后查询、取消回滚、进程重启恢复及三数据库首次完整 Web 流程。 +- [!] 下方 504 与 502 为真实历史失败,异步开发回归通过并不自动关闭实机问题;旧事务、系统通知、备份恢复及总体生产门禁继续保留。 + ## 2026年10月2日15:31 正式候选实机负向恢复与重试 ### 范围和准备 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index 0d4654b..cb31235 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,5 +1,18 @@ # Scert 整改任务执行清单 +## 2026年10月2日16:47 异步开发与自动回归进度 + +本节更新下方 15:43 的早期状态。范围仍为 Linux;先完成开发和自动回归,再统一正式部署验收。开发回归使用隔离源码与临时数据库,不清理线上业务数据。 + +- [x] 申请、首次部署、重试持久化任务与尝试后返回 202;后台上下文不依赖浏览器连接,保留项目授权、幂等键和任务历史。 +- [x] 已实现任务取消收尾、关闭时拒绝新工作、有限执行槽、超时和异常终态;补充排队取消、队列满、异常脱敏和审计保存失败回归,等待竞争检测及复审。 +- [x] 前端已接入任务查询、最近尝试的逐服务器结果、刷新后查看及撤权清缓存;查询失败不自动重新提交,提交成功不等于部署成功。 +- [x] 本机前端 252 项测试全部通过,生产构建通过;第二台隔离 Linux 全后端测试与 go vet 通过。上述结果均非正式部署验收。 +- [~] 第一台使用现有编译器运行异步专项竞争检测,重复二十次;第二台缺少编译器、第一台首次缺少离线依赖的记录保留,不将环境失败计作产品用例失败或通过。 +- [~] 复核发现静态 systemd 单元已增加停止预算,但动态安装模板仍为三十秒;下一步同步模板并增加一致性测试。 +- [ ] 补齐异步查询结果契约、持久化失败及取消提交竞态的复核;其他长操作仍需逐项整改。 +- [!] 本批代码未部署,重启 502、两条缺证据历史事务、系统通知与密码重置、三数据库首次 Web 流程、备份恢复及最终生产门禁仍未关闭。 + ## 2026年10月2日15:43 长任务异步整改入口 需求确认:正式申请、部署与重试不能等待整个工作流结束才响应;沿用持久化任务与权限边界,不以增加代理超时替代异步执行。仅处理 Linux,开发回归不清理线上业务数据;首次初始化验收另行清库执行。 diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index b6e137f..36af240 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,5 +1,14 @@ # Scert 整改、合并与优化清单 +## 2026年10月2日16:47 最新开发进度入口 + +- [x] P0-10/P1-04 的申请、部署、重试已完成异步提交和前端任务查询接线,浏览器断线不终止已接收任务;最近尝试、取消收尾和查询失败提示已补充。完成待复审,不表示整个条目关闭。 +- [x] 本机前端 252 项全部通过、生产构建通过;第二台隔离原生 Linux 全后端测试与 go vet 通过。本批尚未部署,不能沿用旧候选实机结果作为新代码验收。 +- [~] 异步专项竞争检测正在第一台隔离环境执行;同时补核动态安装模板停止预算与终态持久化边界。 +- [!] 其他长操作、单实例重启 502、旧恢复事务证据、通知重置、三数据库首次流程和备份恢复仍有未完成项。P0-10、P0-11 和整体生产门禁继续保持未通过。 + +详细任务点见《Scert整改任务执行清单》,开发回归与实机验收边界见《Linux生产流程测试记录》。下方按时间保留历史证据,不以旧入口覆盖本节进度。 + ## 2026年10月2日15:31 最新进度入口 - [x] 新候选真实部署负向检查:旧文件摘要变化时停止回滚并保留完整现场;启动恢复遇到锁归属变化同样拒绝接管。仅还原受控注入后,产品自动完成双机回滚。 diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 01af627..5c3e4da 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { settingsPayload } from "./SettingsPayload.js"; import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js"; import { useDomainForm } from "./DomainForm.js"; +import { useTaskMonitor } from "./TaskMonitor.js"; const defaultDNSVerificationTimeoutSeconds = 60; const aliyunDNSVerificationTimeoutSeconds = 6; @@ -75,6 +76,11 @@ export function useDashboard() { const taskDetailsError = ref(""); const deploymentResult = ref(null); const domainActionRunning = ref(false); + const taskMonitor = useTaskMonitor({ + fetchAdmin, tasks, deploymentResult, showTaskDetails, taskDetails, + taskAttempts, taskDetailsLoading, taskDetailsError, + refreshResources: refreshTaskResources, + }); const hasOpenModal = computed( () => showForm.value || @@ -549,6 +555,28 @@ export function useDashboard() { } } + async function refreshTaskResources(signal) { + // 任务结束后只刷新运行数据,不覆盖用户正在编辑的系统设置或表单。 + const responses = await Promise.all([ + fetchAdmin("/api/v1/status", { signal }), + fetchAdmin("/api/v1/domains", { signal }), + fetchAdmin("/api/v1/certificates", { signal }), + fetchAdmin("/api/v1/audit-logs?limit=20", { signal }), + ]); + if (responses.some((response) => !response.ok)) throw new Error("刷新任务关联数据失败"); + const [statusResult, domainResult, certificateResult, auditResult] = await Promise.all( + responses.map((response) => response.json()), + ); + if (signal.aborted) return; + if (![domainResult, certificateResult, auditResult].every((result) => Array.isArray(result.items))) { + throw new Error("任务关联数据响应不完整"); + } + status.value = statusResult; + domains.value = domainResult.items; + certificates.value = certificateResult.items; + auditLogs.value = auditResult.items; + } + async function runDomainAction(item, action, successMessage) { if (domainActionRunning.value) return; domainActionRunning.value = true; @@ -559,12 +587,26 @@ export function useDashboard() { notice.value = ""; try { const options = { method: "POST" }; - if (action === "issue") { + if (action === "issue" || action === "deploy") { const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix(); - options.headers = { "Idempotency-Key": `issue-${item.id}-${suffix}` }; + options.headers = { "Idempotency-Key": `${action}-${item.id}-${suffix}` }; } const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options); const result = await response.json().catch(() => ({})); + if (response.status === 202 && !result.task?.id) { + throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交"); + } + if (response.ok && result.task?.id && (response.status === 202 || result.accepted || result.replayed)) { + notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`; + if (action === "deploy") { + deploymentResult.value = { + domain: item.name, running: false, pending: true, + taskID: result.task.id, taskStatus: result.task.status, + taskMessage: result.task.message || "", report: null, error: "", queryError: "", + }; + } + return; + } if (action === "deploy") { deploymentResult.value = { domain: item.name, @@ -601,39 +643,6 @@ export function useDashboard() { return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`); } - async function openTaskDetails(item) { - showTaskDetails.value = true; - taskDetails.value = null; - taskAttempts.value = []; - taskDetailsError.value = ""; - taskDetailsLoading.value = true; - try { - const [taskResponse, attemptsResponse] = await Promise.all([ - fetchAdmin(`/api/v1/tasks/${item.id}`), - fetchAdmin(`/api/v1/tasks/${item.id}/attempts`), - ]); - if (!taskResponse.ok) { - throw new Error(await responseError(taskResponse, "读取任务详情失败")); - } - if (!attemptsResponse.ok) { - throw new Error(await responseError(attemptsResponse, "读取任务尝试记录失败")); - } - taskDetails.value = await taskResponse.json(); - taskAttempts.value = (await attemptsResponse.json()).items || []; - } catch (err) { - taskDetailsError.value = err.message || "读取任务详情失败"; - } finally { - taskDetailsLoading.value = false; - } - } - - function closeTaskDetails() { - showTaskDetails.value = false; - taskDetails.value = null; - taskAttempts.value = []; - taskDetailsError.value = ""; - } - async function cancelTask(item) { if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return; error.value = ""; @@ -642,8 +651,11 @@ export function useDashboard() { 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 = "证书任务已取消"; + notice.value = response.status === 202 || result.task?.status === "cancelling" + ? "正在取消,等待任务停止和部署收尾;此时不能重试" + : "证书任务已取消"; await loadData(); + await taskMonitor.refreshTaskStatus(); } catch (err) { error.value = err.message || "取消任务失败"; } @@ -656,8 +668,9 @@ export function useDashboard() { 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 = "证书任务已重新执行"; + notice.value = "重试已提交,请在任务详情查看本次执行结果"; await loadData(); + await taskMonitor.refreshTaskStatus(); } catch (err) { error.value = err.message || "重试任务失败"; } @@ -1022,6 +1035,7 @@ export function useDashboard() { return { ...domainForm, + ...taskMonitor, status, currentUser, canManageSystemSettings, @@ -1112,8 +1126,6 @@ export function useDashboard() { deployDomain, cancelTask, retryTask, - openTaskDetails, - closeTaskDetails, showTaskDetails, taskDetails, taskAttempts, diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 8bff4f9..9e656a8 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import "./DeploymentResult.test.js"; +import "./TaskMonitor.test.js"; import "./DashboardLogout.test.js"; import "./DomainModal.test.js"; import "./DomainForm.test.js"; @@ -139,6 +140,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) }); app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) }); app.component("DeploymentResult", { render: () => null }); + app.component("TaskDeploymentResults", { render: () => null }); const warnings = []; app.config.warnHandler = (message) => warnings.push(message); const html = await renderToString(app); diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 5d7fc02..0a7a705 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -10,6 +10,7 @@ import DashboardNavigation from "./DashboardNavigation.vue"; import { useDashboardNavigation } from "./DashboardNavigation.js"; import { useDashboard } from "./Dashboard.js"; import DeploymentResult from "./DeploymentResult.vue"; +import TaskDeploymentResults from "./TaskDeploymentResults.vue"; const dashboard = useDashboard(); const { @@ -37,6 +38,8 @@ const { saving, error, notice, + taskQueryError, + taskDeploymentReport, formError, showForm, editingId, @@ -152,6 +155,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi

{{ error }}

{{ notice }}

+
@@ -504,8 +508,8 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
正在读取任务详情……
-

{{ taskDetailsError }}

-