diff --git a/backend/internal/app/certificate_tasks.go b/backend/internal/app/certificate_tasks.go index ba86c2d..6ebf5d4 100644 --- a/backend/internal/app/certificate_tasks.go +++ b/backend/internal/app/certificate_tasks.go @@ -51,14 +51,14 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) defer cancel() finished := time.Now().UTC() - status, message := certificateTaskOutcome(resultErr) + status, message := certificateTaskOutcome(result, resultErr) auditErr := w.audit.CreateAuditLog(finishCtx, repository.AuditLog{ TaskID: task.ID, Action: taskType, ResourceType: "domain", ResourceID: id, Status: status, Message: message, }) if auditErr != nil { - status, message = certificateTaskOutcome(auditErr) + status, message = certificateTaskOutcome(result, auditErr) stage = "record" } taskErr := w.tasks.UpdateTask(finishCtx, task.ID, status, stage, message, &started, &finished) @@ -95,7 +95,7 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s cancel() finishCtx, finishCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) finished := time.Now().UTC() - status, message := certificateTaskOutcome(resultErr) + status, message := certificateTaskOutcome(result, resultErr) recordErr := w.tasks.FinishAttempt(finishCtx, attempt.ID, status, message, &finished) finishCancel() if recordErr != nil { @@ -114,14 +114,17 @@ func (w *certificateTaskWorkflow) run(ctx context.Context, id, taskType, stage s return result, resultErr } -func certificateTaskOutcome(err error) (string, string) { +func certificateTaskOutcome(result CertificateWorkflowResult, err error) (string, string) { if errors.Is(err, context.Canceled) { - return "cancelled", "证书工作流已取消" + return "cancelled", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u5df2\u53d6\u6d88" + } + if result.Deployment.Status == "partial" { + return "partial_success", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u5df2\u5b8c\u6210\u90e8\u5206\u90e8\u7f72" } if err != nil { - return "failed", "证书工作流未完成,请检查受保护的服务日志" + return "failed", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u672a\u5b8c\u6210\uff0c\u8bf7\u68c0\u67e5\u53d7\u4fdd\u62a4\u7684\u670d\u52a1\u65e5\u5fd7" } - return "success", "证书工作流执行完成" + return "success", "\u8bc1\u4e66\u5de5\u4f5c\u6d41\u6267\u884c\u5b8c\u6210" } var _ certificate.CertificateIssuer = (*certificateTaskWorkflow)(nil) diff --git a/backend/internal/app/certificate_tasks_test.go b/backend/internal/app/certificate_tasks_test.go index 78a3939..cce09a9 100644 --- a/backend/internal/app/certificate_tasks_test.go +++ b/backend/internal/app/certificate_tasks_test.go @@ -258,3 +258,16 @@ func TestCertificateTerminalPersistenceFailureIsReturnedWithoutReissue(t *testin t.Fatalf("calls=%d error=%v logged=%v", calls, err, logged) } } + +func TestCertificateTasksRecordPartialDeploymentFailure(t *testing.T) { + w, a := newCertificateTaskFixture(t) + _, err := w.run(t.Context(), "task-domain", "renewal_run", "certificate_issue", 1, 0, func(context.Context, string) (CertificateWorkflowResult, error) { + return CertificateWorkflowResult{ + Deployment: deployment.Report{Total: 2, Success: 1, Failed: 1, Status: "partial"}, + }, errors.New("partial deployment") + }) + if err == nil { + t.Fatal("partial deployment error should remain visible to the caller") + } + assertCertificateTask(t, a, "partial_success", "partial_success") +} diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index afb204d..6ab401f 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -148,10 +148,14 @@ func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Tas status := "success" message := "证书申请和部署完成" cancelled := false - partial := issueErr == nil && deploymentReport.Status == "partial" + partial := deploymentReport.Status == "partial" if issueErr != nil { status = "failed" message = certificateTaskErrorMessage(issueErr) + if partial { + status = "partial_success" + message = "\u8bc1\u4e66\u5df2\u7b7e\u53d1\uff0c\u4f46\u4ec5\u90e8\u5206\u670d\u52a1\u5668\u90e8\u7f72\u6210\u529f" + } if s.logger != nil { s.logger.Error("证书任务执行失败", "task_id", task.ID, "domain_id", task.DomainID, "error", issueErr) } @@ -181,7 +185,7 @@ func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Tas task.UpdatedAt = finished if s.audit != nil { auditStatus := status - if issueErr != nil && status != "cancelled" { + if issueErr != nil && status != "cancelled" && status != "partial_success" { auditStatus = "error" } _ = s.audit.CreateAuditLog(context.Background(), repository.AuditLog{ @@ -198,6 +202,8 @@ func (s *Server) executeCertificateIssueTask(c *gin.Context, task repository.Tas responseStatus := http.StatusBadGateway if status == "cancelled" { responseStatus = http.StatusConflict + } else if status == "partial_success" { + responseStatus = http.StatusMultiStatus } else if errors.Is(issueErr, context.DeadlineExceeded) { responseStatus = http.StatusGatewayTimeout } diff --git a/backend/internal/httpapi/task_api_test.go b/backend/internal/httpapi/task_api_test.go index 7298777..126b559 100644 --- a/backend/internal/httpapi/task_api_test.go +++ b/backend/internal/httpapi/task_api_test.go @@ -563,3 +563,30 @@ func TestTaskAPIRecordsPartialDeploymentSuccess(t *testing.T) { t.Fatalf("partial audit status: %+v %v", logs, err) } } + +func TestTaskAPIRecordsPartialDeploymentFailureAsPartialSuccess(t *testing.T) { + server, taskRepo, auditRepo, closeDB := newTaskAPITestServer(t, func(_ context.Context, id string) (certificate.IssueResult, deployment.Report, error) { + return certificate.IssueResult{DomainID: id}, deployment.Report{ + Total: 2, Success: 1, Failed: 1, Status: "partial", + }, errors.New("deployment partially failed") + }) + 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("partial failure response: %d %s", response.Code, response.Body.String()) + } + items, err := taskRepo.ListTasks(context.Background(), "domain-1", 10) + if err != nil || len(items) != 1 || items[0].Status != "partial_success" { + t.Fatalf("partial failure task status: %+v %v", items, err) + } + attempts, err := taskRepo.ListAttempts(context.Background(), items[0].ID) + if err != nil || len(attempts) != 1 || attempts[0].Status != "partial_success" { + t.Fatalf("partial failure attempt status: %+v %v", attempts, err) + } + logs, err := auditRepo.ListAuditLogs(context.Background(), 10) + if err != nil || len(logs) != 1 || logs[0].Status != "partial_success" { + t.Fatalf("partial failure audit status: %+v %v", logs, err) + } +} diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index dc310be..b54094f 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -163,6 +163,9 @@ export function useDashboard() { success: "已完成", failed: "失败", cancelled: "已取消", + cancelling: "\u53d6\u6d88\u4e2d", + retry_wait: "\u7b49\u5f85\u91cd\u8bd5", + partial_success: "\u90e8\u5206\u6210\u529f", }; taskStatusLabels.error = taskStatusLabels.failed; const taskTypeLabels = { @@ -216,6 +219,9 @@ export function useDashboard() { success: "已完成", failed: "失败", cancelled: "已取消", + cancelling: "\u53d6\u6d88\u4e2d", + retry_wait: "\u7b49\u5f85\u91cd\u8bd5", + partial_success: "\u90e8\u5206\u6210\u529f", }; const domainStatusLabels = { pending: "等待申请", diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 5b4886f..553ecef 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -341,6 +341,8 @@ test("管理续期的任务类型、审计和阶段使用中文", async (t) => { assert.equal(state.auditActionLabel("management_renewal"), "管理证书续期"); assert.equal(state.taskStageLabel("activate"), "激活管理证书"); assert.equal(state.taskStageLabel("retry_wait"), "等待续期重试"); + assert.equal(state.taskStatusLabels.partial_success, "\u90e8\u5206\u6210\u529f"); + assert.equal(state.attemptStatusLabels.retry_wait, "\u7b49\u5f85\u91cd\u8bd5"); }); test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => { @@ -946,12 +948,12 @@ test("runtimeOverview.handlesEmptyDataThresholdsAndTaskOrdering", () => { for (const key of ["total", "valid", "expiring", "expired", "missing", "autoRenew", "configuredServers", "boundDomains", "activeTasks", "failedTasks"]) assert.equal(empty[key], 0); for (const value of [0, -1, "invalid"]) assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: value } } }).threshold, 10); assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: 30 } } }).threshold, 30); - const tasks = ["queued", "running", "failed", "error", "success", "cancelled"].map((status, index) => ({ id: index, status, created_at: `2026-09-25T12:0${index}:00Z` })); + const tasks = ["queued", "running", "retry_wait", "cancelling", "failed", "error", "success", "cancelled"].map((status, index) => ({ id: index, status, created_at: `2026-09-25T12:0${index}:00Z` })); const original = structuredClone(tasks); const result = summarizeRuntime({ tasks }); - assert.equal(result.activeTasks, 2); + assert.equal(result.activeTasks, 4); assert.equal(result.failedTasks, 2); - assert.deepEqual(result.tasks.map((item) => item.id), [5, 4, 3, 2, 1, 0]); + assert.deepEqual(result.tasks.map((item) => item.id), [7, 6, 5, 4, 3, 2, 1, 0]); assert.deepEqual(tasks, original); assert.equal(runtimeTime(null), runtimeTime("invalid")); assert.doesNotMatch(runtimeTime("2026-09-25T12:00:00Z"), /Invalid/); diff --git a/frontend/src/views/Dashboard/RuntimeOverview.js b/frontend/src/views/Dashboard/RuntimeOverview.js index 101e34c..e931da4 100644 --- a/frontend/src/views/Dashboard/RuntimeOverview.js +++ b/frontend/src/views/Dashboard/RuntimeOverview.js @@ -53,7 +53,7 @@ export function summarizeRuntime(data, now = Date.now()) { autoRenew: domains.filter((item) => item.auto_renew === true).length, configuredServers: (data.servers || []).length, boundDomains: domains.filter((item) => (data.domainServers?.[item.id] || []).length > 0).length, - activeTasks: tasks.filter((item) => item.status === "queued" || item.status === "running").length, + activeTasks: tasks.filter((item) => ["queued", "running", "retry_wait", "cancelling"].includes(item.status)).length, failedTasks: tasks.filter((item) => item.status === "failed" || item.status === "error").length, tasks, };