用户定案:「辅助仓就用 openlist,其他不再考虑」。
据此把前一轮为 Gitee 铺的路全部收回,git 远端只剩 CNB 一个。
一、git 配置收口
- `pushall` 别名 `origin + gitee` → `!git push origin main`(只推唯一远端)
- 移除 `gitea` remote(自建 23.254.236.47:3001)—— 远端仓库本身没删,
需要时可 `git remote add` 恢复;改动前配置存
`.workbuddy-backup/git-remotes.20261006-211516.txt`
- 确认无 gitee 相关 credential 残留
二、发布链路去 Gitee 化(6 处)
- `deploy/editor-api/bootstrap.sh`
· 删掉 GITEE_URL / GITEE_SSH_KEY 两个变量与「没给私钥就降级」的分支
· PUSH_REMOTES 默认 → origin
· 原「配 gitee 辅仓远端」一节改为「清理退役远端」循环(gh/gitee/gitea),
让从旧部署续用的工作区自动恢复干净
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认值 origin
- `editor-api/README.md` → 变量表同步
- `editor-api/Dockerfile` → 注释里的「CNB / GitHub」改「CNB」
- `blog-admin/src/routes/rss/tools.ts` → deploy-notify 的注释里
「与 Gitea Actions 的构建通知配套」改为中性描述
(该轮询链路 2026-10-04 起已被 CNB 国内节点直传取代)
三、`scripts/setup-cnb-remotes.sh` 重写为单远端模式
- 去掉 GITEE_URL / GITEE_TOKEN 参数、校验、凭据写入与自检提示
- 新增「清理退役远端」步骤
- 凭据处理改为「已存在空的 credential.helper 就不再添加」,
不再用 --replace-all —— 本仓另有一个从 `$HOME/.workbuddy/secrets/cnb-token`
读令牌的自定义 helper,那是有效的,不能被脚本抹掉
四、备份升级为「唯一辅仓」的配置
- 保留份数 3 → 7(一周窗口;每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)
· `scripts/backup-task.cmd` 默认参数 --keep 7
· `scripts/backup-bundle.mjs` 的 KEEP 默认值同步为 7
(原先写的是 2,一直被命令行参数掩盖着)
- 远端目录 `/本地/备份` → `/本地/备份/blog-bundle`:
根目录是用户自己在用的(放着 github-zqlit-*、local-repos-* 等手工备份),
实测发现直接放根下的 bundle 已被清掉 —— 改子目录隔离,避免混放与误删
五、新增 `scripts/backup-run.mjs`:备份的推荐入口 + 失败告警
- 读 `.workbuddy-backup/openlist-backup.env`(只补空缺,环境变量优先)
- 跑 backup-bundle.mjs 并实时透传输出,同时留一份日志尾部
- 退出码非 0 → 经 `scripts/send_mail.js` 发告警邮件(附日志尾部与常见原因);
成功默认不发,`--notify-success` 才发
- 退出用 `process.exitCode` 而非 `process.exit()`,避免截断未排干的 stdout
- 发信失败不改判备份退出码 —— 通知不该掩盖真正的故障
- 理由:这是当前**唯一**的异地备份,而「每天自动跑」的任务最典型的失败模式
恰恰是静默的(F50 被带出门、换了网段、OpenList 没起来、口令改过……),
没有告警就要等到真要用备份那天才发现
- `scripts/backup-task.cmd` 改调它
六、文档
- `架构总览.md`
· §1.2 地址地图:备份行改指 F50/OpenList;通知行补「兼做备份失败告警」
· §2 旅程图:双推改单推,并说明备份换了介质
· §5.1 / §5.2 推送与远端:只剩 origin;补「已移除远端」表与恢复命令;
GitHub 退役记录保留并补上「CI 定义也已删除」
· §5.3 由「辅仓选型」改为「异地备份的定案」—— 明确不走 git 远端;
平台对比数据保留备查,并注明 `bin/linux/hugo` 出库不必再做了
· §5.6 补「唯一备份」定位、专属子目录、失败告警、keep 7、SMTP 配置键,
实测数据更新为本次复测值
· §6 待办:#2 定案、#3 不必做、#4 已移除、#8 已更新、#10 定位升级,
新增 #11(F50 目录使用约定)
- `CNB构建落地方案.md` §4.0:双远端改单远端,脚本示例去掉 Gitee 参数
- `README.md`:推送说明改单推;脚本表补 backup-run.mjs
实测(2026-10-06,本轮复测):
bundle 7.4s / AES-256-GCM 加密 1.3s / 上传 19.2s(31.6 MB/s)
/ 读回 sha256 一致 → 端到端 60.6s,退出码 0
告警邮件链路已实测(发出一封「备份成功」验证信)
★ 一处过程记录,供以后避免重复踩坑:
中途我把「本机沙箱里 `env -u ... cmd > file` 会让输出整个消失」
误判成 process.exit 截断 stdout,并据此改了日志实现;
随后用 `env -u FOO echo hi > file`(同样零输出)证伪 ——
那是沙箱文件重定向的伪影,与脚本无关。相关改动已回滚,
只留下本身无害的 process.exitCode 写法。
artalk-cf —— 跑在 Cloudflare 上的 Artalk 评论后端 + RSS 订阅机器人
自研的 Artalk API v2 兼容服务端,以及合并进来的 RSS 订阅机器人(rss-robot), 一起跑在 Cloudflare Workers + D1 + KV 上,零服务器、零运维、免费额度内。
状态:已上线 →
https://api.200181.xyzD1artalk-cf(78140776-2bfd-439a-8b05-c3eebe5d5b47)| KVRSS_KV(d7f86a0fb43f4450a10b786fc2635128) 管理员账号admin(后台登录用邮箱+密码,见「部署」)两个后台都可用:
/admin/自研管理面板 |/sidebar/官方 Artalk 后台 评论前端一行不用改:继续用官方 Artalk 客户端(博客里打包的 2.8.7),换后端只改hugo.toml一行server。
目录
这个项目是什么
两件事,各对应一个原本独立的仓库,现在合并进同一个 Worker:
| 子项目 | 来源 | 职责 | 路径 |
|---|---|---|---|
| artalk-cf | 自研 | Artalk API v2 兼容评论后端(44 路径 / 54 操作已实现 46 个) | /api/v2/* |
| rss-robot | 迁移自 EdgeOne 的 api/*.js |
RSS 订阅抓取 + 友链/友圈 + 公众号内容 | /api/*(不含 /api/v2) |
两者共存于一个 Worker、一个 D1、一个 KV,互不冲突:
Artalk 客户端只打 /api/v2/*,RSS 模块只打 /api/*(非 v2),路径天然隔离。
技术选型
整体判断:为什么是 Cloudflare Workers + D1,而不是自建服务器
| 考量 | 结论 |
|---|---|
| 成本 | 免费版够用(Workers 10 万请求/天、D1 读 500 万行/天、存储 5GB),博客评论量级远用不满 |
| 运维 | 无服务器、无 SSH、无进程守护;wrangler deploy 一条命令发布 |
| 大陆可用性 | 见下方「已知取舍」——这是唯一的短板 |
逐项选型与理由
1. 运行环境:Cloudflare Workers(免费版)
- 全球边缘函数,
fetch入口 +scheduled定时入口 - 致命限制:没有 TCP socket → 无法直连 SMTP 发邮件、无法跑原生 bcrypt,这两点直接决定了后续选型(见下)
- 单请求 CPU 预算约 10ms(免费版),驱动了「读写分流 + 覆盖索引 + 计数缓存」一系列优化
2. 数据库:D1(Cloudflare 的 SQLite 边缘库)
- 兼容 SQLite 语法,
wrangler d1 execute直接跑 SQL,批量导入零成本 - 读写分离:D1 Sessions API + 只读副本(
first-primary写 /first-unconstrained读) - 免费额度:读 500 万行/天、写 10 万行/天。这是本项目最需要盯的资源(2026-10-03 曾被打爆过一次,见性能章节的「打爆复盘」)
3. KV(键值存储):RSS 订阅与验证码通行证
- RSS 的
feeds_config/ 抓取游标 / 失败退避、人机验证的通行证、管理员通知节流,全放 KV - 理由:这些是「低频读、高频写、无关系」的数据,放 D1 会白白烧行数额度
4. 语言:TypeScript + Wrangler
- 与 Workers 官方工具链天然契合;
tsc --noEmit做类型检查 - 不引入任何运行时依赖(
package.json里只有@cloudflare/workers-types、typescript、wrangler三个 devDependencies) - 需要原生缺失的东西就自己写:
MD5(Gravatar hash)、Markdown 子集解析、PBKDF2(用 WebCrypto)
5. 鉴权:无状态 HMAC-SHA256 token + PBKDF2 密码
- token 用
TOKEN_SECRET做 HMAC 签名、7 天过期,无状态不落库 - 密码 PBKDF2-SHA256 12 万轮(WebCrypto 实现);兼容读取
(md5)和明文(仅迁移期) - 不用 bcrypt 是因为 Workers 没有 Node 的 native 依赖、也不值得为一个算法引进包
6. 邮件:Resend 的 HTTP API(而非 SMTP)
- Workers 无 TCP,SMTP 连不上;Resend 免费 3000 封/月
- 全程
ctx.waitUntil()异步 + try/catch,发信失败绝不影响评论落库
7. 人机验证:自研「一键验证」(PoW + 行为信号),而非 Turnstile/reCAPTCHA
- 第三方脚本在大陆加载不稳定,失败会卡死评论提交;还会把访客指纹送给第三方
- 自研版:浏览器静默算 20~80ms 的 PoW(sha256 前缀 4 个 0),命中可疑信号才让访客「点一下」,再可疑才回退图形验证码
- 挑战无状态(HMAC 签名不写库),通行证放 KV 不占 D1 额度
8. 前端:零重写,直接复用官方产物
- 客户端 + 官方后台(
/sidebar/)是 Artalk v2.8.7 的预构建产物,内置在public/里 - 自研管理面板
/admin/用纯原生 JS + 零依赖(补齐官方后台缺的「待审视图」「关键词搜索」等)
已知取舍(有意为之)
| 项 | 取舍 | 影响 |
|---|---|---|
| 大陆 → CF 边缘 ~170ms 延迟 | CF 免费版在大陆无节点,流量被送到 LAX | 加 <link rel="preconnect"> 省掉 0.5s TLS 握手,立竿见影 |
| 页面标题自动抓取 | 不做(跨境请求收益低) | 标题改为发评论时从 page_title 落库 |
| 邮箱验证码登录 / 社交登录 | 不做 | 管理员走密码登录,不受影响 |
url_resolver |
必须保持 false | 开启会重写 page_key,现有 /20210911.html 全失配 |
模块分布
artalk-cf/
├── wrangler.toml ★ Worker 配置:D1/KV/静态资源/域名/cron/变量
├── schema.sql ★ 建表(10 张表 + 覆盖索引)
├── 部署清单.md ★ 照着敲的部署步骤(含域名那一坑)
├── .dev.vars.example 本地开发机密模板(复制成 .dev.vars)
│
├── src/ ===== 服务端(TypeScript)=====
│ ├── index.ts 入口:路由注册 + fetch + scheduled 定时任务
│ ├── router.ts 迷你路由器(路径参数匹配,挂 / 与 /api/v2)
│ ├── rss-api.ts RSS 模块入口(路由表 + cron 轮转抓取)
│ ├── types.ts Env / 实体 / 响应类型定义
│ ├── template.generated.ts 设置页 YAML 模板(由官方 conf 生成)
│ │
│ ├── lib/ ===== 通用库(无 I/O 依赖的纯逻辑)=====
│ │ ├── db.ts D1 查询封装 + 限流 + 验证码状态
│ │ ├── cook.ts 实体 → API 输出(对应官方 dao/cook.go)
│ │ ├── session.ts token 签发/校验 + 密码 + 管理员判定
│ │ ├── md5.ts MD5(Gravatar hash,Workers 没有)
│ │ ├── md.ts Markdown 子集 → HTML(先转义再解析,防 XSS)
│ │ ├── util.ts 时间 / CORS / 响应 / 校验工具
│ │ ├── errors.ts ★ 统一错误出口(技术错误翻译成人话 + 机器码)
│ │ ├── human.ts 人机验证:PoW 挑战 + KV 通行证
│ │ ├── mail.ts 邮件发送(Resend HTTP API)
│ │ ├── admin-notify.ts 管理员提醒邮件(带节流)
│ │ └── rss/ ===== RSS 订阅子系统 =====
│ │ ├── cron.ts 每小时轮转抓取(66 源 ÷ 22 = 3 小时一轮)
│ │ ├── fetch.ts 抓取(超时/重试/代理回退/熔断)
│ │ ├── discover.ts feed 地址自动发现
│ │ ├── parse.ts RSS/Atom 解析
│ │ ├── greetings.ts 问候语/每日一图
│ │ ├── notify.ts 更新推送(飞书 webhook 等)
│ │ ├── proxy-health.ts 国内代理(SCF)体检
│ │ └── util.ts RSS 工具
│ │
│ └── routes/ ===== 路由处理器(按业务域分组)=====
│ ├── public.ts conf / 验证码 / 投票 / pv / setup / healthz
│ ├── comments.ts 评论 CRUD + 列表与树(含人机验证门)
│ ├── user.ts 用户 / 登录 / 合并 / SSO
│ ├── admin.ts 设置 / 站点 / 页面 / 用户 / 消息 / 统计 / 导入导出 / 上传
│ ├── human.ts 人机验证端点(challenge / verify / status)
│ └── rss/ ===== RSS 路由 =====
│ ├── feeds.ts 订阅源管理
│ ├── links.ts 友链/友圈
│ ├── results.ts 抓取结果 / 文章
│ ├── media.ts 随机图 / 评论机器人
│ ├── greeting.ts 问候语
│ ├── misc.ts 鉴权 / 健康检查
│ ├── tools.ts favicon / 代理 / cron / 部署状态 / 邮件
│ └── wechat.ts 微信公众号内容
│
├── public/ 静态资源(优先级高于 Worker,改这里不用动 Worker 代码)
│ ├── index.html / 入口页(健康状态 + 两个后台入口)
│ ├── admin/ /admin/ 自研管理面板(原生 JS,6 个视图)
│ │ ├── admin.css 设计令牌 + 全部组件样式
│ │ ├── admin.js 仪表盘/评论/页面/用户/设置/状态
│ │ └── fonts/ Press Start 2P + JetBrains Mono
│ ├── sidebar/ /sidebar/ 官方 v2.8.7 管理后台(Vue 预构建)
│ ├── dist/ 官方 v2.8.7 客户端与插件
│ └── feed.html /feed/ RSS 订阅源管理页
│
└── tools/ 工具脚本(开发/部署/自检)
├── deploy.sh 一键部署
├── import_artrans.py .artrans → D1 SQL(批量迁移)
├── import_delta.py 增量导入
├── gen_template.py 从官方 conf YAML 生成设置模板常量
├── selftest.ts 纯逻辑自检(MD5/时间/Markdown/密码/token/YAML)
├── smoke.sh 冒烟测试
├── preview-mail.ts 邮件样张预览
├── mailtest.ts 发信测试
└── check-*.mjs / probe-*.mjs / shot-*.mjs / measure-*.mjs
无头浏览器(CDP)测量/回归脚本,调试评论前端用
数据流一览
浏览器(博客评论区 / 后台 iframe)
│ /api/v2/*(评论)/ /api/*(RSS)
▼
Worker fetch(src/index.ts)
├─ /api/* 且非 /api/v2/* → rss-api.ts(RSS 子系统)
└─ 其余 → router.ts 分发到 routes/*
│
├─ 读请求 → D1 只读副本(first-unconstrained)
├─ 写请求 → D1 主库(first-primary)
└─ 通行证 → KV(人机验证 / RSS 游标 / 通知节流)
│
scheduled(cron)
├─ 0 * * * * → RSS 轮转抓取(每小时 22 源)
└─ 17 3 * * * → 评论 GC(限流/验证码清理 + healthz 缓存刷新 + 代理体检)
数据模型
10 张表(schema.sql),时间统一存 unix 毫秒,输出时按 Asia/Shanghai 格式化:
| 表 | 用途 | 关键字段 |
|---|---|---|
sites |
站点 | name(唯一)、urls |
pages |
页面 | key(=page_key 如 /20210911.html)、site_name、pv、vote |
users |
用户 | name+email(唯一)、password(pbkdf2/md5/明文兼容)、徽章、头衔 |
comments |
评论 | rid(父节点)、root_id(顶层祖先)、is_pending/pinned/verified、软删除 deleted_at |
votes |
投票 | target_id + user_id + type 唯一 |
notifies |
通知 | user_id、comment_id、is_read/is_emailed |
settings |
键值配置 | key(主键)、value(frontend_conf / admin_users 等) |
rate_limits |
限流 | bucket(如 comment:1.2.3.4)、count、expires_at |
captcha_passes |
验证码通过状态 | ip(主键)、expires_at |
captcha_challenges |
验证码题目 | answer_hash(只存哈希) |
覆盖索引(性能关键,见下):
idx_comments_page_list— 列表ORDER BY is_pinned DESC, created_at DESC专用idx_comments_children— 子评论root_id IN (...)专用idx_comments_site_list— 站点级列表
root_id 语义与官方一致:顶层为 0,子孙为该顶层评论的 id。列表默认 nested 模式——取一页顶层,再捞回全部子孙交给前端组树。
部署
完整步骤看 部署清单.md(照着敲约 10 分钟)。快速版:
cd blog-admin && npm install
npx wrangler login
npx wrangler d1 create artalk-cf # 把返回的 database_id 填进 wrangler.toml
npx wrangler secret put TOKEN_SECRET
npx wrangler secret put ADMIN_PASSWORD
npx wrangler d1 execute artalk-cf --remote --file=./schema.sql
npx wrangler deploy
curl -X POST https://<worker>/api/v2/setup
★ 域名这一步有个坑,动手前先看
Cloudflare Worker 的自定义域名要求「域名的 NS 托管在 Cloudflare」。
而 usj.cc 的 NS 在 DNSPod(还挂着又拍云 + 腾讯云 EdgeOne 调度)——千万不要为了评论 API 把整站 NS 搬去 Cloudflare,会弄坏现有国内双 CDN 链路。
本项目用的是 api.200181.xyz(NS 本来就在 Cloudflare):
[[routes]]
pattern = "api.200181.xyz"
custom_domain = true
*.workers.dev 地址在大陆基本不通,只能自测。四种域名方案取舍见 部署清单.md 第 9 步。
机密清单(不写进仓库)
| Secret / 变量 | 用途 |
|---|---|
TOKEN_SECRET |
登录 token 签名 |
ADMIN_PASSWORD |
管理员密码 |
RESEND_API_KEY |
邮件(可选) |
MAIL_FROM / MAIL_ADMIN |
邮件发件人 / 收件人(可选) |
接到博客上
E:\GitHub\blog\hugo.toml:
[params.artalk]
server = "https://api.200181.xyz" # ← 只改这一行
site = "优世界" # 必须与 SITE_DEFAULT 一致
客户端调用 ${server}/api/v2/...,pageKey 用 .RelPermalink(/20210911.html),与 D1 的 page_key 完全对应,不需要改任何模板或前端代码。
评论数据迁移
方式一:批量 SQL(推荐)
Worker 逐条 INSERT 会产生几千次 D1 往返,免费版单请求 CPU 只有 10ms 容易超时,所以走 wrangler d1 execute --file:
python tools/import_artrans.py # 读 .artrans,只导「优世界」,生成 SQL
bash tools/import-out/run.sh --remote
脚本做了:按 site_name 过滤、按「昵称+邮箱」重建用户、保留原始 id 与 rid 关系、rid 回填与 root_id 计算分三趟做(与导入顺序无关)、最后自检打印各表条数。
方式二:后台界面导入
登录 /sidebar/ → 迁移页 → 上传 .artrans(小数据量可用)。
⚠️ 别踩的坑
url_resolver保持 false,否则 page_key 被重写,评论区变空- 导出里
created_at为 Go 零值(0001-01-01)的 6 条会被兜底成当前时间
性能与免费额度
免费额度够不够(3440 条评论 / 138 篇文章)
| 资源 | 免费额度 | 你的用量 | 结论 |
|---|---|---|---|
| Workers | 10 万请求/天 | 评论量级一天最多几千 | ✅ |
| D1 存储 | 5 GB | 评论约几 MB | ✅ |
| D1 读 | 500 万行/天 | 一次列表查询几十行 | ✅ |
| Workers CPU | 10 ms/请求 | 列表查询含 markdown 渲染,需观察 | ⚠️ |
分层耗时(中国大陆网络实测)
| 项 | 耗时 |
|---|---|
| 冷连接 TLS 握手 | ~0.51s |
| 连接复用后端到端 | ~190ms |
| 1 次 D1 查询 | 8~20ms |
真正的瓶颈是「大陆 → CF 边缘」这段(~170ms),不是数据库。 改善:博客里加 <link rel="preconnect" href="https://api.200181.xyz">,省掉 0.5s TLS 握手,成本最低。
打爆复盘(2026-10-03 D1 额度用尽)
D1 免费版读额度(500 万行/天,UTC 00:00 = 北京 08:00 重置)被一次打爆,实测 525 万行。定位与修复:
- 取证:CF GraphQL
d1QueriesAdaptiveGroups(维度名是query)按sum_rowsRead排序,锁定COUNT(*)+LEFT JOIN users是无索引全表扫 - 修复:
- 三个覆盖索引(
idx_comments_page_list/_children/_site_list) - 计数 KV 缓存(只在增删/审核时失效)
- 列表/计数不再无谓 JOIN users
- 两个 COUNT 合并成一条
COUNT(*) + SUM(CASE ...)
- 三个覆盖索引(
- 教训:改动前先本地预览 + 冒烟测试,别拿线上打爆的额度再跑测试
自查方法
打开 https://api.200181.xyz/api/v2/healthz 看三个字段:
colo—— 请求落在哪个 CF 机房(决定 RTT)region/primary—— 查询由主库还是副本处理mail—— 邮件是否已配置("on")
安全
- 登录 token:HMAC-SHA256 签名,无状态、7 天过期;改密码写
token_valid_from让旧 token 立即失效 - 密码:PBKDF2-SHA256 12 万轮;兼容
(md5)/ 明文(仅迁移) - 后台接口全部走
requireAdmin,未登录 403 - 评论软删除(
deleted_at),误删可从 D1 恢复 - CORS 白名单 + 预检处理;写接口走 D1 计数限流
- 验证码答案只存哈希;人机验证挑战 HMAC 签名无状态
- 顶层 fetch 兜底:任何漏网异常也带 CORS + 人话错误,不给读者甩「Failed to fetch」