فهرست منبع

fix: improve dns apply status and toast feedback

Codex 1 ماه پیش
والد
کامیت
a3074bf002
5فایلهای تغییر یافته به همراه128 افزوده شده و 6 حذف شده
  1. 3 1
      public/app.js
  2. 6 2
      public/styles.css
  3. 40 2
      src/dns-providers.js
  4. 44 1
      src/server.js
  5. 35 0
      test/dns-providers.test.js

+ 3 - 1
public/app.js

@@ -1092,6 +1092,7 @@ function replaceDomain(domain) {
 function statusMeta(target) {
   if (!target || (!target.checkedAt && !target.status)) return { className: 'idle', label: '未检查' };
   if (target.verified || target.status === 'ok') return { className: 'ok', label: '通过' };
+  if (target.status === 'pending') return { className: 'pending', label: '传播中' };
   if (target.status === 'missing') return { className: 'missing', label: '缺失' };
   if (target.status === 'warn') return { className: 'warn', label: '需调整' };
   return { className: 'warn', label: '待配置' };
@@ -1104,6 +1105,7 @@ function badge(meta) {
 function badgeTone(className) {
   return {
     ok: 'bg-green-lt',
+    pending: 'bg-blue-lt',
     warn: 'bg-yellow-lt',
     missing: 'bg-red-lt',
     failed: 'bg-red-lt',
@@ -1124,7 +1126,7 @@ function setBusy(value) {
 
 function toast(message, tone = 'info') {
   const node = document.createElement('div');
-  node.className = `toast ${tone}`;
+  node.className = `app-toast ${tone}`;
   node.textContent = message;
   els.toastHost.appendChild(node);
   setTimeout(() => node.remove(), 3200);

+ 6 - 2
public/styles.css

@@ -218,6 +218,10 @@
   border-color: rgba(245, 159, 0, 0.45);
 }
 
+.record-card.pending {
+  border-color: rgba(32, 107, 196, 0.45);
+}
+
 .record-card.missing,
 .record-card.failed {
   border-color: rgba(214, 57, 57, 0.45);
@@ -402,7 +406,7 @@ code {
   z-index: 1080;
 }
 
-.toast {
+.app-toast {
   background: #ffffff;
   border: 1px solid var(--mh-line);
   border-left: 4px solid var(--mh-blue);
@@ -413,7 +417,7 @@ code {
   padding: 0.75rem 0.9rem;
 }
 
-.toast.error {
+.app-toast.error {
   border-left-color: var(--mh-red);
 }
 

+ 40 - 2
src/dns-providers.js

@@ -19,6 +19,11 @@ export async function applyDnsSetup(domain, credential, guide) {
   const records = (guide.records || []).filter((record) => ['verification', 'dkim', 'spf', 'dmarc', 'sender-a'].includes(record.key));
   const results = [];
   for (const record of records) {
+    const zoneName = credential.zoneName || '';
+    if (zoneName && !isHostInZone(record.host, zoneName)) {
+      results.push(outOfZoneResult(record, zoneName));
+      continue;
+    }
     try {
       const result = await provider.upsert(record, domain);
       results.push({ key: record.key, host: record.host, type: record.type, ok: true, detail: result });
@@ -74,7 +79,12 @@ class CloudflareProvider {
       await this.deleteExtras(zoneId, existing, match, record);
       return 'updated';
     }
-    await this.request(`/zones/${zoneId}/dns_records`, { method: 'POST', body: payload });
+    try {
+      await this.request(`/zones/${zoneId}/dns_records`, { method: 'POST', body: payload });
+    } catch (error) {
+      if (/identical record already exists/i.test(error.message)) return 'unchanged';
+      throw error;
+    }
     await this.deleteExtras(zoneId, existing, null, record);
     return 'created';
   }
@@ -306,7 +316,35 @@ function relativeName(host, zoneName) {
   if (!cleanZone) throw new Error('DNS 凭据缺少 zoneName。');
   if (cleanHost === cleanZone) return '@';
   if (cleanHost.endsWith(`.${cleanZone}`)) return cleanHost.slice(0, -cleanZone.length - 1) || '@';
-  return cleanHost;
+  throw new Error(`记录 ${host} 不在 DNS Zone ${zoneName} 下。`);
+}
+
+function isHostInZone(host, zoneName) {
+  const cleanHost = String(host || '').replace(/\.$/, '').toLowerCase();
+  const cleanZone = String(zoneName || '').replace(/\.$/, '').toLowerCase();
+  return Boolean(cleanHost && cleanZone && (cleanHost === cleanZone || cleanHost.endsWith(`.${cleanZone}`)));
+}
+
+function outOfZoneResult(record, zoneName) {
+  const base = {
+    key: record.key,
+    host: record.host,
+    type: record.type
+  };
+  if (record.key === 'sender-a' && record.status === 'ok') {
+    return {
+      ...base,
+      ok: true,
+      skipped: true,
+      detail: `发信主机不在 ${zoneName} Zone 下,已跳过;当前 A 记录已正确解析。`
+    };
+  }
+  return {
+    ...base,
+    ok: false,
+    skipped: true,
+    error: `记录 ${record.host} 不在 DNS Zone ${zoneName} 下,请绑定正确的 DNS API 或手动配置。`
+  };
 }
 
 function signAliyun(params, accessKeySecret) {

+ 44 - 1
src/server.js

@@ -316,7 +316,7 @@ async function handleApi(req, res, url, user) {
       if (!credential) return sendJson(res, 400, { error: '请先为该域名绑定 DNS API 凭据。' });
       const guide = await buildDnsGuide(row);
       const applyResult = await applyDnsSetup(row, credential, guide);
-      const checkedGuide = await buildDnsGuide(row);
+      const checkedGuide = await buildDnsGuideAfterApply(row, applyResult);
       checkedGuide.apply = applyResult;
       saveDomainStatus(id, user.id, checkedGuide);
       return sendJson(res, applyResult.ok ? 200 : 207, {
@@ -688,6 +688,49 @@ function defaultSelector() {
   return `mh${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
 }
 
+async function buildDnsGuideAfterApply(domain, applyResult) {
+  const appliedKeys = new Set((applyResult.results || [])
+    .filter((result) => result.ok && !result.skipped)
+    .map((result) => result.key));
+  let guide = await buildDnsGuide(domain);
+  for (let attempt = 0; attempt < 2 && hasUnpropagatedAppliedRecords(guide, appliedKeys); attempt += 1) {
+    await sleep(1800);
+    guide = await buildDnsGuide(domain);
+  }
+  return markUnpropagatedAppliedRecords(guide, appliedKeys);
+}
+
+function hasUnpropagatedAppliedRecords(guide, appliedKeys) {
+  return (guide.records || []).some((record) => appliedKeys.has(record.key) && record.status !== 'ok');
+}
+
+function markUnpropagatedAppliedRecords(guide, appliedKeys) {
+  const records = (guide.records || []).map((record) => {
+    if (!appliedKeys.has(record.key) || record.status === 'ok') return record;
+    return {
+      ...record,
+      status: 'pending',
+      warnings: [
+        ...(record.warnings || []),
+        '已提交到 DNS 服务商,正在等待公共 DNS 传播;稍后点击“立即检查”刷新。'
+      ]
+    };
+  });
+  return {
+    ...guide,
+    records,
+    warnings: collectGuideWarnings(records)
+  };
+}
+
+function collectGuideWarnings(records) {
+  return records.flatMap((record) => record.warnings || []);
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
 function isUniqueError(error) {
   return /UNIQUE constraint failed/i.test(String(error?.message || ''));
 }

+ 35 - 0
test/dns-providers.test.js

@@ -86,6 +86,41 @@ test('dnspod provider signs and sends create record actions', async () => {
   assert.ok(actions.includes('CreateRecord'));
 });
 
+test('skips external sender host when it already resolves correctly', async () => {
+  const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
+    records: [
+      {
+        key: 'sender-a',
+        host: 'in.ss5.xyz',
+        type: 'A',
+        value: '127.0.0.1',
+        status: 'ok'
+      }
+    ]
+  });
+
+  assert.equal(result.ok, true);
+  assert.equal(result.results[0].skipped, true);
+});
+
+test('fails external sender host when it is not already correct', async () => {
+  const result = await applyDnsSetup(domainFixture(), cloudflareCredential(), {
+    records: [
+      {
+        key: 'sender-a',
+        host: 'in.ss5.xyz',
+        type: 'A',
+        value: '127.0.0.1',
+        status: 'warn'
+      }
+    ]
+  });
+
+  assert.equal(result.ok, false);
+  assert.equal(result.results[0].skipped, true);
+  assert.match(result.results[0].error, /不在 DNS Zone/);
+});
+
 function cloudflareCredential() {
   return {
     provider: 'cloudflare',