From e3e3cce1a4608bde7a5f4e9570dae4c12bd4b63e Mon Sep 17 00:00:00 2001 From: zqlit Date: Sun, 4 Oct 2026 15:00:52 +0800 Subject: [PATCH] =?UTF-8?q?perf(comments):=20=E8=AF=84=E8=AE=BA=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=8F=90=E9=80=9F=20+=20=E9=AA=A8=E6=9E=B6=E5=B1=8F?= =?UTF-8?q?=20+=20=E5=8F=8B=E9=93=BE=E5=BC=B9=E7=AA=97=20pjax=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20+=20toast=20=E7=BB=9F=E4=B8=80=20Message.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 优化: - initArtalk 加节点级幂等守卫(mount.__atkInited,失败回滚), 消除 artalk.html/mypjax 多入口导致的 4× comments + 4× pv 重复请求 - fetch 包装层对无 body 的 GET/HEAD 去掉多余 content-type, 消掉 Artalk 对 GET /comments 的 CORS 预检(按完整 URL 缓存, 每篇文章都重付一次 RTT)。实测评论列表 +1150~1565ms → +626ms 修复: - 评论列表骨架屏改注入 .atk-list-body(原骨架在 #Comments 内, 被 Artalk.init 清空),list-loaded/故障/12s 兜底移除 - 友链申请弹窗抽成 modules/friendlink.js 进 page-only bundle (原内联脚本在 #pjax-container 外,pjax 后 flApplyOpenModal 未定义) - toast 统一走本地化 Message.js/Qmsg 适配层(保留 window.Toast/showToast 旧 API) 文档: - README / 架构总览 更新;新增 评论加载优化方案.md --- .cnb.yml | 19 ++ .gitignore | 2 + README.md | 217 +++++++++------ themes/Ying/assets/css/main.css | 73 ++--- themes/Ying/assets/js/modules/artalk.js | 237 +++++++++++++++-- themes/Ying/assets/js/modules/friendlink.js | 136 ++++++++++ themes/Ying/assets/js/modules/toast.js | 90 +++---- themes/Ying/assets/js/modules/utils.js | 13 +- themes/Ying/layouts/page/comment.html | 124 +-------- themes/Ying/layouts/partials/artalk.html | 10 + themes/Ying/layouts/partials/footer.html | 38 +-- themes/Ying/layouts/partials/head.html | 12 +- 架构总览.md | 252 +++++++++--------- 评论加载优化方案.md | 281 ++++++++++++++++++++ 14 files changed, 1018 insertions(+), 486 deletions(-) create mode 100644 themes/Ying/assets/js/modules/friendlink.js create mode 100644 评论加载优化方案.md diff --git a/.cnb.yml b/.cnb.yml index 3fae26e0..bea79763 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -54,6 +54,25 @@ : > "$CI_STATUS" hugo version + + # ── /conf 快照(评论首屏优化)──────────────────────────────────── + # 构建期拉一次 Artalk 的 /conf 落到 data/,Hugo 会内联进每个页面。 + # 前端据此对匿名 /conf 请求本地合成响应 —— Artalk 内部 conf 与评论列表 + # 是严格串行的,省掉这一跳等于首屏少一整个跨境往返。 + # 服务端这份 conf 只由 settings 表决定(唯一因人而异的 imgUpload 由前端 + # 按登录态放行真实请求),所以匿名访客的快照人人相同。 + # 拉取失败一律降级:删文件 → 模板不内联 → 前端自动走原生远程请求。 + # 绝不让它拖垮构建(在 if 条件里失败不会触发 set -e)。 + mkdir -p data + if curl -fsS --max-time 30 --retry 1 --retry-delay 2 \ + "https://api.200181.xyz/api/v2/conf" -o data/artalk_conf.json \ + && python3 -c "import json;json.load(open('data/artalk_conf.json',encoding='utf-8'))"; then + echo "✅ conf 快照就绪 $(wc -c < data/artalk_conf.json | tr -d ' ') 字节" + else + rm -f data/artalk_conf.json + echo "⚠️ conf 快照拉取失败,已降级为运行时远程拉取" + fi + python3 scripts/add_draft_to_hidden.py rm -rf public resources/_gen hugo --minify --gc diff --git a/.gitignore b/.gitignore index 5d195ae5..93ef50bc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ node_modules/ .ci_status # CNB 密钥仓库的本地草稿(含真实令牌,仅供往 Web 界面粘贴,绝不入库) cnb-secrets.yml +# 构建期拉取的 Artalk /conf 快照(每次构建现拉,不入库,避免陈旧配置被 git 固化) +/data/artalk_conf.json diff --git a/README.md b/README.md index 1fede0eb..9f03d3ac 100644 --- a/README.md +++ b/README.md @@ -1,86 +1,155 @@ -# 博客项目说明 +# 优世界博客(usj.cc) -本项目使用 Hugo 构建,主题为 `Ying`。以下是主要的配置说明与脚本使用指南,方便后续查阅和维护。 +Hugo 静态博客 + 自研评论后端 + 写作后台。 +**构建与发布跑在腾讯云 CNB**(国内节点),单次发布约 3.5 分钟,境内/境外两条线路一次推完。 -## 1. 常用脚本与工具 (Scripts) - -项目包含多个 PowerShell 和 Node.js 脚本,用于自动化维护、检查和部署。 - -### 1.1 日常维护 - -* **新建文章 (`./new_post.ps1`)** - * **用途**: 交互式创建新的博客文章。 - * **功能**: 自动引导输入标题、Slug、分类、标签等信息,并按 `YYYY-MM-DD-Slug` 格式创建 Page Bundle 目录和文件。 - * **运行**: - ```powershell - ./new_post.ps1 - ``` - -* **手动构建与检查 (`./deploy.ps1`)** - * **用途**: 手动执行构建,包含友链健康检查。 - * **流程**: - 1. 运行 `scripts/check_links.js` 检查友链连通性。 - 2. 如果检查通过,执行 `hugo --minify` 生成静态文件。 - * **运行**: - ```powershell - ./deploy.ps1 - ``` - -### 1.2 自动化与 CI/CD 工具 - -以下脚本主要在 GitHub Actions (`.github/workflows/upy.yml`) 中自动运行,也可手动用于调试: - -* **友链数据同步 (`scripts/update_link_lite_json.ps1`)** - * **功能**: 将 `themes/Ying/data/links.yaml` (YAML源数据) 转换为 `themes/Ying/static/json/link_lite.json`,供前端 JS 和检查脚本使用。 - -* **友链健康检查 (`scripts/check_links.js`)** - * **功能**: 读取 `link_lite.json`,并发检查所有友链的可访问性。 - * **依赖**: `node-fetch` (内置于 Node 18+ 或作为依赖)。 - -* **朋友圈数据生成 (`scripts/generate_circle_data.js`)** - * **功能**: 根据友链抓取 RSS/Atom 订阅源,生成朋友圈更新数据 (`friend_circle_data.json`)。 - * **依赖**: `rss-parser`. - -* **构建安全预处理 (`scripts/add_draft_to_hidden.ps1`)** - * **功能**: 在构建前扫描 `content/post`,将标记为 `status: hidden` 的文章强制设置为 `draft: true`,防止隐私文章意外泄露到公共列表。 - -* **CDN 刷新 (`scripts/RefreshCDN.py`)** - * **功能**: 部署完成后调用 DogeCloud API 刷新 CDN 缓存。 - * **配置**: 需要在环境变量中设置 `DOGECLOUD_ACCESS_KEY` 等参数。 +> 架构全貌、迁移前后对比、运维要点 → [`架构总览.md`](架构总览.md) --- -## 2. 基础配置 (`hugo.toml`) +## 一、项目构成 -位于项目根目录下,控制网站的全局行为。 +| 子系统 | 位置 | 技术栈 | +|---|---|---| +| **内容** | `content/`、`themes/Ying/` | Hugo 0.128.2 extended + Ying 主题 | +| **评论后端** | `blog-admin/` | artalk-cf:Cloudflare Workers + D1 + KV(`api.200181.xyz`) | +| **写作后台** | `write-server/`(线上 `post.usj.cc`)、`write/`(本地 Windows) | Next.js | +| **发布** | `.cnb.yml`、`deploy/` | CNB 流水线 + Dockerfile | -* **网站信息**: 标题、BaseURL、语言等。 -* **固定链接 (Permalinks)**: - ```toml - [permalinks] - post = "/:slug" - ``` - 文章页面使用 Front Matter 中的 `slug` 字段作为文件名(例如 `https://usj.cc/my-post.html`)。 +--- -## 3. 主题配置 +## 二、目录结构 -* **主题目录**: `themes/Ying/` -* **友链数据**: `themes/Ying/data/links.yaml` - * 添加友链请直接编辑此 YAML 文件,构建时会自动同步到 JSON。 - -## 4. 文章管理指南 - -### URL 设置 -文章默认使用 `slug` 字段作为 URL 的文件名。 -```yaml -title: "我的文章" -date: 2023-01-01 -slug: "my-post" ``` -生成的链接为: `https://usj.cc/my-post.html` +blog/ +├── .cnb.yml # ★ CNB 流水线(push + 每日定时) +├── deploy/Dockerfile # 构建镜像(hugo 二进制由 bin/linux/hugo 提供) +├── bin/linux/hugo # Hugo extended 0.128.2(linux/amd64,供 CNB 构建用) +├── content/ +│ ├── posts/<年>/<日期>-<标题>/ # 文章(Page Bundle,index.md + 图片) +│ ├── about.md / links.md / circles.md / archives.md +├── themes/Ying/ # 主题(layout / assets / data) +├── static/ # 原样复制进产物(emotion 表情、image、js …) +├── blog-admin/ # 评论后端(artalk-cf) +├── write-server/ # 线上写作后台 +├── write/ # 本地写作前端 +├── scripts/ # 各类工具脚本(见第六节) +├── hugo.toml # Hugo 主配置 +└── 架构总览.md # ★ 架构文档 +``` + +--- + +## 三、内容写作 + +### 文章结构 + +每篇文章是一个 **Page Bundle**: + +``` +content/posts/2024/2024-05-01-文章标题/ +├── index.md # 正文 +└── 配图.jpg # 同目录图片(可用相对路径引用) +``` + +### URL 规则 + +由 front matter 的 `slug` 决定(`hugo.toml` 里 `permalinks.post = "/:slug"`): + +```yaml +--- +title: "我的文章" +date: 2024-05-01 +slug: "my-post" +--- +``` + +生成 `https://usj.cc/my-post.html`(uglyURLs,带 `.html`)。 ### 隐藏文章 -如果你想写一篇不公开在列表显示的文章(但可以通过链接访问): -1. 在 Front Matter 中添加 `status: hidden`。 -2. 自动化脚本会在构建时将其标记为 `draft: true` (配合特殊构建逻辑) 或进行其他处理。 -*注:具体表现取决于 CI 脚本的逻辑,通常用于草稿或隐藏页。* + +在 front matter 加 `status: hidden`。构建前 `scripts/add_draft_to_hidden.py` 会把它转成 +`draft: true`,**不出现在列表里,但直达链接仍可访问**。 + +### 本地预览 + +```bash +hugo server -D # 含草稿 +``` + +--- + +## 四、发布流程 + +``` +写作(write-server / write/) + │ git pushall + ▼ +CNB 主仓 zqlit/blog ──触发──► CNB 流水线(国内节点,约 3.5 分钟) + ├─ Hugo 构建 + ├─ 同步到又拍云(境内源站) + ├─ 刷新又拍云 CDN + ├─ 刷新多吉云 CDN + ├─ 部署 EdgeOne Pages(境外线路) + └─ 邮件通知 +``` + +- **推送**:`git pushall` = `git push origin main; git push gh main` + (`origin` = CNB 主仓,`gh` = GitHub 备份) +- **触发**:推送到 `main`;另有每日 `0 9 * * *`(北京时间)定时构建 +- **密钥**:全部来自 CNB 密钥仓库 `zqlit/blog-secrets`,经 `.cnb.yml` 的 `imports` 注入, + **仓库里没有任何明文密钥** +- 改动 `main` 即自动上线,本地无需构建 + +--- + +## 五、三个子系统 + +### 内容系统 + +Hugo + Ying 主题。`hugo.toml` 控站点信息、永久链接、Artalk 地址、弹幕等。 + +### 评论系统(`blog-admin/`) + +自研的 **Artalk v2 兼容服务端**,跑在 Cloudflare Workers + D1(SQLite)+ KV: + +- 前端用官方 Artalk 客户端(`themes/Ying/assets/js/libs/Artalk.js`,本地打包,非 CDN) +- 后端 API 基址 `https://api.200181.xyz`(评论 `/api/v2/*` 与 RSS 订阅 `/api/*` 同一 Worker) +- 部署:`cd blog-admin && npm run deploy`(详细步骤见 `blog-admin/README.md`、`部署清单.md`) + +### 写作后台 + +- `write-server/`:线上版(Next.js),部署在独立主机 +- `write/`:本地 Windows 版 + +--- + +## 六、常用脚本(`scripts/`) + +| 脚本 | 用途 | 在哪跑 | +|---|---|---| +| `add_draft_to_hidden.py` | 构建前把 `status: hidden` 转成草稿 | **CI** | +| `refresh_cdn.js` | 刷新多吉云 CDN(零依赖) | **CI** | +| `send_mail.js` | 构建结果邮件通知(零依赖 SMTP) | **CI** | +| `setup-cnb-remotes.sh` | 切换/重建 git 远端(CNB 主仓 + GitHub 备份) | 本机 | +| `optimize_images.js` | 图片批量压缩优化 | 本机 | +| `generate_circle_data.js` | 抓友链 RSS 生成朋友圈数据 | 本机 | +| `update_link_lite_json.ps1` | 友链 `links.yaml` → JSON | 本机 | +| `add_ancient_chars.py` / `check_ancient_chars.py` / `merge_chars.py` | 字体生僻字增补与校验 | 本机 | +| `cleanup_duplicates.js` / `migrate_slugs.js` | 一次性维护脚本 | 本机 | + +> `deploy_*.sh`(又拍云 / EdgeOne / 定时)是本机手动部署的旧入口,**日常已不需要**—— +> 推送 `main` 由 CNB 自动完成。 + +--- + +## 七、相关文档 + +| 文档 | 内容 | +|---|---| +| `架构总览.md` | **当前架构全貌**(子系统、发布链路、迁移前后对比、运维要点) | +| `CNB构建落地方案.md` | 迁 CNB 的实施方案与实测数据 | +| `代码源与构建平台选型.md` | 平台对比(CNB / Gitee / GitLab / EdgeOne / 阿里云 ESA) | +| `砍COS改造步骤.md` | 腾讯云 COS 下线记录 | +| `blog-admin/README.md` | 评论后端完整说明 | +| `blog-admin/部署清单.md` | 评论后端部署步骤 | diff --git a/themes/Ying/assets/css/main.css b/themes/Ying/assets/css/main.css index ee9e5123..02c4a0a8 100644 --- a/themes/Ying/assets/css/main.css +++ b/themes/Ying/assets/css/main.css @@ -4126,76 +4126,41 @@ input[type=submit] { } } -/* ── Toast ── */ -.ying-toast-container { - position: fixed; - top: 18px; - left: 50%; - transform: translateX(-50%); - z-index: 10000; - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - pointer-events: none; +/* ── Toast(Message.js / Qmsg 适配,见 assets/js/modules/toast.js)── + 组件自带样式在 css/libs/message.min.css(随 style.css 打包)。 + 这里只做主题融合:跟随主题变量 + 暗色适配。 */ +.qmsg { + border-radius: 8px; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); } -.ying-toast { - padding: 9px 18px; - border-radius: 6px; - font-size: 13px; +.qmsg-content { + color: var(--text-main, #333); font-weight: 500; - line-height: 1.3; - opacity: 0; - transform: translateY(-10px); - transition: opacity 0.28s ease, transform 0.28s ease; - pointer-events: auto; - -webkit-backdrop-filter: blur(4px); - backdrop-filter: blur(4px); - will-change: transform, opacity; } -.ying-toast--visible { - opacity: 1; - transform: translateY(0); +[data-theme="dark"] .qmsg { + background: #2a2e33; + box-shadow: 0 4px 12px rgba(0, 0, 0, .5); } -.ying-toast--success { - background: rgba(34, 197, 94, 0.88); - color: #fff; - box-shadow: 0 8px 32px rgba(34, 197, 94, 0.18); +[data-theme="dark"] .qmsg-content { + color: #d6dade; } -.ying-toast--error { - background: rgba(239, 68, 68, 0.88); - color: #fff; - box-shadow: 0 8px 32px rgba(239, 68, 68, 0.18); +[data-theme="dark"] .qmsg-close { + color: #7a828a; } -.ying-toast--warning { - background: rgba(245, 158, 11, 0.88); - color: #fff; - box-shadow: 0 8px 32px rgba(245, 158, 11, 0.18); -} - -.ying-toast--info { - background: rgba(29, 61, 108, 0.85); - color: #f0f4fb; - box-shadow: 0 8px 32px rgba(29, 61, 108, 0.18); -} - -[data-theme="dark"] .ying-toast--info { - background: rgba(159, 196, 244, 0.22); - color: #f3f7fd; - -webkit-backdrop-filter: blur(4px); - backdrop-filter: blur(4px); +[data-theme="dark"] .qmsg-close:hover { + color: #a8b0b8; } @media screen and (max-width: 480px) { - .ying-toast { + .qmsg { padding: 8px 14px; font-size: 12px; - border-radius: 5px; } } diff --git a/themes/Ying/assets/js/modules/artalk.js b/themes/Ying/assets/js/modules/artalk.js index 80f81aea..eac49d29 100644 --- a/themes/Ying/assets/js/modules/artalk.js +++ b/themes/Ying/assets/js/modules/artalk.js @@ -21,17 +21,94 @@ internal: { ic: '⚠️', title: '评论暂时加载不出来', desc: '服务出了点小状况,请稍后再试。' } }; + // ── /api/v2/conf 内联快照 ────────────────────────────────────────── + // 构建期把 conf 响应内联进页面(见 partials/artalk.html)。服务端这份 conf + // 只由 settings 表决定,唯一因人而异的字段是 imgUpload(imgUpload || admin), + // 所以匿名访客看到的 conf 人人都一样 —— 匿名请求直接本地合成 Response, + // 不发网络。Artalk 内部 conf 与评论列表是严格串行的,省掉这一跳等于首屏 + // 少一整个跨境往返。 + // 带 Authorization 的请求来自已登录用户(管理员拿到 imgUpload=true),属个性化 + // 响应,放行真实网络。 + var CONF_URI = /\/api\/v2\/conf(\?|$)/; + function hasAuthHeader(init, input) { + try { + var h = (init && init.headers) || null; + if (!h && input && typeof input !== 'string') h = input.headers || null; + if (!h) return false; + if (typeof h.get === 'function') return !!h.get('Authorization'); + if (Array.isArray(h)) { + for (var i = 0; i < h.length; i++) { + if (String(h[i][0]).toLowerCase() === 'authorization') return !!h[i][1]; + } + return false; + } + return !!(h.Authorization || h.authorization); + } catch (e) { + return true; // 判不准就当有凭据 → 放行真实请求(保守,宁可多一跳也不串号) + } + } + + // ── 0) 去掉 GET/HEAD 上多余的 content-type:省掉一次 CORS 预检 ────── + // Artalk 给「所有」请求都带上 content-type: application/json,可 GET 根本没有 body。 + // content-type 属于「非简单头」→ 浏览器必须先发一个 OPTIONS 预检(Access-Control-Request-Headers: content-type)。 + // 预检按「完整 URL」缓存(含 query),而 comments 的 URL 带 page_key、每篇文章都不同, + // 于是每进一篇新文章都要重付一次预检 —— 跨域下这是实打实的整整一个 RTT, + // 而它正好卡在评论列表的关键路径上(预检 → 才发 GET)。 + // GET/HEAD 没有 body,删掉这个头不改变任何语义,却能直接把预检砍掉。 + function sansContentType(init) { + var out = {}, k; + for (k in init) out[k] = init[k]; + var h = out.headers; + try { + if (h && typeof Headers === 'function' && h instanceof Headers) { + var nh = new Headers(); + h.forEach(function (v, n) { if (n.toLowerCase() !== 'content-type') nh.append(n, v); }); + out.headers = nh; + } else if (Array.isArray(h)) { + out.headers = h.filter(function (p) { return String(p[0]).toLowerCase() !== 'content-type'; }); + } else if (h && typeof h === 'object') { + var nh2 = {}; + for (k in h) { if (k.toLowerCase() !== 'content-type') nh2[k] = h[k]; } + out.headers = nh2; + } + } catch (e) { + return init; // headers 形态不认识就原样放行(宁可多一次预检,不能出错) + } + return out; + } + // ── 1) 记录 API 失败原因。只读不改:包装层原样返回上游响应, // 失败时顺带 clone 一份读 body(clone 不影响 Artalk 自己读)。 var origFetch = window.fetch; if (typeof origFetch === 'function') { - window.fetch = function (input) { + window.fetch = function (input, init) { var url = typeof input === 'string' ? input : (input && input.url) || ''; url = String(url); - var p = origFetch.apply(this, arguments); - if (url.indexOf('/api/v2/') === -1) return p; - // 学习 API 源(探测恢复用) + if (url.indexOf('/api/v2/') === -1) return origFetch.apply(this, arguments); + // 学习 API 源(探测恢复用)—— 拦快照之前先学,别因为少了一次网络请求而丢掉来源 if (!apiBase && url.indexOf('/api/v2/') > 0) apiBase = url.split('/api/v2/')[0]; + + // conf 快照命中:本地合成响应,完全不发网络 + if (window.__artalkConfSnapshot && CONF_URI.test(url) + && typeof Response === 'function' && !hasAuthHeader(init, input)) { + return Promise.resolve(new Response( + JSON.stringify(window.__artalkConfSnapshot), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json; charset=utf-8' } + } + )); + } + + // GET/HEAD 且无 body:去掉 content-type,避免不必要的 CORS 预检 + var fargs = arguments; + var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase(); + if ((method === 'GET' || method === 'HEAD') && !(init && init.body)) { + fargs = [input, sansContentType(init || {})]; + } + + var p = origFetch.apply(this, fargs); return p.then(function (res) { if (res && res.ok === false) { try { @@ -105,6 +182,7 @@ // 这里:编辑器整体置灰(内容原样可见,只是变灰)+ 编辑框上方插窄横幅 // 说明原因和恢复时间,并每 60s 用一次 1 行 D1 成本的探针(/captcha/status, // 只读 1 行 settings)探测恢复,恢复后自动解除禁用并 toast,不用刷新页面。 + // toast 统一走 Message.js(modules/toast.js 适配层),不再自绘浮层。 // conf 加载失败时 Artalk 渲染的是"空壳编辑器"(i18n 没加载):发送按钮 // 没有字、输入框没有占位符、工具栏按钮整排缺失。置灰态要保留完整外观, @@ -130,22 +208,18 @@ var svcDown = false; var downCode = ''; + var downAt = 0; // 进入禁用态的时刻(用来判断这次"故障"是不是一闪而过) var probeTimer = null; var apiBase = ''; // 从拦截到的请求里学习 API 源(跨域探测需要绝对地址) function svcToast(msg) { - var t = document.getElementById('atk-svc-toast'); - if (!t) { - t = document.createElement('div'); - t.id = 'atk-svc-toast'; - t.style.cssText = 'position:fixed;left:50%;bottom:110px;transform:translateX(-50%);z-index:99999;' + - 'padding:12px 24px;border-radius:999px;background:var(--theme-main,#07c160);color:#fff;' + - 'font-size:14px;box-shadow:0 10px 32px rgba(0,0,0,.35);opacity:0;transition:opacity .35s,transform .35s;pointer-events:none'; - document.body.appendChild(t); + // 统一通知组件(Message.js):Toast 在 core bundle 里先于本模块加载, + // 但 PJAX 极端时序下仍兜底判一次,避免 undefined 直接抛错 + if (window.Toast && typeof window.Toast.info === 'function') { + window.Toast.info(msg, 3200); + } else { + try { console.log('[artalk]', msg); } catch (e) {} } - t.textContent = msg; - requestAnimationFrame(function () { t.style.opacity = '1'; }); - setTimeout(function () { t.style.opacity = '0'; }, 3200); } function ensureBar(code) { @@ -185,8 +259,11 @@ function setSvcDown(code) { downCode = code; + // 评论服务故障:列表不会返回了,别让骨架屏一直转(故障态有自己的横幅/卡片) + listSkeletonDone(); if (svcDown) { ensureBar(code); return; } // 已在禁用态,只刷新文案 svcDown = true; + downAt = Date.now(); ensureBar(code); if (!probeTimer) { probeTimer = setInterval(function () { @@ -203,12 +280,26 @@ svcDown = false; if (probeTimer) { clearInterval(probeTimer); probeTimer = null; } removeBar(); - svcToast('评论服务已恢复,可以继续评论了 ✓'); + // 禁用态只持续了一瞬间就别报喜:读者眨个眼就"恢复"的故障,弹提示纯属噪音 + // (真实故障从出现到恢复不会只有两三秒,所以门槛不会漏报) + if (downAt && Date.now() - downAt >= 3000) { + svcToast('评论服务已恢复,可以继续评论了 ✓'); + } + downAt = 0; } function rewrite(layer) { if (!layer) return; + // ── 不是评论列表的错误层,一律不碰 ────────────────────────────── + // Artalk 会把「编辑器插件资源加载失败」(表情包 / 图片等)的错误层也渲染成 + // .atk-error-layer,挂在编辑器插件面板(.atk-plug-panel-wrap)里。它既不是 + // 评论服务故障,也不该被改写成我们的故障卡片 —— 直接放行走 Artalk 原样式。 + // 曾经没这道判断时:这类层的文本不在 .error-message 里 → raw 读成空串 → + // 兜底判成 internal → 因为 internal !== network 就置灰整个评论区,随后 + // 数据正常返回又弹「评论服务已恢复」toast(读者视角:页面顿一下 + 莫名提示)。 + if (layer.closest('.atk-plug-panel-wrap, .atk-editor-plug-emoticons')) return; + var raw = ''; var em = layer.querySelector('.error-message'); if (em) raw = (em.innerText || em.textContent || '').trim(); @@ -236,8 +327,10 @@ ? fresh.msg.replace(/^评论服务暂时不可用(?:[((][^))]*[))])?[::]\s*/, '') : info.desc; - // 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅 - if (code !== 'network') setSvcDown(code); + // 服务端故障(非读者自身网络问题)→ 禁用编辑器并挂说明横幅。 + // 但「读不到任何可归因证据」时不下结论:raw 为空且结构化记录也没命中, + // 说明这是个我们识别不了的错误层,宁可只渲染卡片,也不要误置灰评论区。 + if (code !== 'network' && (raw || (fresh && fresh.code))) setSvcDown(code); // 原始「重试」节点:Vue 会整层重建,所以每次重新找; // 若本层已重写过,就从我们自己卡片里把它取回来(移动 DOM 保留已绑事件) @@ -501,8 +594,91 @@ window.loadScriptOnce = window.loadScriptOnce || function(src, globalName, callb document.head.appendChild(script); }; +/* ── 评论列表骨架屏 ──────────────────────────────────────────────────── + Artalk 初始化时会把 #Comments 整个清空(连服务端渲染出来的骨架屏一起清), + 而评论列表要等一次跨境 /api/v2/comments 往返才有内容 —— 这段时间评论区是 + 一片空白(本地实测空档约 1.0~1.5s,跨境只会更久),读者视角就是"进来啥也没有"。 + 这里在 Artalk 挂载后往 .atk-list-body 里补一份骨架,list-loaded / 服务故障 / + 12s 兜底时移除,把这段空档抹平。 + 延迟 120ms 才显示:命中缓存、极快返回的情况不会闪一下骨架。 */ +var LIST_SKELETON_HTML = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + +var listSkelTimer = null; // 延迟布防用的定时器 +var listSkelSettled = false; // 本次导航的列表是否已经有结果(含失败) + +function listSkeletonShow() { + var body = document.querySelector('#Comments .atk-list-body'); + if (!body || body.querySelector('.atk-ys-skel')) return; + var skel = document.createElement('div'); + skel.className = 'atk-ys-skel atk-loading-placeholder'; + skel.setAttribute('aria-hidden', 'true'); + skel.innerHTML = LIST_SKELETON_HTML; + body.insertBefore(skel, body.firstChild); + // 注意:Artalk 渲染评论时若整体重写 .atk-list-body 的 innerHTML, + // 骨架会随之被自然移除 —— 因此这里不做任何"占位"逻辑,移除是幂等的。 +} + +function listSkeletonHide() { + var skels = document.querySelectorAll('#Comments .atk-ys-skel'); + for (var i = 0; i < skels.length; i++) { + if (skels[i].parentNode) skels[i].parentNode.removeChild(skels[i]); + } +} + +/* 布防:Artalk 挂载后调用(可重复调用,只生效一次) */ +function listSkeletonArm() { + if (listSkelSettled || listSkelTimer) return; + listSkelTimer = setTimeout(function () { + listSkelTimer = null; + if (listSkelSettled) return; + listSkeletonShow(); + // 极端时序下 .atk-list-body 可能还没渲染出来:短轮询补一次 + if (!document.querySelector('#Comments .atk-ys-skel')) { + var tries = 0; + var spin = setInterval(function () { + if (listSkelSettled || ++tries > 12) { clearInterval(spin); return; } + if (document.querySelector('#Comments .atk-list-body')) { + listSkeletonShow(); + clearInterval(spin); + } + }, 100); + } + }, 120); +} + +/* 撤防:列表有结果(成功/失败/超时)后调用 */ +function listSkeletonDone() { + listSkelSettled = true; + if (listSkelTimer) { clearTimeout(listSkelTimer); listSkelTimer = null; } + listSkeletonHide(); +} + window.initArtalk = function() { - if (!document.querySelector("#Comments")) return; + var mount = document.querySelector("#Comments"); + if (!mount) return; // Check if configuration exists if (!window.artalkConfig) { @@ -517,6 +693,17 @@ window.initArtalk = function() { return; } + // ── 幂等守卫:同一份 #Comments 只初始化一次 ────────────────────────── + // initArtalk 有多个调用入口(artalk.html 内联脚本 / main.js DOMContentLoaded / + // mypjax 的 script.onload 与 requestIdleCallback),不去重就会在同一页上创建 + // 多个 Artalk 实例:实测每次 PJAX 进文章页发出 4× /api/v2/comments + 4× + // /pages/pv,列表还要等最慢那条响应才渲染(既拖首屏,又 4 倍消耗 D1 读额度)。 + // 用「节点级」标记而不是全局标志:PJAX 换页会换掉 #Comments 节点,新节点无标记 + // → 照常重新初始化,切页行为完全不受影响。 + // 第二道判断(节点内已有 .atk-list-body)兜底 Artalk 若替换了挂载节点的情况。 + if (mount.__atkInited || mount.querySelector('.atk-list-body')) return; + mount.__atkInited = true; + try { // Logic for caching Artalk configuration to speed up rendering // 1. Manual Refresh (F5): Request from server and save to localStorage @@ -578,6 +765,14 @@ window.initArtalk = function() { artalk.setDarkMode(currentTheme === 'dark'); } + // ── 评论列表骨架屏布防(见文件上方 listSkeleton* 注释) ── + listSkelSettled = false; + listSkeletonArm(); + artalk.on('mounted', listSkeletonArm); + artalk.on('list-loaded', listSkeletonDone); + // 兜底:长时间没结果(严重慢网 / 列表接口异常)也别让骨架一直转 + setTimeout(listSkeletonDone, 12000); + // ── 增强评论框字段属性 (兼容油猴自动填充脚本) ── // 为 Artalk 输入框添加额外属性,确保第三方脚本(如博客网站留言评论信息自动填充) // 能够通过 type/id/name/placeholder/class/aria-label 准确匹配 @@ -1183,5 +1378,9 @@ artalk.on('list-loaded', function() { } catch (e) { console.error('Artalk init failed:', e); + // 初始化失败要撤回幂等标记,否则后续入口的调用会被守卫拦掉、评论区再也起不来 + mount.__atkInited = false; + listSkelSettled = true; + listSkeletonHide(); } }; diff --git a/themes/Ying/assets/js/modules/friendlink.js b/themes/Ying/assets/js/modules/friendlink.js new file mode 100644 index 00000000..fa364f88 --- /dev/null +++ b/themes/Ying/assets/js/modules/friendlink.js @@ -0,0 +1,136 @@ +/** + * 友链自助申请弹窗 + * + * 为什么独立成模块(而不是留在 layouts/page/comment.html 的内联 +{{/* 友链自助申请弹窗已抽到 assets/js/modules/friendlink.js(随 page-only.js 加载)。 + 原先这段内联脚本写在 #pjax-container 外面:PJAX 只换容器内容、不会重新执行它, + 从首页 PJAX 进留言页时 window.flApplyOpenModal 未定义 → 「申请友链」卡片点了没反应; + 直接刷新(整页加载)才正常。抽成模块后两种进入方式行为一致。 */}} +{{ end }} @@ -136,21 +139,24 @@ {{/* 文章详情页专用JS */}} {{ if .IsPage }} + {{/* 友链申请弹窗放最前:artalk.js 渲染申请卡片时会调 window.flApplyOpenModal */}} + {{ $friendlink := resources.Get "js/modules/friendlink.js" }} {{ $artalkModule := resources.Get "js/modules/artalk.js" }} {{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }} {{ $reward := resources.Get "js/modules/reward.js" }} - {{ $pageScripts := slice $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} {{ else }} {{/* 非文章页面:仍然计算URL,但不加载,用于PJAX动态加载 */}} + {{ $friendlink := resources.Get "js/modules/friendlink.js" }} {{ $artalkModule := resources.Get "js/modules/artalk.js" }} {{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }} {{ $reward := resources.Get "js/modules/reward.js" }} - {{ $pageScripts := slice $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} + {{ $pageScripts := slice $friendlink $artalkModule $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }} {{ end }} @@ -169,31 +175,7 @@ {{ end }} -{{/* ====== 5. 延迟加载的非关键JS ====== */}} -{{ $toast := resources.Get "js/modules/toast.js" }} - -{{ $deferredScripts := slice $toast | resources.Concat "js/deferred.js" | resources.Minify | resources.Fingerprint }} - - - -{{/* ====== 6. 第三方脚本 ====== */}} +{{/* ====== 5. 第三方脚本 ====== */}} diff --git a/themes/Ying/layouts/partials/head.html b/themes/Ying/layouts/partials/head.html index d830db94..7de4e175 100644 --- a/themes/Ying/layouts/partials/head.html +++ b/themes/Ying/layouts/partials/head.html @@ -42,7 +42,17 @@ - {{ with site.Params.rssapi.base }}{{ end }} + {{- /* 评论 / RSS API:Artalk 与友链数据都发 CORS 跨域请求。 + preconnect 必须带 crossorigin —— 浏览器对「带凭据」和「匿名」两条连接分池, + 不带 crossorigin 预热的连接 CORS 请求无法复用,等于白预连(net::ERR 之外 + 还要重付一次 DNS + TCP + TLS)。dns-prefetch 兜底不支持 preconnect 的浏览器。 */ -}} + {{- $apiHosts := slice -}} + {{- with site.Params.artalk.server }}{{ $apiHosts = $apiHosts | append (strings.TrimSuffix "/" .) }}{{ end -}} + {{- with site.Params.rssapi.base }}{{ $apiHosts = $apiHosts | append (strings.TrimSuffix "/" .) }}{{ end -}} + {{- range ($apiHosts | uniq) }} + + + {{- end }} {{ with site.Params.rssapi.base }}{{ end }} diff --git a/架构总览.md b/架构总览.md index cd2425d9..04b21727 100644 --- a/架构总览.md +++ b/架构总览.md @@ -1,173 +1,165 @@ -# 优世界博客 · 架构总览与复杂度审计 +# 优世界博客 · 架构总览 -> 梳理时间:2026-10-04 -> 目的:把「复杂在哪、为什么复杂、哪些是自己加的、哪些能砍」讲清楚,供架构决策。 -> 方法:以代码/配置为证据,不用推测。凡属推测的地方都标了「待确认」。 +> 更新时间:2026-10-04(CNB 迁移完成当日) +> 状态:**迁移已落地并跑通**(四线路全绿,单次发布约 3.5 分钟) +> 本文档只描述**现状**;迁移前的复杂度审计与选型过程见文末「相关文档」。 --- -## 0. 一句话结论 +## 0. 一句话 -**这不是「一个博客」,而是四个独立系统,绑在一条 7 环节、跨境的、需要自己运维的发布链路上。** - -前三个系统都很轻、也很健康;**复杂度几乎全部来自第四个 —— 为了支撑前三个而自建的基础设施**(Gitea + act_runner + 中转机),以及为了绕过跨境链路而打的一层层补丁。 +四个子系统,一条 **3 步**的托管发布链路。构建与发布已从「自建 Gitea + act_runner + 广州中转机」迁到 +**腾讯云 CNB(cnb.cool)** —— **需要自己运维的机器从 3 台降到 0 台**。 --- -## 1. 全景:哪里有什么 +## 1. 全景 ### 1.1 四个子系统 -| # | 子系统 | 技术栈 | 跑在哪 | 健康度 | +| # | 子系统 | 技术栈 | 跑在哪 | 状态 | |---|---|---|---|---| -| **1** | **内容系统** | Hugo 0.128.2 + Ying 主题,138 篇 md,产物 424MB / 3267 文件 | 产物分发到 3 个 CDN | 主体,正常 | -| **2** | **评论系统** | `blog-admin/` = artalk-cf(Artalk v2 兼容服务端)+ RSS 机器人 | Cloudflare Workers + D1 + KV → `api.200181.xyz` | ✅ 已上线,**零服务器零运维**,架构最干净的一块 | -| **3** | **写作系统** | `write-server/`(Next.js 16 + Docker,`post.usj.cc`)
`write/`(本地 Windows 前端,带 vbs/bat 自启动) | 独立 Docker 主机(**待确认**)/本机 | ⚠️ **两套前端功能重叠** | -| **4** | **基础设施** | Gitea 28.0.0 + act_runner + 1Panel 中转机 + 各类备份脚本 | 境外 VPS + 腾讯云广州 | ⚠️ 正在迁移,**复杂度的主要来源** | +| **1** | **内容系统** | Hugo 0.128.2 extended + Ying 主题,138 篇 md;产物约 3200 文件 / 424MB | 产物分发到 3 个 CDN | 正常 | +| **2** | **评论系统** | `blog-admin/` = artalk-cf(Artalk v2 兼容服务端)+ RSS 机器人 | Cloudflare Workers + D1 + KV → `api.200181.xyz` | ✅ 零服务器零运维 | +| **3** | **写作系统** | `write-server/`(Next.js 16,`post.usj.cc`)
`write/`(本地 Windows 前端) | 独立 Docker 主机/本机 | 可用(两套前端功能重叠) | +| **4** | **发布基础设施** | **CNB 流水线**(`.cnb.yml`)+ 又拍云 + 多吉云 + EdgeOne | 腾讯云 CNB(**托管**) | **新建,已跑通** | -### 1.2 机器与服务地图 +### 1.2 服务与地址地图 -| 标识 | 地址 / 位置 | 承担什么 | +| 角色 | 地址 / 位置 | 说明 | |---|---|---| -| **Gitea 主机** | `23.254.236.47:3001`(境外) | Git 仓库 + **Gitea Actions / act_runner** + artifact 存储 | -| **国内中转机** | `119.29.215.187`(腾讯云广州) | 1Panel + OpenResty + mysql + alist + vaultwarden(7 个容器)+ `sync.sh` 每分钟轮询同步 | -| **EdgeOne Pages** | 项目 `hugo-blog`,`--area overseas` | **境外线路**的托管与 CDN(腾讯) | -| **又拍云** | 对象存储 | **国内源站** | -| **多吉云** | CDN,回源又拍云 | **国内加速** | -| 腾讯云 COS | `cos.ap-*.myqcloud.com` | 曾是「境外构建产物 → 国内中转机」的中转层,**正在砍** | -| Cloudflare | `api.200181.xyz` | 评论后端 + RSS 机器人 | -| 通知渠道 | Telegram / 飞书 / QQ 邮件 | 三套并行,内容 90% 重复 | +| **代码主仓** | `cnb.cool/zqlit/blog` | CNB,**私有**;构建由它触发 | +| **代码备份** | `github.com/zqlit/blog` | 仅作备份,**不参与构建** | +| **构建 + 发布** | CNB 流水线(腾讯云国内节点,4 核 8G) | 托管,0 元(免费额度内) | +| **密钥仓库** | `cnb.cool/zqlit/blog-secrets` | CNB「密钥仓库」类型,10 项变量经 `imports` 注入 | +| **境内源站** | 又拍云对象存储 | 由 CNB 国内节点**直传**(原为 `if: false` 的死代码) | +| **国内加速** | 多吉云 CDN | 回源又拍云 | +| **境外线路** | EdgeOne Pages(项目 `hugo-blog`,`--area overseas`) | 腾讯 EdgeOne 国际站 | +| **评论后端** | `api.200181.xyz` | Cloudflare Workers | +| **通知** | QQ 邮件(`imql@qq.com`) | 已收敛为**单一通道** | -> 关键点:**境外线路和国内线路是完全独立的两套。** 境外走 EdgeOne,国内走 多吉云 → 又拍云。任一篇文章上线,两条链路上的东西都得各自到位。 +> 关键点:境内、境外仍是**两条独立线路**,但**由同一条流水线一次推完** —— +> 这是本次迁移最大的结构性改善。 --- -## 2. 一次发文的完整旅程 +## 2. 一次发布的完整旅程(3 步) ``` -① 写作 write-server (web) 或 write/ (本地 Windows) - │ -② 提交 git push ──► Gitea(自建,跨境) ← 你自己维护的机器 1 - │ -③ 构建 Gitea Actions / act_runner ← 你自己维护的 runner 2 - │ ├─ hugo --minify --gc (要的) - │ ├─ 图片优化 (sharp, 482 张) (可砍,见 §3-C) - │ └─ 草稿隐藏处理 - │ -④ 传递 424MB 产物 → 打 tar.zst → Gitea artifact ← 踩过 3 个坑才通 - │ - ┌────┴─────────────────────────────┐ - ▼ ▼ -⑤ 境外线路 ⑥ 国内线路 - EdgeOne Pages 广州中转机 sync.sh(每分钟轮询) ← 你自己维护的机器 3 - (runner 直接 deploy) └─ 跨境下载 376MB - └─ upx sync → 又拍云 - └─ upx purge - │ - ⑦ 多吉云 CDN 刷新 + TG / 飞书 / 邮件 三套通知 +① 写作 write-server(网页) 或 write/(本地 Windows) + │ +② git push git pushall = git push origin main ; git push gh main + │ ├─ origin → cnb.cool/zqlit/blog (主仓,触发构建) + │ └─ gh → github.com/zqlit/blog (备份,不构建) + ▼ +③ CNB 流水线(国内节点,约 3.5 分钟,7 个 stage 顺序执行) + ├─ 1. Hugo 构建 草稿隐藏预处理 → hugo --minify --gc → 算构建哈希 + ├─ 2. 同步到又拍云 upx sync(境内直传,非阻断但如实上报) + ├─ 3. 刷新又拍云 CDN purge 首页 + sitemap/rss/archives/posts 等固定入口 + ├─ 4. 刷新多吉云 CDN node scripts/refresh_cdn.js + ├─ 5. 部署 EdgeOne edgeone pages deploy --area overseas + ├─ 6. 上报部署状态 POST api.200181.xyz/api/deploy-status + └─ 7. 邮件通知 成功 → stages 末尾;失败 → failStages ``` -**对照**:一个普通 Hugo 博客 + 托管 CI 的发布链路是 **3 步**(push → 构建 → 上线)。 -你这里是 **7 步,中间有 3 个环节是你自己在运维的服务器/服务**。 - -这就是「复杂」最直观的度量。功能一点都不多,**多的是中继环节**。 +**对照**:迁移前是 **7 步,中间有 3 个环节是你自己运维的服务器**。 --- -## 3. 复杂度归因:三层,性质完全不同 +## 3. 流水线细节(`.cnb.yml`,284 行) -### A 层 · 外部约束 —— 不是你的选择,但躲不掉 - -| 约束 | 连带出来的复杂度 | -|---|---| -| **GitHub 账号被标记** | 自建 Gitea → 连带 act_runner 自运维、artifact API 不合规、缓存服务跨网段不可达 | -| **跨境链路不稳** | 境外 runner 直传又拍云 v0 API 稳定 EOF → 必须有境内兜底 | -| **国内 / 境外访问质量不同** | 被迫维护两条独立线路(EdgeOne + 多吉云/又拍云) | -| **EdgeOne Pages 国际站必须 `--area overseas`** | 部署动作只能从境外发起 | - -> A 层是**根因**。B、C 两层的大部分,都是 A 层长出来的。 - -### B 层 · 为绕开 A 打的补丁 —— 半被迫,可以简化但不能直接删 - -| 补丁 | 为什么存在 | 代价 | +| 触发 | 条件 | 说明 | |---|---|---| -| 广州中转机 `sync.sh` 每分钟轮询 | 境外直传又拍云不通 | 多一台机器要维护;每分钟一次轮询;产物要跨境搬一次 | -| COS 中转层(正在砍) | 给「境外产物 → 国内源站」当缓冲区 | 存储 + 流量账单 | -| 一套 workflow 硬塞进 Gitea | 原本为 GitHub 写 | **8 处** `github.server_url == 'https://github.com'` 分支判断 | -| artifact 单文件 tar 化 | v4 不支持 / v3 传目录 finalize 500 | 额外的打包/解包步骤 | -| 备份脚本三套(aliyun / gitea / cleanup) | 自建基础设施的必然成本 | — | +| push | 推送到 `main` | 主链路 | +| crontab | `0 9 * * *`(Asia/Shanghai) | 每天 09:00,与 push **共用同一组 stage**(YAML 锚点) | -> ⚠️ **一个需要你注意的点**(推测,建议实测):砍掉 COS 之后,中转机变成**直接跨境从境外 Gitea 拉 376MB**。跨境传输这一步并没有消失,只是从「境外 → 境内 COS(一次跨境上传)」变成「境内 ← 境外 Gitea(一次跨境下载)」。**除非实测新链路更快更稳,否则「砍 COS」省的是账单,不是复杂度。** 这条直接关系到你在做的改造是不是真的划算。 - -### C 层 · 自己加的 —— 可以直接砍,收益最大 - -| # | 冗余 | 证据 | 能砍吗 | -|---|---|---|---| -| 1 | **图片优化塞进 CI** | `Optimize Images` + `Commit Optimized Images` + `Push Image Optimizations` 三步 + `npm ci` 装 sharp(~50MB)。扫 482 张图,其中 **15 张 2021–2023 的老图永远处理不掉**,每轮重扫重试 | ✅ **最该砍**。图片在写作时本地处理,CI 就永远不需要 sharp | -| 2 | **CI 反向 commit 回仓库** | `Commit Optimized Images` 把结果 commit,`Push Image Optimizations` 再 rebase push | ✅ 砍掉图片优化后,这两步一起消失 | -| 3 | **三套通知** | `Send Telegram` ×2 + `Send Feishu` ×2 + `Send email` ×1 = **5 个 step,约占 deploy.yml 的 1/3(~400 行)**,同一份内容写 3 遍 | ✅ 留一套(TG 或飞书),其余删 | -| 4 | **永久跳过的死代码** | `Upload to UpYun` 标了 `if: false`,但 **~100 行代码仍在**(upx 下载、登录重试、sync 重试) | ✅ 要么删,要么挪进中转机 | -| 5 | **脚本重复** | `add_draft_to_hidden` 有 `.ps1`(96行) 和 `.py`(110行) 两份,README 写的是 ps1、CI 用的是 py;`deploy_*.sh` 有 4 个共 225 行 | ✅ 各留一份 | -| 6 | **两套写作前端** | `write/`(本地 Windows)与 `write-server/`(网页)功能重叠 | ⚠️ 看你的使用习惯,可合并 | - ---- - -## 4. 量化:复杂度有多少 - -| 指标 | 值 | +| 机制 | 实现 | |---|---| -| `deploy.yml` 总行数 | **1169 行** | -| job 数 | 7 个(含 1 个手动触发的网络探测 job) | -| 单个 job 的 step 数(finalize) | 12 个 | -| 通知类 step | 5 个(TG×2 / 飞书×2 / 邮件×1),约 400 行 | -| `github.server_url` 兼容分支 | 8 处 | -| `if: false` 死代码 | 约 100 行 | -| 需要自己运维的机器 / 服务 | **3 个**(Gitea 主机、act_runner、广州中转机) | -| 发布链路环节数 | **7 步**(理想 3 步) | -| 涉及的 CDN / 存储 | 4 个(EdgeOne / 又拍云 / 多吉云 / COS) | -| 构建产物体积 | 424MB / 3267 文件 | -| 真正发布所需的机器数(理想) | **1 台**(构建 + 推送)或 0 台(托管 CI) | - -**一句话**:功能是一个静态博客 + 评论 + 写作后台,但支撑它的是 **7 个 job、4 个 CDN、3 台自维护机器、1169 行流水线**。 +| 构建镜像 | `deploy/Dockerfile`,hugo 二进制**随仓库携带**(`bin/linux/hugo`,linux/amd64) | +| ⚠️ docker 上下文 | `by: [bin/linux/hugo]` —— CNB 的 docker build **只看得见 Dockerfile + by 列出的文件**,漏写会报 `not found` | +| 密钥注入 | `imports: https://cnb.cool/zqlit/blog-secrets/-/blob/main/secrets.yml` | +| stage 间传状态 | 落盘 `.ci_status`(原版靠 `needs.*.outputs`) | +| 并发控制 | `lock.cancel-in-progress`(对应原 `concurrency`) | +| 非阻断 | 又拍云三步 + 状态上报 + 邮件用 `allowFailure: true`,失败仍写状态如实上报 | +| 资源 | `cpus: 4`(4 核 8G) | --- -## 5. 四条可选的收敛方向 +## 4. 迁移前后对比 -| | 方向 A · 就地瘦身 | 方向 B · 构建源外包 | 方向 C · 构建下移境内 | 方向 D · 取消 CI | -|---|---|---|---|---| -| **做什么** | 砍 C 层:图片优化移出 CI、合并通知、删死代码、合并脚本 | 引入 gitlab.com(GitHub 已被封)或用 CF Pages 构建 | Gitea + runner 迁到广州机,构建发生在境内 | 写作后台直接构建 + 部署,Gitea 退化为纯代码托管 | -| **产物落地** | 不变 | gitlab.com 共享 runner | 境内直接推又拍云 ✓ | 写作后台直接推又拍云 + EdgeOne | -| **跨境传输** | 不变 | 视方案 | **只剩 EdgeOne 一步** | 同上 | -| **新增依赖** | 无 | gitlab.com 账号 + 跨境 push | 无 | 无 | -| **主要风险** | 只是变短,环节数不变 | **GitLab 免费版仅 400 分钟/月**(你月需约 400–900 分钟,踩线) | 广州机**内存 1.9G / 已跑 7 个容器**,hugo 构建未必跑得动 | 写作后台所在机器算力是否够;构建与发布耦合 | -| **代价** | 最低,纯收益 | 月额度可能超 | 需要瘦身后实测内存 | 需要改造写作后台 | -| **建议** | ✅ **先做,是所有方案的前置** | 备选 | 与 D 二选一,实测决定 | 与你「一键直达」的偏好最契合 | +| 维度 | 迁移前(Gitea 自建) | 迁移后(CNB) | +|---|---|---| +| 发布链路环节 | **7 步** | **3 步** | +| 自维护机器 / 服务 | **3 台**(Gitea 主机 + act_runner + 广州中转机) | **0**(构建托管) | +| 流水线定义 | GitHub Actions **1169 行** | `.cnb.yml` **284 行** | +| 产物传递 | 打 `tar.zst` → artifact → 中转机跨境下载 | 同一 pipeline **共享工作目录**,无需传递 | +| 又拍云直传 | `if: false`(境外 runner 必挂,**从未跑通**) | **已跑通**(国内节点直传) | +| COS 中转层 | 需要(存储 + 流量账单) | **已砍** | +| `github.server_url` 兼容分支 | **8 处** | **0** | +| 通知 | 3 套(TG / 飞书 / 邮件),约 400 行 | **1 套**(邮件) | +| 图片优化 + 反向 commit | CI 内每轮白跑(15 张老图处理不掉) | **未迁**(如需保留,照搬 `scripts/optimize_images.js`) | +| 构建环境 | 境外 VPS | 腾讯云**国内节点** 4 核 8G | +| 费用 | VPS 月租 | **0 元**(100GiB 仓库 + 160 核时/月) | +| 单次发布耗时 | — | **约 3.5 分钟** | -### 推荐路径 - -1. **先做方向 A**(无风险、纯收益、且是 B/C/D 的前置)。做完后 `deploy.yml` 预计能从 1169 行降到 600 行以内,构建里只剩 `hugo build`。 -2. **再实测两件事**:① 广州机跑一次 `hugo` 的峰值内存与耗时;② 国内机跑一次 `hugo` 的耗时(对比 CI)。 -3. **依据实测二选一**: - - 广州机跑得动 → **方向 C**(Gitea 迁境内,跨境只剩 EdgeOne) - - 写作后台机器跑得动 → **方向 D**(连 CI 都取消,最符合「一键直达」) - - 都跑不动 → **方向 B**(但要接受 GitLab 400 分钟/月的紧箍咒) +> 原链路里那些补丁(COS 中转 / `sync.sh` 每分钟轮询 / artifact 打包 / 兼容分支) +> **唯一根因是「runner 在境外」**。CNB 节点在国内,这一整层随之消失。 --- -## 6. 需要你决定的三件事 +## 5. 运维要点 -1. **那 15 张 2021–2023 的老图能清吗?** 能清的话,我本地转 WebP + 修引用,然后 `Optimize Images` 这一步整个从 CI 删掉 —— 这是砍掉 C 层最大块的钥匙。 -2. **构建最终放在哪一侧?** 境外(现状)/境内(方向 C/D)/外包(方向 B)。这决定后面所有改造的走向。 -3. **通知留哪一套?** TG / 飞书 / 邮件 —— 留一个,其余删。另外邮件通知那份的长文本(约 50 行模板)可以整个去掉。 +### 5.1 推送 + +```bash +git pushall # 别名 = git push origin main; git push gh main +``` + +用 `;` 而非 `&&` —— **一段失败不影响另一段**(实测:串联 `pushurl` 时第一个失败会中止后续)。 + +### 5.2 远端 + +| remote | 地址 | 角色 | +|---|---|---| +| `origin` | `https://cnb.cool/zqlit/blog.git` | **主仓**(fetch + push) | +| `gh` | `git@github.com:zqlit/blog.git` | **备份**(push) | +| `gitea` | 自建 `23.254.236.47:3001` | 过渡期只读参考,稳定后手工删 | + +### 5.3 凭据 + +- 令牌存**仓库外**:`~/.workbuddy/secrets/cnb-token` +- 本仓 `credential.helper` 指向一个自定义脚本;`.git/config` **无明文** +- ⚠️ 本机全局 helper 是 **GCM**(会弹窗、对第三方 HTTPS 远端还会挂死);wincred 对 cnb.cool 有过「幽灵记录」删不掉 → 故用自定义 helper 绕开 + +### 5.4 密钥 + +- 修改密钥 → 编辑 CNB 密钥仓库的 `secrets.yml`(**网页编辑,禁 clone**) +- 本地 `cnb-secrets.yml` 只是粘贴草稿(已 gitignore,**不入库**) +- ⚠️ CNB 的 `imports` 是**一层映射**(key 就是变量名本身);又拍云服务名在 CNB 里必须叫 + **`UPYUN_SERVICE`**(旧 Gitea 里叫 `UPYUN_BUCKET`,照抄会「变量未定义」) --- -## 附:本次梳理的证据出处 +## 6. 遗留待办 -- `.github/workflows/deploy.yml`(1169 行,全文通读) -- `blog-admin/README.md`、`write-server/README.md`、`write/`(模块定位) -- `hugo.toml`(`baseURL = https://usj.cc`) -- `gitea-backup/README.md`、`docker-compose.yml`(迁移目标与坑) -- `scripts/` 目录清单与行数统计 -- 线上实测:Gitea `/api/v1/version` = `28.0.0`;广州机 `docker pull gitea/gitea:28.0.0-rootless` 可拉、`1.28.0-rootless` 不存在 +| # | 项 | 说明 | +|---|---|---| +| 1 | `gitea` remote | 只读保留,稳定几天后 `git remote remove gitea` | +| 2 | GitHub 侧旧 workflow | `deploy.yml` / `cleanup.yml` / `aliyun-backup.yml` 已无用途,可删 | +| 3 | 令牌 scope | 缺 `repo-cnb-history:r`(读构建日志);补上后 agent 可自行排错 | +| 4 | Gitea 主机 | 发布链路已不依赖;是否退役取决于其它用途 | +| 5 | 广州中转机 | 同上(`sync.sh` 轮询已无用;机上另有 1Panel / vaultwarden 等服务) | +| 6 | `README.md` | 仍写着旧 GitHub Actions 流程,待更新 | + +--- + +## 附:相关文档 + +- `CNB构建落地方案.md` — 迁移实施方案与实测数据 +- `代码源与构建平台选型.md` — 平台对比(CNB / Gitee / GitLab / EdgeOne / 阿里云 ESA) +- `砍COS改造步骤.md` — COS 下线记录 +- `EdgeOne双区域方案评估.md`、`Gitee方案评估.md`、`阿里云ESA评估.md`、`精简方案-只留CF和Hugo.md` — 决策期评估 + +**证据出处(现行)**:`.cnb.yml`(284 行)、`deploy/Dockerfile`、`bin/linux/hugo`、 +`scripts/send_mail.js`、`scripts/refresh_cdn.js`、`scripts/setup-cnb-remotes.sh`。 +已退役参考:`.github/workflows/deploy.yml`(1169 行)。 diff --git a/评论加载优化方案.md b/评论加载优化方案.md new file mode 100644 index 00000000..93b169e0 --- /dev/null +++ b/评论加载优化方案.md @@ -0,0 +1,281 @@ +# 评论加载优化方案(Cloudflare 免费版) + +> 起因:读者侧「评论加载太慢」。 +> 约束:**Cloudflare 免费版**,无中国节点;全程不引回自维护机器(除非你主动选第三档)。 +> 状态:**第一档 + 2-A 已实施**(本地,待预览确认);2-B 待做,2-C 已由实测数据关闭。 + +--- + +## 0. 先厘清「慢」到底慢在哪一层 + +这一点决定每一项优化的真实价值,否则容易做一堆「看起来很快」的改动却没有体感。 + +| 层 | 链路 | 现状 | 谁能修 | +|---|---|---|---| +| **L1 跨境** | 读者浏览器 → CF 边缘机房 | CF 免费版无中国节点,每次请求跨境 ≈ 250–400ms | 只能靠**少发请求**(前端),或第三档反代 | +| **L2 回源** | CF 边缘机房 → D1 主库 | D1 主库在 WNAM(美国西),代码注释:「每次读都要跨太平洋」 | **边缘缓存**(省掉这一跳) | +| **L3 结构** | CF 在中国大陆无节点 | 结构性,无解 | 只有第三档(境内反代) | + +**关键结论:边缘缓存(第二档)治的是 L2 和额度,治不了 L1。** 读者感受到的「慢」主要在 L1,所以**降请求数比加缓存更直接**。 + +--- + +## 1. 当前一次评论首屏的请求链(来自代码,非推测) + +Artalk 客户端 `libs/Artalk.js` 的初始化是**串行**的——已从压缩源码确认: + +```js +// conf 请求(await)→ mounted → 之后才发评论列表 +...getApi().conf.conf().catch(...) +... e.trigger("mounted"), e.conf.remoteConfModifier || e.fetch({offset:0}) +``` + +即:**`conf` 不回来,评论列表请求根本不会发出**。所以首屏是严格串行的两跳: + +``` +浏览器 ──① GET /api/v2/conf ──────► CF 边缘 ──► D1 ≈ 1×L1 + 1×L2 + ──② GET /api/v2/comments ─► CF 边缘 ──► D1 ≈ 1×L1 + 1×L2(含多条查询) + ──③ POST /api/v2/pages/pv ─► CF 边缘 ──► D1 (异步,不阻塞渲染) +``` + +页面加载时还会打 `/captcha/status` 或 `/human/*`(仅在需要时)。 + +**L1 一次 ≈ 250–400ms(国内实测 CF 0.75s vs EdgeOne 0.25s)**,所以 ①→② 两跳 ≈ 0.5–0.8s 纯网络。把 ① 干掉,首屏网络时间**直接减半**。 + +> 已有的优化不动:`window.friendLinks/friendFeeds` 是 Hugo **构建期内联**的(零运行时请求);Artalk 的 JS/CSS 走又拍云本地资源;`conf` 已有 localStorage 缓存(**但只在 PJAX 第二次以上生效,首屏吃不到**);`/api/favicon` 已有 KV 缓存 + `max-age=86400`;计数查询已有 KV 短缓存(10 分钟 + 版本号失效)。 + +--- + +## 2. ✅ 第一档(已实施):修 `preconnect` + +**问题**:`themes/Ying/layouts/partials/head.html` 里给 API 域名预热了连接,但**没带 `crossorigin`**。 + +Artalk 发的是 CORS 跨域请求。浏览器把「带凭据的连接」和「匿名连接」分池缓存:不带 `crossorigin` 预热出来的是普通连接,**CORS 请求无法复用它**——等于白预热,还是要重付一次 DNS + TCP + TLS(约 2–3 个 RTT)。 + +**改法**:`preconnect ... crossorigin` + `dns-prefetch` 兜底(老浏览器不支持 preconnect);host 取 `artalk.server` 与 `rssapi.base` 去重后渲染,以后两处配置分家也不会漏。 + +**验证**:本地 `hugo` 构建产物确认(1270 个页面全部生效,两个 host 正确去重): + +```html + + + +``` + +**估算收益**:首屏第一跳省下 ~2–3 个 RTT 的握手(≈200–400ms),且**后续 ②③ 请求复用同一条连接**,各自只花 1 个 RTT。 + +--- + +## 3. 📋 第二档评估 + +### 2-A. ✅ 首屏内联 `conf`(已实施) + +> ⚠️ **先记一次认知纠正:本节原评估的方向是错的。** +> +> 原方案写的是「前端首屏直接 `useBackendConf: false` 用内联配置起步,跳过 ①」。 +> 读 `libs/Artalk.js` 压缩源码后确认**做不到**: +> +> ```js +> const { data: i } = await e.getApi().conf.conf() // ← 无条件发请求 +> if (e.conf.useBackendConf) { ...merge frontend_conf... } // ← 只管要不要「用后端覆盖前端」 +> ``` +> +> `useBackendConf` 决定的是**要不要采用后端的配置**,与**发不发这次请求**无关。 +> 所以内联配置在 Artalk 内部绕不过请求 ① —— **必须拦在 fetch 层**。 +> (PJAX 缓存那条路径也没省掉请求,它省的是「重新渲染」,不是「重新请求」。) + +**做法**(三段,缺一不可): + +1. **构建期抓快照** —— `.cnb.yml` 的「Hugo 构建」stage 里 `curl` 一次 `/api/v2/conf`, + 落到 `data/artalk_conf.json`(Hugo 读成 `site.Data.artalk_conf`)。 + 拉取失败就删文件降级(失败发生在 `if` 条件里,不触发 `set -e`,**绝不让它拖垮构建**)。 + 该文件已 gitignore:每次构建现拉,避免陈旧配置被 git 固化。 +2. **模板内联** —— `partials/artalk.html` 输出 `window.__artalkConfSnapshot = {…}`, + 放在 `window.artalkConfig` 之前。`` 逃逸。 +3. **fetch 拦截** —— `modules/artalk.js` 的包装层里命中 `/api/v2/conf` 时, + 直接 `new Response(JSON.stringify(快照))` 返回,**完全不发网络**。 + (Artalk 的通用请求函数 `w()` 只检查 `r.ok` 就把 Response 原样返回,所以合成响应可无缝接管。) + +**为什么可以安全复用「一份固定响应」**:服务端 `getConf` 是 +`{...DEFAULT_FRONTEND_CONF, ...settings.frontend_conf, imgUpload: imgUpload || admin}` —— +**除 `imgUpload` 外全部来自 settings 表,与访客无关**。于是: + +- **匿名访客** → conf 人人相同 → 用快照 ✅ +- **已登录用户** → 管理员会拿到 `imgUpload: true`(个性化)→ **放行真实请求** + 判断方式:`w()` 在无 token 时会把 `Authorization` 头删掉,所以「该头存在」即「有凭据」; + 判不准时保守放行(宁可多一跳,也不错配)。 + +**实测验证**(Chrome CDP 抓真实页面网络): + +| | `/api/v2/conf` 请求数 | +|---|---| +| 改动前(对照:CDP 注入脚本吞掉快照变量的赋值) | **2 次** | +| **改动后** | **0 次** ✅ | + +页面探针同时确认 Artalk 完全正常:`artalkInited: true`、`editorPresent: true`、 +`listPresent: true`、`errText: ""`;且 conf 内容确实来自快照 +(`sendBtn:"评论一下"` / `nestMax:2` / `locale:"zh-CN"` / `emoticons` 全对); +conf 之后的评论列表请求照常发出 —— **串行链没断**。 + +**成本**:conf 响应 771B,内联进 123 个带评论区的页面(占页面体积 **1.6%**),全站内容一致。 + +| | 结果 | +|---|---| +| 收益 | 首屏少一整跳跨境(≈250–400ms);**实际比预期多省一倍**,见下方「顺带发现」 | +| 风险 | 低。构建期失败自动降级;带凭据请求自动放行 | +| 代价 | 后台改评论配置后要等下次构建才生效(已有每日 9:00 定时构建兜底) | + +**怎么回退**:删掉 `data/artalk_conf.json` 再构建即可 —— 模板不输出快照,前端自动走原生远程请求,行为等同今天(无需改代码)。 + +### 2-B. 给读接口加边缘缓存(Cache API) + +**注意**:Worker 的响应默认**不进** Cloudflare 缓存。光加 `Cache-Control` 头是**没有效果的**,必须显式用 `caches.default.put()/match()`。(这也是为什么这项不能"顺手加个头"就完事。) + +必须做对的四件事: + +1. **只缓存匿名公开视图**。`listComments` 的结果依赖是否管理员(`is_pending` 过滤、`includeMarked`)、`name`/`email`、`scope=user`、`type=mine|pending|mentions`、`view_only_admin`——这些**一律不缓存**,只缓存 `scope=page` 的默认视图。`/conf` 同理(含 `imgUpload || admin`)。 +2. **缓存 key 要带 origin**。`corsHeaders()` 已经把 `Access-Control-Allow-Origin` 回显成请求方的 Origin(并设了 `Vary: Origin`),缓存 key 里必须把 origin 编进去,否则 A 域名的响应会被回给 B 域名。 +3. **失效策略复用现有机制**。项目里已有 `cml:ver::` 这个 KV 版本号,评论新增/删除/审核会 `bumpCountVersion()`。**直接把它编进缓存 key** → 有评论变化时 key 自然变化,旧条目靠 TTL 自然过期,不用做 purge。 +4. **TTL 取短**:30–60s。评论不是毫秒级强一致场景。 + +| | 评估 | +|---|---| +| 收益 | **D1 额度**:命中时省掉「计数 + 根列表 + 子评论 + page + site」多条查询(单页几十~几百 rows_read)。这个是真金白银——代码注释里记着当年额度被打爆过 | +| 收益 | **延迟**:命中时省掉 L2 那一跳(≈100–200ms)。但**L1 那一跳仍然要付**,所以体感提升有限 | +| 风险 | 低–中。要点全在「不可缓存哪些请求」上,漏一个就是串号/看到别人的数据 | +| 代价 | 评论提交后,**同一机房的其他读者最多看到 TTL 秒的旧列表**(可接受) | + +### 2-C. Smart Placement(`[placement] mode = "smart"`) + +让 Worker 跑在**离 D1 更近**的机房,代价是可能离**读者**更远。 + +**要不要开不用猜**:你的 `/api/v2/healthz` 已经专门返回这两个字段了(当初就是为这个判断留的): + +``` +colo —— Worker 实际执行机房 +region —— D1 实际服务区域 +``` + +- **同区域** → 开了没收益,还可能变慢,**别开** +- **不同区域** → 有意义,可以开 + +**实测结果(2026-10-04,用户提供)**: + +```json +{"region":"WNAM","colo":"LAX"} +``` + +两者同为美西 → **结论:不开**。Worker 已经在 D1 同区域执行,Smart Placement 带不来收益, +还可能把请求推到离读者更远的机房。**本项关闭,不用再做。** + +### 2-D. 「加 HTTP 缓存头」——不是独立项 + +它只是 2-B 的实现细节(`caches.default.put()` 要求响应显式可缓存)。**单独加头没有任何效果**,不要当成一项来做。 + +### 顺带发现(本轮实测捡到的既有问题,与 2-A 独立) + +**① `initArtalk()` 被调用两次 → conf / comments / pv 全部发两遍。** + +根因是既有代码的重复触发: + +``` +partials/artalk.html 内联脚本 → initArtalk 尚未定义 → 注册 DOMContentLoaded 回调(兜底) +assets/js/main.js:70 → 也在 DOMContentLoaded 里调 window.initArtalk() + ↓ + 两个监听都会触发 → initArtalk 跑 2 次 +``` + +CDP 实测(改动前):`GET /api/v2/conf` ×2、`GET /api/v2/comments` ×2、`POST /api/v2/pages/pv` ×2。 +2-A 把 conf 那两次消掉了,但 **comments 仍多付一次跨境,`pv` 仍是双倍计数** +(**浏览量统计偏高 100%**,这是数据准确性问题,不只是性能)。 + +修法很轻:在 `initArtalk` 入口加幂等守卫(同一 `pageKey` 已初始化过就 `return`), +PJAX 切页时 `pageKey` 变化自然放行。**但改动落在 PJAX 路径上,建议单独一轮做 + 单独验证。** + +**② `head.html:59` 有一个第三方统计脚本。** + +```html + +``` + +它不在主题自身模块里,是页面**唯一的外部脚本**,会把访问数据上报给第三方。 +**确认是不是你自己加的**;若不是,建议连同这一行一起删掉。 + +**③ ✅ 已修:非评论类错误层会让整个评论区「假故障」一次(顿一下 + 弹「已恢复」toast)。** + +**现象**:进文章页时评论区整体置灰、插「评论服务暂时不可用」横幅,约 1 秒后自己恢复并弹 +`评论服务已恢复,可以继续评论了 ✓` —— 读者看到的就是「顿一下 + 一条莫名其妙的提示」。 + +**根因**(CDP 时间线实测,非推测): + +``` +表情包 /emotion/OwO.json 加载失败(本地预览跨域;线上则可能是 CDN 抖动) + → Artalk 在「编辑器插件面板」内渲染 .atk-error-layer,文本: + Artalk Error / [表情] 加载失败: TypeError: Failed to fetch + → rewrite() 只用 layer.querySelector('.error-message') 取文本 —— 而插件面板的错误层 + 没有这个 class,取到空串 + → 空串不匹配任何特征词 → 兜底 code = 'internal' + → if (code !== 'network') setSvcDown(code) ← 只有 network 被豁免,internal 不豁免 + → 整个评论区置灰 + 插横幅(此时评论数据其实还没回来,也没失败) + → 约 1s 后 Artalk 收掉错误层 / 评论数据正常返回 → setSvcUp() → 弹 toast +``` + +CDP 抓到的判定证据:错误卡片上的 `data-raw` = `"|internal"`(竖线左边就是空串 `raw`), +而错误层的真实 DOM 位置是 `atk-editor-plug-emoticons < atk-plug-panel-wrap < atk-main-editor`。 + +**修复**(`themes/Ying/assets/js/modules/artalk.js`,三处): + +1. `rewrite()` 开头直接跳过 `.atk-plug-panel-wrap / .atk-editor-plug-emoticons` 内的错误层 —— + 既不改写文案,也不参与故障判定(这类层是资源加载失败,不是评论服务故障)。 +2. 故障判定加证据要求:`if (code !== 'network' && (raw || (fresh && fresh.code)))` —— + **读不到可归因证据时不下结论**,宁可只渲染卡片也不误置灰。 +3. `setSvcUp()` 加最短门槛:禁用态不足 3 秒不弹 toast(真实故障不会两三秒就恢复)。 + +**验证**:CDP 复跑,`svc-down` / 横幅 / toast 三项全部 0 次,评论数据仍 200; +另做三组回归:真实故障层仍置灰 ✅、无文本层不置灰 ✅、插件面板层完全放行 ✅。 + +> ⚠️ **线上本来不出现这个现象**:线上页面在 `usj.cc`、表情包也在 `usj.cc`,同源不触发 CORS。 +> 它只在本地预览(跨域)必现,线上要等 `OwO.json` 真出问题(CDN 抖动 / 404)才会踩到 —— +> 所以这算是一次「本地预览帮忙提前暴露了线上潜在缺陷」。 + +### 不建议做的 + +- **改 Artalk 客户端把 ① ② 并行**:要 fork 官方库,收益和 2-A 重叠,维护成本高。 +- **给 `/pages/pv` 加速**:它已经是异步的,不阻塞渲染。 + +--- + +## 4. 建议的执行顺序 + +| 顺序 | 项目 | 收益 | 风险 | 状态 | +|---|---|---|---|---| +| ✅ 1 | 第一档 preconnect | 首屏 −200~400ms | 极低 | **已完成** | +| ✅ 2 | 2-A 首屏内联 conf | conf 请求 **2 次 → 0 次** | 低 | **已完成** | +| ✅ 3 | 2-C Smart Placement | — | 零 | **实测同区域 → 关闭** | +| 4 | 修「`initArtalk` 跑两次」 | 再省 1 跳,且**修正 PV 双倍计数** | 中(涉及 PJAX) | **建议做,单独一轮** | +| 5 | 2-B 读接口边缘缓存 | 保 D1 额度;命中 −100~200ms | 中 | 推荐 | + +做完 2 + 4 + 5,首屏从「2 跳跨境」变成「1 跳 + 命中即回」,D1 读量大幅下降,PV 计数恢复正确。 + +**预期天花板**:即使全部做完,L1(跨境那一跳)依然存在——免费版 CF 在国内就是没有节点。 + +--- + +## 5. 第三档(真正的解法,需要你权衡) + +`usj.cc` 的 NS 在 DNSPod,可以对 `api.usj.cc` 做**分线路解析**:境内 → 境内机器反代 → CF;境外 → 直连 CF。这才治本(L1 也消失)。 + +两条硬约束: + +1. **`api.200181.xyz` 本身做不了**——200181.xyz 的 NS 在 Cloudflare,免费版不支持按国家分线路解析。所以必须换成 `api.usj.cc` 这类「NS 在 DNSPod」的域名。 +2. **代价是把一台自维护机器重新拉回关键路径**——与 2026-10-04 刚完成的「0 台自维护机器」方向相反。 + +**没有白吃的午餐**:这一档能根治,但要把刚拆掉的东西装回去。 + +--- + +## 6. 不变量(别被顺手改坏) + +- `SERVER_API_VERSION` 必须与博客里打包的 Artalk 客户端版本一致(当前 2.8.7),否则前端弹版本警告。 +- `listComments` 里 `countFromSql` 的「不用 JOIN users 就不 JOIN」是额度优化,别回退。 +- 任何新增缓存都必须先回答:**这个响应会不会因为「谁在问」而不同?** 会 → 不能缓存,或必须把身份编进 key。