完成手动续期后台任务及权限隔离并更新回归进度

保留管理与业务续期调度,增加系统级幂等任务、取消传播和统计审计原子收尾;前端跟踪真实任务状态。原生 Linux 全后端、静态检查、相关专项二十次重复及前端二百七十二项测试和生产构建通过,未进行正式生产部署。
This commit is contained in:
xuans 2026-10-02 21:32:30 +08:00
parent 4ab3f93beb
commit 413a0e66a9
25 changed files with 739 additions and 107 deletions

View File

@ -16,6 +16,7 @@
| POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 | | POST /api/v1/domains/:id/deploy | 提交已有证书部署,无请求体 | 可见域名、部署执行权限;202 |
| POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 | | POST /api/v1/domains/:id/certificate/import | 上传证书后提交导入及部署任务 | 可见域名、证书管理、部署执行权限;不要求 DNS 凭据权限;202 |
| POST /api/v1/domains/:id/certificate/validate | 提交当前本地证书校验,无请求体 | 可见域名、私钥读取权限;不额外要求证书管理、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 | | POST /api/v1/tasks/:id/retry | 同一任务新增一次尝试,无请求体 | 重试、证书管理、部署执行权限;申请类还检查 DNS 凭据;202 |
| DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 | | DELETE /api/v1/tasks/:id | 请求取消 | 任务可见及取消权限;后台任务返回 202,非异步历史任务可能返回 200 |
| GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 | | GET /api/v1/tasks | 当前身份可见任务列表 | 支持 domain_id 和 limit;返回 200 与 items 数组 |
@ -65,6 +66,22 @@
- 400:幂等键等输入无效;401:登录失效;403:无权访问或来源错误;409:初始化未完成或状态冲突;500:持久化等内部错误;501:工作流未配置;503:权限服务未就绪、服务停止或队列已满。 - 400:幂等键等输入无效;401:登录失效;403:无权访问或来源错误;409:初始化未完成或状态冲突;500:持久化等内部错误;501:工作流未配置;503:权限服务未就绪、服务停止或队列已满。
- 错误响应一般含 error,可能带 request_id 或 task;网关也可能返回非 JSON 页面。解析失败只能提示暂时无法确认,不能推断任务已失败或自动再次提交。 - 错误响应一般含 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 管理提交状态和幂等键,复用任务详情跟踪结果;禁止重复点击时并发提交,不能仅凭受理响应显示续期完成。
## 数据模型 ## 数据模型
| 模型 | 字段 | | 模型 | 字段 |

View File

@ -12,23 +12,9 @@ func (s *Server) runRenewalNow(c *gin.Context) {
s.writeError(c, http.StatusNotImplemented, "续期服务未配置", errors.New("续期工作流未配置")) s.writeError(c, http.StatusNotImplemented, "续期服务未配置", errors.New("续期工作流未配置"))
return return
} }
report, err := s.runRenewal(c.Request.Context()) if taskRepositoryUnavailable(s.tasks) || s.users == nil {
if len(report.Errors) > 0 { s.writeError(c, http.StatusServiceUnavailable, "续期任务或权限服务尚未就绪", nil)
// 保留失败数量,不将证书路径、凭据或下游 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)
return return
} }
c.JSON(http.StatusOK, response) s.submitRenewalTask(c)
} }

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

View 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())
}
}

View File

@ -13,18 +13,20 @@ import (
) )
func TestRenewalReportHidesNestedWorkflowErrors(t *testing.T) { func TestRenewalReportHidesNestedWorkflowErrors(t *testing.T) {
server := New(Dependencies{ server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
Config: readyTestConfig(t.TempDir()), defer cleanup()
RunRenewal: func(context.Context) (certificate.RenewalReport, error) { server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil return certificate.RenewalReport{Failed: 1, Errors: []string{"internal-secret-sentinel"}}, nil
}, }
})
response := httptest.NewRecorder() response := httptest.NewRecorder()
serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil)) 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") { task := submittedTask(t, response)
t.Fatalf("续期报告不得返回底层敏感错误:%d", response.Code) 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("脱敏不能丢失续期失败计数") t.Fatal("脱敏不能丢失续期失败计数")
} }
} }

