Files
blog/blog-admin
zqlit 3eeafa07b2 fix(证书管家): writeapi.usj.cc 证书纳管(相对软链接);pushall 改为先 rebase 再推
用户:「动手 推送吧」—— 动手处理两个有死线的遗留。

一、writeapi.usj.cc 的证书(12 月会断)→ 已解决

现状复核推翻了上一轮的判断:
  · 上一轮记的「它的 ssl_certificate 引用不在 /www/sites/ 树里」是**错的** ——
    /www/conf.d/writeapi.usj.cc.conf 里明确写着
    ssl_certificate /www/sites/writeapi.usj.cc/ssl/fullchain.pem;
  · 真正原因是**它在 1Panel 面板上根本不是一个网站**(面板共 11 个站点,没有它),
    所以 `findWebsite('writeapi.usj.cc')` 必然抛「找不到网站」→
    加进 one_panel_sites 只会让**每次续期都报错**(而且只警告不阻断,长期没人发现)
  · 实测确认:redeploy 时其余 5 个站点正常,只有它失败

解法:**不写一行代码** —— 它与 artalk.usj.cc 本来就同用一张 *.usj.cc 证书,
让它用**相对软链接跟随 artalk** 即可,随续期自动更新:
  ln -sfn ../../artalk.usj.cc/ssl/fullchain.pem /1panel/1panel/www/sites/writeapi.usj.cc/ssl/fullchain.pem

★★ 踩到的坑:软链接**必须相对路径**
  宿主 /1panel/1panel/www/sites/ ↔ 容器内 /www/sites/(1Panel 的 openresty 跑在容器
  `1Panel-openresty-Kmh2`)。第一版写成宿主绝对路径 → **容器侧断链** →
  nginx -s reload **静默失败**(保留旧配置、服务不中断、握手照旧拿旧证书)→
  表面全绿,真实后果是「等某天 nginx 重启,writeapi 的 HTTPS 直接挂」。
  唯一线索是 worker 进程的启动时间没变 —— **所以验收必须看 worker 是否真的重启**。

验收(全部实测):
  替换前 CN=usj.cc    / LiteSSL RSA CA / 2026-12-07(mtime 10-04 21:50)
  替换后 CN=*.usj.cc  / LiteSSL ECC CA / 2027-01-04
  容器内 head -1 → -----BEGIN CERTIFICATE----- / -----BEGIN PRIVATE KEY-----
  预检 syntax is ok / test is successful
  reload → signal process started;**worker 22:51:39 重启**(证明软链接真被读了)
  握手 SNI=writeapi.usj.cc → *.usj.cc / Jan 4 2027;实访 /health → HTTP 200

新增 deploy/cn-certkeeper/src/dump-cert.mjs:把 KV 里(AES-GCM 加密)的证书导成明文 PEM,
补上「KV ↔ 手工 vhost 磁盘文件」之间原先不存在的桥。只读,用完即删(含明文私钥)。

操作层的两个坑(写进文档):
  · 1Panel 文件 API **拒绝写 .mjs**(返回 500「目标路径不存在」,实为可执行扩展名过滤);
    往新建的 root:root 700 目录写也会失败 → 临时脚本用
    `docker exec -i … sh -c 'cat > 路径' < 本地文件` 送
  · cnrun.py(1Panel 计划任务通道)偶发「退出码 0 但零输出」;
    加 `timeout 30 docker exec …` 包一层即稳定,判断状态优先看容器内直接证据

二、配置回退 + 文档更正

  · certkeeper-config.mjs:把 writeapi 从 one_panel_sites 移除,换成一段注释说明
    「它不是 1Panel 站点,别加进来」+ 软链接做法 + 必须相对的警告;
    线上 config.kv 同步回退(sha256 与改动前完全一致 ccac2530…)
  · 更正「3 个手工 vhost」的说法:vaultwarden **是**正常纳管的站点(面板 #13,
    主域名 vw.usj.cc,别名才是 vaultwarden)→ 手工 vhost 实际只剩 writeapi + dnsapi
  · dnsapi.usj.cc 重新定性为**不做**:它是 cn-dns-helper 的反代入口
    (proxy_pass 127.0.0.1:8018,仅 HTTP),去留应与 cn-dns-helper 一起决定,
    不该单独给一个待退役的服务加 HTTPS

