完成手动续期后台任务及权限隔离并更新回归进度
保留管理与业务续期调度,增加系统级幂等任务、取消传播和统计审计原子收尾;前端跟踪真实任务状态。原生 Linux 全后端、静态检查、相关专项二十次重复及前端二百七十二项测试和生产构建通过,未进行正式生产部署。
This commit is contained in:
parent
4ab3f93beb
commit
413a0e66a9
@ -16,6 +16,7 @@
|
||||
| POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 |
|
||||
| POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 |
|
||||
| POST /api/v1/domains/:id/certificate/validate | 提交当前本地证书校验,无请求体 | 可见域名、私钥读取权限;不额外要求证书管理、DNS 或部署执行权限;202 |
|
||||
| POST /api/v1/renewal/run | 提交管理证书及业务域名的全局续期检查,无请求体 | 当前有效超级管理员;202,不再同步返回 report |
|
||||
| POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 |
|
||||
| DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 |
|
||||
| GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 |
|
||||
@ -65,6 +66,22 @@
|
||||
- 400:幂等键等输入无效;401:登录失效;403:无权访问或来源错误;409:初始化未完成或状态冲突;500:持久化等内部错误;501:工作流未配置;503:权限服务未就绪、服务停止或队列已满。
|
||||
- 错误响应一般含 error,可能带 request_id 或 task;网关也可能返回非 JSON 页面。解析失败只能提示暂时无法确认,不能推断任务已失败或自动再次提交。
|
||||
|
||||
## 全局手动续期检查
|
||||
|
||||
本入口继续调用原续期调度器,保留管理证书、业务域名、调度互斥锁、续期子任务、紧急重试等待与取消传播;不缩减为只检查业务域名。自动调度行为不因 HTTP 后台化改变。
|
||||
|
||||
- 系统任务固定为 task_type=renewal_check、domain_id=global-renewal-check、scope_type=system、project_id 为空;它既不是业务域名,也不是单次管理证书签发任务。
|
||||
- 提交与取得执行槽后均重新核验当前超级管理员。普通管理员及普通用户不能读取该任务、尝试或完成审计,也不能取消;排队期间停用或降级则不执行续期。
|
||||
- 支持合法 Idempotency-Key。同键命中原续期任务返回 200、task、replayed=true;另一键遇到活动全局检查返回 409、原 task、replayed=true;新任务返回 202、task、accepted=true 和 Location。前端必须确认任务类型,不能把任意 409 当作已受理。
|
||||
- 同步旧版的 200 加 report 不是新版受理响应,前端必须提示结果无法确认,不能显示检查完成。响应丢失时保留原幂等键;确认受理后,下一次明确操作才生成新键。
|
||||
- DELETE /api/v1/tasks/:id 接收取消意图后,继续轮询终态。已发生的续期或部署不会因父任务取消自动撤销;取消和超时摘要保留已返回的执行统计。
|
||||
- 终态摘要保存检查、临期、选中、紧急、成功、失败和异常数量,与任务尝试及完成审计同事务提交;通过现有 task.message 和 attempt.message 展示。没有新增结构化续期 report 接口,也不伪造部署报告或保存底层敏感错误。
|
||||
- 有失败或异常时为 failed,已有成功项时可为 partial_success;取消为 cancelled、超时为 failed,不因下游返回空错误而显示整体成功。异常中断提示核查已生成的子任务,不自动重放。
|
||||
- 不支持通过通用 retry 重试 renewal_check;完成后如需再次检查,由超级管理员明确重新提交。逐域名部署、校验与恢复结果仍以对应子任务为准。
|
||||
- 400 表示幂等键无效,403 表示超级管理员权限校验未通过,501 表示续期工作流未配置,503 表示任务依赖未就绪、服务停止或队列已满,500 表示持久化等错误。队列拒绝时可能已保存失败任务,不能假定从未创建任务。
|
||||
|
||||
前端使用独立 RenewalAction.js 管理提交状态和幂等键,复用任务详情跟踪结果;禁止重复点击时并发提交,不能仅凭受理响应显示续期完成。
|
||||
|
||||
## 数据模型
|
||||
|
||||
| 模型 | 字段 |
|
||||
|
||||
@ -12,23 +12,9 @@ func (s *Server) runRenewalNow(c *gin.Context) {
|
||||
s.writeError(c, http.StatusNotImplemented, "续期服务未配置", errors.New("续期工作流未配置"))
|
||||
return
|
||||
}
|
||||
report, err := s.runRenewal(c.Request.Context())
|
||||
if len(report.Errors) > 0 {
|
||||
// 保留失败数量,不将证书路径、凭据或下游 SDK 响应带回浏览器。
|
||||
messages := make([]string, len(report.Errors))
|
||||
for index := range messages {
|
||||
messages[index] = "证书续期失败,请检查对应任务及受保护的服务日志"
|
||||
}
|
||||
report.Errors = messages
|
||||
}
|
||||
response := gin.H{"report": report}
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Error("执行证书续期失败", "error", err)
|
||||
}
|
||||
response["error"] = "执行证书续期失败"
|
||||
c.JSON(http.StatusConflict, response)
|
||||
if taskRepositoryUnavailable(s.tasks) || s.users == nil {
|
||||
s.writeError(c, http.StatusServiceUnavailable, "续期任务或权限服务尚未就绪", nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
s.submitRenewalTask(c)
|
||||
}
|
||||
|
||||
161
backend/internal/httpapi/renewal_task.go
Normal file
161
backend/internal/httpapi/renewal_task.go
Normal file
@ -0,0 +1,161 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
// 全局检查不是业务域名,也不是单次管理证书签发;子续期仍由原调度器负责。
|
||||
func (s *Server) submitRenewalTask(c *gin.Context) {
|
||||
operatorID := s.currentOperatorID(c)
|
||||
if err := s.authorizeRenewalOperator(c.Request.Context(), operatorID); err != nil {
|
||||
s.writeError(c, http.StatusForbidden, "只有当前有效的超级管理员可以执行全局续期检查", nil)
|
||||
return
|
||||
}
|
||||
key := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||
if key != "" && !validTaskIdempotencyKey(key) {
|
||||
s.writeError(c, http.StatusBadRequest, "幂等键格式无效", nil)
|
||||
return
|
||||
}
|
||||
s.issueTaskMu.Lock()
|
||||
defer s.issueTaskMu.Unlock()
|
||||
if key != "" {
|
||||
item, err := s.tasks.FindTaskByIdempotencyKey(c.Request.Context(), repository.RenewalCheckResourceID, key)
|
||||
if err == nil {
|
||||
if item.TaskType != "renewal_check" || item.ScopeType != repository.ScopeSystem {
|
||||
s.writeError(c, http.StatusConflict, "幂等键已用于另一种操作", nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"task": item, "replayed": true})
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.writeError(c, http.StatusInternalServerError, "检查续期幂等任务失败", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if active, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), repository.RenewalCheckResourceID, "renewal_check"); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "全局续期检查已在执行,请查看现有任务", "task": active, "replayed": true})
|
||||
return
|
||||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||||
s.writeError(c, http.StatusInternalServerError, "检查活动续期任务失败", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
task := repository.Task{
|
||||
HistoryScope: repository.HistoryScope{ScopeType: repository.ScopeSystem},
|
||||
ID: domain.NewID(), DomainID: repository.RenewalCheckResourceID, TaskType: "renewal_check",
|
||||
Status: "queued", Stage: "queued", Message: "等待检查管理证书和业务域名续期",
|
||||
OperatorID: operatorID, IdempotencyKey: key, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "创建续期检查任务失败", err)
|
||||
return
|
||||
}
|
||||
attempt := repository.TaskAttempt{
|
||||
ID: domain.NewID(), TaskID: task.ID, AttemptNumber: 1, Stage: "renewal_check",
|
||||
Status: "running", Message: task.Message, CreatedAt: now,
|
||||
}
|
||||
if err := s.tasks.CreateAttempt(c.Request.Context(), attempt); err != nil {
|
||||
finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if finishErr := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
|
||||
TaskID: task.ID, Status: "failed", Stage: "record", Message: "创建续期尝试失败,未执行续期",
|
||||
FinishedAt: time.Now().UTC(), WithoutAttempt: true, AuditAction: task.TaskType,
|
||||
}); finishErr != nil && s.logger != nil {
|
||||
s.logger.Error("续期任务收尾失败,保留启动恢复状态", "task_id", task.ID, "error", finishErr)
|
||||
}
|
||||
s.writeError(c, http.StatusInternalServerError, "创建续期尝试失败", err)
|
||||
return
|
||||
}
|
||||
if s.enqueueTaskWorker(c, task, attempt, func(ctx context.Context) {
|
||||
s.runRenewalTask(ctx, task, attempt)
|
||||
}, nil) {
|
||||
writeAcceptedCertificateTask(c, task)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorizeRenewalOperator(ctx context.Context, operatorID string) error {
|
||||
if s.users == nil || operatorID == "" {
|
||||
return repository.ErrProjectAccessDenied
|
||||
}
|
||||
user, err := s.users.GetUserByID(ctx, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user.Role != repository.UserRoleSuperAdmin || user.Status != repository.UserStatusActive {
|
||||
return repository.ErrProjectAccessDenied
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) runRenewalTask(ctx context.Context, task repository.Task, attempt repository.TaskAttempt) {
|
||||
if s.finishCancelledTask(task, attempt, ctx) {
|
||||
return
|
||||
}
|
||||
if err := s.authorizeRenewalOperator(ctx, task.OperatorID); err != nil {
|
||||
attempt.Stage = "authorize"
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", "续期执行前超级管理员权限校验未通过,未执行续期")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := s.tasks.UpdateTask(ctx, task.ID, "running", "renewal_check", "正在检查管理证书和业务域名续期", &now, nil); err != nil {
|
||||
if !s.finishCancelledTask(task, attempt, ctx) {
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", "续期检查启动失败,未执行续期")
|
||||
}
|
||||
return
|
||||
}
|
||||
if s.audit != nil {
|
||||
if err := s.audit.CreateAuditLog(ctx, repository.AuditLog{
|
||||
TaskID: task.ID, OperatorID: task.OperatorID, Action: task.TaskType,
|
||||
ResourceType: "task", ResourceID: task.ID, Status: "running", Message: "开始全局续期检查",
|
||||
}); err != nil {
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", "记录续期开始审计失败,未执行续期")
|
||||
return
|
||||
}
|
||||
}
|
||||
if s.finishCancelledTask(task, attempt, ctx) {
|
||||
return
|
||||
}
|
||||
// 不继承父任务事务编号,原续期工作流自行生成子任务及部署事务。
|
||||
executionCtx := repository.WithTaskOperator(ctx, task.OperatorID)
|
||||
executionCtx = repository.WithTaskCancellationHooks(executionCtx, repository.TaskCancellationHooks{
|
||||
Register: s.registerTaskCancel, Unregister: s.unregisterTaskCancel,
|
||||
})
|
||||
report, runErr := s.runRenewal(executionCtx)
|
||||
s.taskMu.Lock()
|
||||
defer s.taskMu.Unlock()
|
||||
status, prefix := "success", "续期检查完成"
|
||||
if runErr != nil || report.Failed > 0 || len(report.Errors) > 0 {
|
||||
status, prefix = "failed", "续期检查未全部完成"
|
||||
if report.Succeeded > 0 {
|
||||
status = "partial_success"
|
||||
}
|
||||
}
|
||||
// 即使下游忽略取消并返回成功,也不得将取消或超时伪装为整体成功。
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) || errors.Is(runErr, context.DeadlineExceeded) {
|
||||
status, prefix = "failed", "续期检查超时,以下为已执行统计"
|
||||
} else if errors.Is(ctx.Err(), context.Canceled) || errors.Is(runErr, context.Canceled) {
|
||||
status, prefix = "cancelled", "续期检查已取消,以下为已执行统计"
|
||||
}
|
||||
message := fmt.Sprintf("%s:检查 %d 项,临期 %d 项,选中 %d 项,紧急 %d 项,成功 %d 项,失败 %d 项,异常 %d 项;逐项结果请查看续期任务",
|
||||
prefix, report.Checked, report.NearExpiry, report.Selected, report.Urgent, report.Succeeded, report.Failed, len(report.Errors))
|
||||
finishCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := s.tasks.FinishTaskAttempt(finishCtx, repository.TaskCompletion{
|
||||
TaskID: task.ID, AttemptID: attempt.ID, Status: status, Stage: "renewal_check",
|
||||
Message: message, StartedAt: &now, FinishedAt: time.Now().UTC(), AuditAction: task.TaskType,
|
||||
}); err != nil && s.logger != nil {
|
||||
s.logger.Error("保存续期检查终态失败,保留启动恢复状态", "task_id", task.ID, "error", err)
|
||||
}
|
||||
}
|
||||
207
backend/internal/httpapi/renewal_task_test.go
Normal file
207
backend/internal/httpapi/renewal_task_test.go
Normal file
@ -0,0 +1,207 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestAsyncRenewalLifecycle(t *testing.T) {
|
||||
for _, scenario := range []string{"success", "partial", "failure", "nested_error", "panic", "cancel", "shutdown", "timeout", "replay", "duplicate", "revoked", "demoted", "full", "stopped"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
var calls atomic.Int32
|
||||
server.runRenewal = func(ctx context.Context) (certificate.RenewalReport, error) {
|
||||
calls.Add(1)
|
||||
if ctx.Err() != nil || repository.TaskOperatorFromContext(ctx) != "test-super-admin" {
|
||||
t.Error("后台续期上下文错误或继承已断开的请求")
|
||||
}
|
||||
report := certificate.RenewalReport{Checked: 3, NearExpiry: 2, Selected: 2, Urgent: 1, Succeeded: 2}
|
||||
switch scenario {
|
||||
case "partial":
|
||||
report.Succeeded, report.Failed = 1, 1
|
||||
case "failure":
|
||||
return certificate.RenewalReport{}, errors.New("private-renewal-secret")
|
||||
case "nested_error":
|
||||
return certificate.RenewalReport{Failed: 1, Errors: []string{"private-renewal-secret"}}, nil
|
||||
case "panic":
|
||||
panic("private-renewal-secret")
|
||||
case "timeout":
|
||||
return report, context.DeadlineExceeded
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
server.asyncTasks = make(map[string]bool)
|
||||
server.taskSlots = make(chan struct{}, 1)
|
||||
server.taskSlots <- struct{}{}
|
||||
if scenario == "full" {
|
||||
for index := range 32 {
|
||||
server.asyncTasks[string(rune('a'+index))] = true
|
||||
}
|
||||
}
|
||||
if scenario == "stopped" {
|
||||
server.stopTaskWorkers()
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil)
|
||||
request.Header.Set("Idempotency-Key", "renewal-check-test")
|
||||
ctx, cancel := context.WithCancel(request.Context())
|
||||
response := httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, request.WithContext(ctx))
|
||||
cancel()
|
||||
if scenario == "full" || scenario == "stopped" {
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("未拒绝不可执行任务:%d", response.Code)
|
||||
}
|
||||
items, err := tasks.ListTasks(t.Context(), repository.RenewalCheckResourceID, 10)
|
||||
if err != nil || len(items) != 1 || items[0].Status != "failed" || calls.Load() != 0 {
|
||||
t.Fatalf("拒绝后遗留任务:%+v %v", items, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
task := submittedTask(t, response)
|
||||
if calls.Load() != 0 || task.ScopeType != repository.ScopeSystem || task.ProjectID != "" {
|
||||
t.Fatalf("受理阶段已执行或伪装项目:%+v", task)
|
||||
}
|
||||
if scenario == "replay" || scenario == "duplicate" {
|
||||
repeat := httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil)
|
||||
key := "renewal-check-test"
|
||||
if scenario == "duplicate" {
|
||||
key = "renewal-check-other"
|
||||
}
|
||||
repeat.Header.Set("Idempotency-Key", key)
|
||||
response = httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, repeat)
|
||||
if (response.Code != http.StatusOK && response.Code != http.StatusConflict) || !strings.Contains(response.Body.String(), task.ID) || !strings.Contains(response.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("未找回原任务:%s", response.Body.String())
|
||||
}
|
||||
}
|
||||
want, wantCalls := "success", int32(1)
|
||||
switch scenario {
|
||||
case "partial":
|
||||
want = "partial_success"
|
||||
case "failure", "nested_error", "panic", "timeout":
|
||||
want = "failed"
|
||||
case "cancel":
|
||||
response = httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(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())
|
||||
}
|
||||
want, wantCalls = "cancelled", 0
|
||||
case "shutdown":
|
||||
server.stopTaskWorkers()
|
||||
want, wantCalls = "cancelled", 0
|
||||
case "revoked", "demoted":
|
||||
user, err := server.users.GetUserByID(t.Context(), task.OperatorID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scenario == "revoked" {
|
||||
user.Status = repository.UserStatusDisabled
|
||||
} else {
|
||||
user.Role = repository.UserRoleAdmin
|
||||
}
|
||||
if err := server.users.UpdateUser(t.Context(), user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, wantCalls = "failed", 0
|
||||
}
|
||||
<-server.taskSlots
|
||||
waitTaskWorkersForTest(t, server)
|
||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||
if err != nil || stored.Status != want || calls.Load() != wantCalls || strings.Contains(stored.Message, "private-renewal-secret") {
|
||||
t.Fatalf("续期终态错误:%+v %v calls=%d", stored, err, calls.Load())
|
||||
}
|
||||
attempts, err := tasks.ListAttempts(t.Context(), task.ID)
|
||||
if err != nil || len(attempts) != 1 || attempts[0].Status != want || attempts[0].Message != stored.Message || attempts[0].ScopeType != repository.ScopeSystem {
|
||||
t.Fatalf("尝试与任务不一致:%+v %v", attempts, err)
|
||||
}
|
||||
for _, role := range []string{repository.UserRoleAdmin, repository.UserRoleUser} {
|
||||
response = httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+task.ID, nil)
|
||||
request.AddCookie(httpUserSessionCookie(server, "renewal-viewer-"+role, role))
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("普通账户可读取全局统计:%s %d", role, response.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncRenewalRunningCancellationPreservesCounts(t *testing.T) {
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
started := make(chan struct{})
|
||||
server.runRenewal = func(ctx context.Context) (certificate.RenewalReport, error) {
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
// 下游错误地忽略取消返回成功时,父任务仍不得宣称全部成功。
|
||||
return certificate.RenewalReport{Checked: 3, Selected: 2, Succeeded: 1}, nil
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
|
||||
task := submittedTask(t, response)
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("续期未开始")
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodDelete, "/api/v1/tasks/"+task.ID, nil))
|
||||
if response.Code != http.StatusAccepted {
|
||||
t.Fatalf("取消未接收:%d", response.Code)
|
||||
}
|
||||
waitTaskWorkersForTest(t, server)
|
||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||
if err != nil || stored.Status != "cancelled" || !strings.Contains(stored.Message, "成功 1 项") {
|
||||
t.Fatalf("取消丢失已执行事实:%+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
type renewalTaskDomainLister struct{}
|
||||
|
||||
func (renewalTaskDomainLister) List(context.Context) ([]domain.Domain, error) {
|
||||
return []domain.Domain{{ID: "domain-1", Name: "example.test", AutoRenew: true}}, nil
|
||||
}
|
||||
|
||||
type renewalTaskIssuer struct{ calls atomic.Int32 }
|
||||
|
||||
func (i *renewalTaskIssuer) Issue(context.Context, string) (certificate.IssueResult, error) {
|
||||
i.calls.Add(1)
|
||||
return certificate.IssueResult{}, nil
|
||||
}
|
||||
|
||||
type renewalTaskManagement struct{ calls atomic.Int32 }
|
||||
|
||||
func (m *renewalTaskManagement) RunRenewal(context.Context, certificate.RenewalPolicy, time.Time) (certificate.RenewalReport, error) {
|
||||
m.calls.Add(1)
|
||||
return certificate.RenewalReport{Checked: 1, Selected: 1, Succeeded: 1}, nil
|
||||
}
|
||||
|
||||
func TestAsyncRenewalPreservesManagementAndDomainScheduler(t *testing.T) {
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
issuer := &renewalTaskIssuer{}
|
||||
management := &renewalTaskManagement{}
|
||||
scheduler := certificate.NewRenewalSchedulerWithManagement(renewalTaskDomainLister{}, issuer, certificate.RenewalPolicy{}, management)
|
||||
server.runRenewal = scheduler.RunOnce
|
||||
response := httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
|
||||
task := submittedTask(t, response)
|
||||
waitTaskWorkersForTest(t, server)
|
||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||
if err != nil || stored.Status != "success" || issuer.calls.Load() != 1 || management.calls.Load() != 1 || !strings.Contains(stored.Message, "检查 2 项") || !strings.Contains(stored.Message, "成功 2 项") {
|
||||
t.Fatalf("真实调度器未完整运行管理及业务续期:%+v %v business=%d management=%d", stored, err, issuer.calls.Load(), management.calls.Load())
|
||||
}
|
||||
}
|
||||
@ -13,18 +13,20 @@ import (
|
||||
)
|
||||
|
||||
func TestRenewalReportHidesNestedWorkflowErrors(t *testing.T) {
|
||||
server := New(Dependencies{
|
||||
Config: readyTestConfig(t.TempDir()),
|
||||
RunRenewal: func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil
|
||||
},
|
||||
})
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "internal-secret-sentinel") {
|
||||
t.Fatalf("续期报告不得返回底层敏感错误:%d", response.Code)
|
||||
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, "internal-secret-sentinel") {
|
||||
t.Fatalf("续期报告不得返回底层敏感错误:%+v %v", stored, err)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"failed":1`) {
|
||||
if !strings.Contains(stored.Message, "失败 1 项") {
|
||||
t.Fatal("脱敏不能丢失续期失败计数")
|
||||
}
|
||||
}
|
||||
|
||||
@ -243,7 +243,7 @@ func (s *Server) registerRoutes() {
|
||||
adminAPI.GET("/dns/accounts", s.listDNSAccounts)
|
||||
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
|
||||
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain)
|
||||
adminAPI.POST("/renewal/run", s.requireSuperAdmin, s.longOperation(certificateTaskTimeout), s.runRenewalNow)
|
||||
adminAPI.POST("/renewal/run", s.requireSuperAdmin, 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.deployCertificateCurrent)
|
||||
|
||||
@ -362,57 +362,34 @@ func TestSettingsAPIReturnsChineseNotificationCredentialError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenewalAPIUsesConfiguredRunner(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir())
|
||||
cfg.SetupReady = true
|
||||
cfg.DNS.VerificationState = "verified"
|
||||
cfg.Web.Hostname = "scert.example.test"
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.Email = "admin@example.test"
|
||||
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
|
||||
called := false
|
||||
server := New(Dependencies{
|
||||
Config: cfg,
|
||||
RunRenewal: func(context.Context) (certificate.RenewalReport, error) {
|
||||
called = true
|
||||
return certificate.RenewalReport{Checked: 2, Selected: 1, Succeeded: 1}, nil
|
||||
},
|
||||
})
|
||||
cookie := loginForSettingsTest(t, server)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil)
|
||||
request.AddCookie(cookie)
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{Checked: 2, Selected: 1, Succeeded: 1}, nil
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !called || !strings.Contains(response.Body.String(), `"selected":1`) {
|
||||
t.Fatalf("续期接口调用结果不正确:%d %s;called=%v", response.Code, response.Body.String(), called)
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
|
||||
task := submittedTask(t, response)
|
||||
waitTaskWorkersForTest(t, server)
|
||||
stored, err := tasks.GetTask(t.Context(), task.ID)
|
||||
if err != nil || stored.Status != "success" || !strings.Contains(stored.Message, "检查 2 项") || !strings.Contains(stored.Message, "成功 1 项") {
|
||||
t.Fatalf("续期任务结果不正确:%+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalAPIHidesWorkflowErrors(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir())
|
||||
cfg.SetupReady = true
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.Email = "admin@example.test"
|
||||
cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123")
|
||||
server := New(Dependencies{
|
||||
Config: cfg,
|
||||
RunRenewal: func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{}, errors.New("private-key-secret-path")
|
||||
},
|
||||
})
|
||||
cookie := loginForSettingsTest(t, server)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil)
|
||||
request.AddCookie(cookie)
|
||||
server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
|
||||
defer cleanup()
|
||||
server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
|
||||
return certificate.RenewalReport{}, errors.New("private-key-secret-path")
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, request)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("renewal failure returned an unexpected status: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
body := response.Body.String()
|
||||
if strings.Contains(body, "private-key-secret-path") {
|
||||
t.Fatalf("renewal API leaked an internal error: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, "\u6267\u884c\u8bc1\u4e66\u7eed\u671f\u5931\u8d25") {
|
||||
t.Fatalf("renewal API did not include the safe error message: %s", body)
|
||||
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", 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-key-secret-path") || !strings.Contains(stored.Message, "未全部完成") {
|
||||
t.Fatalf("续期失败任务未安全保留结果:%+v %v", stored, err)
|
||||
}
|
||||
}
|
||||
func TestRenewalAPIRequiresSuperAdmin(t *testing.T) {
|
||||
|
||||
@ -444,7 +444,12 @@ func (s *Server) cancelTask(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "管理证书任务由续期调度控制,不能单独修改取消状态", "task": item})
|
||||
return
|
||||
}
|
||||
if !s.authorizeProjectPermissions(c, item.ProjectID, domain.PermissionTaskCancel) {
|
||||
if item.TaskType == "renewal_check" {
|
||||
if err := s.authorizeRenewalOperator(c.Request.Context(), s.currentOperatorID(c)); err != nil {
|
||||
s.writeError(c, http.StatusForbidden, "只有当前有效的超级管理员可以取消全局续期检查", nil)
|
||||
return
|
||||
}
|
||||
} else if !s.authorizeProjectPermissions(c, item.ProjectID, domain.PermissionTaskCancel) {
|
||||
return
|
||||
}
|
||||
s.taskMu.Lock()
|
||||
|
||||
@ -54,6 +54,9 @@ func (s *Server) enqueueTaskWorker(c *gin.Context, task repository.Task, attempt
|
||||
if task.TaskType == "certificate_validate" {
|
||||
message = "证书校验异常中断,未保存通过结果"
|
||||
}
|
||||
if task.TaskType == "renewal_check" {
|
||||
message = "续期检查异常中断,请检查已生成的续期任务,不会自动重放"
|
||||
}
|
||||
s.finishTaskBeforeExecution(task, attempt, "failed", message)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -7,10 +7,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeProject = "project"
|
||||
ScopeSystem = "system"
|
||||
ScopeUnknown = "unknown"
|
||||
ManagementResourceID = "management-certificate"
|
||||
ScopeProject = "project"
|
||||
ScopeSystem = "system"
|
||||
ScopeUnknown = "unknown"
|
||||
ManagementResourceID = "management-certificate"
|
||||
RenewalCheckResourceID = "global-renewal-check"
|
||||
)
|
||||
|
||||
// HistoryScope 保存事件发生时的范围,资源或项目删除后仍保留;不是项目授权凭证。
|
||||
|
||||
@ -26,6 +26,9 @@ func (r *SQLProjectMemberRepository) ListVisibleAuditLogs(ctx context.Context, a
|
||||
logs.scope_type, logs.project_id, logs.task_id, logs.operator_id
|
||||
FROM audit_logs logs WHERE EXISTS (
|
||||
SELECT 1 FROM users actor WHERE actor.id = ? AND actor.status = 'active'
|
||||
AND (actor.role = 'super_admin' OR NOT EXISTS (
|
||||
SELECT 1 FROM tasks restricted_task WHERE restricted_task.id = logs.task_id
|
||||
AND restricted_task.task_type = 'renewal_check'))
|
||||
AND (actor.role IN ('super_admin', 'admin') OR (actor.role = 'user'
|
||||
AND logs.scope_type = 'project' AND EXISTS (
|
||||
SELECT 1 FROM project_members member
|
||||
|
||||
@ -48,6 +48,7 @@ func taskVisibilityPredicate(alias string, permission domain.ProjectPermission)
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM users task_actor
|
||||
WHERE task_actor.id = ? AND task_actor.status = 'active'
|
||||
AND (` + alias + `.task_type <> 'renewal_check' OR task_actor.role = 'super_admin')
|
||||
AND (task_actor.role IN ('super_admin', 'admin') OR (
|
||||
task_actor.role = 'user' AND ` + alias + `.scope_type = 'project'
|
||||
AND EXISTS (SELECT 1 FROM project_members task_member
|
||||
|
||||
93
backend/internal/repository/renewal_check_test.go
Normal file
93
backend/internal/repository/renewal_check_test.go
Normal file
@ -0,0 +1,93 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRenewalCheckScopeVisibilityAndCompletion(t *testing.T) {
|
||||
repo, db := newTaskRepositoryTest(t)
|
||||
defer db.Close()
|
||||
ctx := t.Context()
|
||||
for _, role := range []string{UserRoleSuperAdmin, UserRoleAdmin, UserRoleUser} {
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO users (id, username, password_hash, role, status) VALUES (?, ?, 'test-hash', ?, 'active')`, role, role, role); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
task := Task{ID: "global-check", DomainID: RenewalCheckResourceID, TaskType: "renewal_check", Status: "queued", Stage: "queued", OperatorID: UserRoleSuperAdmin}
|
||||
for _, forged := range []Task{
|
||||
{ID: "forged-domain", DomainID: "domain-1", TaskType: "renewal_check", Status: "queued", Stage: "queued"},
|
||||
{ID: "forged-management", DomainID: ManagementResourceID, TaskType: "renewal_check", Status: "queued", Stage: "queued"},
|
||||
{ID: "forged-type", DomainID: RenewalCheckResourceID, TaskType: "certificate_issue", Status: "queued", Stage: "queued"},
|
||||
{ID: "forged-project", DomainID: RenewalCheckResourceID, TaskType: "renewal_check", Status: "queued", Stage: "queued", HistoryScope: HistoryScope{ScopeType: ScopeProject, ProjectID: "default"}},
|
||||
} {
|
||||
if err := repo.CreateTask(ctx, forged); err == nil {
|
||||
t.Fatalf("接受了伪造范围:%+v", forged)
|
||||
}
|
||||
}
|
||||
if err := repo.CreateTask(ctx, task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
duplicate := task
|
||||
duplicate.ID = "duplicate-check"
|
||||
if err := repo.CreateTask(ctx, duplicate); !errors.Is(err, ErrTaskAlreadyRunning) {
|
||||
t.Fatalf("未拒绝重复检查:%v", err)
|
||||
}
|
||||
if err := repo.CreateAttempt(ctx, TaskAttempt{ID: "check-attempt", TaskID: task.ID, AttemptNumber: 1, Status: "running", Stage: "renewal_check"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{UserRoleAdmin, UserRoleUser} {
|
||||
if _, err := repo.GetVisibleTask(ctx, role, task.ID); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("全局任务越权可见:%s %v", role, err)
|
||||
}
|
||||
if err := repo.RequestCancelVisibleTask(ctx, role, task.ID); !errors.Is(err, ErrProjectAccessDenied) {
|
||||
t.Fatalf("全局任务越权取消:%s %v", role, err)
|
||||
}
|
||||
}
|
||||
if stored, err := repo.GetVisibleTask(ctx, UserRoleSuperAdmin, task.ID); err != nil || stored.ScopeType != ScopeSystem || stored.ProjectID != "" {
|
||||
t.Fatalf("系统范围丢失:%+v %v", stored, err)
|
||||
}
|
||||
completion := TaskCompletion{TaskID: task.ID, AttemptID: "check-attempt", Status: "success", Stage: "renewal_check", Message: "续期检查完成:检查 2 项,成功 1 项", FinishedAt: time.Now().UTC(), AuditAction: "renewal_check"}
|
||||
if _, err := db.ExecContext(ctx, `CREATE TRIGGER reject_renewal_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'injected failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.FinishTaskAttempt(ctx, completion); err == nil {
|
||||
t.Fatal("审计失败未回滚")
|
||||
}
|
||||
stored, err := repo.GetTask(ctx, task.ID)
|
||||
if err != nil || stored.Status != "queued" {
|
||||
t.Fatalf("审计失败仍保存任务成功:%+v %v", stored, err)
|
||||
}
|
||||
attempts, err := repo.ListAttempts(ctx, task.ID)
|
||||
if err != nil || len(attempts) != 1 || attempts[0].Status != "running" {
|
||||
t.Fatalf("审计失败仍保存尝试成功:%+v %v", attempts, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `DROP TRIGGER reject_renewal_audit`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.FinishTaskAttempt(ctx, completion); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var scope, resourceType, resourceID, message string
|
||||
if err := db.QueryRowContext(ctx, `SELECT scope_type, resource_type, resource_id, message FROM audit_logs WHERE task_id = ?`, task.ID).Scan(&scope, &resourceType, &resourceID, &message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scope != ScopeSystem || resourceType != "task" || resourceID != task.ID || message != completion.Message {
|
||||
t.Fatalf("全局检查被伪装为管理证书或未记录摘要:%s %s %s %s", scope, resourceType, resourceID, message)
|
||||
}
|
||||
for _, role := range []string{UserRoleSuperAdmin, UserRoleAdmin, UserRoleUser} {
|
||||
logs, err := NewSQLProjectMemberRepository(db).ListVisibleAuditLogs(ctx, role, 10)
|
||||
want := 0
|
||||
if role == UserRoleSuperAdmin {
|
||||
want = 1
|
||||
}
|
||||
if err != nil || len(logs) != want {
|
||||
t.Fatalf("全局续期审计可见性错误:%s %+v %v", role, logs, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := repo.RetryVisibleTask(ctx, UserRoleSuperAdmin, task.ID, nil); !errors.Is(err, ErrTaskNotRetryable) {
|
||||
t.Fatalf("全局检查不应通用重试:%v", err)
|
||||
}
|
||||
}
|
||||
@ -14,8 +14,8 @@ func (r *SQLTaskRepository) recordCompletionAudit(ctx context.Context, tx *sql.T
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, r.query(`INSERT INTO audit_logs
|
||||
(action, resource_type, resource_id, status, message, created_at, scope_type, project_id, task_id, operator_id)
|
||||
SELECT ?, CASE WHEN scope_type = 'system' THEN 'management_certificate' ELSE 'domain' END,
|
||||
domain_id, ?, ?, ?, scope_type, project_id, id, operator_id
|
||||
SELECT ?, CASE WHEN task_type = 'renewal_check' THEN 'task' WHEN scope_type = 'system' THEN 'management_certificate' ELSE 'domain' END,
|
||||
CASE WHEN task_type = 'renewal_check' THEN id ELSE domain_id END, ?, ?, ?, scope_type, project_id, id, operator_id
|
||||
FROM tasks WHERE id = ?`), completion.AuditAction, status, completion.Message,
|
||||
completion.FinishedAt.UTC(), completion.TaskID)
|
||||
if err != nil {
|
||||
|
||||
@ -110,8 +110,12 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
|
||||
}
|
||||
insertQuery := `INSERT INTO tasks
|
||||
(id, domain_id, task_type, status, stage, message, operator_id, certificate_version_id, idempotency_key, started_at, finished_at, created_at, updated_at, scope_type, project_id) `
|
||||
if item.TaskType == "management_renewal" {
|
||||
if item.DomainID != ManagementResourceID || !item.HistoryScope.matches(HistoryScope{ScopeType: ScopeSystem}) {
|
||||
if item.TaskType == "management_renewal" || item.TaskType == "renewal_check" {
|
||||
resourceID := ManagementResourceID
|
||||
if item.TaskType == "renewal_check" {
|
||||
resourceID = RenewalCheckResourceID
|
||||
}
|
||||
if item.DomainID != resourceID || !item.HistoryScope.matches(HistoryScope{ScopeType: ScopeSystem}) {
|
||||
return errors.New("管理证书任务范围无效")
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
@ -139,7 +143,7 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if item.DomainID == ManagementResourceID || (item.ScopeType != "" && item.ScopeType != ScopeProject) {
|
||||
if item.DomainID == ManagementResourceID || item.DomainID == RenewalCheckResourceID || (item.ScopeType != "" && item.ScopeType != ScopeProject) {
|
||||
return errors.New("业务任务必须归属于实际域名项目")
|
||||
}
|
||||
|
||||
@ -441,6 +445,7 @@ func (r *SQLTaskRepository) CreateAttempt(ctx context.Context, item TaskAttempt)
|
||||
SELECT ?, id, ?, ?, ?, ?, ?, ?, ? FROM tasks t WHERE id = ?
|
||||
AND (? = '' OR scope_type = ?) AND (? = '' OR project_id = ?)
|
||||
AND ((t.scope_type = 'system' AND t.task_type = 'management_renewal' AND t.domain_id = 'management-certificate')
|
||||
OR (t.scope_type = 'system' AND t.task_type = 'renewal_check' AND t.domain_id = 'global-renewal-check')
|
||||
OR (t.scope_type = 'project' AND EXISTS (SELECT 1 FROM domains d JOIN projects p ON p.id = d.project_id
|
||||
WHERE d.id = t.domain_id AND d.project_id = t.project_id)))`), item.ID, item.AttemptNumber, item.Stage, item.Status, item.Message,
|
||||
nullableTime(item.StartedAt), nullableTime(item.FinishedAt), item.CreatedAt.UTC(), item.TaskID,
|
||||
|
||||
@ -1,5 +1,17 @@
|
||||
# Linux 生产流程测试记录
|
||||
|
||||
## 2026年10月2日21:30 手动全局续期后台化回归(未部署)
|
||||
|
||||
- [x] 修改前基线 4ab3f93 已提交推送。本批继续只用密码 SSH/SCP 和严格主机密钥校验;第一台隔离目录 /var/tmp/scert-report-race-20261002-1801/renewal-source,以 nobody 运行原生测试,不创建辅助验收脚本。
|
||||
- [x] 最新后端增量 renewal-changes-second-20261002.tar 两端 SHA256 一致:ac2fa2715cb83848f54bbb25e235fe1bb079f5e21c39520dda03bd6b6a5b5f7d。完整基线 renewal-full-baseline-4ab3f93-20261002.tar 两端 SHA256 一致:e8efd10986484bad1d9d4bb0ffb01ef72b40f14c2d53f4fbffe253370ad3ae09。归档为测试源码,不是正式发布包。
|
||||
- [x] 第一版专项 renewal-first.log 完成,RENEWAL_FIRST_EXIT=0;断开 SSH 后重新核验进程及日志确认终态,未因会话断开重复开测。该版不含后补审计隔离及真实调度器联调用例,不用它代表最终全部改动。
|
||||
- [!] 最新源码第一次全量 renewal-second.log 退出一:后端单独基线漏带 deploy、build、.gitignore 等源树依赖,安装器、发布边界及服务单元测试失败;HTTP 与仓储包分别通过。没有删除失败日志或跳过检查。确认终态后补齐完整同提交基线、重新覆盖最新增量,并为源码边界测试建立隔离 Git 索引。
|
||||
- [x] renewal-final-full-vet.log:go test -p 1 ./... -count=1 与 go vet -p 1 ./... 均完成,RENEWAL_FINAL_FULL_EXIT=0、RENEWAL_FINAL_VET_EXIT=0。使用 Go 1.26.6、GOMAXPROCS=1、GOMEMLIMIT=384MiB 和现有本地模块代理;测试自行创建独立空数据库,外部实库默认跳过,不算三数据库完整验收。
|
||||
- [x] renewal-final-repeat.log:TestAsyncRenewal、TestRenewalAPI、TestRenewalReport、TestRenewalCheck 重复二十次通过,RENEWAL_FINAL_REPEAT_EXIT=0;HTTP 包 45.438 秒、仓储包 2.480 秒。包含十四种生命周期场景、运行取消保留计数、真实调度器管理与业务双路径、权限隔离及审计故障事务回滚;不是竞争检测。
|
||||
- [x] .runtime/renewal-frontend-second-20261002.log:272 项通过、零失败、零跳过;.runtime/renewal-frontend-build-20261002.log:生产构建退出零。新增测试已纳入正式入口,第一版 266 项结果不作为本批完整证明。
|
||||
- [x] 21:30 复核第一台 Scert、Nginx、MySQL 均 active,磁盘约剩 13GB;本批未替换线上程序、未重启生产服务、未清空线上数据库、未新增公网监听。
|
||||
- [!] 尚无本批竞争检测、浏览器或正式部署验收通过证据;不关闭其他长操作、历史恢复、升级、通知重置、三库首次 Web、备份恢复、监控及整体生产门禁。
|
||||
|
||||
## 2026年10月2日20:34 证书校验后台任务及幂等契约回归(未部署)
|
||||
|
||||
### 代码与隔离边界
|
||||
|
||||
@ -1,5 +1,19 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月2日21:30 手动续期后台化回归完成,等待复审
|
||||
|
||||
需求沿用已确认的 P1-05/P2-03:保留管理证书和全部业务域名的原续期调度,不缩减行为;先开发、自动回归,再统一部署。修改前基线 4ab3f93 已提交推送。
|
||||
|
||||
- [x] 新增系统级手动续期检查任务,持久化后返回 202,支持同键找回和活动任务去重;不伪装业务域名或管理证书签发。
|
||||
- [x] 提交、执行及取消均校验当前超级管理员权限;任务及审计查询隔离普通管理员和用户。保留管理证书与全部业务域名的原续期调度、锁、子任务、重试等待和取消传播。
|
||||
- [x] 任务、尝试及审计同事务收尾,中文摘要保存真实计数;失败、部分成功、取消和超时分别表达,不保存底层敏感错误或伪造部署报告。异常中断不自动重放,不开放通用任务重试。
|
||||
- [x] 独立 RenewalAction 管理防重复提交和幂等键,前端显示已提交并跟踪任务,重开详情可恢复结果;运行概览补齐导入、校验和续期取消入口。
|
||||
- [x] 最新前端 272 项全部通过、零跳过,生产构建通过。此前 266 项未包含新增入口测试,不作为本批完整结果;已补入正式测试入口并执行。
|
||||
- [x] 异步接口说明已更新,明确 202/200/409 语义、有效超级管理员范围及统计只保存在任务摘要;没有新增结构化续期报告接口。
|
||||
- [x] 最新原生 Linux 全后端测试及 go vet 均通过;首次全量因隔离归档漏带安装器、发布脚本及仓库规则失败的日志保留,补齐同一基线完整源码和隔离 Git 索引后重跑,未跳过失败检查。
|
||||
- [x] 续期生命周期、取消保留统计、真实管理与业务调度联动、范围隔离及审计事务回滚专项重复二十次通过;HTTP 包 45.438 秒、仓储包 2.480 秒。普通重复不替代竞争检测。
|
||||
- [!] 本批不是完整生产验收,不关闭历史恢复、升级、通知重置、三库首次流程、备份恢复及 P3。
|
||||
|
||||
## 2026年10月2日20:34 证书校验后台任务回归完成,等待复审
|
||||
|
||||
本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月2日21:30 当前开发进度:手动续期后台化完成待复审
|
||||
|
||||
- [x] P1-05/P2-03 子项:全局手动续期持久化为系统任务后返回 202,保留管理证书及业务域名原调度;支持幂等找回、活动任务去重、取消传播和执行前超级管理员复核。
|
||||
- [x] P0-10/P1-04 配套:统计摘要、任务、尝试与完成审计同事务;失败、部分成功、取消、超时及异常不伪装整体成功。普通管理员及用户不可读取该全局任务或审计;不自动重放,不支持通用重试。
|
||||
- [x] 前端独立提交模块防重复点击并保留丢失响应的幂等键,复用任务详情;最新 272 项测试及生产构建通过。接口说明已同步,统计保存在摘要,不声称存在独立结构化续期报告。
|
||||
- [x] 最新原生 Linux 全后端、go vet 及相关专项二十次重复回归通过。首次全量因隔离测试输入不完整而失败,补齐同一提交的完整源码及 Git 索引后重跑通过;失败记录保留,未跳过检查。
|
||||
- [!] 尚未正式部署,历史版本恢复、升级重启 502、通知重置、三库首次 Web、备份恢复、监控和 P3 未关闭;不处理 Windows,但不从原清单删除范围。
|
||||
|
||||
## 2026年10月2日20:34 当前开发进度:证书校验后台化完成待复审
|
||||
|
||||
当前开发批次对应 P0-10/P1-04/P2-03;不是仍停留在 P0-03 的代码开发,也不代表这些整项已经复审通过。下方带时间的记录保留为历史证据,最新状态以本入口和执行清单为准。原 P0/P1/P2/P3 范围不缩减,Windows 按本轮要求不处理。
|
||||
@ -1471,6 +1479,8 @@ frontend/src/views/Tasks/
|
||||
|
||||
**状态:** `[~] 进行中`
|
||||
|
||||
**2026年10月2日21:30 子项进度:** 手动全局检查已后台化,保留管理与业务原续期调度及取消语义;新增系统任务权限隔离、幂等和真实摘要,前端回归及构建、原生 Linux 全后端与静态检查、相关专项二十次重复均通过,等待部署复审。此子项不代表固定日期、标签、项目、证书类型等全部策略均已实现,整项继续进行中。
|
||||
|
||||
续期策略支持:
|
||||
|
||||
```text
|
||||
@ -1767,7 +1777,7 @@ cancelled 已取消
|
||||
|
||||
**状态:** `[~] 进行中`
|
||||
|
||||
**2026年10月2日20:34 子项进度:** 申请、当前证书部署、申请/部署重试、生产证书导入及本地证书校验已接入 HTTP 异步任务;导入具有上传生命周期和保存标志,校验具有独立元数据及最近尝试详情,均复核执行前权限。最新自动回归通过待复审,尚未统一生产部署;手动续期、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。
|
||||
**2026年10月2日21:30 子项进度:** 申请、当前证书部署、申请/部署重试、生产证书导入及本地证书校验已接入 HTTP 异步任务;导入具有上传生命周期和保存标志,校验具有独立元数据及最近尝试详情,均复核执行前权限。手动全局续期本批已实现,前后端自动回归、构建及相关专项二十次重复通过。尚未统一生产部署;历史恢复及其余列出的长操作不因本批推进而关闭,整项继续进行中。
|
||||
|
||||
以下操作不能长期阻塞 HTTP 请求:
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { settingsPayload } from "./SettingsPayload.js";
|
||||
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
|
||||
import { useDomainForm } from "./DomainForm.js";
|
||||
import { useTaskMonitor } from "./TaskMonitor.js";
|
||||
import { useRenewalAction } from "./RenewalAction.js";
|
||||
|
||||
const defaultDNSVerificationTimeoutSeconds = 60;
|
||||
const aliyunDNSVerificationTimeoutSeconds = 6;
|
||||
@ -82,6 +83,9 @@ export function useDashboard() {
|
||||
taskAttempts, taskDetailsLoading, taskDetailsError,
|
||||
refreshResources: refreshTaskResources,
|
||||
});
|
||||
const { runRenewalNow, renewalSubmitting } = useRenewalAction({
|
||||
canRunRenewal, error, notice, fetchAdmin, taskMonitor,
|
||||
});
|
||||
const hasOpenModal = computed(
|
||||
() =>
|
||||
showForm.value ||
|
||||
@ -143,6 +147,7 @@ export function useDashboard() {
|
||||
};
|
||||
taskStatusLabels.error = taskStatusLabels.failed;
|
||||
const taskTypeLabels = {
|
||||
renewal_check: "全局续期检查",
|
||||
management_renewal: "管理证书续期",
|
||||
certificate_issue: "申请证书",
|
||||
certificate_import: "导入证书",
|
||||
@ -153,6 +158,7 @@ export function useDashboard() {
|
||||
dns_test: "DNS 在线测试",
|
||||
};
|
||||
const auditActionLabels = {
|
||||
renewal_check: "全局续期检查",
|
||||
management_renewal: "管理证书续期",
|
||||
certificate_issue: "申请证书",
|
||||
certificate_import: "导入证书",
|
||||
@ -170,6 +176,7 @@ export function useDashboard() {
|
||||
renewal_run: "执行自动续期",
|
||||
};
|
||||
const taskStageLabels = {
|
||||
renewal_check: "检查管理证书和业务域名续期",
|
||||
management_renewal: "管理证书续期",
|
||||
preflight: "续期前置检查",
|
||||
issue: "申请管理证书",
|
||||
@ -680,25 +687,6 @@ export function useDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runRenewalNow() {
|
||||
if (!canRunRenewal.value) {
|
||||
error.value = "只有超级管理员可以执行续期检查";
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
const response = await fetchAdmin("/api/v1/renewal/run", { method: "POST" });
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "执行续期检查失败");
|
||||
const report = result.report || {};
|
||||
notice.value = `续期检查完成:检查 ${report.checked || 0} 项,选中 ${report.selected || 0} 项,成功 ${report.succeeded || 0} 项,失败 ${report.failed || 0} 项`;
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
error.value = err.message || "执行续期检查失败";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDNSProviderChange() {
|
||||
dnsRequestVersion += 1;
|
||||
stopDNSVerificationTimer();
|
||||
@ -1159,6 +1147,7 @@ export function useDashboard() {
|
||||
taskStageLabel,
|
||||
attemptStatusLabel,
|
||||
runRenewalNow,
|
||||
renewalSubmitting,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
saveSettings,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import "./DeploymentResult.test.js";
|
||||
import "./TaskMonitor.test.js";
|
||||
import "./RenewalAction.test.js";
|
||||
import "./DashboardLogout.test.js";
|
||||
import "./DomainModal.test.js";
|
||||
import "./DomainForm.test.js";
|
||||
@ -1087,7 +1088,7 @@ test("workflowTasks.renderOnlySupportedActions", async () => {
|
||||
});
|
||||
assert.deepEqual(compiled.errors, []);
|
||||
const render = new Function("Vue", compiled.code)(Vue);
|
||||
for (const taskType of ["certificate_issue", "renewal_run", "certificate_deploy", "management_renewal", "unknown"]) {
|
||||
for (const taskType of ["certificate_issue", "certificate_import", "certificate_validate", "renewal_check", "renewal_run", "certificate_deploy", "management_renewal", "unknown"]) {
|
||||
for (const status of ["queued", "running", "success", "failed", "cancelled", "partial_success"]) {
|
||||
const html = await renderToString(Vue.createSSRApp({
|
||||
render,
|
||||
@ -1104,7 +1105,7 @@ test("workflowTasks.renderOnlySupportedActions", async () => {
|
||||
retryTask: () => {},
|
||||
}),
|
||||
}));
|
||||
const canCancel = ["certificate_issue", "certificate_deploy"].includes(taskType) && ["queued", "running"].includes(status);
|
||||
const canCancel = ["certificate_issue", "certificate_deploy", "certificate_import", "certificate_validate", "renewal_check"].includes(taskType) && ["queued", "running"].includes(status);
|
||||
const canRetry = (taskType === "certificate_issue" && ["failed", "cancelled"].includes(status)) || (taskType === "certificate_deploy" && ["failed", "cancelled", "partial_success"].includes(status));
|
||||
const buttonCount = (html.match(/<button\b/g) || []).length;
|
||||
assert.equal(buttonCount, canCancel || canRetry ? 2 : 1, `${taskType}:${status}`);
|
||||
|
||||
@ -107,6 +107,7 @@ const {
|
||||
validateDomain,
|
||||
deployDomain,
|
||||
runRenewalNow,
|
||||
renewalSubmitting,
|
||||
canRunRenewal,
|
||||
cancelTask,
|
||||
openTaskDetails,
|
||||
@ -137,8 +138,14 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
<button class="secondary-button" type="button" @click="loadData">
|
||||
刷新状态
|
||||
</button>
|
||||
<button v-if="activeSection === 'activity' && canRunRenewal" class="secondary-button" type="button" @click="runRenewalNow">
|
||||
立即检查续期
|
||||
<button
|
||||
v-if="activeSection === 'activity' && canRunRenewal"
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="renewalSubmitting"
|
||||
@click="runRenewalNow"
|
||||
>
|
||||
{{ renewalSubmitting ? "正在提交" : "立即检查续期" }}
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
|
||||
44
frontend/src/views/Dashboard/RenewalAction.js
Normal file
44
frontend/src/views/Dashboard/RenewalAction.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function useRenewalAction({ canRunRenewal, error, notice, fetchAdmin, taskMonitor }) {
|
||||
const renewalSubmitting = ref(false);
|
||||
let idempotencyKey = "";
|
||||
|
||||
async function runRenewalNow() {
|
||||
if (!canRunRenewal.value) {
|
||||
error.value = "只有超级管理员可以执行续期检查";
|
||||
return;
|
||||
}
|
||||
if (renewalSubmitting.value) return;
|
||||
renewalSubmitting.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
if (!idempotencyKey) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
idempotencyKey = `renewal-check-${Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("")}`;
|
||||
}
|
||||
const response = await fetchAdmin("/api/v1/renewal/run", {
|
||||
method: "POST", headers: { "Idempotency-Key": idempotencyKey },
|
||||
});
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if ((response.ok || response.status === 409 && result.replayed) &&
|
||||
result.task?.id && result.task.task_type === "renewal_check" &&
|
||||
(response.status === 202 || result.replayed)) {
|
||||
idempotencyKey = "";
|
||||
notice.value = `续期检查任务已提交:${result.task.id},请在任务详情查看最终结果`;
|
||||
await taskMonitor.openTaskDetails(result.task);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error(result.error || "提交续期检查失败");
|
||||
throw new Error("服务未返回有效续期任务,请在运行概览确认是否已接收,勿重复提交");
|
||||
} catch (err) {
|
||||
// 未确认受理结果时保留幂等键,重试只找回原任务,不重放续期。
|
||||
error.value = err.message || "无法确认续期检查是否已接收,请先查看运行概览";
|
||||
} finally {
|
||||
renewalSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { runRenewalNow, renewalSubmitting };
|
||||
}
|
||||
93
frontend/src/views/Dashboard/RenewalAction.test.js
Normal file
93
frontend/src/views/Dashboard/RenewalAction.test.js
Normal file
@ -0,0 +1,93 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ref } from "vue";
|
||||
import { useRenewalAction } from "./RenewalAction.js";
|
||||
import { useDashboard } from "./Dashboard.js";
|
||||
import { deferred, mountComposable } from "../../test-support/mount.js";
|
||||
|
||||
const task = { id: "renewal-check-task", task_type: "renewal_check", status: "queued" };
|
||||
|
||||
test("续期提交防重复点击,响应丢失后保留幂等键找回原任务", async () => {
|
||||
const pending = deferred();
|
||||
const keys = [];
|
||||
const opened = [];
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const state = useRenewalAction({
|
||||
canRunRenewal: ref(true), error, notice,
|
||||
fetchAdmin: async (_, options) => {
|
||||
keys.push(options.headers["Idempotency-Key"]);
|
||||
if (keys.length === 1) return pending.promise;
|
||||
return Response.json({ task, replayed: true });
|
||||
},
|
||||
taskMonitor: { openTaskDetails: async (item) => opened.push(item) },
|
||||
});
|
||||
const request = state.runRenewalNow();
|
||||
assert.equal(state.renewalSubmitting.value, true);
|
||||
await state.runRenewalNow();
|
||||
assert.equal(keys.length, 1);
|
||||
pending.reject(new Error("连接中断"));
|
||||
await request;
|
||||
assert.equal(state.renewalSubmitting.value, false);
|
||||
assert.equal(notice.value, "");
|
||||
await state.runRenewalNow();
|
||||
assert.equal(keys[0], keys[1]);
|
||||
assert.match(keys[0], /^[A-Za-z0-9._-]{8,128}$/);
|
||||
assert.equal(opened.length, 1);
|
||||
assert.equal(error.value, "");
|
||||
assert.match(notice.value, /已提交/);
|
||||
assert.doesNotMatch(notice.value, /续期检查完成/);
|
||||
await state.runRenewalNow();
|
||||
assert.notEqual(keys[1], keys[2]);
|
||||
});
|
||||
|
||||
for (const response of [
|
||||
{ status: 202, body: {} },
|
||||
{ status: 200, body: { report: { succeeded: 5 } } },
|
||||
{ status: 202, body: { task: { ...task, task_type: "certificate_issue" } } },
|
||||
{ status: 503, body: { error: "任务队列已满" } },
|
||||
]) {
|
||||
test(`续期无有效受理结果不显示完成:${response.status} ${JSON.stringify(response.body)}`, async () => {
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const state = useRenewalAction({
|
||||
canRunRenewal: ref(true), error, notice,
|
||||
fetchAdmin: async () => Response.json(response.body, { status: response.status }),
|
||||
taskMonitor: { openTaskDetails: () => assert.fail("无有效任务不得打开详情") },
|
||||
});
|
||||
await state.runRenewalNow();
|
||||
assert.ok(error.value);
|
||||
assert.equal(notice.value, "");
|
||||
});
|
||||
}
|
||||
|
||||
test("真实续期入口跟踪已有任务,失败摘要持久化后可重新打开", async (t) => {
|
||||
let current = { ...task };
|
||||
let posts = 0;
|
||||
const { state } = await mountComposable(t, useDashboard, async (path, options = {}) => {
|
||||
if (path === "/api/v1/renewal/run") {
|
||||
posts++;
|
||||
assert.match(options.headers["Idempotency-Key"], /^[A-Za-z0-9._-]{8,128}$/);
|
||||
return Response.json({ task: current, replayed: true }, { status: 409 });
|
||||
}
|
||||
if (path === `/api/v1/tasks/${task.id}`) return Response.json(current);
|
||||
if (path.endsWith("/attempts")) return Response.json({ items: [{ attempt_number: 1, status: current.status }] });
|
||||
if (path.startsWith("/api/v1/tasks?")) return Response.json({ items: [current] });
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
state.currentUser.value = { role: "super_admin" };
|
||||
await state.runRenewalNow();
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(state.error.value, "");
|
||||
assert.equal(state.showTaskDetails.value, true);
|
||||
assert.equal(state.taskDetails.value.status, "queued");
|
||||
current = { ...current, status: "partial_success", message: "续期检查未全部完成:成功 1 项,失败 1 项" };
|
||||
await state.refreshTaskStatus();
|
||||
assert.equal(state.taskDetails.value.message, current.message);
|
||||
assert.equal(state.taskDeploymentReport.value, null);
|
||||
state.closeTaskDetails();
|
||||
await state.openTaskDetails(current);
|
||||
assert.equal(state.taskDetails.value.status, "partial_success");
|
||||
assert.equal(state.taskTypeLabel("renewal_check"), "全局续期检查");
|
||||
assert.equal(posts, 1);
|
||||
});
|
||||
@ -78,6 +78,7 @@ export function useRuntimeOverview(state) {
|
||||
}, clock.value));
|
||||
const visibleTasks = computed(() => detailView.value === "tasks" ? summary.value.tasks : summary.value.tasks.slice(0, 5));
|
||||
function taskDomain(task) {
|
||||
if (task.task_type === "renewal_check") return "管理证书与全部业务域名";
|
||||
if (task.task_type === "management_renewal") return "管理页面证书";
|
||||
return state.domains.value.find((item) => item.id === task.domain_id)?.name || "系统任务或已移除域名";
|
||||
}
|
||||
|
||||
@ -92,7 +92,7 @@ const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(pro
|
||||
<td>
|
||||
<button type="button" @click="openTaskDetails(item)">查看详情</button>
|
||||
<button
|
||||
v-if="(item.task_type === 'certificate_issue' || item.task_type === 'certificate_deploy') && (item.status === 'queued' || item.status === 'running')"
|
||||
v-if="['certificate_issue', 'certificate_deploy', 'certificate_import', 'certificate_validate', 'renewal_check'].includes(item.task_type) && (item.status === 'queued' || item.status === 'running')"
|
||||
type="button"
|
||||
@click="cancelTask(item)"
|
||||
>取消任务</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user