衔接异步任务前后端并记录自动回归进度,竞争复审待完成

This commit is contained in:
xuans 2026-10-02 16:47:30 +08:00
parent 13872ea3b1
commit 9e666be522
18 changed files with 767 additions and 104 deletions

View File

@ -12,6 +12,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/certificate"
"git.pcux.cn/xuans/Scert/backend/internal/deployment" "git.pcux.cn/xuans/Scert/backend/internal/deployment"
"git.pcux.cn/xuans/Scert/backend/internal/repository"
) )
func TestApplicationDeployRetryUsesExistingTask(t *testing.T) { func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
@ -62,21 +63,43 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
} }
cookies = login.Result().Cookies() cookies = login.Result().Cookies()
initial := request("/api/v1/domains/task-domain/deploy", "") initial := request("/api/v1/domains/task-domain/deploy", "")
if initial.Code != http.StatusBadGateway || calls.Load() != 1 { if initial.Code != http.StatusAccepted {
t.Fatalf("首次故障未经过真实部署接线:%d %s", initial.Code, initial.Body.String()) t.Fatalf("首次部署没有异步接收:%d %s", initial.Code, initial.Body.String())
} }
tasks, err := application.Tasks.ListTasks(t.Context(), "task-domain", 10) waitTerminal := func(status string) []repository.Task {
if err != nil || len(tasks) != 1 || tasks[0].Status != "failed" { t.Helper()
t.Fatalf("首次部署未生成唯一失败任务:%+v %v", tasks, err) 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] task := tasks[0]
retryPath := "/api/v1/tasks/" + task.ID + "/retry" retryPath := "/api/v1/tasks/" + task.ID + "/retry"
done := make(chan *httptest.ResponseRecorder, 1) retry := request(retryPath, "")
go func() { done <- 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 { select {
case <-started: case <-started:
case response := <-done:
t.Fatalf("重试未进入原始部署工作流:%d %s", response.Code, response.Body.String())
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
t.Fatal("重试工作流启动超时") t.Fatal("重试工作流启动超时")
} }
@ -85,17 +108,9 @@ func TestApplicationDeployRetryUsesExistingTask(t *testing.T) {
t.Fatalf("并发重试未被阻止:%d %s", concurrent.Code, concurrent.Body.String()) t.Fatalf("并发重试未被阻止:%d %s", concurrent.Code, concurrent.Body.String())
} }
release <- struct{}{} release <- struct{}{}
select { tasks = waitTerminal("success")
case response := <-done: if tasks[0].ID != task.ID {
if response.Code != http.StatusOK { t.Fatalf("重试重复创建任务:%+v", tasks)
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)
} }
attempts, err := application.Tasks.ListAttempts(t.Context(), task.ID) attempts, err := application.Tasks.ListAttempts(t.Context(), task.ID)
if err != nil || len(attempts) != 2 { if err != nil || len(attempts) != 2 {

View File

@ -219,7 +219,7 @@ func (s *Server) registerRoutes() {
domainAPI.POST("/domains", s.dnsOperation, s.createDomain) domainAPI.POST("/domains", s.dnsOperation, s.createDomain)
domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain) domainAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
domainAPI.DELETE("/domains/:id", s.deleteDomain) 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 := s.router.Group("/api/v1", privateProjectResponse, s.requireAuthenticated)
api.GET("/tasks", s.listTasks) api.GET("/tasks", s.listTasks)
api.GET("/tasks/:id", s.getTask) 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) 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/import", s.longOperation(certificateTaskTimeout), s.importCertificateFiles)
domainAPI.POST("/domains/:id/certificate/validate", s.longOperation(certificateTaskTimeout), s.validateCertificate) 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) domainAPI.POST("/domains/:id/certificate/versions/:version/restore", s.longOperation(certificateTaskTimeout), s.restoreCertificateVersion)
api.POST("/servers", s.createServer) api.POST("/servers", s.createServer)
api.PUT("/servers/:id", s.updateServer) api.PUT("/servers/:id", s.updateServer)

View File

@ -170,19 +170,20 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
}) })
now := time.Now().UTC() now := time.Now().UTC()
if err := executionCtx.Err(); err != nil { if err := executionCtx.Err(); err != nil {
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止") s.finishTaskContext(task, attempt, err)
return return
} }
if err := s.tasks.UpdateTask(executionCtx, task.ID, "running", stage, runningMessage, &now, nil); err != nil { if err := s.tasks.UpdateTask(executionCtx, task.ID, "running", stage, runningMessage, &now, nil); err != nil {
finished := time.Now().UTC()
failureMessage := "任务启动失败" failureMessage := "任务启动失败"
if s.finishCancelledTask(task, attempt, executionCtx) { if s.finishCancelledTask(task, attempt, executionCtx) {
return return
} }
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", stage, failureMessage, &now, &finished) attempt.Stage = stage
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished) s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
if s.audit != nil { 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, TaskID: task.ID, OperatorID: task.OperatorID,
Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID, Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
Status: "error", Message: failureMessage, 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, Action: auditAction, ResourceType: "domain", ResourceID: task.DomainID,
Status: "running", Message: runningMessage, Status: "running", Message: runningMessage,
}); err != nil { }); err != nil {
finished := time.Now().UTC()
failureMessage := "任务启动审计记录失败" failureMessage := "任务启动审计记录失败"
if s.finishCancelledTask(task, attempt, executionCtx) { if s.finishCancelledTask(task, attempt, executionCtx) {
return return
} }
_ = s.tasks.UpdateTask(context.Background(), task.ID, "failed", "record", failureMessage, &now, &finished) attempt.Stage = "record"
_ = s.tasks.FinishAttempt(context.Background(), attempt.ID, "failed", failureMessage, &finished) s.finishTaskBeforeExecution(task, attempt, "failed", failureMessage)
return return
} }
} }
@ -248,15 +248,6 @@ func (s *Server) runCertificateTask(ctx context.Context, task repository.Task, a
issueErr = errors.New(message) 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 { if s.audit != nil {
auditStatus := status auditStatus := status
if issueErr != nil && status != "cancelled" && status != "partial_success" { 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", "任务完成审计保存失败,请检查任务恢复状态" 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 { 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) s.logger.Error("保存任务终态失败", "task_id", task.ID, "error", err)
} }
} }
func (s *Server) finishCancelledTask(task repository.Task, attempt repository.TaskAttempt, ctx context.Context) bool { func (s *Server) finishCancelledTask(task repository.Task, attempt repository.TaskAttempt, ctx context.Context) bool {
persisted, err := s.tasks.GetTask(context.Background(), task.ID) if ctx.Err() != nil {
if (err == nil && (persisted.Status == "cancelled" || persisted.Status == "cancelling")) || errors.Is(ctx.Err(), context.Canceled) { s.finishTaskContext(task, attempt, ctx.Err())
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务已取消,未继续执行") 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 true
} }
return false return false

View File

@ -28,9 +28,10 @@ func TestTaskDeploymentResultsIssueAndRead(t *testing.T) {
defer closeDB() defer closeDB()
response := httptest.NewRecorder() response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusMultiStatus { if response.Code != http.StatusAccepted {
t.Fatalf("部分部署失败响应错误:%d %s", response.Code, response.Body.String()) t.Fatalf("任务提交响应错误:%d %s", response.Code, response.Body.String())
} }
waitTaskWorkersForTest(t, server)
items, err := tasks.ListTasks(t.Context(), "", 10) items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "partial_success" { if err != nil || len(items) != 1 || items[0].Status != "partial_success" {
t.Fatalf("部分成功任务记录错误:%+v %v", items, err) t.Fatalf("部分成功任务记录错误:%+v %v", items, err)
@ -86,9 +87,10 @@ func TestTaskDeploymentResultsKeepAllowlistedLockDiagnosis(t *testing.T) {
defer closeDB() defer closeDB()
response := httptest.NewRecorder() response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) 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") { if response.Code != http.StatusAccepted || strings.Contains(response.Body.String(), "secret-path") {
t.Fatalf("部署接口缺少安全的锁诊断:%d %s", response.Code, response.Body.String()) t.Fatalf("任务提交失败或泄露诊断原文:%d %s", response.Code, response.Body.String())
} }
waitTaskWorkersForTest(t, server)
items, err := tasks.ListTasks(t.Context(), "", 10) items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 { if err != nil || len(items) != 1 {
t.Fatalf("任务未保存:%+v %v", items, err) t.Fatalf("任务未保存:%+v %v", items, err)
@ -116,21 +118,19 @@ func TestTaskDeploymentResultsHTTPSFailurePreservesTransferEvidence(t *testing.T
defer closeDB() defer closeDB()
response := httptest.NewRecorder() response := httptest.NewRecorder()
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil)) serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/issue", nil))
if response.Code != http.StatusBadGateway { if response.Code != http.StatusAccepted {
t.Fatalf("HTTPS 校验失败应返回失败响应:%d %s", response.Code, response.Body.String()) t.Fatalf("任务提交失败:%d %s", response.Code, response.Body.String())
} }
var payload struct { waitTaskWorkersForTest(t, server)
Deployment deployment.Report `json:"deployment"` location := response.Header().Get("Location")
TaskStatus string `json:"task_status"` response = httptest.NewRecorder()
} serveAdminHTTP(server, response, httptest.NewRequest(http.MethodGet, location, nil))
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { var task repository.Task
if err := json.Unmarshal(response.Body.Bytes(), &task); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if payload.TaskStatus != "failed" || payload.Deployment.Status != "failed" || payload.Deployment.VerificationStatus != "failed" { if response.Code != http.StatusOK || task.Status != "failed" || strings.Contains(response.Body.String(), "/secret-path") {
t.Fatalf("接口未保留整体失败与站点校验失败:%+v", payload) t.Fatalf("状态查询未保留整体失败或泄露底层错误:%d %s", response.Code, response.Body.String())
}
if payload.Deployment.Success != 1 || payload.Deployment.Results[0].Status != "success" || strings.Contains(response.Body.String(), "/secret-path") {
t.Fatalf("接口丢失真实推送结果或泄露底层错误:%s", response.Body.String())
} }
items, err := tasks.ListTasks(t.Context(), "", 10) items, err := tasks.ListTasks(t.Context(), "", 10)
if err != nil || len(items) != 1 || items[0].Status != "failed" { if err != nil || len(items) != 1 || items[0].Status != "failed" {

View File

@ -2,6 +2,7 @@ package httpapi
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"time" "time"
@ -47,7 +48,7 @@ func (s *Server) enqueueCertificateTask(c *gin.Context, task repository.Task, at
case s.taskSlots <- struct{}{}: case s.taskSlots <- struct{}{}:
defer func() { <-s.taskSlots }() defer func() { <-s.taskSlots }()
case <-ctx.Done(): case <-ctx.Done():
s.finishTaskBeforeExecution(task, attempt, "cancelled", "任务在开始前已停止") s.finishTaskContext(task, attempt, ctx.Err())
return return
} }
s.runCertificateTask(ctx, task, attempt, action, operation, stage, runningMessage, successMessage) 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}) 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) { func (s *Server) finishTaskBeforeExecution(task repository.Task, attempt repository.TaskAttempt, status, message string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()

View File

@ -6,6 +6,8 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@ -15,6 +17,128 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/repository" "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 { func submittedTask(t *testing.T, response *httptest.ResponseRecorder) repository.Task {
t.Helper() t.Helper()
if response.Code != http.StatusAccepted { if response.Code != http.StatusAccepted {

View File

@ -17,7 +17,8 @@ ExecStart=/scert/scert --config /scert/conf/scert.json serve
Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external Environment=SCERT_BOOTSTRAP_FIREWALL_MODE=external
Environment=SCERT_SERVER_HOST=127.0.0.1 Environment=SCERT_SERVER_HOST=127.0.0.1
KillSignal=SIGTERM KillSignal=SIGTERM
TimeoutStopSec=30 # 为任务取消、部署回滚及持久化收尾预留时间,超过预算仍由启动恢复处理。
TimeoutStopSec=180
Restart=on-failure Restart=on-failure
UMask=0077 UMask=0077
RuntimeDirectory=scert RuntimeDirectory=scert

View File

@ -1,5 +1,20 @@
# Linux 生产流程测试记录 # 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 正式候选实机负向恢复与重试 ## 2026年10月2日15:31 正式候选实机负向恢复与重试
### 范围和准备 ### 范围和准备

View File

@ -1,5 +1,18 @@
# Scert 整改任务执行清单 # 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 长任务异步整改入口 ## 2026年10月2日15:43 长任务异步整改入口
需求确认:正式申请、部署与重试不能等待整个工作流结束才响应;沿用持久化任务与权限边界,不以增加代理超时替代异步执行。仅处理 Linux,开发回归不清理线上业务数据;首次初始化验收另行清库执行。 需求确认:正式申请、部署与重试不能等待整个工作流结束才响应;沿用持久化任务与权限边界,不以增加代理超时替代异步执行。仅处理 Linux,开发回归不清理线上业务数据;首次初始化验收另行清库执行。

View File

@ -1,5 +1,14 @@
# Scert 整改、合并与优化清单 # 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 最新进度入口 ## 2026年10月2日15:31 最新进度入口
- [x] 新候选真实部署负向检查:旧文件摘要变化时停止回滚并保留完整现场;启动恢复遇到锁归属变化同样拒绝接管。仅还原受控注入后,产品自动完成双机回滚。 - [x] 新候选真实部署负向检查:旧文件摘要变化时停止回滚并保留完整现场;启动恢复遇到锁归属变化同样拒绝接管。仅还原受控注入后,产品自动完成双机回滚。

View File

@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js"; import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js"; import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
import { useDomainForm } from "./DomainForm.js"; import { useDomainForm } from "./DomainForm.js";
import { useTaskMonitor } from "./TaskMonitor.js";
const defaultDNSVerificationTimeoutSeconds = 60; const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 6; const aliyunDNSVerificationTimeoutSeconds = 6;
@ -75,6 +76,11 @@ export function useDashboard() {
const taskDetailsError = ref(""); const taskDetailsError = ref("");
const deploymentResult = ref(null); const deploymentResult = ref(null);
const domainActionRunning = ref(false); const domainActionRunning = ref(false);
const taskMonitor = useTaskMonitor({
fetchAdmin, tasks, deploymentResult, showTaskDetails, taskDetails,
taskAttempts, taskDetailsLoading, taskDetailsError,
refreshResources: refreshTaskResources,
});
const hasOpenModal = computed( const hasOpenModal = computed(
() => () =>
showForm.value || 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) { async function runDomainAction(item, action, successMessage) {
if (domainActionRunning.value) return; if (domainActionRunning.value) return;
domainActionRunning.value = true; domainActionRunning.value = true;
@ -559,12 +587,26 @@ export function useDashboard() {
notice.value = ""; notice.value = "";
try { try {
const options = { method: "POST" }; const options = { method: "POST" };
if (action === "issue") { if (action === "issue" || action === "deploy") {
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix(); 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 response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
const result = await response.json().catch(() => ({})); 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") { if (action === "deploy") {
deploymentResult.value = { deploymentResult.value = {
domain: item.name, domain: item.name,
@ -601,39 +643,6 @@ export function useDashboard() {
return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`); 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) { async function cancelTask(item) {
if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return; if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return;
error.value = ""; error.value = "";
@ -642,8 +651,11 @@ export function useDashboard() {
const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" }); const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "取消任务失败"); if (!response.ok) throw new Error(result.error || "取消任务失败");
notice.value = "证书任务已取消"; notice.value = response.status === 202 || result.task?.status === "cancelling"
? "正在取消,等待任务停止和部署收尾;此时不能重试"
: "证书任务已取消";
await loadData(); await loadData();
await taskMonitor.refreshTaskStatus();
} catch (err) { } catch (err) {
error.value = err.message || "取消任务失败"; error.value = err.message || "取消任务失败";
} }
@ -656,8 +668,9 @@ export function useDashboard() {
const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" }); const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" });
const result = await response.json().catch(() => ({})); const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "重试任务失败"); if (!response.ok) throw new Error(result.error || "重试任务失败");
notice.value = "证书任务已重新执行"; notice.value = "重试已提交,请在任务详情查看本次执行结果";
await loadData(); await loadData();
await taskMonitor.refreshTaskStatus();
} catch (err) { } catch (err) {
error.value = err.message || "重试任务失败"; error.value = err.message || "重试任务失败";
} }
@ -1022,6 +1035,7 @@ export function useDashboard() {
return { return {
...domainForm, ...domainForm,
...taskMonitor,
status, status,
currentUser, currentUser,
canManageSystemSettings, canManageSystemSettings,
@ -1112,8 +1126,6 @@ export function useDashboard() {
deployDomain, deployDomain,
cancelTask, cancelTask,
retryTask, retryTask,
openTaskDetails,
closeTaskDetails,
showTaskDetails, showTaskDetails,
taskDetails, taskDetails,
taskAttempts, taskAttempts,

View File

@ -1,5 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import "./DeploymentResult.test.js"; import "./DeploymentResult.test.js";
import "./TaskMonitor.test.js";
import "./DashboardLogout.test.js"; import "./DashboardLogout.test.js";
import "./DomainModal.test.js"; import "./DomainModal.test.js";
import "./DomainForm.test.js"; import "./DomainForm.test.js";
@ -139,6 +140,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) }); app.component("ProjectManagement", { render: () => Vue.h("h2", headings.projects) });
app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) }); app.component("DNSAccounts", { render: () => Vue.h("h2", headings["dns-accounts"]) });
app.component("DeploymentResult", { render: () => null }); app.component("DeploymentResult", { render: () => null });
app.component("TaskDeploymentResults", { render: () => null });
const warnings = []; const warnings = [];
app.config.warnHandler = (message) => warnings.push(message); app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app); const html = await renderToString(app);

View File

@ -10,6 +10,7 @@ import DashboardNavigation from "./DashboardNavigation.vue";
import { useDashboardNavigation } from "./DashboardNavigation.js"; import { useDashboardNavigation } from "./DashboardNavigation.js";
import { useDashboard } from "./Dashboard.js"; import { useDashboard } from "./Dashboard.js";
import DeploymentResult from "./DeploymentResult.vue"; import DeploymentResult from "./DeploymentResult.vue";
import TaskDeploymentResults from "./TaskDeploymentResults.vue";
const dashboard = useDashboard(); const dashboard = useDashboard();
const { const {
@ -37,6 +38,8 @@ const {
saving, saving,
error, error,
notice, notice,
taskQueryError,
taskDeploymentReport,
formError, formError,
showForm, showForm,
editingId, editingId,
@ -152,6 +155,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<div class="dashboard-messages" aria-live="polite"> <div class="dashboard-messages" aria-live="polite">
<p v-if="error" class="error-banner">{{ error }}</p> <p v-if="error" class="error-banner">{{ error }}</p>
<p v-if="notice" class="notice-banner">{{ notice }}</p> <p v-if="notice" class="notice-banner">{{ notice }}</p>
<p v-if="taskQueryError" class="error-banner" role="alert">{{ taskQueryError }}</p>
</div> </div>
<div class="dashboard-workspace"> <div class="dashboard-workspace">
@ -504,8 +508,8 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<button class="close-button" type="button" aria-label="关闭" @click="closeTaskDetails">×</button> <button class="close-button" type="button" aria-label="关闭" @click="closeTaskDetails">×</button>
</div> </div>
<div v-if="taskDetailsLoading" class="empty-state">正在读取任务详情……</div> <div v-if="taskDetailsLoading" class="empty-state">正在读取任务详情……</div>
<p v-else-if="taskDetailsError" class="form-error">{{ taskDetailsError }}</p> <p v-if="taskDetailsError" class="form-error" role="alert">{{ taskDetailsError }}</p>
<template v-else-if="taskDetails"> <template v-if="taskDetails">
<div class="task-detail-summary"> <div class="task-detail-summary">
<div><span>当前状态</span><strong class="tag" :class="statusClass(taskDetails.status)">{{ displayLabel(taskStatusLabels, taskDetails.status) }}</strong></div> <div><span>当前状态</span><strong class="tag" :class="statusClass(taskDetails.status)">{{ displayLabel(taskStatusLabels, taskDetails.status) }}</strong></div>
<div><span>执行阶段</span><strong>{{ taskStageLabel(taskDetails.stage) }}</strong></div> <div><span>执行阶段</span><strong>{{ taskStageLabel(taskDetails.stage) }}</strong></div>
@ -516,6 +520,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<span>任务信息</span> <span>任务信息</span>
<p>{{ taskDetails.message || "无附加信息" }}</p> <p>{{ taskDetails.message || "无附加信息" }}</p>
</div> </div>
<TaskDeploymentResults v-if="taskDeploymentReport" :report="taskDeploymentReport" />
<h3 class="modal-section-title">执行尝试</h3> <h3 class="modal-section-title">执行尝试</h3>
<div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div> <div v-if="taskAttempts.length === 0" class="empty-state compact-empty">暂无执行尝试记录</div>
<div v-else class="attempt-list"> <div v-else class="attempt-list">

View File

@ -2,6 +2,14 @@ export function serverAuthLabel(server) {
return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置"; return server.password_configured ? "SSH 密码已配置" : "SSH 密码未配置";
} }
export function taskExecutionLabel(status) {
return {
queued: "排队中", running: "执行中", retry_wait: "等待重试",
cancelling: "正在取消,等待收尾", cancelled: "已取消",
success: "已完成", failed: "失败", partial_success: "部分成功",
}[status] || "等待确认状态";
}
export function deploymentSummary(report) { export function deploymentSummary(report) {
if (!report || !Array.isArray(report.results)) return "未收到有效部署结果"; if (!report || !Array.isArray(report.results)) return "未收到有效部署结果";
if (report.status !== "success" && report.recovery_status === "success") { if (report.status !== "success" && report.recovery_status === "success") {

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { deploymentStatusLabel, deploymentSummary } from "./DeploymentFeedback.js"; import { deploymentStatusLabel, deploymentSummary, taskExecutionLabel } from "./DeploymentFeedback.js";
defineProps({ result: { type: Object, required: true } }); defineProps({ result: { type: Object, required: true } });
defineEmits(["close"]); defineEmits(["close"]);
@ -27,8 +27,14 @@ defineEmits(["close"]);
@click="$emit('close')" @click="$emit('close')"
>×</button> >×</button>
</div> </div>
<p v-if="result.running" role="status">正在连接服务器、推送证书并校验重载,请稍候……</p> <p v-if="result.running" role="status">正在提交操作,请稍候……</p>
<template v-else> <template v-else>
<template v-if="result.taskID">
<p class="muted">任务编号:{{ result.taskID }}</p>
<p role="status">{{ taskExecutionLabel(result.taskStatus) }}:{{ result.taskMessage }}</p>
<p v-if="result.pending" class="muted">任务已在后台接收,可关闭此窗口,在运行概览继续查看;请勿重复提交。</p>
<p v-if="result.queryError" class="form-error" role="alert">{{ result.queryError }}</p>
</template>
<p v-if="result.certificateImported" role="status">证书已导入并保存。</p> <p v-if="result.certificateImported" role="status">证书已导入并保存。</p>
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p> <p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
<p v-if="result.report" class="deployment-summary" role="status"> <p v-if="result.report" class="deployment-summary" role="status">
@ -49,7 +55,7 @@ defineEmits(["close"]);
</span> </span>
</article> </article>
</div> </div>
<p class="muted">各服务器结果记录文件推送、配置校验与重载;站点 HTTPS 校验单独汇总,不代表逐台服务器均已验证。</p> <p v-if="result.report" class="muted">各服务器结果仅记录本次尝试的推送结果,整体状态以任务终态为准;不能据此推断每台站点的 HTTPS 校验均通过。</p>
</template> </template>
<div class="modal-actions"> <div class="modal-actions">
<button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')"> <button class="secondary-button" type="button" :disabled="result.running" @click="$emit('close')">

View File

@ -0,0 +1,24 @@
<script setup>
import { deploymentSummary, deploymentStatusLabel } from "./DeploymentFeedback.js";
defineProps({ report: { type: Object, required: true } });
</script>
<template>
<section aria-label="最近一次尝试的部署结果">
<h3 class="modal-section-title">本次部署结果</h3>
<p role="status">{{ deploymentSummary(report) }}</p>
<div class="attempt-list">
<article v-for="(item, index) in report.results" :key="item.server_id || index" class="attempt-row">
<div>
<strong>{{ item.server || "未命名服务器" }}</strong>
<span class="muted">{{ item.message || "无附加信息" }}</span>
</div>
<span class="tag">{{ deploymentStatusLabel(item.status) }}</span>
</article>
</div>
<p class="muted">逐服务器结果不代表整体部署或站点 HTTPS 校验成功,请结合任务状态判断。</p>
</section>
</template>
<style scoped src="./Dashboard.css"></style>

View File

@ -0,0 +1,190 @@
import { onBeforeUnmount, onMounted, ref } from "vue";
const terminalStatuses = new Set(["success", "failed", "cancelled", "partial_success"]);
export function taskIsPending(task) {
return !terminalStatuses.has(task?.status);
}
export function latestDeploymentReport(task, attempts, results) {
const latest = Math.max(0, ...attempts.map((item) => item.attempt_number || 0));
const items = results.filter((item) => item.attempt_number === latest);
if (taskIsPending(task) || latest === 0 || items.length === 0) return null;
return {
status: task.status,
total: items.length,
success: items.filter((item) => item.status === "success").length,
failed: items.filter((item) => item.status !== "success").length,
results: items,
};
}
// 查询失败只表示暂时无法确认状态,不能据此重发任务或宣布执行失败。
export function useTaskMonitor({ fetchAdmin, tasks, deploymentResult, showTaskDetails,
taskDetails, taskAttempts, taskDetailsLoading, taskDetailsError, refreshResources }) {
const taskQueryError = ref("");
const taskDeploymentReport = ref(null);
let selectedID = "";
let stopped = false;
let timer;
let controller;
let inFlight;
let resourcesDirty = false;
async function readJSON(path, signal) {
const response = await fetchAdmin(path, { signal });
const result = await response.json().catch(() => null);
if (!response.ok || !result) {
const error = new Error("任务状态查询失败,将自动重试;请勿重复提交任务");
error.status = response.status;
throw error;
}
return result;
}
async function readTask(id, signal) {
const path = `/api/v1/tasks/${encodeURIComponent(id)}`;
const task = await readJSON(path, signal);
if (task.id !== id || !task.status) throw new Error("任务状态响应不完整,将自动重试");
const [attempts, results] = await Promise.all([
readJSON(`${path}/attempts`, signal),
readJSON(`${path}/deployment-results`, signal),
]);
if (!Array.isArray(attempts.items) || !Array.isArray(results.items)) {
throw new Error("任务执行记录响应不完整,将自动重试");
}
const latestAttempt = attempts.items.reduce((latest, item) =>
!latest || item.attempt_number > latest.attempt_number ? item : latest, null);
if (!taskIsPending(task) && latestAttempt && latestAttempt.status !== task.status) {
throw new Error("任务记录正在更新,将重新查询一致的执行结果");
}
return { task, attempts: attempts.items, report: latestDeploymentReport(task, attempts.items, results.items) };
}
async function poll() {
const activeController = new AbortController();
controller = activeController;
const signal = activeController.signal;
const timeout = setTimeout(() => activeController.abort(), 10000);
const detailID = selectedID;
const deploymentID = deploymentResult.value?.pending ? deploymentResult.value.taskID : "";
try {
const list = await readJSON("/api/v1/tasks?limit=20", signal);
if (!Array.isArray(list.items)) throw new Error("任务列表响应不完整,将自动重试");
if (stopped) return;
const previous = new Map(tasks.value.map((item) => [item.id, item]));
if (list.items.some((item) => previous.has(item.id) &&
taskIsPending(previous.get(item.id)) && !taskIsPending(item))) resourcesDirty = true;
tasks.value = list.items;
taskQueryError.value = "";
const ids = [...new Set([detailID, deploymentID].filter(Boolean))];
await Promise.all(ids.map(async (id) => {
try {
const snapshot = await readTask(id, signal);
if (stopped) return;
if (selectedID === id && showTaskDetails.value) {
taskDetails.value = snapshot.task;
taskAttempts.value = snapshot.attempts;
taskDeploymentReport.value = snapshot.report;
taskDetailsError.value = "";
}
if (deploymentResult.value?.taskID === id) {
if (deploymentResult.value.pending && !taskIsPending(snapshot.task)) resourcesDirty = true;
deploymentResult.value = {
...deploymentResult.value,
pending: taskIsPending(snapshot.task),
taskStatus: snapshot.task.status,
taskMessage: snapshot.task.message || "",
report: snapshot.report,
queryError: "",
};
}
} catch (error) {
if (stopped) return;
const denied = error.status === 401 || error.status === 403;
const message = denied ? "当前已无权读取此任务,请联系项目管理员"
: "暂时无法查询任务状态,将自动重试;这不代表任务失败,请勿重复提交";
if (selectedID === id) {
taskDetailsError.value = message;
if (denied) {
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
}
}
if (deploymentResult.value?.taskID === id) {
deploymentResult.value.queryError = message;
if (denied) {
deploymentResult.value.report = null;
deploymentResult.value.taskMessage = "";
deploymentResult.value.taskStatus = "";
}
}
}
}));
if (!stopped && resourcesDirty && refreshResources) {
await refreshResources(signal);
resourcesDirty = false;
}
} catch (error) {
if (!stopped) {
if (error.status === 401 || error.status === 403) {
tasks.value = [];
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
if (deploymentResult.value) {
deploymentResult.value.report = null;
deploymentResult.value.taskMessage = "";
}
}
taskQueryError.value = "任务状态查询失败,将自动重试;请勿重复提交任务";
if (selectedID === detailID && detailID) taskDetailsError.value = taskQueryError.value;
if (deploymentID && deploymentResult.value?.taskID === deploymentID) {
deploymentResult.value.queryError = taskQueryError.value;
}
}
} finally {
clearTimeout(timeout);
if (!stopped && selectedID === detailID) taskDetailsLoading.value = false;
}
}
function refreshTaskStatus() {
if (stopped) return Promise.resolve();
if (!inFlight) inFlight = poll().finally(() => { inFlight = null; });
return inFlight;
}
async function openTaskDetails(item) {
selectedID = item.id;
showTaskDetails.value = true;
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskDetailsError.value = "";
taskDetailsLoading.value = true;
await refreshTaskStatus();
// 用户切换详情时,旧请求不能覆盖新选择;等待旧查询结束后再读取新任务。
if (!stopped && selectedID === item.id && taskDetailsLoading.value) await refreshTaskStatus();
}
function closeTaskDetails() {
selectedID = "";
showTaskDetails.value = false;
taskDetails.value = null;
taskAttempts.value = [];
taskDeploymentReport.value = null;
taskDetailsError.value = "";
taskDetailsLoading.value = false;
}
onMounted(() => { timer = window.setInterval(refreshTaskStatus, 3000); });
onBeforeUnmount(() => {
stopped = true;
window.clearInterval(timer);
controller?.abort();
});
return { taskQueryError, taskDeploymentReport, refreshTaskStatus, openTaskDetails, closeTaskDetails };
}

View File

@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import test from "node:test";
import { useDashboard } from "./Dashboard.js";
import { latestDeploymentReport } from "./TaskMonitor.js";
import { deploymentSummary } from "./DeploymentFeedback.js";
import { deferred, mountComposable } from "../../test-support/mount.js";
const initialTask = { id: "async-task", domain_id: "domain-1", task_type: "certificate_deploy", status: "running" };
const target = { server_id: "server-1", server: "部署目标", status: "success", attempt_number: 1 };
function taskFetcher(readTask, onPost = () => {}) {
return async (path, options = {}) => {
const task = readTask();
if (options.method === "POST") {
onPost(options);
return Response.json({ task, accepted: true }, { status: 202 });
}
if (path === `/api/v1/tasks/${task.id}`) return Response.json(task);
if (path.endsWith("/attempts")) return Response.json({ items: [{ attempt_number: 1, status: task.status }] });
if (path.endsWith("/deployment-results")) return Response.json({ items: task.status === "running" ? [] : [target] });
if (path.startsWith("/api/v1/tasks?")) return Response.json({ items: [task] });
return Response.json({ items: [] });
};
}
test("异步部署接收后不误报成功或缺失结果,最终失败不被逐目标成功覆盖", async (t) => {
let current = { ...initialTask };
const posts = [];
const { state } = await mountComposable(t, useDashboard, taskFetcher(() => current, (options) => posts.push(options)));
await state.deployDomain({ id: "domain-1", name: "example.test" });
assert.equal(posts.length, 1);
assert.match(posts[0].headers["Idempotency-Key"], /^deploy-domain-1-/);
assert.equal(state.deploymentResult.value.pending, true);
assert.equal(state.deploymentResult.value.running, false);
assert.equal(state.deploymentResult.value.error, "");
assert.equal(state.deploymentResult.value.report, null);
assert.match(state.notice.value, /任务已提交/);
current = { ...current, status: "failed", message: "站点校验未通过" };
await state.refreshTaskStatus();
assert.equal(state.deploymentResult.value.pending, false);
assert.equal(state.deploymentResult.value.taskStatus, "failed");
assert.equal(state.deploymentResult.value.report.results[0].status, "success");
assert.doesNotMatch(deploymentSummary(state.deploymentResult.value.report), /部署完成/);
assert.equal(posts.length, 1);
});
test("刷新页面从持久化任务恢复详情,关闭结果窗口不取消任务", async (t) => {
let current = { ...initialTask };
const { state } = await mountComposable(t, useDashboard, taskFetcher(() => current));
assert.equal(state.tasks.value[0].id, current.id);
await state.openTaskDetails(current);
assert.equal(state.taskDetails.value.status, "running");
state.deploymentResult.value = null;
current = { ...current, status: "success", message: "证书部署成功" };
await state.refreshTaskStatus();
assert.equal(state.taskDetails.value.status, "success");
assert.equal(state.taskDeploymentReport.value.results.length, 1);
state.closeTaskDetails();
assert.equal(state.taskDetails.value, null);
});
test("部署结果只汇总最新尝试,不混用上一次失败或伪造站点验证", () => {
const task = { ...initialTask, status: "success" };
const attempts = [{ attempt_number: 1 }, { attempt_number: 2 }];
const report = latestDeploymentReport(task, attempts, [
{ ...target, status: "failed" }, { ...target, attempt_number: 2 },
]);
assert.equal(report.total, 1);
assert.equal(report.failed, 0);
assert.equal(report.verification_status, undefined);
assert.equal(latestDeploymentReport(task, attempts, [target]), null);
assert.equal(latestDeploymentReport(initialTask, attempts, [target]), null);
});
test("状态查询断线保留后台任务,恢复后继续读取且不会再次提交", async (t) => {
let unavailable = false;
let posts = 0;
const fetchTask = taskFetcher(() => initialTask, () => { posts += 1; });
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (unavailable && path.startsWith("/api/v1/tasks")) return Promise.reject(new Error("连接中断"));
return fetchTask(path, options);
});
await state.deployDomain({ id: "domain-1", name: "example.test" });
unavailable = true;
await state.refreshTaskStatus();
assert.equal(state.deploymentResult.value.pending, true);
assert.equal(state.deploymentResult.value.taskStatus, "running");
assert.match(state.deploymentResult.value.queryError, /勿重复提交/);
unavailable = false;
await state.refreshTaskStatus();
assert.equal(state.deploymentResult.value.queryError, "");
assert.equal(posts, 1);
});
test("任务轮询不重叠,卸载取消在途查询并停止计时器", async (t) => {
let block = false;
let requests = 0;
let aborted = false;
const fetchTask = taskFetcher(() => initialTask);
const { state, unmount, runIntervals } = await mountComposable(t, useDashboard, (path, options) => {
if (block && path.startsWith("/api/v1/tasks?")) {
requests += 1;
return new Promise((resolve, reject) => {
options.signal.addEventListener("abort", () => { aborted = true; reject(new Error("已停止查询")); });
});
}
return fetchTask(path, options);
});
block = true;
const pending = state.refreshTaskStatus();
runIntervals();
runIntervals();
assert.equal(requests, 1);
unmount();
await pending;
runIntervals();
assert.equal(aborted, true);
assert.equal(requests, 1);
assert.equal(state.taskQueryError.value, "");
});
test("切换详情时迟到请求不能覆盖新任务", async (t) => {
const delayed = deferred();
let blocked = true;
const fetchTask = taskFetcher(() => initialTask);
const { state } = await mountComposable(t, useDashboard, async (path, options) => {
if (path === "/api/v1/tasks/first" && blocked) return delayed.promise;
if (path === "/api/v1/tasks/second") return Response.json({ ...initialTask, id: "second" });
return fetchTask(path, options);
});
const first = state.openTaskDetails({ id: "first" });
await new Promise((resolve) => setImmediate(resolve));
const second = state.openTaskDetails({ id: "second" });
blocked = false;
delayed.resolve(Response.json({ ...initialTask, id: "first" }));
await Promise.all([first, second]);
assert.equal(state.taskDetails.value.id, "second");
assert.equal(state.taskDetailsLoading.value, false);
});
test("异步取消与重试只提示请求已接收,不提前宣布终态", async (t) => {
const fetchTask = taskFetcher(() => initialTask);
const { state } = await mountComposable(t, useDashboard, (path, options = {}) => {
if (options.method === "DELETE") return Response.json({ task: { ...initialTask, status: "cancelling" } }, { status: 202 });
return fetchTask(path, options);
});
window.confirm = () => true;
await state.cancelTask(initialTask);
assert.match(state.notice.value, /等待任务停止和部署收尾/);
assert.doesNotMatch(state.notice.value, /任务已取消/);
await state.retryTask({ ...initialTask, status: "failed" });
assert.match(state.notice.value, /重试已提交/);
assert.doesNotMatch(state.notice.value, /已重新执行/);
});
test("代理返回非 JSON 时保留原状态,不泄露 HTML 或误报完成", async (t) => {
let invalid = false;
const fetchTask = taskFetcher(() => initialTask);
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (invalid && path.startsWith("/api/v1/tasks/")) return Promise.resolve(new Response("<html>504</html>", { status: 504 }));
return fetchTask(path, options);
});
await state.openTaskDetails(initialTask);
invalid = true;
await state.refreshTaskStatus();
assert.equal(state.taskDetails.value.status, "running");
assert.match(state.taskDetailsError.value, /不代表任务失败/);
assert.doesNotMatch(state.taskDetailsError.value, /<html>|Unexpected/);
});
test("任务与尝试读取遇到并发重试时不混用旧终态和新结果", async (t) => {
let retrying = false;
const current = { ...initialTask, status: "success" };
const fetchTask = taskFetcher(() => current);
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (retrying && path.endsWith("/attempts")) {
return Promise.resolve(Response.json({ items: [{ attempt_number: 2, status: "running" }] }));
}
return fetchTask(path, options);
});
await state.openTaskDetails(current);
retrying = true;
await state.refreshTaskStatus();
assert.match(state.taskDetailsError.value, /暂时无法查询/);
assert.equal(state.taskAttempts.value[0].attempt_number, 1);
});
test("项目撤权后清除缓存的任务详情和部署结果", async (t) => {
let denied = false;
const current = { ...initialTask, status: "success" };
const fetchTask = taskFetcher(() => current);
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (denied && path.startsWith("/api/v1/tasks/")) {
return Promise.resolve(Response.json({ error: "无权访问此任务" }, { status: 403 }));
}
return fetchTask(path, options);
});
await state.openTaskDetails(current);
assert.equal(state.taskDeploymentReport.value.results.length, 1);
denied = true;
await state.refreshTaskStatus();
assert.equal(state.taskDetails.value, null);
assert.equal(state.taskDeploymentReport.value, null);
assert.deepEqual(state.taskAttempts.value, []);
assert.match(state.taskDetailsError.value, /无权读取/);
});
test("任务完成后自动刷新域名证书数据,但不覆盖正在编辑的设置", async (t) => {
let current = { ...initialTask };
let resourceReads = 0;
const fetchTask = taskFetcher(() => current);
const { state } = await mountComposable(t, useDashboard, (path, options) => {
if (path === "/api/v1/domains") {
resourceReads += 1;
return Promise.resolve(Response.json({ items: [{ id: "domain-1", status: current.status === "success" ? "active" : "pending" }] }));
}
return fetchTask(path, options);
});
state.settingsForm.value.notification.sender_name = "正在编辑的名称";
assert.equal(state.domains.value[0].status, "pending");
current = { ...current, status: "success" };
await state.refreshTaskStatus();
assert.equal(state.domains.value[0].status, "active");
assert.equal(state.settingsForm.value.notification.sender_name, "正在编辑的名称");
assert.equal(resourceReads, 2);
});