完善任务部分成功状态与运行概览

补充证书部署部分成功的任务状态、HTTP响应和前端任务展示,并增加对应测试。
This commit is contained in:
xuans 2026-09-30 00:12:23 +08:00
parent a9f17c740a
commit 2b9d82df2f
7 changed files with 70 additions and 13 deletions

View File

@ -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)

View File

@ -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")
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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: "等待申请",

View File

@ -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/);

View File

@ -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,
};