Scert/backend/internal/httpapi/renewal_task.go
xuans 413a0e66a9 完成手动续期后台任务及权限隔离并更新回归进度
保留管理与业务续期调度,增加系统级幂等任务、取消传播和统计审计原子收尾;前端跟踪真实任务状态。原生 Linux 全后端、静态检查、相关专项二十次重复及前端二百七十二项测试和生产构建通过,未进行正式生产部署。
2026-10-02 21:32:30 +08:00

162 lines
6.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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