From cd80a3fc45d5927fcb9de3959520b07351e77eae Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 30 Aug 2026 21:53:26 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AE=BE=E7=BD=AE=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E6=B5=8B=E8=AF=95=E4=B8=8E=E4=BB=BB=E5=8A=A1=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=E5=B9=B6=E5=8F=91=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/httpapi/server.go | 1 + backend/internal/httpapi/settings_api_test.go | 110 ++++++++++++++++++ backend/internal/httpapi/task_api.go | 8 ++ 3 files changed, 119 insertions(+) diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index f8deeb9..0a8ed64 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -85,6 +85,7 @@ type Server struct { tasks repository.TaskRepository taskMu sync.Mutex taskCancels map[string]context.CancelFunc + issueTaskMu sync.Mutex resetMu sync.Mutex resetClosedHash string resetLimiter *resetRateLimiter diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index 3b9c6c3..b2fc3f8 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -124,6 +124,116 @@ func TestSettingsAPIWebAddressChangeDoesNotRequireRestart(t *testing.T) { } } +func TestSettingsAPIReturnsAndUpdatesRenewalPolicy(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + 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") + configPath := dir + "\\config\\scert.json" + if err := cfg.Save(configPath); err != nil { + t.Fatalf("保存测试配置失败:%v", err) + } + server := New(Dependencies{Config: cfg, ConfigPath: configPath}) + cookie := loginForSettingsTest(t, server) + + getRequest := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil) + getRequest.AddCookie(cookie) + getResponse := httptest.NewRecorder() + server.Handler().ServeHTTP(getResponse, getRequest) + if getResponse.Code != http.StatusOK { + t.Fatalf("读取设置应成功,实际为 %d:%s", getResponse.Code, getResponse.Body.String()) + } + var initial struct { + ACME settingsACMEView `json:"acme"` + } + if err := json.Unmarshal(getResponse.Body.Bytes(), &initial); err != nil { + t.Fatalf("解析初始设置失败:%v", err) + } + if initial.ACME.RenewalCheckIntervalDays != 3 || initial.ACME.RenewalNearExpiryDays != 10 || initial.ACME.RenewalThresholdDays != 6 || initial.ACME.RenewalUrgentThresholdDays != 3 || initial.ACME.RenewalMaxRetries != 3 || initial.ACME.RenewalRetryIntervalMins != 30 { + t.Fatalf("初始续期策略返回不完整:%+v", initial.ACME) + } + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "acme": { + "renewal_check_interval_days": 2, + "renewal_near_expiry_days": 14, + "renewal_threshold_days": 7, + "renewal_urgent_threshold_days": 2, + "renewal_max_retries": 5, + "renewal_retry_interval_mins": 45 + } + }`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(cookie) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("更新续期策略应成功,实际为 %d:%s", response.Code, response.Body.String()) + } + var payload struct { + Settings settingsView `json:"settings"` + Restart bool `json:"restart_required"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatalf("解析续期策略响应失败:%v", err) + } + if !payload.Restart || payload.Settings.ACME.RenewalCheckIntervalDays != 2 || payload.Settings.ACME.RenewalNearExpiryDays != 14 || payload.Settings.ACME.RenewalThresholdDays != 7 || payload.Settings.ACME.RenewalUrgentThresholdDays != 2 || payload.Settings.ACME.RenewalMaxRetries != 5 || payload.Settings.ACME.RenewalRetryIntervalMins != 45 { + t.Fatalf("更新后的续期策略不正确:restart=%v acme=%+v", payload.Restart, payload.Settings.ACME) + } + saved, err := config.Load(configPath) + if err != nil { + t.Fatalf("读取续期策略配置失败:%v", err) + } + if saved.ACME.RenewalCheckIntervalDays != 2 || saved.ACME.RenewalNearExpiryDays != 14 || saved.ACME.RenewalThresholdDays != 7 || saved.ACME.RenewalUrgentThresholdDays != 2 || saved.ACME.RenewalMaxRetries != 5 || saved.ACME.RenewalRetryIntervalMins != 45 { + t.Fatalf("续期策略没有正确持久化:%+v", saved.ACME) + } +} + +func TestSettingsAPIRejectsInvalidRenewalPolicy(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + 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") + configPath := dir + "\\config\\scert.json" + if err := cfg.Save(configPath); err != nil { + t.Fatalf("保存测试配置失败:%v", err) + } + server := New(Dependencies{Config: cfg, ConfigPath: configPath}) + cookie := loginForSettingsTest(t, server) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "acme": { + "renewal_near_expiry_days": 2, + "renewal_threshold_days": 7 + } + }`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(cookie) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("非法续期阈值关系应返回 400,实际为 %d:%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "设置内容校验失败") { + t.Fatalf("错误响应应说明设置校验失败:%s", response.Body.String()) + } + saved, err := config.Load(configPath) + if err != nil { + t.Fatalf("读取回滚后的配置失败:%v", err) + } + if saved.ACME.RenewalNearExpiryDays != cfg.ACME.RenewalNearExpiryDays || saved.ACME.RenewalThresholdDays != cfg.ACME.RenewalThresholdDays { + t.Fatalf("非法设置不应持久化:before=%+v after=%+v", cfg.ACME, saved.ACME) + } +} + func TestSettingsAPIReturnsChineseNotificationCredentialError(t *testing.T) { dir := t.TempDir() cfg := config.Default(dir) diff --git a/backend/internal/httpapi/task_api.go b/backend/internal/httpapi/task_api.go index d75c451..e31658d 100644 --- a/backend/internal/httpapi/task_api.go +++ b/backend/internal/httpapi/task_api.go @@ -29,9 +29,11 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { s.writeError(c, http.StatusInternalServerError, "读取域名失败", err) return } + s.issueTaskMu.Lock() idempotencyKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) if idempotencyKey != "" { if !validTaskIdempotencyKey(idempotencyKey) { + s.issueTaskMu.Unlock() s.writeError(c, http.StatusBadRequest, "幂等键格式无效", errors.New("幂等键长度或字符无效")) return } @@ -41,16 +43,20 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { status = http.StatusConflict } c.JSON(status, gin.H{"task": existing, "replayed": true}) + s.issueTaskMu.Unlock() return } else if !errors.Is(err, sql.ErrNoRows) { + s.issueTaskMu.Unlock() s.writeError(c, http.StatusInternalServerError, "检查幂等任务失败", err) return } } if active, err := s.tasks.FindActiveTaskByDomain(c.Request.Context(), domainID, "certificate_issue"); err == nil { + s.issueTaskMu.Unlock() c.JSON(http.StatusConflict, gin.H{"error": "该域名已有正在执行的证书任务", "task": active}) return } else if !errors.Is(err, sql.ErrNoRows) { + s.issueTaskMu.Unlock() s.writeError(c, http.StatusInternalServerError, "检查正在执行的任务失败", err) return } @@ -60,9 +66,11 @@ func (s *Server) issueDomainAsTask(c *gin.Context) { Message: "等待执行证书申请", IdempotencyKey: idempotencyKey, CreatedAt: now, UpdatedAt: now, } if err := s.tasks.CreateTask(c.Request.Context(), task); err != nil { + s.issueTaskMu.Unlock() s.writeError(c, http.StatusInternalServerError, "创建证书任务失败", err) return } + s.issueTaskMu.Unlock() attempt := repository.TaskAttempt{ ID: domain.NewID(), TaskID: task.ID, AttemptNumber: 1, Stage: "certificate_issue", Status: "running", Message: "开始申请证书", StartedAt: &now, CreatedAt: now,