View File

@ -243,7 +243,7 @@ func (s *Server) registerRoutes() {
adminAPI.GET("/dns/accounts", s.listDNSAccounts) adminAPI.GET("/dns/accounts", s.listDNSAccounts)
adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS) adminAPI.POST("/dns/test", s.requireSuperAdminSystemConfig, s.dnsOperation, s.testAdminDNS)
adminAPI.POST("/dns/aliyun/check", s.requireSuperAdminSystemConfig, s.dnsOperation, s.checkAliyunDNSDomain) 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/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.deployCertificateCurrent) domainAPI.POST("/domains/:id/deploy", s.deployCertificateCurrent)

View File

@ -362,57 +362,34 @@ func TestSettingsAPIReturnsChineseNotificationCredentialError(t *testing.T) {
} }
func TestRenewalAPIUsesConfiguredRunner(t *testing.T) { func TestRenewalAPIUsesConfiguredRunner(t *testing.T) {
cfg := config.Default(t.TempDir()) server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
cfg.SetupReady = true defer cleanup()
cfg.DNS.VerificationState = "verified" server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
cfg.Web.Hostname = "scert.example.test" return certificate.RenewalReport{Checked: 2, Selected: 1, Succeeded: 1}, nil
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)
response := httptest.NewRecorder() response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request) serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
if response.Code != http.StatusOK || !called || !strings.Contains(response.Body.String(), `"selected":1`) { task := submittedTask(t, response)
t.Fatalf("续期接口调用结果不正确:%d %s;called=%v", response.Code, response.Body.String(), called) 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) { func TestRenewalAPIHidesWorkflowErrors(t *testing.T) {
cfg := config.Default(t.TempDir()) server, tasks, _, cleanup := newTaskAPITestServer(t, nil)
cfg.SetupReady = true defer cleanup()
cfg.Admin.Username = "admin" server.runRenewal = func(context.Context) (certificate.RenewalReport, error) {
cfg.Admin.Email = "admin@example.test" return certificate.RenewalReport{}, errors.New("private-key-secret-path")
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)
response := httptest.NewRecorder() response := httptest.NewRecorder()
server.Handler().ServeHTTP(response, request) serveSuperAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/renewal/run", nil))
if response.Code != http.StatusConflict { task := submittedTask(t, response)
t.Fatalf("renewal failure returned an unexpected status: %d %s", response.Code, response.Body.String()) waitTaskWorkersForTest(t, server)
} stored, err := tasks.GetTask(t.Context(), task.ID)
body := response.Body.String() if err != nil || stored.Status != "failed" || strings.Contains(stored.Message, "private-key-secret-path") || !strings.Contains(stored.Message, "未全部完成") {
if strings.Contains(body, "private-key-secret-path") { t.Fatalf("续期失败任务未安全保留结果:%+v %v", stored, err)
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)
} }
} }
func TestRenewalAPIRequiresSuperAdmin(t *testing.T) { func TestRenewalAPIRequiresSuperAdmin(t *testing.T) {

View File

@ -444,7 +444,12 @@ func (s *Server) cancelTask(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{"error": "管理证书任务由续期调度控制,不能单独修改取消状态", "task": item}) c.JSON(http.StatusConflict, gin.H{"error": "管理证书任务由续期调度控制,不能单独修改取消状态", "task": item})
return 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 return
} }
s.taskMu.Lock() s.taskMu.Lock()

View File

@ -54,6 +54,9 @@ func (s *Server) enqueueTaskWorker(c *gin.Context, task repository.Task, attempt
if task.TaskType == "certificate_validate" { if task.TaskType == "certificate_validate" {
message = "证书校验异常中断,未保存通过结果" message = "证书校验异常中断,未保存通过结果"
} }
if task.TaskType == "renewal_check" {
message = "续期检查异常中断,请检查已生成的续期任务,不会自动重放"
}
s.finishTaskBeforeExecution(task, attempt, "failed", message) s.finishTaskBeforeExecution(task, attempt, "failed", message)
} }
}() }()

