完善 DNS 验证等待提示

This commit is contained in:
xuans 2026-09-15 23:29:01 +08:00
parent 90ab0abb5e
commit ddbf3b2d29
10 changed files with 241 additions and 20 deletions

View File

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

View File

@ -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 在线验证通过,凭据已加密保存。",
})
}

View File

@ -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 在线测试应收到带截止时间的上下文")
}

View File

@ -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();
},
};
}

View File

@ -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;

View File

@ -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,

View File

@ -60,6 +60,8 @@ const {
selectedDNSProvider,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
handleDNSProviderChange,
clearDNSCredentials,
@ -570,6 +572,21 @@ const {
{{ settingsForm.dns.verification_state === "verified" ? "已验证" : "待验证" }}
</span>
</div>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>
<p v-if="dnsTestError" class="form-error">{{ dnsTestError }}</p>
<div
v-if="settingsForm.dns.test_report?.stages?.length"

View File

@ -431,6 +431,7 @@ label > small {
.provider-description,
.dns-verification-success,
.dns-verification-progress,
.dns-verification-report {
min-width: 0;
display: grid;
@ -452,6 +453,21 @@ label > small {
background: #fff8e8;
}
.dns-verification-progress {
border: 1px solid #cbdcf2;
color: #2d5b99;
background: #f0f6ff;
}
.dns-verification-progress strong {
font-size: 11px;
}
.dns-verification-progress small {
color: #53647b;
font-size: 9px;
}
.provider-description strong,
.dns-verification-success strong {
font-size: 11px;
@ -831,6 +847,7 @@ button:disabled {
.provider-description,
.dns-verification-success,
.dns-verification-progress,
.dns-test-actions {
margin-top: 6px;
}
@ -984,13 +1001,15 @@ button:disabled {
}
.provider-description,
.dns-verification-success {
.dns-verification-success,
.dns-verification-progress {
padding: 6px 8px;
font-size: 9px;
}
.provider-description span,
.dns-verification-success span,
.dns-verification-progress small,
.dns-test-actions > span {
display: none;
}

View File

@ -1,5 +1,8 @@
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
const defaultDNSVerificationTimeoutSeconds = 45;
const dnsVerificationCleanupBufferSeconds = 30;
const commonEmailDomains = [
{ label: "常用邮箱", value: "" },
{ label: "@163.com", value: "163.com" },
@ -25,6 +28,8 @@ export function useSetupWizard(props, emit) {
const dnsCatalogError = ref("");
const dnsTesting = ref(false);
const dnsTestError = ref("");
const dnsVerificationElapsedSeconds = ref(0);
const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds);
const dnsProviders = ref([]);
const dnsCredentials = ref({});
const dnsCredentialsVisible = ref(true);
@ -41,6 +46,7 @@ export function useSetupWizard(props, emit) {
const emailAutofillElement = ref(null);
let emailVisibleEdited = false;
let verificationTimer;
let dnsVerificationTimer;
let dnsRequestVersion = 0;
let emailAutofillTimer;
const targetType = ref(props.status.web_public_ip ? "ip" : "hostname");
@ -143,11 +149,53 @@ export function useSetupWizard(props, emit) {
function invalidateDNSVerification() {
dnsRequestVersion += 1;
stopDNSVerificationTimer();
dnsTesting.value = false;
dnsVerification.value = null;
dnsTestError.value = "";
}
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(
form.value.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);
}
function formatVerificationExpiry(value) {
if (!value) return "";
return new Intl.DateTimeFormat("zh-CN", {
@ -417,8 +465,9 @@ export function useSetupWizard(props, emit) {
}
}
dnsTesting.value = true;
const requestVersion = dnsRequestVersion;
dnsTesting.value = true;
startDNSVerificationTimer(requestVersion);
try {
const response = await fetch("/api/v1/setup/dns/test", {
method: "POST",
@ -431,6 +480,7 @@ export function useSetupWizard(props, emit) {
});
const result = await response.json().catch(() => ({}));
if (requestVersion !== dnsRequestVersion) return;
updateDNSVerificationTimeoutSeconds(result.verification_timeout_secs);
if (result.test_report) {
dnsVerification.value = { test_report: result.test_report };
}
@ -440,7 +490,10 @@ export function useSetupWizard(props, emit) {
if (requestVersion !== dnsRequestVersion) return;
dnsTestError.value = err.message || "DNS API 在线验证失败";
} finally {
if (requestVersion === dnsRequestVersion) dnsTesting.value = false;
if (requestVersion === dnsRequestVersion) {
stopDNSVerificationTimer(requestVersion);
dnsTesting.value = false;
}
}
}
@ -810,6 +863,8 @@ export function useSetupWizard(props, emit) {
dnsCatalogError,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsProviders,
dnsCredentials,
dnsCredentialsVisible,

View File

@ -20,6 +20,8 @@ const {
dnsCatalogError,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsProviders,
dnsCredentials,
dnsCredentialsVisible,
@ -797,6 +799,21 @@ const {
记录并自动清理,明文凭据不会写入普通配置文件。</span
>
</div>
<div
v-if="dnsTesting"
class="dns-verification-progress"
role="status"
aria-live="polite"
>
<strong>正在进行 DNS 验证,请等待</strong>
<span>
已等待 {{ dnsVerificationElapsedSeconds }} 秒,最长等待
{{ dnsVerificationTimeoutSeconds }} 秒。
</span>
<small>
系统正在创建临时 TXT、查询验证记录并自动清理。这是 DNS API 权限验证;实际申请证书时,仍可能需要等待公网 DNS-01 记录传播。
</small>
</div>
<p v-if="dnsTestError" class="inline-error">{{ dnsTestError }}</p>
<div v-if="dnsVerificationValid" class="dns-verification-success">
<strong>DNS API 在线验证通过</strong>