三、pushall 加固:先 fetch + rebase 再推(本次真实撞到的问题)

推 CNB 时被 rejected —— 因为线上写作后台(editor-api)发文章会**直接推 main**,
本机两笔提交与之分叉。这不是异常,是**日常**。原别名直接 push,必然反复撞。

  · 新增 scripts/pushall.sh:fetch → 已在远端之后则 rebase → 推所有远端
    - 冲突时**停在 rebase 中途**交人工(不强推、不丢东西)
    - 工作区不干净时 git 自己会拒绝 rebase(不会吞改动)
    - 任一远端失败**不改判另一个**(主仓失败辅仓照样推),退出码以第一次失败为准
    - ⚠️ 修了自己写的一处疏漏:`if ! cmd; then ec=$?` 的 $? 是**取反后**的结果(0),
      不是 git 的退出码 —— 必须显式 ec=1(靠 set -o pipefail 保证管道退出码不被 sed 掩盖)
  · setup-cnb-remotes.sh 的别名改指向脚本;README 脚本表补这一行

四、文档

  · docs/证书管家.md:速览表「已知遗留」→ 已解决;§9.14 遗留段重写(含纠正自己写错的判据
    「路径像不像不构成判据,要直接查面板站点清单」);**新增 §9.15**
    「手工 vhost 的证书:用相对软链接跟随已纳管站点」(机制 + 路径坑 + 三条验收 + dump-cert 用法)
  · docs/架构精简候选.md:候选 5 标记完成,补上「为什么 A 走不通、B 未采用」;
    执行顺序里划掉它
  · 架构总览.md:§5.7「已知遗留」重写(2 个手工 vhost + vaultwarden 更正);
    §6 待办 #13 改为已解决、#14 标注完成
2026-10-06 22:54:58 +08:00
..

artalk-cf —— 跑在 Cloudflare 上的 Artalk 评论后端 + RSS 订阅机器人

自研的 Artalk API v2 兼容服务端,以及合并进来的 RSS 订阅机器人(rss-robot), 一起跑在 Cloudflare Workers + D1 + KV 上,零服务器、零运维、免费额度内。

状态:已上线 → https://api.200181.xyz D1 artalk-cf(78140776-2bfd-439a-8b05-c3eebe5d5b47)| KV RSS_KV(d7f86a0fb43f4450a10b786fc2635128) 管理员账号 admin(后台登录用邮箱+密码,见「部署」)

两个后台都可用:/admin/ 自研管理面板 | /sidebar/ 官方 Artalk 后台 评论前端一行不用改:继续用官方 Artalk 客户端(博客里打包的 2.8.7),换后端只改 hugo.toml 一行 server。


目录

  1. 这个项目是什么
  2. 技术选型
  3. 模块分布
  4. 数据模型
  5. 部署
  6. 接到博客上
  7. 评论数据迁移
  8. 性能与免费额度

这个项目是什么

两件事,各对应一个原本独立的仓库,现在合并进同一个 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 万行。定位与修复:

  1. 取证:CF GraphQL d1QueriesAdaptiveGroups(维度名是 query)按 sum_rowsRead 排序,锁定 COUNT(*) + LEFT JOIN users 是无索引全表扫
  2. 修复:
    • 三个覆盖索引(idx_comments_page_list / _children / _site_list)
    • 计数 KV 缓存(只在增删/审核时失效)
    • 列表/计数不再无谓 JOIN users
    • 两个 COUNT 合并成一条 COUNT(*) + SUM(CASE ...)
  3. 教训:改动前先本地预览 + 冒烟测试,别拿线上打爆的额度再跑测试

自查方法

打开 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」