上一篇写了第二轮改造的坑(ISR 的 DYNAMIC_SERVER_USAGE 与自托管统计三连坑),这一轮继续:功能补全 + 收录基建,把博客的最后几块拼图装完。老规矩,只记踩过的坑和最终方案。
一、路径式分页:把 ISR 从 searchParams 手里救回来
上篇说到分类/标签页因为 await searchParams 在 ISR 缓存填充上下文里炸出 500,当时用 force-dynamic 止的血——页面级缓存没了。这轮做根治:
/blog?page=2 → /blog/page/2
/category/32?page=2 → /category/32/page/2
每个模块拆成两个路由文件复用一个视图组件,第 1 页恢复 revalidate = 600 的完整 ISR,第 N 页独立 URL 同样可缓存。改造后 build 日志里 /blog 回到了 ○ Static。
这个方案的额外收益是 SEO:每页一个干净的规范 URL,搜索引擎不用处理 query 参数去重。Pagination 组件只需要改一行链接生成逻辑。
一条通用原则:凡是想按请求变化的维度(页码、筛选),优先编码进路径而不是 query——除非你明确需要它不被缓存(比如搜索结果页,那就大方地 force-dynamic)。
二、匿名点赞:IP 去重就够了
个人博客加登录体系纯属自找麻烦,但「无门槛互动」对作者的正反馈是真实的。设计很简单:
CREATE TABLE article_likes ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, article_id INT UNSIGNED NOT NULL, client_ip VARCHAR(45) NOT NULL, create_time INT NOT NULL, UNIQUE KEY uk_article_ip (article_id, client_ip) ); ALTER TABLE document ADD COLUMN like_count INT UNSIGNED NOT NULL DEFAULT 0;
核心技巧在写入侧:
res := db.Exec( "INSERT IGNORE INTO article_likes (article_id, client_ip, create_time) VALUES (?,?,?)", req.ID, c.ClientIP(), time.Now().Unix(), ) if res.RowsAffected > 0 { db.Exec("UPDATE document SET like_count = like_count + 1 WHERE id = ?", req.ID) }
INSERT IGNORE 撞唯一键时 RowsAffected 为 0——天然幂等,不需要先查再插的两步竞态。前端按钮拿到返回的 liked 状态做视觉锁定,localStorage 都不用。
局限也直说:同一 Wi-Fi 下多人共享出口 IP 只能赞一次;手机流量切换会重复计数。对个人博客这个精度完全够。
三、代码块行号:一行 CSS counter 的事
shiki 输出的高亮 HTML 里每行天然是 <span class="line">…</span>,所以根本不需要在 React 层手动拆行插序号,纯 CSS 搞定:
.shiki-pre code { counter-reset: line-no; } .shiki-pre code .line::before { counter-increment: line-no; content: counter(line-no); display: inline-block; width: 1.6rem; margin-right: 1.1rem; text-align: right; user-select: none; /* 关键:复制时不带行号 */ }
user-select: none 保证访客点「复制」拿到的是干净代码。暗色模式换一档灰即可。
四、JSON-LD 结构化数据:白捡的搜索富摘要
Next.js 里给文章页塞一段 BlogPosting schema,十几行:
<script type="application/ld+json" dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: article.title,
datePublished: article.create_time,
dateModified: article.update_time,
author: { "@type": "Person", name: siteConfig.author },
image: `${siteConfig.domain}/article/${id}/opengraph-image`,
keywords: article.keyword,
}),
}} />
配合已有的 OG 卡片和 sitemap,Google 富摘要需要的信号基本齐了。
五、搜索收录闭环:三个坑排一个雷
收录这件事的配置本身不难,难的是中间挖出一个隐藏很深的基础设施问题。
坑:裸域 HTTPS 证书不存在
GSC 报 sitemap 错误,排查时发现 https://wxbuluo.com(不带 www)TLS 握手直接失败——证书只签了 www.wxbuluo.com!也就是说裸域在 HTTPS 世界里从来就没通过,之前所有浏览器访问都是先走了 http 301 或者用户手输 www 才绕过去的。
修复用 certbot webroot 方式扩签双域名证书:
certbot certonly --webroot -w /acme-webroot \ -d wxbuluo.com -d www.wxbuluo.com --expand
然后在 nginx 把裸域单独拆一个 server 块,只做一件事:
server {
listen 443 ssl http2;
server_name wxbuluo.com;
ssl_certificate /etc/letsencrypt/live/www.wxbuluo.com/fullchain.pem;
location / { return 301 https://www.wxbuluo.com$request_uri; }
}
主域名归一化后,GSC 里只保留 www 一个资源、一份 sitemap,跨域报错自然消失。
坑中坑:shell 转义让 nginx reload 静默失败
改配置过程中踩了个值得记录的细节:通过 ssh 远程执行 heredoc 时,如果分隔符不加引号(<< PYEOF 而不是 << 'PYEOF'),远端 shell 会展开里面的 $request_uri——变成空串,nginx 配置直接语法错误。更糟的是我把 nginx -t 的输出重定向丢弃了,reload 失败毫无感知,排查了半天「为什么配置不生效」。
教训:远程执行务必 << 'EOF',且 nginx -t 的输出永远别丢。
百度验证:文件方式失败就换 meta 标签
百度的文件验证报「无法连接到服务器」,翻遍服务器日志发现它的抓取请求根本没到达过。没有纠结原因(可能是平台瞬时线路问题),直接切 HTML 标签验证——head 里加一行 <meta name="baidu-site-verification">,一次通过。收录通道不必恋战,能过就是赢。
六、异地备份:加密推到私有仓库
数据库备份原来就躺在服务器本地盘——机器挂了副本陪葬,这是拖了很久的风险。最终方案:每日打包(MySQL dump + Fathom 统计库 + nginx/compose 配置)→ AES-256 加密 → 推到私有 GitHub 仓库。
几个实现细节:
- Fathom 是 SQLite,WAL 模式下直接 cp 可能不一致,用 Python 只读连接
iterdump()导出 SQL 文本,顺带恢复了兼容性更好 - 服务器 openssl 太老不支持
-pbkdf2,降级用默认 KDF + 24 位随机密码,个人数据够用 - 解密密码绝不能只存在服务器上——否则服务器没了密码陪葬,加密备份等于自杀。生成后第一时间进密码管理器
- git 天然保留全部历史,工作树只留近 30 天防膨胀
cron 全家桶现在是:03:30 本地 dump → 04:10 加密推 GitHub → 周一续证书 → 每天 09:00 向百度 API 轮转推送 10 条 URL(新站配额就这么多,20 天覆盖全站)。
收尾
三轮迭代下来:阅读体验、SEO 全链路、互动、统计、容灾都齐了。回头看最大的心得反而是克制——每一轮都想再加点什么,但真正提升体验的是把已有功能的坑填平(分页、收录、备份),而不是继续堆新玩具。
接下来该还债了:多写文章。