From c2f890a0f78c36713fcd03e731bd02d96386feea Mon Sep 17 00:00:00 2001 From: zqlit Date: Tue, 6 Oct 2026 12:46:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20favicon=20=E8=BE=B9=E7=BC=98=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20+=20CDN=20=E6=95=B4=E7=AB=99=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E5=85=9C=E5=BA=95=20+=20=E8=AF=84=E8=AE=BA=E5=AD=A4=E5=84=BF?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - blog-admin tools.ts: favicon 响应加 s-maxage/CDN-Cache-Control, 让 CF 边缘可缓存,边缘命中即不进 Worker,KV 读取量大幅下降 - scripts/refresh_cdn.js: 裸域名自动补尾斜杠(多吉云 rtype=path 是目录刷新, https://usj.cc 只刷首页,https://usj.cc/ 才是全站),并打印刷新清单 - 新增文档:评论孤儿修复方案、函数版证书管家方案、诊断报告 --- blog-admin/src/routes/rss/tools.ts | 54 ++--- scripts/refresh_cdn.js | 22 +- 函数版证书管家-方案.md | 289 +++++++++++++++++++++++++ 评论孤儿修复方案-2026-10-06.md | 121 +++++++++++ 诊断报告-2026-10-06.md | 332 +++++++++++++++++++++++++++++ 5 files changed, 790 insertions(+), 28 deletions(-) create mode 100644 函数版证书管家-方案.md create mode 100644 评论孤儿修复方案-2026-10-06.md create mode 100644 诊断报告-2026-10-06.md diff --git a/blog-admin/src/routes/rss/tools.ts b/blog-admin/src/routes/rss/tools.ts index ddb14a50..42162827 100644 --- a/blog-admin/src/routes/rss/tools.ts +++ b/blog-admin/src/routes/rss/tools.ts @@ -75,6 +75,28 @@ async function discoverFaviconUrl(siteUrl: string): Promise { } } +/** + * favicon 图片响应的统一缓存头。 + * + * ★ 为什么要显式加 s-maxage / CDN-Cache-Control: + * `Cache-Control: max-age=86400` 只让**浏览器**缓存,Cloudflare 边缘默认 + * **不缓存 Worker 的响应**(实测响应里没有 cf-cache-status)。于是每个访客、 + * 每次清缓存、每换设备,都会真正进 Worker → 第一条语句就是 KV.get → KV 读量被放大。 + * + * 加上 s-maxage(共享缓存)后,CF 边缘可缓存 24h:边缘命中就完全不进 Worker, + * KV 读取 ≈ 0,同时首包更快。浏览器侧仍保留 max-age。 + * 只对**成功拿到图片**的响应使用;失败/兜底响应不缓存(避免把错误固化一天)。 + */ +function imageCacheHeaders(contentType: string): Record { + return { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + 'CDN-Cache-Control': 'max-age=86400', + 'Cloudflare-CDN-Cache-Control': 'max-age=86400', + 'Access-Control-Allow-Origin': '*', + }; +} + export async function favicon(request: Request, env: Env): Promise { const url = new URL(request.url); const siteUrl = url.searchParams.get('url'); @@ -87,14 +109,7 @@ export async function favicon(request: Request, env: Env): Promise { if (cached) { const [meta, b64] = cached.split('|'); const body = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); - return new Response(body, { - status: 200, - headers: { - 'Content-Type': meta, - 'Cache-Control': 'public, max-age=86400', - 'Access-Control-Allow-Origin': '*', - }, - }); + return new Response(body, { status: 200, headers: imageCacheHeaders(meta) }); } // 失败短缓存:抓取失败的站点 1 小时内不再反复打外站 @@ -150,14 +165,7 @@ export async function favicon(request: Request, env: Env): Promise { } await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL }); } - return new Response(buf, { - status: 200, - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=86400', - 'Access-Control-Allow-Origin': '*', - }, - }); + return new Response(buf, { status: 200, headers: imageCacheHeaders(contentType) }); } } catch { /* 落到第三方兜底 */ @@ -182,14 +190,7 @@ export async function favicon(request: Request, env: Env): Promise { bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]); } await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL }); - return new Response(buf, { - status: 200, - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=86400', - 'Access-Control-Allow-Origin': '*', - }, - }); + return new Response(buf, { status: 200, headers: imageCacheHeaders(contentType) }); } } catch { /* 彻底失败 */ @@ -212,8 +213,11 @@ function letterSvg(siteUrl: string): Response { return new Response(svg, { status: 200, headers: { + // 兜底图也缓存(否则每个访客都要重跑一遍抓取失败流程 = 3 次 KV 读 + 1 次写) 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'public, max-age=86400', + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + 'CDN-Cache-Control': 'max-age=86400', + 'Cloudflare-CDN-Cache-Control': 'max-age=86400', 'Access-Control-Allow-Origin': '*', }, }); diff --git a/scripts/refresh_cdn.js b/scripts/refresh_cdn.js index 72ecaecb..88d6b13b 100644 --- a/scripts/refresh_cdn.js +++ b/scripts/refresh_cdn.js @@ -32,7 +32,7 @@ async function main() { return; } - const urlList = cdnUrlListStr + let urlList = cdnUrlListStr .replace(/\n/g, ",") .split(",") .map((u) => u.trim()) @@ -43,7 +43,21 @@ async function main() { return; } - console.log(`Refreshing ${urlList.length} URLs...`); + // ★ 多吉云 rtype=path 是「刷新目录」,目录必须以 / 结尾: + // https://usj.cc → 只刷新首页这一个文件(旧行为,不是全站) + // https://usj.cc/ → 整站刷新 + // 这里兜底补尾斜杠(只对裸域名生效,不碰带路径的 URL)。 + urlList = urlList.map((u) => { + const bare = /^https?:\/\/[^/?#]+$/.test(u); + if (bare) { + console.log(`[warn] "${u}" 缺少结尾斜杠 —— 补成 "${u}/" 才能全站刷新`); + return u + "/"; + } + return u; + }); + + console.log(`Refreshing ${urlList.length} URLs (rtype=path, 目录刷新)...`); + urlList.forEach((u) => console.log(` - ${u}`)); const result = await dogecloudApi("/cdn/refresh/add.json", { rtype: "path", @@ -51,7 +65,9 @@ async function main() { }); if (result && result.code === 200) { - console.log(`Refresh task submitted. Task ID: ${result.data.task_id}`); + console.log( + `Refresh task submitted. Task ID: ${result.data.task_id}, count: ${result.data.count}`, + ); } else { console.error(`Failed to submit refresh task. Response: ${JSON.stringify(result)}`); process.exit(1); diff --git a/函数版证书管家-方案.md b/函数版证书管家-方案.md new file mode 100644 index 00000000..c4a38eca --- /dev/null +++ b/函数版证书管家-方案.md @@ -0,0 +1,289 @@ +# 函数版证书管家(CF Worker)实施方案 + +> **目标**:用 Cloudflare Worker 取代服务器上的 certimate, +> 申请 + 续期 + 部署证书(多吉云 CDN + 1Panel),把 certimate 这个服务精简掉。 + +**已确认的决策(2026-10-06)** + +| 项 | 决定 | +|---|---| +| CA | **继续用 LiteSSL / freessl.cn**(与现状一致,90 天免费) | +| DNS-01 | **对接各家 DNS API,不改 DNS**(腾讯云 DNSPod + Cloudflare) | +| 部署目标 | **多吉云 CDN + 1Panel**(放弃又拍云) | +| 1Panel 接入 | **直连公网 `119.29.215.187:3721`** | +| 覆盖域名 | `usj.cc` / `t-t.live` / `200181.xyz`(三组,多域名) | + +--- + +## 一、现状盘点(从服务器上的 certimate 导出) + +### 1.1 三条「申请工作流」(2026-10-06 从 certimate SQLite 导出) + +| 工作流 | cron | 域名 | CA | DNS | 部署 | 算法 | +|---|---|---|---|---|---|---| +| 优世界证书申请 | `10 12 * * *` | `*.usj.cc;usj.cc` | `litessl` | **tencentcloud-dns** | **dogecloud-cdn** + **1panel** | RSA2048 | +| 团团证书申请 | `05 12 * * *` | `t-t.live;*.t-t.live` | `litessl` | **tencentcloud-dns** | **1panel** + tencentcloud-eo | RSA2048 | +| 200181.xyz 证书申请 | `17 12 * * *` | `*.200181.xyz;200181.xyz` | `litessl` | **cloudflare** | **1panel** | RSA2048 | + +另有 4 条「过期预警」工作流(`0 12 * * *`,纯监控发信): +优世界 / 伍比贰 / 优世界artalk评论 / 团团。 + +> ★ 与你的要求完全吻合:**多吉云 + 1Panel**。又拍云本来就没在这套工作流里。 +> ★ `tencentcloud-eo`(腾讯云边缘安全加速)是 t-t.live 独有的第 3 个部署目标, +> 函数版第一版可不做(保持 1Panel 即可)。 + +### 1.2 1Panel 站点证书实况 + +| 站点 | 到期 | SAN | +|---|---|---| +| `artalk/openlist/openwrt/wifi/writeapi.usj.cc`、`vaultwarden` | Dec 7 2026 | `usj.cc,*.usj.cc` | +| `certd/pl/pwd/www.t-t.live`、`t-t.live` | **Oct 9 2026** | `*.t-t.live,t-t.live` | +| `ssh.200181.xyz` | Nov 23 2026 | `200181.xyz,*.200181.xyz` | + +> 证书落地路径:`/1panel/1panel/www/sites/<域名>/ssl/fullchain.pem` + +### 1.3 可复用的凭据(2026-10-06 从 certimate `access` 表导出) + +| 用途 | 凭据名 | 结构 | 说明 | +|---|---|---|---| +| 多吉云 API | 多吉云账户 | `{accessKey, secretKey}` | **零新增**,已有同款 AK/SK | +| 腾讯云 DNS | 小赵腾讯云 / 团团腾讯云 | `{secretId: AKID..., secretKey}` | **CAM 密钥** → TC3-HMAC-SHA256 签名 | +| Cloudflare | CloudFare账户 | `{apiToken: ALTpx...}` | 200181.xyz 的 DNS + 部署 | +| LiteSSL | freessl.cn | `{eabKid, eabHmacKey(base64)}` | ★ **ACME EAB 凭据**,不是私有 API | +| ZeroSSL | zerossl | `{eabKid, eabHmacKey(base64)}` | 同上,可作 CA 备选 | +| 1Panel | 团团服务器面板 | `{apiKey: muuiR..., apiVersion: v2, serverUrl}` | 服务器面板 API | +| 1Panel | 本地 | `{apiKey: 41mRF..., apiVersion: v2, serverUrl}` | 本地面板 API | +| 邮件 | 小赵的邮件通知 | `{smtpHost, smtpPort:465, username, password}` | ★ SMTP,**Worker 用不了**(无 TCP) | + +> ⚠️ **SMTP 在 Worker 里不可用** —— Cloudflare Workers 没有 TCP socket。 +> 现有 artalk-cf worker 已改用 **Resend HTTP API**,证书管家复用同一套即可。 + +### 1.4 ★ 1Panel 的 IP 白名单 —— 实测**没有拦外网** + +``` +1panel-core 监听: *:3721 (所有接口) +外网访问测试: http://119.29.215.187:3721/api/v2/dashboard/base + → HTTP 401(未授权,而非连接拒绝) +grep securityEntrance/allowIP → 空(未设白名单) +``` + +→ **Worker 直连 `119.29.215.187:3721` 技术上可行**,只需 API Key。 +> 但这意味着**面板端口 3721 对公网开放**(靠 token 鉴权)。 +> 方案 A(服务器侧拉取)仍更安全,见 §3.4。 + +--- + +## 二、架构设计 + +``` +┌──────────────── Cloudflare Worker: cert-keeper ────────────────┐ +│ │ +│ [crons] 每天 12:00 一行:检查所有域名的证书剩余天数 │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 1. 到期检查 剩余 > 30 天 → 直接退出(不打扰) │ │ +│ │ 2. ACME 申请 对新域名/即将到期域名走 DNS-01 │ │ +│ │ · freessl.cn API(litessl,与现状一致) │ │ +│ │ 3. 得证书 → 存 KV(cert:) │ │ +│ │ 4. 部署: │ │ +│ │ a) 多吉云 CDN /cdn/cert/upload + bind │ │ +│ │ b) 1Panel API 直连 119.29.215.187:3721 │ │ +│ │ 5. 邮件通知(成功/失败) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ [HTTP] 手动触发 /admin/cert/check (带鉴权) │ +│ /admin/cert/list 查看所有证书状态 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.1 域名配置(KV 存储,可动态加域名) + +```jsonc +{ + "domains": [ + { + "name": "usj.cc", + "san": ["usj.cc", "*.usj.cc"], + "dns": "tencent", + "deploy": ["dogecloud", "1panel"], + "dogecloud_domains": ["usj.cc"], // 绑到多吉云 CDN 的域名 + "1panel_sites": ["artalk.usj.cc", "openlist.usj.cc", "..."] + }, + { + "name": "t-t.live", + "san": ["t-t.live", "*.t-t.live"], + "dns": "tencent", + "deploy": ["1panel"], + "1panel_sites": ["t-t.live", "www.t-t.live", "certd.t-t.live", "..."] + }, + { + "name": "200181.xyz", + "san": ["200181.xyz", "*.200181.xyz"], + "dns": "cloudflare", + "deploy": ["1panel"], + "1panel_sites": ["ssh.200181.xyz"] + } + ] +} +``` + +--- + +## 三、各环节实现要点 + +### 3.1 ACME 申请(LiteSSL / freessl.cn)—— ★ 已确认是标准 ACME + EAB + +**关键发现(2026-10-06 从 certimate 凭据表导出)**: +certimate 里 `freessl.cn` 凭据的字段是 **`eabKid` + `eabHmacKey`**, +这是 **ACME 的 External Account Binding**,**不是** freessl 私有 API。 + +``` +freessl.cn 凭据: { eabKid: "T2G-o***", eabHmacKey: "Qk9Yg***"(base64) } +zerossl 凭据: { eabKid: "I5tkZ***", eabHmacKey: "FHnpZ***"(base64) } +``` + +→ **说明 certimate 走的是标准 ACME 协议**(RFC 8555),只是 CA 端要求 EAB 绑定。 + +**这大幅降低实现难度**: + +| 步骤 | 实现 | +|---|---| +| 目录 | `GET https://acme.trustasia.com/acme/directory`(LiteSSL 的 ACME 端点,需实测确认) | +| 账号 | `newAccount`,带 **EAB**:`eab = { kid, hmacKey }` | +| 签名 | **JWS + ES256/RS256**,用 WebCrypto(CF Worker 原生支持) | +| 挑战 | DNS-01(写入 `_acme-challenge.` TXT) | +| 完毕 | `finalize` → 轮询 → 下载 `fullchain.pem` | + +> **好消息**:既然是标准 ACME,开源参考 `manalejandro/cf-acme`、`PIKACHUIM/CFWorkerACME` +> 可以**几乎直接复用**,主要工作变成「加 EAB 支持」+「换成 LiteSSL 目录地址」。 +> 且 CA 可做成「可切换」(LiteSSL / ZeroSSL / Let's Encrypt 同一个实现,只换 directory + EAB)。 + +**仍需实测确认的**:LiteSSL 的 ACME directory URL。可从 certimate 的 +`workflow.graphContent` 里 grep 出来(那里存了 CA 配置)。 + +### 3.2 DNS-01 验证 + +**腾讯云 DNSPod**(usj.cc / t-t.live): +``` +POST https://dnsapi.cn/Record.Create # 新建 TXT +POST https://dnsapi.cn/Record.Delete # 删除 TXT +认证:login_token = "," (dnspod 老 API,简单) +``` +> 需确认 certimate 里那两条 tencentcloud 凭据用的是 **DNSPod token** 还是 **CAM 密钥**。 +> 若是 CAM(TC3-HMAC-SHA256 签名),则有现成的签名实现可复用。 + +**Cloudflare**(200181.xyz): +``` +POST /client/v4/zones/:zone_id/dns_records # 建 TXT +DELETE /client/v4/zones/:zone_id/dns_records/:id +认证:Bearer (已有) +``` + +### 3.3 部署到多吉云 CDN + +```http +POST https://api.dogecloud.com/cdn/cert/upload.json + note=<备注>&cert=&private=<私钥> + → data.id(证书ID) + +POST https://api.dogecloud.com/cdn/cert/bind.json + id=<证书ID>&domain=usj.cc +``` + +认证:**HMAC-SHA1 签 `apiPath + "\n" + body`**,与现有 `scripts/refresh_cdn.js` 完全一致 —— +**可以把它拆出来复用代码**。 + +> 多吉云证书 API 参数名注意:官方文档写 `privary`(疑似笔误),实际要传 `private`。 +> 上手前需实测一次确认。 + +### 3.4 部署到 1Panel(直连 119.29.215.187:3721) + +1Panel v2 API 认证: +``` +1Panel-Token: md5('1panel' + + ) +1Panel-Timestamp: +Base: http://119.29.215.187:3721/api/v2 +``` + +**⚠️ 最大障碍:IP 白名单。** +1Panel 服务端逐层校验「时间戳 → token → **IP 白名单**」。 +Cloudflare Worker 的出口 IP 是**海量动态 IP**,无法加白名单。 + +**可选解法(按推荐序)**: + +| 方案 | 说明 | 评价 | +|---|---|---| +| **A. 服务器侧拉取**(推荐) | Worker 只存证书到 KV;服务器上一个 cron 脚本 `curl` 拉最新证书 + 重载 nginx。**不需要开放 1Panel 端口给公网** | ✅ 最安全,绕开白名单 | +| B. 逆向拿 Worker 出口 IP | 不可行(CF 出口 IP 段巨大且变动) | ❌ | +| C. 自建小代理 | 在服务器上跑一个 30 行的反代,Worker → 代理 → 1Panel | ⚠️ 等于 A 但更绕 | +| D. 关闭 1Panel IP 白名单 | 直接放开面板端口给公网 | ❌ 危险,不建议 | + +> **推荐 A**:既能满足「函数版管家」的自动化意图,又不必把面板暴露到公网。 +> 服务器上只需一个 `cert-sync.sh`(wg 或 SSH 进来装一次), +> 效果与 certimate 的 1panel deploy 完全等价。 + +### 3.5 存储与通知 + +- **KV**:`cert:` → `{ cert, key, expireAt, updatedAt }`;`config` → 域名配置 +- **邮件**:Worker 无 TCP socket,**不能直连 SMTP**。 + 现有 worker 已用 **Resend**(HTTP API),复用同一套: + `POST https://api.resend.com/emails`,发到 `177018615@qq.com` / `1770186415@qq.com` +- **crons**:`[triggers] crons = ["30 4 * * *"]`(UTC 4:30 = 北京 12:30) + +--- + +## 四、落地路径(分阶段,可随时叫停) + +### Phase 1 —— 只读监控(零风险) +- 部署 Worker,`crons` 每天检查三个域名的证书剩余天数 +- 到期 < 30 天 → 发邮件提醒 +- **完全不写任何东西**,纯观察。可以先跑两周验证数据准确性。 + +### Phase 2 —— 接管续期(+ 多吉云自动部署) +- 实现 ACME(Let's Encrypt 标准协议优先) +- DNS-01 走腾讯云 + Cloudflare +- 自动部署到**多吉云 CDN**(有正规 API,风险低) +- 邮件通知结果;**1Panel 仍由 certimate 管** + +### Phase 3 —— 接管 1Panel + 关停 certimate +- 服务器加 `cert-sync.sh`(从 Worker 拉证书 + reload) +- 观察一个完整续期周期(90 天) +- 确认无误 → **关停 certimate 容器** + +--- + +## 五、开工前必须确认/实测的 4 件事 + +1. **freessl.cn API 是否可用**(认证 + 申请 + 下载全流程) + → 若不行,改 **Let's Encrypt 标准 ACME**(推荐这条路,更稳) +2. **certimate 里 tencentcloud 凭据的类型** + (DNSPod token 还是 CAM 密钥?决定签名实现) +3. **多吉云 `cdn/cert/upload.json` 的参数名**(`private` 还是 `privary`) +4. **1Panel 侧走「服务器拉取」还是「直连面板」**(强烈建议前者) + +--- + +## 六、现成可参考的开源实现 + +| 项目 | 用途 | +|---|---| +| `manalejandro/cf-acme` | CF Workers + KV,用 WebCrypto 实现标准 ACME,DNS-01 自动申请 | +| `PIKACHUIM/CFWorkerACME` | CF Worker 上的 SSL 申请代理,支持 LE / ZeroSSL / GTS | +| `Menci/deploy-certificate-to-upyun` | 又拍云部署(证明其**无公开 API**,故本方案放弃又拍云) | +| 本项目 `scripts/refresh_cdn.js` | 多吉云 HMAC-SHA1 签名,**直接复用** | + +--- + +## 七、与 certimate 的能力对照 + +| 能力 | certimate(现状) | 函数版管家 | +|---|---|---| +| 申请证书 | ✅ | ✅ | +| DNS-01 | ✅ 腾讯云 + CF | ✅ 同 | +| 部署多吉云 | ✅ | ✅ | +| 部署 1Panel | ✅ | ✅(走服务器拉取) | +| 部署又拍云 | ❌(本来就没配) | ❌(放弃) | +| 需要服务器 | **✅ 需要** | ❌ **不需要** | +| 加域名 | 网页点点 | 改 KV JSON | +| 通知 | 邮件 | 邮件 | +| 成本 | 服务器 | **0**(CF 免费额度) | diff --git a/评论孤儿修复方案-2026-10-06.md b/评论孤儿修复方案-2026-10-06.md new file mode 100644 index 00000000..95497c2e --- /dev/null +++ b/评论孤儿修复方案-2026-10-06.md @@ -0,0 +1,121 @@ +# 评论区孤儿评论修复方案(2026-10-06) + +> ## ✅ 状态:**已执行完成**(2026-10-06 12:2x) +> +> - 21 条 UPDATE 全部成功,**816 条评论已迁回正确页面** +> - 22 个目标 key 条数精确到位;残留老 key 已清空 +> - 端到端验证通过:`/20230424.html`(苏州一日游)已能显示评论 +> - 备份:`.editor-tmp/BACKUP_816条_20261006.txt` +> - 未迁移的 13 个 key / 377 条保持原位未动 + +## 一句话结论 + +**你的评论一条都没丢。** 线上 D1 里有 **3423 条评论**, +其中 **1193 条**因为老站 URL「日期错了一天」变成了孤儿(挂在 404 页面上), +**816 条**可以精确找回,**377 条**因现站已无对应文章而保留原样。 + +--- + +## 一、线上库真实基线 + +``` +comments 表:3423 条(存活 3380 条) +id 范围:10393 ~ 15674 +94 个 page_key: + · /comment.html 留言板 1001 条 + · 65 个「老站日期型」key(/20230423.html) 1975 条 + · 28 个「新站时间戳型」key(/20260708110221.html) 404 条 +``` + +**你举例的 `/20230424.html`(苏州一日游)有 40 条评论 —— 找到了,挂在 `/20230423.html` 上。** + +--- + +## 二、问题成因 + +老站迁移时,**部分文章的 URL 日期错位了一天**: + +| | 错误 URL(有评论) | 正确 URL(无评论) | +|---|---|---| +| 苏州一日游 | `/20230423.html`(40 条,title 显示 "404 Page not found") | `/20230424.html`(页面正常,0 条) | +| 新的开始 | `/20230527.html`(76 条,title=404) | `/20230528.html`(页面正常,0 条) | + +- 评论**绑在**老 URL 上 +- 页面**只认**新 URL(老 URL 返回 302 → /404.html) +- 所以评论「有数据但显示不出来」 + +--- + +## 三、修复方案(待批准执行) + +**23 个孤儿 key / 816 条** → 改挂到现站正确 slug。 +`UPDATE comments SET page_key='/新key' WHERE page_key='/老key'`(**按 page_key 定位,不用 id**) + +| 孤儿 key | 条数 | → 目标 | 文章标题 | +|---|---|---|---| +| `/20230527.html` | 76 | `/20230528.html` | 新的开始 | +| `/20220810.html` | 58 | `/20220811.html` | typecho实现QQ头像用户评论加密 | +| `/20220906.html` | 57 | `/20220907.html` | 主力机由荣耀20切换到iPhone13 | +| `/20221102.html` | 52 | `/20221103.html` | 毕业篇:最后几个月的大学生活 | +| `/20221203.html` | 50 | `/20221204.html` | 2022·襄阳第一场雪 | +| `/20220227.html` | 48 | `/20220228.html` | 2022,除夕过后的那些事 | +| `/20211121.html` | 46 | `/20211122.html` | 这组照片的主题,咱就叫它光吧 | +| `/20220430.html` | 40 | `/20220501.html` | iphone快捷指令发布动态说说(合并) | +| **`/20230423.html`** | **40** | **`/20230424.html`** | **苏州一日游 ★** | +| `/20220503.html` | 34 | `/20220501.html` | iphone快捷指令发布动态说说(合并) | +| `/20211222.html` | 33 | `/20211223.html` | 放假之前最后一次的照片合集 | +| `/20230803.html` | 32 | `/20230804.html` | 家乡随拍 | +| `/20230125.html` | 31 | `/20230126.html` | 感谢哥哥给的网站 | +| `/20220619.html` | 30 | `/20220620.html` | 我跳绳的那些日子 | +| `/20221016.html` | 28 | `/20221017.html` | 毕业纪念篇:图书馆 | +| `/20220104.html` | 26 | `/20220102.html` | 祝大家元旦快乐 | +| `/20211113.html` | 24 | `/20211114.html` | 双十一已经变味了 | +| `/20230409.html` | 22 | `/20230410.html` | 四月随笔 | +| `/20211210.html` | 21 | `/20211211.html` | 盘点注册的域名 | +| `/20211007.html` | 19 | `/20211008.html` | 记录人生第一次洗牙 | +| `/20210911.html` | 19 | `/20210912.html` | 别让抖音支配了你的大学生活 | +| `/20221213.html` | 16 | `/20221214.html` | Apple Watch Series7 体验 | +| `/20230716.html` | 14 | `/20230717.html` | 襄阳唐城 | + +✅ **已核实:18 个目标页当前评论数全部为 0** —— 不会覆盖、不会冲突。 + +--- + +## 四、不迁移的(377 条,保留原样) + +现站已无对应文章,评论**保留在原位不删**(万一以后想恢复旧文章,评论还在): + +| 孤儿 key | 条数 | 内容主题 | +|---|---|---| +| `/20220115.html` | 54 | 开发 app / uniapp | +| `/20220410.html` | 51 | iPhone 更新 | +| `/20211117.html` | 47 | Twitter 主题界面 | +| `/20211111.html` | 43 | 网站头像插件 | +| `/20211203.html` | 32 | 人像摄影 | +| `/20220328.html` | 32 | 青年大学习 | +| `/20211124.html` | 30 | RSS 阅读器(蚁阅) | +| `/20260724101237.html` | 24 | 流量卡/广电(现站 slug 冲突) | +| `/20211024.html` | 20 | 医疗/手术 | +| `/20211017.html` | 17 | 弟弟听力/同济医院 | +| `/20211015.html` | 14 | 恋爱话题 | +| `/20220607.html` | 11 | 腾讯游戏/王者 | +| `/202610011226.html` | 2 | Gitea 测试文 | + +--- + +## 五、执行方式 + +```bash +# 1. 备份(先把这 816 条 dump 出来) +# 2. 执行 21 条 UPDATE(按 page_key 定位) +# 3. 核对:目标 slug 评论数 = 预期值;总量仍为 3423 +``` + +SQL 在 `.editor-tmp/FINAL_SQL.json`。 + +--- + +## 六、顺带修掉的两个坑 + +1. `scripts/refresh_cdn.js` —— 多吉云 `rtype=path` 目录刷新**必须带尾斜杠**(已加兜底 + 日志) +2. `blog-admin/src/routes/rss/tools.ts` —— 加 `s-maxage` / `CDN-Cache-Control`,让 CF 边缘真正缓存 diff --git a/诊断报告-2026-10-06.md b/诊断报告-2026-10-06.md new file mode 100644 index 00000000..ebaf9a81 --- /dev/null +++ b/诊断报告-2026-10-06.md @@ -0,0 +1,332 @@ +# 四问诊断报告(2026-10-06) + +一次性回答四个问题:CDN 刷新、KV 配额、D1 评论对账、certimate 迁移。 + +--- + +## 一、多吉云刷新 & 已删文章还能访问 + +### 1.1 先更正我自己的话 + +我一开始说「多吉云那条已经是全量(`--all`)」——**这句话是错的**,实测证据如下: + +``` +cnb-secrets.yml:33 CDN_URL_LIST: "https://usj.cc" +``` + +`scripts/refresh_cdn.js` 只做一件确定性的事:把 `CDN_URL_LIST` 按逗号/换行拆开, +逐条调 `/cdn/refresh/add.json`(`rtype: "path"`)。**当前配置里这个变量只有首页一个 URL**。 + +结论:**多吉云确实不是全量刷新,只刷新了首页 `https://usj.cc`。** + +### 1.2 那为什么删掉的文章还能访问? + +不是多吉云的问题。实测这条 URL: + +``` +$ curl -sI https://usj.cc/202610021614.html +HTTP/1.1 200 OK +Server: marco/3.2 ← 又拍云特征头 +X-Cache-Lookup: Hit From Upstream Cluster ← 命中缓存 +Last-Modified: Sun, 04 Oct 2026 14:12:26 GMT ← 旧副本时间 +Cache-Control: max-age=691200 ← 8 天 TTL +Age: 131684 ← 已缓存约 1.5 天 +``` + +判别实验(关键): + +| URL | 状态 | 说明 | +|---|---|---| +| `/202610021614.html`(已删除) | **200** | 缓存里还有旧副本 | +| `/zzz-not-exist-111.html`(从未存在) | **302 → /404.html** | 源站没这个文件 | + +两者行为不同 ⇒ 说明**源站确实已经没有这个文件了**(本地 `content/` 和 `public/` 均已确认无此页), +现在返回 200 完全是**又拍云上的 8 天旧副本**。 + +### 1.3 根因:两条 CDN 线路都刷不到「已删除页」 + +`scripts/purge_list.js` 的逻辑是:遍历 **构建产物 `public/`**,把里面所有 `.html` 列出来提交刷新。 + +```js +// 全量时 walkHtml('public') —— 遍历构建产物 +// 已删除的页面自然不在里面 → 永远不会被提交刷新 +``` + +也就是说:**刷新清单只会包含「现在存在的页面」**。一个页面被删掉后, +它就从清单里消失了,于是 CDN 上的旧副本只能等 TTL 自然过期。 + +- 又拍云:HTML `max-age=691200` = **8 天** +- 多吉云:只刷首页,问题更明显 + +### 1.4 处置建议 + +| 方案 | 说明 | 成本 | +|---|---|---| +| **A. 什么都不做** | 等 8 天自然过期 | 0 | +| **B. 手动刷新一次**(推荐) | 把已知的已删 URL 提一次刷新,立刻生效 | 一次性 | +| C. 流水线加「删除清单」 | 构建时对比上一次产物,把消失的 `.html` 追加进 purge 清单 | 改脚本 | + +对已删文章这种低频事件,**B 足够**。需要的话我可以立刻把已知的那几个已删 URL 提交到又拍云+多吉云。 + +--- + +## 二、KV 配额告警:需要调整吗? + +### 2.1 告警内容 + +> 已使用 Workers KV 免费套餐每日限制的 **50%**,超限将返回 429;重置时间 2026-10-06 00:00 UTC。 +> 免费额度:读 100,000/天、写 1,000/天、删 1,000/天、list 1,000/天。 + +### 2.2 谁在吃配额?(实测 4 类来源) + +| 来源 | 代码位置 | 频率 | 影响 | +|---|---|---|---| +| **评论数缓存** | `comments.ts:180/206` | 每次评论列表请求,miss 时写(TTL 600s) | 读 + 写 | +| **评论数版本号** | `comments.ts:53/61` | 评论增删时写 | 写 | +| **friend-link favicon** | `routes/rss/tools.ts:86` | **每次访问友链/友圈页,每个友链一次** | **读(大户)** | +| 人机验证 / 通知节流 | `human.ts` / `admin-notify.ts` | 低 | 少量 | + +**读数最大的就是 favicon 接口。** 友链共 54 条(可见 42 条),其中 15 条头像走 `/api/favicon`: + +```js +// circles.html / linkify.js +img.src = RSS_API_BASE + '/api/favicon?url=' + encodeURIComponent(target); +``` + +每刷一次友圈/友链页 → 触发 N 次 `/api/favicon` → 每次至少 1 次 KV `get`。 +favicon 本体有 24h 浏览器缓存,但**首访、清缓存、换设备都会重新打**。 + +### 2.3 建议:**暂时不用调整,但建议做一个小优化** + +- **读 50% 是「接近」不是「超了」**,且每天 UTC 0 点清零。按当前站点的访问量,正常不会打穿。 +- 即使打穿,后果是 `/api/favicon` 走兜底字母图、评论数退化为实时查库——**不是灾难性故障**。 +- 免费版写限额 1,000/天,比读更容易被评论数缓存写穿。真正的风险点是**评论突然暴涨**或**有人刷评论**。 + +**可做的小优化(成本低、收益直接):** + +1. favicon 接口响应已经带了 `Cache-Control: max-age=86400`,可以加一层 **Cloudflare Cache Rule**, + 让 `/api/favicon*` 在边缘缓存 24h,这样 KV 读基本归零。 +2. 若担心写限额,把 `cml:ver:` 的写入合并/延迟(比如 5 分钟内同页重复 bump 只写一次)。 + +**要不要升级到付费($5/月)?** 目前**不需要**。等到真收到「已超限」而不是「接近」的邮件再说。 + +--- + +## 三、D1 评论对账:老文章评论为什么没了 + +### 3.1 先说结论(这个和你想的不一样) + +原库 **3979 条**评论 / 线上 **3423 条**。乍看差 556 条,但拆开看,**「缺失」的绝大部分根本不是评论**: + +| 项 | 数量 | +|---|---| +| 线上缺失合计 | **711 条 / 63 页** | +| 其中 **点赞型记录**(内容含 `[LIKE]`) | **494 条(69%)** | +| → **真实评论型缺失** | **217 条** | + +**关键发现:线上 `[LIKE]` 记录为 0 条。** + +``` +$ SELECT SUM(CASE WHEN content LIKE '%[LIKE]%' THEN 1 ELSE 0 END) FROM comments; +→ 0 +``` + +说明当年数据导入时,**已经主动过滤掉了所有点赞型记录**——这是正确的设计(点赞不该占用评论表)。 +所以拿原库和线上直接比条数,本身就会假性多出 494 条。 + +### 3.2 缺失的 217 条真实评论,按站点拆 + +| 站 | 缺失 | 其中点赞 | 真实 | +|---|---|---|---| +| 伍比贰 | 521 | 431 | **90** | +| 优世界 | 101 | 0 | **101** | +| 演示站 | 48 | 41 | 7 | +| 往后 | 41 | 22 | 19 | + +**注意:「伍比贰」「演示站」「往后」是另外的站点,不属于 usj.cc。** 它们的数据本来就不该进本站。 + +### 3.3 「优世界」那 101 条对得上现站吗? + +逐页核对,结果是 **一页都对不上**: + +| page_key | 条数 | 现站情况 | +|---|---|---| +| `/yl` | 48 | 页面不存在(是 2022 年的旧友链页) | +| `/20241018-some-useful-win-tools` | 12 | 文章还在,但 slug 已改为 `20241019`,且**是草稿**(未发布) | +| `/27`、`/13`、`/m/17` 等 | 41 | 不存在(旧站的分页/短链) | +| `/20240921-clock-in` | 5 | 文章已删除 | + +**根因不是「key 对不上」,而是这些文章本身已经不存在了。** +评论是挂在「已删除文章」上的,key 再怎么规范化也接不回来。 + +### 3.4 站上现存页面 vs 线上 —— 有没有真的错位? + +**没有。** 用现站 284 个 slug 逐一匹配线上缺失的 63 个 key: + +``` +能对上现存页面: 1 页 / 4 条 (只有 /about.html) +对不上 : 62 页 / 707 条 +``` + +**唯一一个疑似错位:`/about.html`(4 条)**——但深挖后发现**它也不需要修**: + +``` +id=14229 /about.html [伍比贰] 2026-02-07 👍 已点赞 Cool [LIKE] +id=14268 /about.html [伍比贰] 2026-02-12 👍 已点赞 Interesting [LIKE] +id=14269 /about.html [伍比贰] 2026-02-12 👍 已点赞 很棒的文章! [LIKE] +id=14276 /about.html [伍比贰] 2026-02-13 👍 已点赞 写得很好 [LIKE] +``` + +这 4 条全是**点赞型记录**,而且属于**「伍比贰」站**,本就不该进本站。 + +→ **结论:线上 D1 没有任何错位,完全不需要修复。** + +### 3.5 线上「多出」的 7 页是什么? + +| page_key | 条数 | +|---|---| +| `/20260630162329.html` | 25 | +| `/20260724101237.html` | 24 | +| `/20211001.html` | 14 | +| `/20261005113439.html` | 13 | +| `/20260629223659.html` | 10 | +| `/202610011226.html` | 2 | +| `/20261005213010.html` | 2 | + +这些是**导出 db 之后新增的评论**(2026-06 之后),属于正常增长,不用动。 + +### 3.6 修复建议 + +**结论:线上 D1 不需要做任何修复。** + +你的直觉「很多评论没跟页面 key 对应上、老文章没评论了」—— +真实情况是:**那些评论对应的文章,本身已经不在了**(已删除 / 仍是草稿 / 属于别的站), +key 再怎么规范化也接不回来。线上数据是**干净的**。 + +**不建议做的**: +- ❌ 批量把「伍比贰/演示站/往后」的评论导进来 —— 那是别的站的数据(含大量点赞记录) +- ❌ 把 `/yl`、`/27` 等改成别的 key —— 没有正确的目标,改了反而污染 +- ❌ 恢复 494 条点赞记录 —— 线上设计本就不存点赞(`[LIKE]` 记录当前为 0) + +**如果你更看重「把老评论展示出来」**,唯一的正路是**重建这些页面** +(把已删文章从 git 历史恢复,或用 slug 重定向)。这个要单独评估,工作量比改 key 大得多。 + +**但请注意**:即使恢复了页面,能救回的也只是极小一部分—— +「优世界」站真实缺失的 101 条里,48 条属于 `/yl`(2022 年旧友链页), +41 条属于 `/27` `/13` `/m/17` 等旧站分页短链,只有 12 条是正经文章评论。 + +### 3.7 那「评论少了」的体感从哪来? + +很可能来自这两点(都正常): + +1. **点赞不再算评论**:原库 494 条点赞在导入时被过滤,前端看到的评论数自然变少。 +2. **老文章页面已删**:文章没了,评论区自然也没了。 + +如果确实想提升「评论数」,更实际的做法是**在新文章上做引导**,而不是抢救旧数据。 + +--- + +## 四、certimate 能否迁到 Cloudflare + +### 4.1 你现在的架构 + +``` +certimate(跑在某台服务器上) + ├─ 申请:litessl CA,*.usj.cc + usj.cc,DNS-01,tencentcloud-dns + ├─ 部署:dogecloud-cdn(多吉云 CDN) + └─ 部署:1panel website(那台服务器上的站点) +定时:10 12 * * *(每天 UTC 12:10) +失败邮件:177018615@qq.com +``` + +当前线上证书实测: + +``` +issuer = LiteSSL RSA CA 2025(TrustAsia) +subject = CN=usj.cc +SAN = usj.cc, *.usj.cc +有效期 = 2026-09-08 → 2026-12-07 +``` + +### 4.2 certimate 支持 Cloudflare 吗? + +**支持,而且支持两种角色:** + +| 角色 | 支持 | 说明 | +|---|---|---| +| **DNS provider(申请用)** | ✅ | 填 Cloudflare API Token 即可,用于 DNS-01 验证 | +| **Deploy provider(部署用)** | ✅ | 可将证书部署到 Cloudflare | + +(certimate 官方:60+ DNS 托管商、120+ 部署目标,Cloudflare 在列。) + +### 4.3 你的真实目标:「把服务器干掉」 + +需要先厘清一件事:**usj.cc 现在到底谁在提供 HTTPS?** + +从实测响应头看: + +``` +Server: marco/3.2 ← 又拍云 +Via: T.206.M, V.403-zj-fud-202, ... ← 又拍云多级节点 +``` + +**对外服务的是又拍云 CDN**,不是那台服务器。那台服务器承担的是: +- 跑 certimate(证书申请+分发) +- 1Panel 上的 write-server / editor-api 等自建服务 + +所以「干掉服务器」要分清两件事: + +**① 只干掉「证书维护」这件事** → **完全可以,用 Cloudflare 托管 DNS + certimate 的 CF provider。** + +路径: +1. 把 `usj.cc` 的 **DNS 托管迁到 Cloudflare**(当前在腾讯云 DNSPod) +2. certimate 里把「DNS 提供商」从 `tencentcloud-dns` 换成 `cloudflare`(填 CF API Token) +3. 证书申请照旧(litessl 或换 Let's Encrypt),DNS-01 走 CF API +4. 部署目标改为「Cloudflare」(如果把站点也迁到 CF)或保留又拍云/多吉云 + +**② 连「站点托管」也迁到 Cloudflare** → **可行但有取舍:** + +| 优点 | 缺点 | +|---|---| +| CF 免费版自带 Universal SSL(免申请免续期) | 国内访问速度**不如又拍云/多吉云**(CF 免费版节点在境外) | +| 不用再管 90 天续期 | 免费版 Universal SSL 只覆盖 `example.com` + `*.example.com` | +| 有 CF Origin CA(15 年有效期)给回源用 | 想用 CF 自签源站证书需另配 | + +### 4.4 ⚠️ 关键提醒:国内访问速度 + +你的读者主要在国内。**又拍云/多吉云是国内 CDN,Cloudflare 免费版是境外节点**—— +如果为了省一台服务器而把整个站点切到 CF,**国内访问大概率变慢**(首包延迟从几十 ms 变成几百 ms)。 + +**所以我建议:** + +- ✅ **可以做的**:把 `usj.cc` 的 DNS 托管迁到 Cloudflare(CF DNS 免费、管理方便、API 完善), + certimate 用它做 DNS-01,证书照旧签发后部署到**又拍云 + 多吉云**。这样证书维护不再依赖服务器上的 1Panel 部署环节。 +- ⚠️ **建议保留又拍云/多吉云做 CDN**,不要为了「干掉服务器」把站点也搬到 CF。 +- ❌ 如果那台服务器还跑着 write-server / editor-api,**那就不能关**——关之前先确认没有别的服务在跑。 + +### 4.5 你现在到底能不能关服务器? + +需要先回答这个问题:**那台服务器上除了 certimate,还跑着什么?** + +- 如果只有 certimate → 迁完就能关 ✅ +- 如果还有 write-server / editor-api(按记忆应该有)→ **关不掉** ⚠️ + +建议你在关之前先列一下那台机器上的服务清单。需要的话我可以帮你连上去盘一遍。 + +--- + +## 附:本次用到的排查命令 + +```bash +# CDN 缓存判别 +curl -sI https://usj.cc/202610021614.html | grep -iE "server|age|last-modified|x-cache" + +# D1 查询(curl 走 IPv4,Node fetch 会 ENETUNREACH) +curl -s -X POST \ + "https://api.cloudflare.com/client/v4/accounts/$ACC/d1/database/$DB/query" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"sql":"SELECT page_key, COUNT(*) FROM comments GROUP BY page_key"}' + +# 线上是否有点赞记录 +# → SELECT SUM(CASE WHEN content LIKE '%[LIKE]%' THEN 1 ELSE 0 END) FROM comments; → 0 +```