View File

@ -7,10 +7,11 @@ import (
) )
const ( const (
ScopeProject = "project" ScopeProject = "project"
ScopeSystem = "system" ScopeSystem = "system"
ScopeUnknown = "unknown" ScopeUnknown = "unknown"
ManagementResourceID = "management-certificate" ManagementResourceID = "management-certificate"
RenewalCheckResourceID = "global-renewal-check"
) )
// HistoryScope 保存事件发生时的范围,资源或项目删除后仍保留;不是项目授权凭证。 // HistoryScope 保存事件发生时的范围,资源或项目删除后仍保留;不是项目授权凭证。

View File

@ -26,6 +26,9 @@ func (r *SQLProjectMemberRepository) ListVisibleAuditLogs(ctx context.Context, a
logs.scope_type, logs.project_id, logs.task_id, logs.operator_id logs.scope_type, logs.project_id, logs.task_id, logs.operator_id
FROM audit_logs logs WHERE EXISTS ( FROM audit_logs logs WHERE EXISTS (
SELECT 1 FROM users actor WHERE actor.id = ? AND actor.status = 'active' 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 (actor.role IN ('super_admin', 'admin') OR (actor.role = 'user'
AND logs.scope_type = 'project' AND EXISTS ( AND logs.scope_type = 'project' AND EXISTS (
SELECT 1 FROM project_members member SELECT 1 FROM project_members member

View File

@ -48,6 +48,7 @@ func taskVisibilityPredicate(alias string, permission domain.ProjectPermission)
return `EXISTS ( return `EXISTS (
SELECT 1 FROM users task_actor SELECT 1 FROM users task_actor
WHERE task_actor.id = ? AND task_actor.status = 'active' 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 ( AND (task_actor.role IN ('super_admin', 'admin') OR (
task_actor.role = 'user' AND ` + alias + `.scope_type = 'project' task_actor.role = 'user' AND ` + alias + `.scope_type = 'project'
AND EXISTS (SELECT 1 FROM project_members task_member AND EXISTS (SELECT 1 FROM project_members task_member

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

View File

@ -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 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) (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, SELECT ?, CASE WHEN task_type = 'renewal_check' THEN 'task' WHEN scope_type = 'system' THEN 'management_certificate' ELSE 'domain' END,
domain_id, ?, ?, ?, scope_type, project_id, id, operator_id 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, FROM tasks WHERE id = ?`), completion.AuditAction, status, completion.Message,
completion.FinishedAt.UTC(), completion.TaskID) completion.FinishedAt.UTC(), completion.TaskID)
if err != nil { if err != nil {

View File

@ -110,8 +110,12 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
} }
insertQuery := `INSERT INTO tasks 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) ` (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.TaskType == "management_renewal" || item.TaskType == "renewal_check" {
if item.DomainID != ManagementResourceID || !item.HistoryScope.matches(HistoryScope{ScopeType: ScopeSystem}) { resourceID := ManagementResourceID
if item.TaskType == "renewal_check" {
resourceID = RenewalCheckResourceID
}
if item.DomainID != resourceID || !item.HistoryScope.matches(HistoryScope{ScopeType: ScopeSystem}) {
return errors.New("管理证书任务范围无效") return errors.New("管理证书任务范围无效")
} }
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
@ -139,7 +143,7 @@ func (r *SQLTaskRepository) CreateTask(ctx context.Context, item Task) error {
} }
return nil 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("业务任务必须归属于实际域名项目") return errors.New("业务任务必须归属于实际域名项目")
} }
@ -441,6 +445,7 @@ func (r *SQLTaskRepository) CreateAttempt(ctx context.Context, item TaskAttempt)
SELECT ?, id, ?, ?, ?, ?, ?, ?, ? FROM tasks t WHERE id = ? SELECT ?, id, ?, ?, ?, ?, ?, ?, ? FROM tasks t WHERE id = ?
AND (? = '' OR scope_type = ?) AND (? = '' OR project_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') 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 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, 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, nullableTime(item.StartedAt), nullableTime(item.FinishedAt), item.CreatedAt.UTC(), item.TaskID,

View File

@ -1,5 +1,17 @@
# Linux 生产流程测试记录 # 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 证书校验后台任务及幂等契约回归(未部署) ## 2026年10月2日20:34 证书校验后台任务及幂等契约回归(未部署)
### 代码与隔离边界 ### 代码与隔离边界

View File

@ -1,5 +1,19 @@
# Scert 整改任务执行清单 # 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 证书校验后台任务回归完成,等待复审 ## 2026年10月2日20:34 证书校验后台任务回归完成,等待复审
本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。 本批需求沿用已确认的 P0-10、P1-04、P2-03 清单:先开发和自动回归,再统一生产部署;不处理 Windows。修改前已提交并推送 fce5a84。

View File

@ -1,5 +1,13 @@
# Scert 整改、合并与优化清单 # 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 当前开发进度:证书校验后台化完成待复审 ## 2026年10月2日20:34 当前开发进度:证书校验后台化完成待复审
当前开发批次对应 P0-10/P1-04/P2-03;不是仍停留在 P0-03 的代码开发,也不代表这些整项已经复审通过。下方带时间的记录保留为历史证据,最新状态以本入口和执行清单为准。原 P0/P1/P2/P3 范围不缩减,Windows 按本轮要求不处理。 当前开发批次对应 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 ```text
@ -1767,7 +1777,7 @@ cancelled 已取消
**状态:** `[~] 进行中` **状态:** `[~] 进行中`
**2026年10月2日20:34 子项进度:** 申请、当前证书部署、申请/部署重试、生产证书导入及本地证书校验已接入 HTTP 异步任务;导入具有上传生命周期和保存标志,校验具有独立元数据及最近尝试详情,均复核执行前权限。最新自动回归通过待复审,尚未统一生产部署;手动续期、历史恢复及其余列出的长操作不因本批通过而关闭,整项继续进行中。 **2026年10月2日21:30 子项进度:** 申请、当前证书部署、申请/部署重试、生产证书导入及本地证书校验已接入 HTTP 异步任务;导入具有上传生命周期和保存标志,校验具有独立元数据及最近尝试详情,均复核执行前权限。手动全局续期本批已实现,前后端自动回归、构建及相关专项二十次重复通过。尚未统一生产部署;历史恢复及其余列出的长操作不因本批推进而关闭,整项继续进行中。
以下操作不能长期阻塞 HTTP 请求: 以下操作不能长期阻塞 HTTP 请求:

View File

@ -3,6 +3,7 @@ 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"; import { useTaskMonitor } from "./TaskMonitor.js";
import { useRenewalAction } from "./RenewalAction.js";
const defaultDNSVerificationTimeoutSeconds = 60; const defaultDNSVerificationTimeoutSeconds = 60;
const aliyunDNSVerificationTimeoutSeconds = 6; const aliyunDNSVerificationTimeoutSeconds = 6;
@ -82,6 +83,9 @@ export function useDashboard() {
taskAttempts, taskDetailsLoading, taskDetailsError, taskAttempts, taskDetailsLoading, taskDetailsError,
refreshResources: refreshTaskResources, refreshResources: refreshTaskResources,
}); });
const { runRenewalNow, renewalSubmitting } = useRenewalAction({
canRunRenewal, error, notice, fetchAdmin, taskMonitor,
});
const hasOpenModal = computed( const hasOpenModal = computed(
() => () =>
showForm.value || showForm.value ||
@ -143,6 +147,7 @@ export function useDashboard() {
}; };
taskStatusLabels.error = taskStatusLabels.failed; taskStatusLabels.error = taskStatusLabels.failed;
const taskTypeLabels = { const taskTypeLabels = {
renewal_check: "全局续期检查",
management_renewal: "管理证书续期", management_renewal: "管理证书续期",
certificate_issue: "申请证书", certificate_issue: "申请证书",
certificate_import: "导入证书", certificate_import: "导入证书",
@ -153,6 +158,7 @@ export function useDashboard() {
dns_test: "DNS 在线测试", dns_test: "DNS 在线测试",
}; };
const auditActionLabels = { const auditActionLabels = {
renewal_check: "全局续期检查",
management_renewal: "管理证书续期", management_renewal: "管理证书续期",
certificate_issue: "申请证书", certificate_issue: "申请证书",
certificate_import: "导入证书", certificate_import: "导入证书",
@ -170,6 +176,7 @@ export function useDashboard() {
renewal_run: "执行自动续期", renewal_run: "执行自动续期",
}; };
const taskStageLabels = { const taskStageLabels = {
renewal_check: "检查管理证书和业务域名续期",
management_renewal: "管理证书续期", management_renewal: "管理证书续期",
preflight: "续期前置检查", preflight: "续期前置检查",
issue: "申请管理证书", 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() { function handleDNSProviderChange() {
dnsRequestVersion += 1; dnsRequestVersion += 1;
stopDNSVerificationTimer(); stopDNSVerificationTimer();
@ -1159,6 +1147,7 @@ export function useDashboard() {
taskStageLabel, taskStageLabel,
attemptStatusLabel, attemptStatusLabel,
runRenewalNow, runRenewalNow,
renewalSubmitting,
openSettings, openSettings,
closeSettings, closeSettings,
saveSettings, saveSettings,

View File

@ -1,6 +1,7 @@
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 "./TaskMonitor.test.js";
import "./RenewalAction.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";
@ -1087,7 +1088,7 @@ test("workflowTasks.renderOnlySupportedActions", async () => {
}); });
assert.deepEqual(compiled.errors, []); assert.deepEqual(compiled.errors, []);
const render = new Function("Vue", compiled.code)(Vue); 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"]) { for (const status of ["queued", "running", "success", "failed", "cancelled", "partial_success"]) {
const html = await renderToString(Vue.createSSRApp({ const html = await renderToString(Vue.createSSRApp({
render, render,
@ -1104,7 +1105,7 @@ test("workflowTasks.renderOnlySupportedActions", async () => {
retryTask: () => {}, 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 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; const buttonCount = (html.match(/<button\b/g) || []).length;
assert.equal(buttonCount, canCancel || canRetry ? 2 : 1, `${taskType}:${status}`); assert.equal(buttonCount, canCancel || canRetry ? 2 : 1, `${taskType}:${status}`);

View File

@ -107,6 +107,7 @@ const {
validateDomain, validateDomain,
deployDomain, deployDomain,
runRenewalNow, runRenewalNow,
renewalSubmitting,
canRunRenewal, canRunRenewal,
cancelTask, cancelTask,
openTaskDetails, openTaskDetails,
@ -137,8 +138,14 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<button class="secondary-button" type="button" @click="loadData"> <button class="secondary-button" type="button" @click="loadData">
刷新状态 刷新状态
</button> </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>
<button <button
class="secondary-button" class="secondary-button"

View 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 };
}

View 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);
});

View File

@ -78,6 +78,7 @@ export function useRuntimeOverview(state) {
}, clock.value)); }, clock.value));
const visibleTasks = computed(() => detailView.value === "tasks" ? summary.value.tasks : summary.value.tasks.slice(0, 5)); const visibleTasks = computed(() => detailView.value === "tasks" ? summary.value.tasks : summary.value.tasks.slice(0, 5));
function taskDomain(task) { function taskDomain(task) {
if (task.task_type === "renewal_check") return "管理证书与全部业务域名";
if (task.task_type === "management_renewal") return "管理页面证书"; if (task.task_type === "management_renewal") return "管理页面证书";
return state.domains.value.find((item) => item.id === task.domain_id)?.name || "系统任务或已移除域名"; return state.domains.value.find((item) => item.id === task.domain_id)?.name || "系统任务或已移除域名";
} }

View File

@ -92,7 +92,7 @@ const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(pro
<td> <td>
<button type="button" @click="openTaskDetails(item)">查看详情</button> <button type="button" @click="openTaskDetails(item)">查看详情</button>
<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" type="button"
@click="cancelTask(item)" @click="cancelTask(item)"
>取消任务</button> >取消任务</button>