From f1c7fc34bdcc12303626f5aef12deaf57db0027a Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 30 Aug 2026 23:08:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=B3=BB=E7=BB=9F=E8=AE=BE?= =?UTF-8?q?=E7=BD=AEDNS=E9=AA=8C=E8=AF=81=E4=BF=9D=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/httpapi/settings_api.go | 71 +++++- backend/internal/httpapi/settings_api_test.go | 238 ++++++++++++++++++ frontend/src/views/Dashboard/Dashboard.js | 139 +++++++++- 3 files changed, 440 insertions(+), 8 deletions(-) diff --git a/backend/internal/httpapi/settings_api.go b/backend/internal/httpapi/settings_api.go index 5edeb03..678c04d 100644 --- a/backend/internal/httpapi/settings_api.go +++ b/backend/internal/httpapi/settings_api.go @@ -1,6 +1,7 @@ package httpapi import ( + "context" "errors" "net/http" "reflect" @@ -51,8 +52,9 @@ type settingsACMEView struct { } type settingsDNSView struct { - Provider string `json:"provider"` - VerificationState string `json:"verification_state"` + Provider string `json:"provider"` + VerificationState string `json:"verification_state"` + CredentialConfigured bool `json:"credential_configured"` } type settingsNotificationView struct { @@ -122,7 +124,10 @@ type settingsACMERequest struct { } type settingsDNSRequest struct { - Provider *string `json:"provider"` + Provider *string `json:"provider"` + CredentialRef *string `json:"credential_ref"` + VerificationToken *string `json:"verification_token"` + TestDomain *string `json:"test_domain"` } type settingsNotificationRequest struct { @@ -182,6 +187,19 @@ func (s *Server) updateSettings(c *gin.Context) { s.writeError(c, http.StatusBadRequest, "保存通知凭据失败", err) return } + dnsCredentialRef, dnsVerificationToken, dnsTestDomain, err := s.applySettingsDNS(c.Request.Context(), &updated, previous, request.DNS) + if err != nil { + s.rollbackSetupCredentialsWithLog(createdCredentialRefs) + if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef { + s.rollbackSetupCredentialsWithLog([]string{dnsCredentialRef}) + } + s.configMu.Unlock() + s.writeError(c, http.StatusBadRequest, "保存 DNS 设置失败", err) + return + } + if dnsCredentialRef != "" && dnsCredentialRef != previous.DNS.CredentialRef { + createdCredentialRefs = append(createdCredentialRefs, dnsCredentialRef) + } if err := updated.Validate(); err != nil { s.rollbackSetupCredentialsWithLog(createdCredentialRefs) s.configMu.Unlock() @@ -208,10 +226,12 @@ func (s *Server) updateSettings(c *gin.Context) { s.config = updated s.configMu.Unlock() oldCredentialRefs := []string{ + previous.DNS.CredentialRef, previous.Notification.HTTPCredentialRef, previous.Notification.SMTPCredentialRef, } newCredentialRefs := map[string]struct{}{ + updated.DNS.CredentialRef: {}, updated.Notification.HTTPCredentialRef: {}, updated.Notification.SMTPCredentialRef: {}, } @@ -227,7 +247,12 @@ func (s *Server) updateSettings(c *gin.Context) { } if len(obsoleteCredentialRefs) > 0 { if err := s.rollbackSetupCredentials(obsoleteCredentialRefs); err != nil && s.logger != nil { - s.logger.Warn("\u6e05\u7406\u5df2\u66ff\u6362\u7684\u901a\u77e5\u654f\u611f\u51ed\u636e\u5931\u8d25", "error", err) + s.logger.Warn("\u6e05\u7406\u5df2\u66ff\u6362\u7684\u654f\u611f\u51ed\u636e\u5931\u8d25", "error", err) + } + } + if dnsVerificationToken != "" && s.setupDNS != nil { + if err := s.setupDNS.Consume(dnsVerificationToken, updated.DNS.Provider, dnsCredentialRef, dnsTestDomain); err != nil && s.logger != nil { + s.logger.Warn("系统设置已保存,但清理 DNS 验证令牌失败", "error", err) } } @@ -238,6 +263,39 @@ func (s *Server) updateSettings(c *gin.Context) { }) } +func (s *Server) applySettingsDNS(ctx context.Context, cfg *config.Config, previous config.Config, request *settingsDNSRequest) (string, string, string, error) { + if cfg == nil || request == nil { + return "", "", "", nil + } + providerChanged := cfg.DNS.Provider != strings.TrimSpace(previous.DNS.Provider) + hasVerificationInput := request.CredentialRef != nil || request.VerificationToken != nil || request.TestDomain != nil + if !hasVerificationInput { + if providerChanged { + cfg.DNS.CredentialRef = "" + cfg.DNS.VerificationState = "pending" + } + return "", "", "", nil + } + if s.setupDNS == nil { + return "", "", "", errors.New("DNS 在线验证服务尚未初始化") + } + if request.CredentialRef == nil || request.VerificationToken == nil || request.TestDomain == nil { + return "", "", "", errors.New("DNS 验证参数不完整,请先完成在线测试") + } + credentialRef := strings.TrimSpace(*request.CredentialRef) + verificationToken := strings.TrimSpace(*request.VerificationToken) + testDomain := strings.TrimSpace(*request.TestDomain) + if credentialRef == "" || verificationToken == "" || testDomain == "" { + return credentialRef, verificationToken, testDomain, errors.New("DNS 验证参数不完整,请先完成在线测试") + } + if err := s.setupDNS.Check(verificationToken, cfg.DNS.Provider, credentialRef, testDomain); err != nil { + return credentialRef, verificationToken, testDomain, err + } + cfg.DNS.CredentialRef = credentialRef + cfg.DNS.VerificationState = "verified" + return credentialRef, verificationToken, testDomain, nil +} + func settingsViewFromConfig(cfg config.Config) settingsView { return settingsView{ Server: settingsServerView{Host: cfg.Server.Host, Port: cfg.Server.Port, HTTPSMode: cfg.Server.HTTPSMode}, @@ -250,7 +308,10 @@ func settingsViewFromConfig(cfg config.Config) settingsView { RenewalMaxRetries: cfg.ACME.RenewalMaxRetries, RenewalRetryIntervalMins: cfg.ACME.RenewalRetryIntervalMins, PropagationTimeoutSecs: cfg.ACME.PropagationTimeoutSecs, PropagationPollingIntervalSecs: cfg.ACME.PropagationPollingIntervalSecs, }, - DNS: settingsDNSView{Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState}, + DNS: settingsDNSView{ + Provider: cfg.DNS.Provider, VerificationState: cfg.DNS.VerificationState, + CredentialConfigured: strings.TrimSpace(cfg.DNS.CredentialRef) != "", + }, Notification: settingsNotificationView{ HTTPEnabled: cfg.Notification.HTTPEnabled, SMTPEnabled: cfg.Notification.SMTPEnabled, Provider: cfg.Notification.Provider, SMTPHost: cfg.Notification.SMTPHost, SMTPPort: cfg.Notification.SMTPPort, SMTPUsername: cfg.Notification.SMTPUsername, diff --git a/backend/internal/httpapi/settings_api_test.go b/backend/internal/httpapi/settings_api_test.go index b2fc3f8..8666ff0 100644 --- a/backend/internal/httpapi/settings_api_test.go +++ b/backend/internal/httpapi/settings_api_test.go @@ -3,8 +3,10 @@ package httpapi import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" + "os" "strings" "testing" @@ -12,6 +14,7 @@ import ( "git.pcux.cn/xuans/Scert/backend/internal/certificate" "git.pcux.cn/xuans/Scert/backend/internal/config" + "git.pcux.cn/xuans/Scert/backend/internal/setup" ) func TestSettingsAPIRequiresAuthentication(t *testing.T) { @@ -439,3 +442,238 @@ func TestSettingsAPIRollsBackEarlierCredentialWhenLaterCredentialFails(t *testin t.Fatalf("earlier HTTP credentials should be rolled back: values=%v deleted=%v", secrets.values, secrets.deleted) } } + +func TestSettingsAPIDNSRequiresCompleteVerification(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.DNS.Provider = "cloudflare" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/old" + 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, + SetupDNS: &fakeSetupDNSVerifier{}, + }) + cookie := loginForSettingsTest(t, server) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "dns": {"provider": "cloudflare", "verification_token": "token-only"} + }`)) + 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("DNS 验证参数不完整应返回 400,实际为 %d:%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "保存 DNS 设置失败") { + t.Fatalf("错误响应应说明 DNS 设置保存失败:%s", response.Body.String()) + } +} + +func TestSettingsAPIDNSProviderChangeRequiresReverification(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.DNS.Provider = "cloudflare" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/old" + 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(`{ + "dns": {"provider": "aliyun"} + }`)) + 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("更换 DNS 服务商但未重新验证应返回 400,实际为 %d:%s", response.Code, response.Body.String()) + } + if !strings.Contains(response.Body.String(), "设置内容校验失败") { + t.Fatalf("错误响应应说明设置校验失败:%s", response.Body.String()) + } +} + +func TestSettingsAPISavesVerifiedDNSCredentialWithoutReturningSecret(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.DNS.Provider = "cloudflare" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/old" + 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) + } + const newCredentialRef = "secret://dns/cloudflare/new" + verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{ + CredentialRef: newCredentialRef, VerificationToken: "new-verification-token", + }} + secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{ + cfg.DNS.CredentialRef: {"api_token": "old-token"}, + newCredentialRef: {"api_token": "new-token"}, + }} + server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets}) + cookie := loginForSettingsTest(t, server) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "dns": { + "provider": "cloudflare", + "credential_ref": "secret://dns/cloudflare/new", + "verification_token": "new-verification-token", + "test_domain": "example.com" + } + }`)) + 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("保存已验证 DNS 设置应成功,实际为 %d:%s", response.Code, response.Body.String()) + } + body := response.Body.String() + for _, secret := range []string{"new-verification-token", newCredentialRef, "new-token", "credential_ref"} { + if strings.Contains(body, secret) { + t.Fatalf("设置响应不应返回 DNS 敏感内容 %q:%s", secret, body) + } + } + if !verifier.consumed { + t.Fatal("保存成功后应消费 DNS 验证令牌") + } + if _, ok := secrets.values[cfg.DNS.CredentialRef]; ok { + t.Fatal("替换后的旧 DNS 凭据应被清理") + } + if _, ok := secrets.values[newCredentialRef]; !ok { + t.Fatal("新的 DNS 凭据应继续保留") + } + saved, err := config.Load(configPath) + if err != nil { + t.Fatalf("读取保存后的配置失败:%v", err) + } + if saved.DNS.CredentialRef != newCredentialRef || saved.DNS.VerificationState != "verified" { + t.Fatalf("DNS 配置没有正确持久化:%+v", saved.DNS) + } +} + +func TestSettingsAPIDNSCheckFailureRollsBackCredential(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.DNS.Provider = "cloudflare" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/old" + 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) + } + const newCredentialRef = "secret://dns/cloudflare/new" + verifier := &fakeSetupDNSVerifier{checkErr: errors.New("DNS 验证令牌无效")} + secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{ + newCredentialRef: {"api_token": "new-token"}, + }} + server := New(Dependencies{Config: cfg, ConfigPath: configPath, SetupDNS: verifier, Secrets: secrets}) + cookie := loginForSettingsTest(t, server) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "dns": { + "provider": "cloudflare", + "credential_ref": "secret://dns/cloudflare/new", + "verification_token": "invalid-token", + "test_domain": "example.com" + } + }`)) + 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("无效 DNS 验证令牌应返回 400,实际为 %d:%s", response.Code, response.Body.String()) + } + if _, ok := secrets.values[newCredentialRef]; ok { + t.Fatal("DNS 验证失败后不应保留新凭据") + } + if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef { + t.Fatalf("DNS 验证失败后应删除新凭据:%v", secrets.deleted) + } +} + +func TestSettingsAPIDNSSaveFailureRollsBackCredential(t *testing.T) { + dir := t.TempDir() + cfg := config.Default(dir) + cfg.SetupReady = true + cfg.DNS.VerificationState = "verified" + cfg.DNS.Provider = "cloudflare" + cfg.DNS.CredentialRef = "secret://dns/cloudflare/old" + cfg.Web.Hostname = "scert.example.test" + cfg.Admin.Username = "admin" + cfg.Admin.Email = "admin@example.test" + cfg.Admin.PasswordHash = mustTestPasswordHash(t, "Password123") + initialConfigPath := dir + "\\config\\initial.json" + if err := cfg.Save(initialConfigPath); err != nil { + t.Fatalf("保存测试配置失败:%v", err) + } + blockedPath := dir + "\\blocked" + if err := os.WriteFile(blockedPath, []byte("not a directory"), 0o600); err != nil { + t.Fatalf("创建保存失败测试文件失败:%v", err) + } + const newCredentialRef = "secret://dns/cloudflare/new" + verifier := &fakeSetupDNSVerifier{verifyResult: setup.DNSVerificationResult{ + CredentialRef: newCredentialRef, VerificationToken: "new-verification-token", + }} + secrets := &fakeSetupSecretWriter{values: map[string]map[string]string{ + newCredentialRef: {"api_token": "new-token"}, + }} + server := New(Dependencies{Config: cfg, ConfigPath: blockedPath + "\\scert.json", SetupDNS: verifier, Secrets: secrets}) + cookie := loginForSettingsTest(t, server) + + request := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(`{ + "dns": { + "provider": "cloudflare", + "credential_ref": "secret://dns/cloudflare/new", + "verification_token": "new-verification-token", + "test_domain": "example.com" + } + }`)) + request.Header.Set("Content-Type", "application/json") + request.AddCookie(cookie) + response := httptest.NewRecorder() + server.Handler().ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError { + t.Fatalf("配置保存失败应返回 500,实际为 %d:%s", response.Code, response.Body.String()) + } + if _, ok := secrets.values[newCredentialRef]; ok { + t.Fatal("配置保存失败后不应保留新 DNS 凭据") + } + if len(secrets.deleted) != 1 || secrets.deleted[0] != newCredentialRef { + t.Fatalf("配置保存失败后应删除新 DNS 凭据:%v", secrets.deleted) + } + if verifier.consumed { + t.Fatal("配置保存失败后不应消费 DNS 验证令牌") + } +} diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 9406ef1..1d194b5 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -11,6 +11,7 @@ export function useDashboard() { const tasks = ref([]); const auditLogs = ref([]); const settings = ref(null); + const dnsProviderCatalog = ref([]); const domainServers = ref({}); const loading = ref(true); const saving = ref(false); @@ -34,6 +35,8 @@ export function useDashboard() { const showSettings = ref(false); const settingsSaving = ref(false); const settingsError = ref(""); + const dnsTesting = ref(false); + const dnsTestError = ref(""); const settingsForm = ref(createEmptySettingsForm()); const showCertificateImport = ref(false); const importDomain = ref(null); @@ -184,6 +187,27 @@ export function useDashboard() { const formTitle = computed(() => isEditing.value ? "编辑域名配置" : "添加托管域名", ); + const selectedDNSProvider = computed(() => + dnsProviderCatalog.value.find( + (provider) => provider.id === settingsForm.value.dns.provider, + ) || null, + ); + const dnsVerificationRequired = computed(() => { + const current = settingsForm.value.dns; + const saved = settings.value?.dns; + if (!current.provider) return false; + return ( + current.provider !== saved?.provider || + !saved?.credential_configured || + current.verification_state === "pending" + ); + }); + const dnsReadyToSave = computed( + () => + !dnsVerificationRequired.value || + (Boolean(settingsForm.value.dns.credential_ref) && + Boolean(settingsForm.value.dns.verification_token)), + ); const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem"; const defaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem"; @@ -304,7 +328,16 @@ export function useDashboard() { propagation_timeout_secs: 900, propagation_polling_interval_secs: 10, }, - dns: { provider: "" }, + dns: { + provider: "", + credential_configured: false, + verification_state: "not_configured", + test_domain: "", + credentials: {}, + credential_ref: "", + verification_token: "", + test_report: null, + }, notification: { http_enabled: false, smtp_enabled: false, @@ -344,7 +377,15 @@ export function useDashboard() { web: { ...defaults.web, ...(source.web || {}) }, paths: { ...defaults.paths, ...(source.paths || {}) }, acme: { ...defaults.acme, ...(source.acme || {}) }, - dns: { ...defaults.dns, ...(source.dns || {}) }, + dns: { + ...defaults.dns, + ...(source.dns || {}), + credentials: {}, + credential_ref: "", + verification_token: "", + test_domain: "", + test_report: null, + }, notification: { ...defaults.notification, ...(source.notification || {}), @@ -682,8 +723,84 @@ export function useDashboard() { } } + function handleDNSProviderChange() { + const dns = settingsForm.value.dns; + const saved = settings.value?.dns; + dnsTestError.value = ""; + dns.credential_ref = ""; + dns.verification_token = ""; + dns.test_report = null; + if (dns.provider && dns.provider === saved?.provider && saved.credential_configured) { + dns.verification_state = saved.verification_state || "verified"; + return; + } + dns.verification_state = dns.provider ? "pending" : "not_configured"; + } + + function clearDNSCredentials() { + settingsForm.value.dns.credentials = {}; + dnsTestError.value = ""; + } + + async function testDNS() { + const dns = settingsForm.value.dns; + const provider = selectedDNSProvider.value; + dnsTestError.value = ""; + dns.test_report = null; + if (!dns.provider || !provider) { + dnsTestError.value = "请选择 DNS 验证服务商"; + return; + } + if (!dns.test_domain.trim()) { + dnsTestError.value = "请输入 DNS 在线测试域名"; + return; + } + const credentials = Object.fromEntries( + Object.entries(dns.credentials || {}) + .map(([key, value]) => [key, String(value || "").trim()]) + .filter(([, value]) => value), + ); + const missingField = (provider.credential_fields || []).find( + (field) => field.required && !credentials[field.name], + ); + if (missingField) { + dnsTestError.value = `请填写${missingField.label}`; + return; + } + dnsTesting.value = true; + try { + const response = await fetchAdmin("/api/v1/dns/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: dns.provider, + test_domain: dns.test_domain.trim(), + credentials, + }), + }); + const result = await response.json().catch(() => ({})); + dns.test_report = result.test_report || null; + if (!response.ok) { + throw new Error(result.error || "DNS API 在线测试失败"); + } + dns.credential_ref = result.credential_ref || ""; + dns.verification_token = result.verification_token || ""; + dns.verification_state = "verified"; + dns.credential_configured = true; + notice.value = "DNS API 在线测试通过,可以保存系统设置"; + } catch (err) { + dns.credential_ref = ""; + dns.verification_token = ""; + dns.verification_state = "pending"; + dnsTestError.value = err.message || "DNS API 在线测试失败"; + } finally { + dnsTesting.value = false; + } + } + function openSettings() { settingsError.value = ""; + dnsTestError.value = ""; settingsForm.value = normalizeSettings(settings.value); showSettings.value = true; } @@ -694,12 +811,28 @@ export function useDashboard() { async function saveSettings() { settingsError.value = ""; + if (!dnsReadyToSave.value) { + settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试"; + return; + } settingsSaving.value = true; try { + const current = settingsForm.value; + const payload = { + ...current, + dns: { + provider: current.dns.provider, + }, + }; + if (current.dns.credential_ref && current.dns.verification_token) { + payload.dns.credential_ref = current.dns.credential_ref; + payload.dns.verification_token = current.dns.verification_token; + payload.dns.test_domain = current.dns.test_domain; + } const response = await fetchAdmin("/api/v1/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(settingsForm.value), + body: JSON.stringify(payload), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || "保存系统设置失败");