diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index 013bc70..9505138 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -55,15 +55,16 @@ func TestAdminDNSTestReturnsVerificationResult(t *testing.T) { t.Fatalf("DNS 在线测试失败:%d %s", response.Code, response.Body.String()) } var payload struct { - Verified bool `json:"verified"` - CredentialRef string `json:"credential_ref"` - VerificationToken string `json:"verification_token"` - TestReport setup.DNSVerificationReport `json:"test_report"` + Verified bool `json:"verified"` + CredentialRef string `json:"credential_ref"` + VerificationToken string `json:"verification_token"` + TestReport setup.DNSVerificationReport `json:"test_report"` + VerificationTimeoutSecs int `json:"verification_timeout_secs"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { t.Fatalf("解析 DNS 在线测试响应失败:%v", err) } - if !payload.Verified || payload.CredentialRef == "" || payload.VerificationToken == "" || payload.TestReport.RecordName == "" { + if !payload.Verified || payload.CredentialRef == "" || payload.VerificationToken == "" || payload.TestReport.RecordName == "" || payload.VerificationTimeoutSecs <= 0 { t.Fatalf("DNS 在线测试响应内容不正确:%+v", payload) } } diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index 21a48df..287b465 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -157,6 +157,7 @@ func (s *Server) testAdminDNS(c *gin.Context) { } func (s *Server) testDNS(c *gin.Context) { + verificationTimeoutSecs := int(s.dnsVerificationTimeout().Seconds()) if s.setupDNS == nil { s.writeError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil) return @@ -173,7 +174,10 @@ func (s *Server) testDNS(c *gin.Context) { }) if err != nil { if errors.Is(err, dnsprovider.ErrProviderUnavailable) { - s.writeError(c, http.StatusNotImplemented, "该 DNS 服务商在线验证适配器尚未开放", nil) + c.JSON(http.StatusNotImplemented, gin.H{ + "error": "该 DNS 服务商在线验证适配器尚未开放", + "verification_timeout_secs": verificationTimeoutSecs, + }) return } report := result.TestReport @@ -184,16 +188,21 @@ func (s *Server) testDNS(c *gin.Context) { if s.logger != nil { s.logger.Error("DNS API 在线验证失败", "provider", request.ProviderID, "test_domain", request.TestDomain) } - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "DNS API 在线验证失败,请检查凭据、测试域名和 DNS 权限", "test_report": report}) + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": "DNS API 在线验证失败,请检查凭据、测试域名和 DNS 权限", + "test_report": report, + "verification_timeout_secs": verificationTimeoutSecs, + }) return } c.JSON(http.StatusOK, gin.H{ - "verified": true, - "credential_ref": result.CredentialRef, - "verification_token": result.VerificationToken, - "expires_at": result.ExpiresAt, - "test_report": result.TestReport, - "message": "DNS API 在线验证通过,凭据已加密保存。", + "verified": true, + "credential_ref": result.CredentialRef, + "verification_token": result.VerificationToken, + "expires_at": result.ExpiresAt, + "test_report": result.TestReport, + "verification_timeout_secs": verificationTimeoutSecs, + "message": "DNS API 在线验证通过,凭据已加密保存。", }) } diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index a57cc1b..065e6b8 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -239,6 +239,9 @@ func TestSetupDNSProviderCatalogAndOnlineTest(t *testing.T) { !strings.Contains(testResponse.Body.String(), "清理记录") { t.Fatalf("DNS 在线测试响应应包含阶段报告:%s", testResponse.Body.String()) } + if !strings.Contains(testResponse.Body.String(), `"verification_timeout_secs":930`) { + t.Fatalf("DNS 在线测试响应应包含当前最大等待时间:%s", testResponse.Body.String()) + } } func TestSetupDNSTestUsesConfiguredRequestTimeout(t *testing.T) { @@ -262,6 +265,9 @@ func TestSetupDNSTestUsesConfiguredRequestTimeout(t *testing.T) { if response.Code != http.StatusUnprocessableEntity { t.Fatalf("模拟 DNS 验证失败应返回 422,实际为 %d:%s", response.Code, response.Body.String()) } + if !strings.Contains(response.Body.String(), `"verification_timeout_secs":31`) { + t.Fatalf("DNS 验证失败响应应包含当前最大等待时间:%s", response.Body.String()) + } if !verifier.verifyHasDeadline { t.Fatal("DNS 在线测试应收到带截止时间的上下文") } diff --git a/frontend/src/test-support/mount.js b/frontend/src/test-support/mount.js index 18c8a0d..9a6f337 100644 --- a/frontend/src/test-support/mount.js +++ b/frontend/src/test-support/mount.js @@ -28,9 +28,22 @@ export function verifiedResponse(token = "test-token") { // 运行真实组合式逻辑和生命周期,不加载浏览器,也不连接 DNS 或业务数据库。 export async function mountComposable(t, factory, fetcher) { + const intervals = new Map(); + let nextIntervalID = 1; const originals = new Map(); for (const [key, value] of Object.entries({ - window: { setInterval: () => 1, clearInterval() {}, location: { assign() {} } }, + window: { + setInterval(callback) { + const intervalID = nextIntervalID; + nextIntervalID += 1; + intervals.set(intervalID, callback); + return intervalID; + }, + clearInterval(intervalID) { + intervals.delete(intervalID); + }, + location: { assign() {} }, + }, document: { body: { style: { overflow: "" } } }, })) { originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); @@ -66,5 +79,11 @@ export async function mountComposable(t, factory, fetcher) { }); await new Promise((resolve) => setImmediate(resolve)); await nextTick(); - return { state, unmount }; + return { + state, + unmount, + runIntervals() { + for (const callback of [...intervals.values()]) callback(); + }, + }; } diff --git a/frontend/src/views/Dashboard/Dashboard.css b/frontend/src/views/Dashboard/Dashboard.css index 1aec34e..1b77960 100644 --- a/frontend/src/views/Dashboard/Dashboard.css +++ b/frontend/src/views/Dashboard/Dashboard.css @@ -1091,6 +1091,28 @@ code { background: #e7f8ee; } +.dns-verification-progress { + display: grid; + gap: 4px; + margin: -4px 0 16px; + padding: 12px 13px; + border: 1px solid #cbdcf2; + border-radius: 11px; + color: #2d5b99; + background: #f0f6ff; + font-size: 12px; + line-height: 1.55; +} + +.dns-verification-progress strong { + font-size: 13px; +} + +.dns-verification-progress small { + color: #6d7b91; + font-size: 12px; +} + .dns-test-report { display: grid; gap: 8px; diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 32cb586..2e1e330 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -1,5 +1,8 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; +const defaultDNSVerificationTimeoutSeconds = 45; +const dnsVerificationCleanupBufferSeconds = 30; + export function useDashboard() { const scertLogo = "/Scert.png"; @@ -37,8 +40,11 @@ export function useDashboard() { const settingsError = ref(""); const dnsTesting = ref(false); const dnsTestError = ref(""); + const dnsVerificationElapsedSeconds = ref(0); + const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds); const verificationClock = ref(Date.now()); let verificationTimer; + let dnsVerificationTimer; let dnsRequestVersion = 0; const settingsForm = ref(createEmptySettingsForm()); const showCertificateImport = ref(false); @@ -212,6 +218,47 @@ export function useDashboard() { new Date(dns.expires_at).getTime() > Date.now(); }); + function resolveDNSVerificationTimeoutSeconds(value) { + const propagationTimeout = Number(value); + if (!Number.isFinite(propagationTimeout) || propagationTimeout <= 0) { + return defaultDNSVerificationTimeoutSeconds; + } + return Math.floor(propagationTimeout) + dnsVerificationCleanupBufferSeconds; + } + + function updateDNSVerificationTimeoutSeconds(value) { + const timeout = Number(value); + if (Number.isFinite(timeout) && timeout > 0) { + dnsVerificationTimeoutSeconds.value = Math.floor(timeout); + } + } + + function stopDNSVerificationTimer(requestVersion) { + if (requestVersion !== undefined && requestVersion !== dnsRequestVersion) return; + if (dnsVerificationTimer) { + window.clearInterval(dnsVerificationTimer); + dnsVerificationTimer = undefined; + } + dnsVerificationElapsedSeconds.value = 0; + } + + function startDNSVerificationTimer(requestVersion) { + stopDNSVerificationTimer(); + dnsVerificationTimeoutSeconds.value = resolveDNSVerificationTimeoutSeconds( + settingsForm.value.acme.propagation_timeout_secs, + ); + const startedAt = Date.now(); + const updateElapsedSeconds = () => { + if (requestVersion !== dnsRequestVersion) return; + dnsVerificationElapsedSeconds.value = Math.max( + 0, + Math.floor((Date.now() - startedAt) / 1000), + ); + }; + updateElapsedSeconds(); + dnsVerificationTimer = window.setInterval(updateElapsedSeconds, 1000); + } + watch( () => [settingsForm.value.dns.provider, settingsForm.value.dns.test_domain, settingsForm.value.dns.credentials], handleDNSProviderChange, @@ -713,6 +760,7 @@ export function useDashboard() { function handleDNSProviderChange() { dnsRequestVersion += 1; + stopDNSVerificationTimer(); dnsTesting.value = false; const dns = settingsForm.value.dns; const saved = settings.value?.dns; @@ -761,9 +809,10 @@ export function useDashboard() { dnsTestError.value = `请填写${missingField.label}`; return; } + const requestVersion = dnsRequestVersion; dnsTesting.value = true; dns.verification_state = "pending"; - const requestVersion = dnsRequestVersion; + startDNSVerificationTimer(requestVersion); try { const response = await fetchAdmin("/api/v1/dns/test", { method: "POST", @@ -776,6 +825,7 @@ export function useDashboard() { }); const result = await response.json().catch(() => ({})); if (requestVersion !== dnsRequestVersion) return; + updateDNSVerificationTimeoutSeconds(result.verification_timeout_secs); dns.test_report = result.test_report || null; if (!response.ok) { throw new Error(result.error || "DNS API 在线测试失败"); @@ -793,7 +843,10 @@ export function useDashboard() { dns.verification_state = "pending"; dnsTestError.value = err.message || "DNS API 在线测试失败"; } finally { - if (requestVersion === dnsRequestVersion) dnsTesting.value = false; + if (requestVersion === dnsRequestVersion) { + stopDNSVerificationTimer(requestVersion); + dnsTesting.value = false; + } } } @@ -1100,6 +1153,7 @@ export function useDashboard() { }); onBeforeUnmount(() => { dnsRequestVersion += 1; + stopDNSVerificationTimer(); if (verificationTimer) window.clearInterval(verificationTimer); document.body.style.overflow = previousBodyOverflow; }); @@ -1119,6 +1173,8 @@ export function useDashboard() { selectedDNSProvider, dnsTesting, dnsTestError, + dnsVerificationElapsedSeconds, + dnsVerificationTimeoutSeconds, dnsReadyToSave, handleDNSProviderChange, clearDNSCredentials, diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 366c55c..f4e3915 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -60,6 +60,8 @@ const { selectedDNSProvider, dnsTesting, dnsTestError, + dnsVerificationElapsedSeconds, + dnsVerificationTimeoutSeconds, dnsReadyToSave, handleDNSProviderChange, clearDNSCredentials, @@ -570,6 +572,21 @@ const { {{ settingsForm.dns.verification_state === "verified" ? "已验证" : "待验证" }} +
{{ dnsTestError }}
{{ dnsTestError }}