Files
blog/blog-admin/README.md
T
zqlit a74b3c7127
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped
归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
2026-10-04 08:45:40 +08:00

365 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](部署清单.md)**(照着敲约 10 分钟)。快速版:
```bash
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):
```toml
[[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`:
```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`:
```bash
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」